@ringozz/godot 4.7.2-661 → 4.7.2-676

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/load.ts CHANGED
@@ -10,6 +10,20 @@ import { ResourceUID } from '../gen/classes/ResourceUID.ts';
10
10
  import type { Resource } from '../gen/classes/Resource.ts';
11
11
  import { cleanupHooks, stageFile } from './runtime.ts';
12
12
  import { transcoder } from '@ringozz/godot/transcoder';
13
+ import {
14
+ FILES_DIR,
15
+ PACK_DOC,
16
+ mountClosure,
17
+ parseDoc,
18
+ parseRelease,
19
+ pckOf,
20
+ resolveEntry,
21
+ type PackDoc,
22
+ type Release,
23
+ type ResolvedEntry,
24
+ type StagedFile,
25
+ type StagedFiles,
26
+ } from './pack.ts';
13
27
  import { decodeCtex } from './web-image.ts';
14
28
 
15
29
  /**
@@ -57,18 +71,6 @@ function track<T extends Resource>(res: T): T {
57
71
  */
58
72
  type ResourceConstructor = { new(...args: any[]): Resource } & Function;
59
73
 
60
- /**
61
- * A file to stage before loading, keyed by `res://` path: sidecars (`.import`)
62
- * arrive as `content` (text); imported products (`.scn`/`.ctex`) arrive as
63
- * `path` (fetched).
64
- */
65
- interface AssetFile {
66
- content?: string;
67
- path?: string;
68
- }
69
-
70
- export type AssetFiles = Record<string, AssetFile>;
71
-
72
74
  const UID_RE = /uid="(uid:\/\/[\w.]+)"/;
73
75
 
74
76
  // Compressed-texture products whose payloads may need decoding/transcoding:
@@ -79,7 +81,7 @@ const COMPRESSED_TEXTURE_RE = /\.(ctex|ctexarray|ccube|ccubearray)$/;
79
81
 
80
82
  // Textures the runtime must receive *exactly* as the server served them, so they are decoded to raw RGBA8
81
83
  // instead of going through the platform transcoder (spark -> BC7/ASTC): the terrain height atlas
82
- // (`<level>.dat/terrain/height.webp`), whose 12-bit packed R/G the vertex shader decodes per texel, so
84
+ // (`<name>.dat/terrain/height.webp`), whose 12-bit packed R/G the vertex shader decodes per texel, so
83
85
  // block quantisation shows as blocky relief on every slope. Everything else may be compressed — the
84
86
  // surface arrays, the weight planes and the `_n` tile normal all ship as layered containers the runtime
85
87
  // only samples (see `docs/godot.md`'s M4 section). The `.ctex` product is named after its source plus an
@@ -112,7 +114,27 @@ function registerAssetUid(uid: string, resPath: string): void {
112
114
  }
113
115
  }
114
116
 
115
- async function materialize([resPath, entry]: [string, AssetFile]): Promise<void> {
117
+ // res:// paths already staged, or being staged: MEMFS cannot be read back reliably — the engine's
118
+ // `res://` lookup and the Emscripten path disagree by a leading slash — so `FileAccess.fileExists`
119
+ // cannot answer "is this staged?", and two asset modules needing the same file would fetch and
120
+ // allocate it twice. A path is forgotten when its bytes are removed after a load.
121
+ const staged = new Map<string, Promise<void>>();
122
+
123
+ async function materialize(entry: [string, StagedFile]): Promise<void> {
124
+ const [resPath] = entry;
125
+ const existing = staged.get(resPath);
126
+ if (existing) {
127
+ return existing;
128
+ }
129
+ const p = stage(entry).catch((err) => {
130
+ staged.delete(resPath);
131
+ throw err;
132
+ });
133
+ staged.set(resPath, p);
134
+ return p;
135
+ }
136
+
137
+ async function stage([resPath, entry]: [string, StagedFile]): Promise<void> {
116
138
  if (FileAccess.fileExists(resPath)) {
117
139
  return;
118
140
  }
@@ -163,7 +185,7 @@ const nextTick = () => new Promise(requestAnimationFrame);
163
185
  * straight into Emscripten's MEMFS via Godot's `copyToFS` (JS-heap only, no wasm
164
186
  * copy); a no-op on desktop where the files already exist.
165
187
  */
166
- function materializeFiles(files: AssetFiles): Promise<unknown> {
188
+ function materializeFiles(files: StagedFiles): Promise<unknown> {
167
189
  if (stageFile === undefined) {
168
190
  // Desktop: the files already exist on disk (res:// = cwd) and uids come
169
191
  // from `.godot/uid_cache.bin`, so there is nothing to stage.
@@ -199,7 +221,7 @@ function cachedResource<C extends ResourceConstructor>(path: string): Promise<In
199
221
  export function loadAsset<C extends ResourceConstructor>(
200
222
  path: string,
201
223
  cls: C,
202
- files: AssetFiles,
224
+ files: StagedFiles,
203
225
  data: Record<string, unknown> = {},
204
226
  ready?: Promise<unknown>,
205
227
  ): { materialize: Promise<unknown>; load: Promise<InstanceType<C>> } {
@@ -230,7 +252,7 @@ export function loadAsset<C extends ResourceConstructor>(
230
252
  export async function loadResourceAsync<C extends ResourceConstructor>(
231
253
  path: string,
232
254
  cls: C,
233
- files?: AssetFiles,
255
+ files?: StagedFiles,
234
256
  ): Promise<InstanceType<C>> {
235
257
  const err = ResourceLoader.loadThreadedRequest(path, cls.name);
236
258
  if (err) {
@@ -263,6 +285,8 @@ export async function loadResourceAsync<C extends ResourceConstructor>(
263
285
  for (const resPath of Object.keys(files)) {
264
286
  if (!resPath.endsWith('.import')) {
265
287
  DirAccess.removeAbsolute(resPath);
288
+ // The bytes are gone, so the memo must forget the path — a later module stages it again.
289
+ staged.delete(resPath);
266
290
  }
267
291
  }
268
292
  }
@@ -321,3 +345,131 @@ function packName(url: string): string {
321
345
  const base = url.split('?')[0].split('/').pop() ?? 'pack.pck';
322
346
  return base.replace(/[^A-Za-z0-9._-]/g, '_') || 'pack.pck';
323
347
  }
348
+
349
+ // ── Content packs ───────────────────────────────────────────────────────────
350
+ // A content pack is deployed apart from the app, so a client is told where a release-relative path can
351
+ // be read (`at`) and everything else follows: the release graph, each mounted pack's manifest and
352
+ // `.pck`, and the loose files the entry needs. The bake also hands over the boot pack's own config —
353
+ // its `.pck` and a bundled URL per loose product, the one thing only the app can provide, since those
354
+ // bytes ship beside it. The runtime only ever sees URLs, all of them version-addressed and immutable.
355
+
356
+ /**
357
+ * A pack the runtime has at hand: its `.pck`, its document, and where its staged products are read. The
358
+ * app's own pack is one of these — its bytes ship beside it, so its URLs are the host's, not a
359
+ * version-addressed path — and it is what `configurePacks({ boot })` hands over.
360
+ */
361
+ export interface MountedPack {
362
+ /** The `.pck` to mount (`loadAssetPack`). */
363
+ pck: string;
364
+ doc: PackDoc;
365
+ /** The URL of one of this pack's products, by the file name `doc.stage` gives it. */
366
+ url: (file: string) => string;
367
+ }
368
+
369
+ /**
370
+ * How the runtime reads content packs: a URL per file, and the release that names the packs (or where
371
+ * to read one). The host owns its base and the way a version is encoded in it, so the protocol never
372
+ * builds a release-relative path — it knows a pack's name and its files (`pack.json`, `<name>.pck`,
373
+ * `files/<product>`), and asks.
374
+ */
375
+ export interface PackSource {
376
+ /** A pack's file, as a fetchable URL or a local path — how the host addresses a version is its own. */
377
+ at?: (pack: string, version: string, file: string) => string;
378
+ /** The app's own pack, configured by the bake's own code: always mounted, its bytes bundled. */
379
+ boot?: MountedPack;
380
+ /**
381
+ * The packs to mount. The app's own dependencies (pinned by its lockfile) are one way: there is no
382
+ * release document to fetch, and the versions the app was built and tested with are the ones it
383
+ * mounts. A URL is the other: mount what a release document names — a bake's `release.json`, a CDN's.
384
+ */
385
+ release?: Release | string;
386
+ }
387
+
388
+ let packSource: PackSource = {};
389
+
390
+ /**
391
+ * Points the runtime at a pack source. Merges, so the bake's boot config and the app's `at` may land in
392
+ * either order — the boot config rides on the app's own asset imports.
393
+ */
394
+ export function configurePacks(source: PackSource): void {
395
+ packSource = { ...packSource, ...source };
396
+ }
397
+
398
+ /** Reads a document: a URL over the network, anything else off the filesystem. */
399
+ async function readText(url: string): Promise<string> {
400
+ if (/^https?:/i.test(url)) {
401
+ const res = await fetch(url);
402
+ if (!res.ok) throw new Error(`godot-assets: ${url} (${res.status})`);
403
+ return res.text();
404
+ }
405
+ // Desktop and a dev bundle hand over a local path, and `res://` is the working tree there.
406
+ const text = FileAccess.getFileAsString(url.startsWith('res://') ? url : ProjectSettings.globalizePath(url));
407
+ if (!text) throw new Error(`godot-assets: cannot read ${url}`);
408
+ return text;
409
+ }
410
+
411
+ /** The release to mount: the packs the app pinned, or the document `configurePacks` was pointed at. */
412
+ async function mountedRelease(): Promise<Release> {
413
+ const pinned = packSource.release;
414
+ if (!pinned) throw new Error('godot-assets: no release — configurePacks({ release }) with the packs to mount or its URL.');
415
+ return typeof pinned === 'string' ? parseRelease(await readText(pinned)) : pinned;
416
+ }
417
+
418
+ /**
419
+ * A resolved entry's files, as the loader stages them: the app's own pack addresses a product from its
420
+ * bundled URLs, a mounted pack from the host's addressing. This is the addressing the protocol
421
+ * deliberately does not know — which is why a resolution returns packs and file names, not URLs.
422
+ */
423
+ function stagedFiles(
424
+ plan: ResolvedEntry,
425
+ boot: MountedPack | undefined,
426
+ fileUrl: (pack: string, file: string) => string,
427
+ ): StagedFiles {
428
+ const files: StagedFiles = {};
429
+ for (const { sidecar, content, product, pack, file } of plan.files) {
430
+ files[sidecar] = { content };
431
+ files[product] = { path: pack === null ? boot!.url(file) : fileUrl(pack, `${FILES_DIR}/${file}`) };
432
+ }
433
+ return files;
434
+ }
435
+
436
+ /**
437
+ * Loads one declared entry of a content pack: the release, then the packs the entry reaches and the
438
+ * loose files they stage, then the `res://` path the entry names. Nothing about a pack is baked into
439
+ * the app, so it can be republished without one.
440
+ */
441
+ export async function loadPackEntry<C extends ResourceConstructor>(
442
+ pack: string,
443
+ entry: string,
444
+ cls: C,
445
+ ): Promise<InstanceType<C>> {
446
+ const at = packSource.at;
447
+ if (!at) throw new Error('godot-assets: no pack source — call configurePacks({ at }) first.');
448
+ const release = await mountedRelease();
449
+ // Mounting needs only the release — a pack's `.pck` is `<name>.pck` by convention — so the closure's
450
+ // packs are mounted while their documents are read, and only staging waits on those.
451
+ const names = mountClosure(release, pack);
452
+ const url = (name: string, file: string) => at(name, release[name].version, file);
453
+ const boot = packSource.boot;
454
+ const mounts = [...(boot ? [boot.pck] : []), ...names.map((name) => url(name, pckOf(name)))].map((pck) =>
455
+ loadAssetPack(pck),
456
+ );
457
+ const docs = Object.fromEntries(
458
+ await Promise.all(
459
+ names.map(async (name): Promise<[string, PackDoc]> => {
460
+ const doc = parseDoc(await readText(url(name, PACK_DOC)));
461
+ // A base that serves a version it wasn't asked for is the one thing a client cannot see:
462
+ // production's URLs are immutable, and a dev server pointed at a local bake serves content
463
+ // at another version on purpose. Say so; the load still works.
464
+ if (doc.version !== release[name].version) {
465
+ console.warn(`godot-assets: ${name} ${release[name].version} asked, ${doc.version ?? 'no version'} served`);
466
+ }
467
+ return [name, doc];
468
+ }),
469
+ ),
470
+ );
471
+ const plan = resolveEntry(release, docs, pack, entry, boot?.doc);
472
+ // The boot is a mounted pack like any other, so an entry borrows its loose files too.
473
+ await Promise.all(mounts);
474
+ return loadAsset(plan.res, cls, stagedFiles(plan, boot, url)).load;
475
+ }