@ringozz/godot 4.7.2-661 → 4.7.2-664

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/pack.ts CHANGED
@@ -2,259 +2,320 @@
2
2
  Copyright (c) Vladimir Davidovich. All rights reserved.
3
3
  ***********************************************************************/
4
4
 
5
- import { spawnSync } from 'node:child_process';
6
- import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
7
- import { dirname, resolve } from 'node:path';
8
- import { fileURLToPath } from 'node:url';
9
- import { resolveEditor } from './editor.ts';
10
-
11
- // Bakes a Godot project's assets for the runtime: imports them, then exports one native `.pck` **per
12
- // level** plus a shared one, so a host fetches the shared data and only the level it is playing.
13
- // `dump-deps.gd` supplies both inputs (no host-side scanning), in the dump's own two words: each
14
- // source's `packed` closure — which decides what a pack *owns* — and the `loose` (WebP-backed)
15
- // texture sources, which the host stages and transcodes per file.
5
+ // The pack protocol: the document a bake emits for every pack, the names that document and its pack are
6
+ // written and read under, the entries a pack declares, and the resolution that turns one entry into the
7
+ // packs to mount and the loose files to stage before it is loaded. Three callers speak it — the bake
8
+ // (`bake.ts`, which writes the documents), the preload plugin (which reads them at build time to serve
9
+ // a pack's module) and the runtime loader (which fetches them) — so the shapes live here once instead of
10
+ // being re-spelled by each of them.
16
11
  //
17
- // The one file the runtime reads is `<out>/packs.json`, beside the packs: every pack's `pck` and
18
- // `owns`, plus the `loose` lists. `preload.ts` mounts the reserved `shared` pack and the asset's
19
- // owner.
20
- //
21
- // The project owns `export_presets.cfg` (a committed, editor-managed file), and the scoping has to
22
- // live there: `--export-pack` runs against a preset. Per-pack scoping is therefore an
23
- // `exclude_filter` — the repo's `all_resources` convention, which ignores `include_filter` and,
24
- // unlike the selected modes, never pulls dependencies back in — so each pack excludes exactly the
25
- // sources the others own (see `splitPacks`). Those presets are cloned from the project's own base
26
- // preset and the file is restored once the bake ends: they are derived, and the file is tracked.
27
-
28
- const HERE = dirname(fileURLToPath(import.meta.url));
29
-
30
- /** The dump's one build-time output, merged into the manifest beside the packs. */
31
- const DUMP = '.godot/pack-dump.json';
32
-
33
- export interface BuildAssetPackOptions {
34
- /** res:// roots whose assets are baked (default `['res://src']`). */
35
- roots?: string[];
36
- /** Output directory for the packs + manifest, project-relative (default `.godot/packs`). */
37
- out?: string;
38
- /** The `export_presets.cfg` preset the generated pack presets derive from (default `Pack`). */
39
- preset?: string;
40
- /** Editor executable override (default: `resolveEditor()`). */
41
- godot?: string;
12
+ // Nothing here touches the filesystem, the editor or the engine. A document arrives as text, and a
13
+ // resolved file names the pack that owns its product rather than a URL: how a pack's bytes are
14
+ // addressed — fetched over the network, bundled beside the app, or read off the disk — belongs to the
15
+ // caller doing the I/O, which is also the only caller that knows which of the three it is.
16
+
17
+ import { classOf } from './formats.ts';
18
+
19
+ /** One pack in a release: the version to mount, and its declared dependencies by pack name. */
20
+ export interface ReleasePack {
21
+ version: string;
22
+ deps: string[];
42
23
  }
43
24
 
44
- /** A pack: `shared`, or a level's file stem, and the sources it owns. */
45
- export interface Pack {
46
- /** `shared`, or a level's file stem. */
47
- name: string;
48
- /** The packable sources this pack owns — the lookup the plugin mounts by. */
49
- owns: string[];
25
+ /** A release: pack name → what to mount. */
26
+ export type Release = Record<string, ReleasePack>;
27
+
28
+ /** A `bun.lock` package entry: `[name@version, registry, { dependencies? }, "sha512-…"]`. */
29
+ type LockEntry = [string, string, { dependencies?: Record<string, string>; optionalDependencies?: Record<string, string> }?, ...unknown[]];
30
+
31
+ /**
32
+ * Parses a release document: a `bun.lock` (whose `packages` map is flattened to one entry per name,
33
+ * with the dependency *names* as its edges) or an explicit `{ name: { version, deps } }` map. The
34
+ * edges are names, not ranges: the lockfile already chose one version per name, so nothing here has to
35
+ * resolve semver. Throws on anything else — a document this can't read would mount nothing and show up
36
+ * as missing assets.
37
+ */
38
+ export function parseRelease(text: string): Release {
39
+ const doc = JSON.parse(text.replace(/,\s*([}\]])/g, '$1')) as Record<string, unknown>;
40
+ const packages = doc.packages as Record<string, LockEntry> | undefined;
41
+ if (packages) {
42
+ const release: Release = {};
43
+ for (const [name, entry] of Object.entries(packages)) {
44
+ if (!Array.isArray(entry) || typeof entry[0] !== 'string') continue;
45
+ const at = entry[0].lastIndexOf('@');
46
+ release[name] = { version: at > 0 ? entry[0].slice(at + 1) : entry[0], deps: depsOf(entry) };
47
+ }
48
+ if (!Object.keys(release).length) throw new Error('godot-assets: the release document has no packages.');
49
+ return release;
50
+ }
51
+ const values = Object.values(doc);
52
+ if (values.length && values.every(isPack)) {
53
+ return Object.fromEntries(
54
+ Object.entries(doc).map(([name, pack]) => [
55
+ name,
56
+ { version: (pack as ReleasePack).version, deps: (pack as ReleasePack).deps ?? [] },
57
+ ]),
58
+ );
59
+ }
60
+ throw new Error('godot-assets: unrecognized release document (expected a bun.lock or a name → {version, deps} map).');
61
+ }
62
+
63
+ /** A value shaped like a {@link ReleasePack}. */
64
+ function isPack(value: unknown): boolean {
65
+ return !!value && typeof value === 'object' && typeof (value as ReleasePack).version === 'string';
50
66
  }
51
67
 
52
- /** A pack as the manifest carries it: a {@link Pack} plus its `.pck`. */
53
- interface PackFile extends Pack {
54
- /** `res://` path of the `.pck`, for `loadAssetPack`. */
55
- pck: string;
68
+ /** A lock entry's dependency *names* — the lockfile already chose one version per name. */
69
+ function depsOf(entry: LockEntry): string[] {
70
+ return Object.keys(entry[2]?.dependencies ?? entry[2]?.optionalDependencies ?? {});
56
71
  }
57
72
 
58
- /** The runtime manifest beside the packs: what to mount, and what to stage per file. */
59
- export interface PackManifest {
60
- packs: PackFile[];
61
- /** Packed source → the loose texture sources it needs at runtime (from `dump-deps.gd`). */
62
- loose: Record<string, string[]>;
73
+ /** The packs to mount for `entry`: the pack itself, then every pack its dependencies reach (depth first). */
74
+ export function mountClosure(release: Release, entry: string): string[] {
75
+ if (!release[entry]) throw new Error(`godot-assets: the release has no pack ${entry}.`);
76
+ const out: string[] = [];
77
+ const seen = new Set<string>();
78
+ const visit = (name: string) => {
79
+ if (seen.has(name)) return;
80
+ const pack = release[name];
81
+ if (!pack) throw new Error(`godot-assets: the release has no pack ${name} (a declared dependency).`);
82
+ seen.add(name);
83
+ out.push(name);
84
+ for (const dep of pack.deps) visit(dep);
85
+ };
86
+ visit(entry);
87
+ return out;
88
+ }
89
+
90
+ /** A pack's file, relative to a release base: `<name>/<version>/<file>` (version-addressed, immutable). */
91
+ export function packPath(name: string, version: string, file: string): string {
92
+ return `${name}/${version}/${file}`;
63
93
  }
64
94
 
65
- /** One bucket of the dump: a source → its closure (`dump-deps.gd`'s `packed` or `loose` half). */
66
- type Closures = Record<string, string[]>;
95
+ /** The `.pck` beside a pack's document: its name's last segment (a scoped name stays flat). The one
96
+ * convention the bake's writer and the runtime's reader share — mounting resolves it from the release
97
+ * alone, so it never waits on the document. */
98
+ export function pckOf(name: string): string {
99
+ return `${name.split('/').pop()}.pck`;
100
+ }
67
101
 
68
- /** A `spawnSync` runner for the editor. */
69
- type Run = (args: string[], env?: Record<string, string>) => void;
102
+ // The names a pack is written and read under, shared by the bake (the writer) and its readers (the
103
+ // preload plugin and the runtime loader).
70
104
 
71
105
  /**
72
- * The ownership partition, from the packed closure of every source. A **level is a scene**
73
- * (`.tscn`) among the scanned sources — a project's levels are its scenes, so nothing declares them
74
- * — and: **a source exactly one level reaches is that level's** (so a model only one level uses
75
- * rides its pack, though it lives in a shared asset tree), while **a source several levels reach, or
76
- * none does, is shared**. A level whose own pack would be empty is dropped: every source it reaches
77
- * is reached by another level too, so it is already in `shared`, and a pack is mounted by *owner*.
78
- *
79
- * When a project stops baking one scene per level — a level built from its plan at run time is a
80
- * single shared scene plus the plan — the level unit becomes the `.gltfx` its plan lives in instead;
81
- * until then both units exist and this keying keeps the scenes.
106
+ * A pack's document, beside its `.pck`. Not `package.json`: that is the pack's own manifest in its
107
+ * source directory — an input the bake reads to discover a pack — and one string naming both an input
108
+ * and an output is one string nobody can read unambiguously.
82
109
  */
83
- export function splitPacks(packed: Closures): Pack[] {
84
- const stem = (asset: string) =>
85
- asset.slice(asset.lastIndexOf('/') + 1).replace(/\.(prefab|level)\.[^.]+$/i, '').replace(/\.[^.]+$/, '');
86
- // A level is its scene — and, in a project that builds levels from their plans at run time, the
87
- // `.gltfx` its plan lives in. A `.gltfx` a scene **reaches** is that scene's composition, not a
88
- // second level: keying both would make every source of a baked level look shared.
89
- const scenes = Object.keys(packed).filter((s) => s.toLowerCase().endsWith('.tscn')).sort();
90
- const reachedByScene = new Set(scenes.flatMap((s) => packed[s] ?? []));
91
- const levels = [
92
- ...scenes,
93
- ...Object.keys(packed).filter((s) => s.toLowerCase().endsWith('.gltfx') && !reachedByScene.has(s)),
94
- ].sort();
95
- const closure = new Map(levels.map((s) => [s, new Set([s, ...(packed[s] ?? [])])]));
96
-
97
- // One pack per level, named after its file stem (`shared` is reserved; a repeated stem —
98
- // two `level.tscn` in different directories — gets a suffix).
99
- const used = new Set(['shared']);
100
- const packOf = new Map<string, Pack>();
101
- for (const level of levels) {
102
- let name = stem(level);
103
- for (let n = 2; used.has(name); n++) name = `${stem(level)}-${n}`;
104
- used.add(name);
105
- packOf.set(level, { name, owns: [] });
106
- }
110
+ export const PACK_DOC = 'pack.json';
107
111
 
108
- const shared: string[] = [];
109
- const sources = new Set([...Object.keys(packed), ...Object.values(packed).flat()]);
110
- for (const source of sources) {
111
- const owners = levels.filter((level) => closure.get(level)!.has(source));
112
- if (owners.length === 1) packOf.get(owners[0])!.owns.push(source);
113
- else shared.push(source);
114
- }
115
- return [
116
- { name: 'shared', owns: shared.sort() },
117
- ...[...packOf.values()]
118
- .filter((pack) => pack.owns.length > 0)
119
- .map((pack) => ({ ...pack, owns: pack.owns.sort() })),
120
- ];
112
+ /** The release document: which version of which pack a client mounts. */
113
+ export const RELEASE = 'release.json';
114
+
115
+ /** A pack's directory of loose products, beside its document. */
116
+ export const FILES_DIR = 'files';
117
+
118
+ /** The bake's output directory, project-relative, unless {@link PACK_DIR_ENV} names another. */
119
+ export const PACK_DIR = '.godot/packs';
120
+
121
+ /** The environment variable that overrides {@link PACK_DIR}. */
122
+ export const PACK_DIR_ENV = 'GODOT_PACKS';
123
+
124
+ /** The boot pack's `.pck`, in the bake's output directory. */
125
+ export const BOOT_PCK = 'boot.pck';
126
+
127
+ /** The boot pack's document — the `needs`/`stage` every document carries, for the bake's own pack. */
128
+ export const BOOT_MANIFEST = 'packs.json';
129
+
130
+ /** The generated module an app's bundle imports for the boot pack (`{ mounting }` + its config). */
131
+ export const BOOT_MODULE = 'boot-pack.js';
132
+
133
+ /**
134
+ * One pack, as a document: what the pack **declares** (`name`/`version`/`dependencies`/`exports` — its
135
+ * own `package.json`) plus the `needs`/`stage` a client stages from. Both kinds of pack carry the
136
+ * *same* document: a content pack's is emitted beside its `.pck`, the boot pack's is `<out>/packs.json`.
137
+ * So the bake's writer, the preload plugin's build-time read and the runtime's fetch all read one
138
+ * shape.
139
+ */
140
+ export interface PackDoc {
141
+ /** Declared package name (the boot pack is the app's own, and declares none). */
142
+ name?: string;
143
+ /** Declared version (the release pins the one a client mounts). */
144
+ version?: string;
145
+ /** Declared dependencies: package name → version range. */
146
+ dependencies?: Record<string, string>;
147
+ /** Declared entries verbatim — a subpath → its conditions, one of which is `godot`. */
148
+ exports?: Exports;
149
+ /** Packed source → the loose files it needs (paths, whoever owns them). */
150
+ needs: Record<string, string[]>;
151
+ /** A loose source this pack owns → the sidecar text and the product it remaps to. */
152
+ stage: Record<string, { path: string; import: string }>;
121
153
  }
122
154
 
155
+ /** A package manifest's `exports`, as far as this project reads it: a subpath → its conditions. */
156
+ export type Exports = Record<string, string | { [condition: string]: string | undefined }> | undefined;
157
+
123
158
  /**
124
- * Bakes the packs (see {@link splitPacks}) and writes the runtime manifest: import, dump, split,
125
- * then one preset and one `.pck` per pack. A project with no scene under its roots gets a single
126
- * `shared.pck` and the same manifest.
159
+ * A pack document, validated: its manifest half (`needs`/`stage`) must be there — a document without
160
+ * one would stage nothing and show up much later as missing assets. `null` when it isn't a pack.
127
161
  */
128
- export async function buildAssetPacks(opts: BuildAssetPackOptions = {}): Promise<void> {
129
- const editor = opts.godot ?? (await resolveEditor());
130
- const cwd = process.cwd();
131
- const roots = opts.roots ?? ['res://src'];
132
- const out = opts.out ?? '.godot/packs';
133
- const base = opts.preset ?? 'Pack';
134
- const prefix = `${base}: `;
135
- const presets = resolve(cwd, 'export_presets.cfg');
136
- const cfg = readFileSync(presets, 'utf8');
137
- const basePreset = presetBlock(cfg, base);
138
- mkdirSync(resolve(cwd, out), { recursive: true });
139
-
140
- const run = runner(editor, cwd);
141
- run(['--headless', '--path', '.', '--import']);
142
- // `dump-deps.gd` reads a level's plan for the refs no resource edge exposes, so its `loose` lists
143
- // carry the level's whole closure with or without a baked scene.
144
- const { packed, loose } = dump(run, cwd, roots);
145
- const packs = splitPacks(packed);
146
-
147
- // One preset per pack, all written from the project's own base preset before any export: each
148
- // excludes exactly the sources the other packs own (see the module comment).
149
- const original = pruneGenerated(cfg, prefix);
150
- const first = (original.match(/^\[preset\.\d+\]/gm) ?? []).length;
151
- const baseExcludes = excludeFilter(basePreset);
152
- const generated = packs.map((pack, i) =>
153
- presetFor(basePreset, first + i, `${prefix}${pack.name}`, excludesFor(baseExcludes, packs, pack)),
154
- );
155
- try {
156
- writeFileSync(presets, `${original.trimEnd()}\n\n${generated.join('\n\n')}\n`);
157
- const manifest: PackFile[] = [];
158
- for (const { name, owns } of packs) {
159
- const pck = `${out}/${name}.pck`;
160
- run(['--headless', '--path', '.', '--export-pack', `${prefix}${name}`, pck]);
161
- manifest.push({ name, pck: resPath(pck), owns });
162
- }
163
- writeFileSync(
164
- resolve(cwd, out, 'packs.json'),
165
- `${JSON.stringify({ packs: manifest, loose } satisfies PackManifest, null, '\t')}\n`,
166
- );
167
- const sources = manifest.reduce((n, pack) => n + pack.owns.length, 0);
168
- console.log(`[pack] ${manifest.length} packs (${manifest.map((p) => `${p.name}:${p.owns.length}`).join(', ')}) over ${sources} sources`);
169
- } finally {
170
- // Derived — leave the project's own file as it was, so a bake does not churn a tracked,
171
- // editor-managed file.
172
- writeFileSync(presets, original);
173
- }
162
+ function packDoc(document: unknown): PackDoc | null {
163
+ const doc = document as PackDoc | null;
164
+ return doc && doc.needs && doc.stage ? doc : null;
174
165
  }
175
166
 
176
- /** A `spawnSync` runner that throws on a non-zero editor exit. */
177
- function runner(editor: string, cwd: string): Run {
178
- return (args, env) => {
179
- const r = spawnSync(editor, args, { cwd, stdio: 'inherit', env: { ...process.env, ...env } });
180
- if ((r.status ?? 1) !== 0) {
181
- throw new Error(`godot-assets: \`${editor} ${args.join(' ')}\` failed (${r.status})`);
182
- }
183
- };
167
+ /** A document's text, as the shape it is: throws when the text is not a pack document. */
168
+ export function parseDoc(text: string): PackDoc {
169
+ const doc = packDoc(JSON.parse(text) as unknown);
170
+ if (!doc) throw new Error('godot-assets: the document carries no manifest half (`needs`/`stage`).');
171
+ return doc;
184
172
  }
185
173
 
186
- /** Runs `dump-deps.gd`: every source's packed closure, and the loose textures it needs. */
187
- function dump(run: Run, cwd: string, roots: string[]): { packed: Closures; loose: Closures } {
188
- copyFileSync(resolve(HERE, 'dump-deps.gd'), resolve(cwd, '.godot', 'pack-dump-deps.gd'));
189
- run(['--headless', '--path', '.', '--script', 'res://.godot/pack-dump-deps.gd'], {
190
- GODOT_DUMP_OUT: resPath(DUMP),
191
- GODOT_DUMP_ROOTS: roots.join(','),
192
- });
193
- const abs = resolve(cwd, DUMP);
194
- if (!existsSync(abs)) throw new Error(`godot-assets: the dump wrote no ${DUMP} (see dump-deps.gd).`);
195
- const sources = JSON.parse(readFileSync(abs, 'utf8')) as Record<string, { packed: string[]; loose: string[] }>;
196
- const entries = Object.entries(sources);
197
- return {
198
- packed: Object.fromEntries(entries.map(([source, s]) => [source, s.packed])),
199
- loose: Object.fromEntries(entries.map(([source, s]) => [source, s.loose])),
200
- };
174
+ /** A document as it is written: one tab-indented JSON document, with a trailing newline. */
175
+ export function serializeDoc(value: PackDoc | Release): string {
176
+ return `${JSON.stringify(value, null, '\t')}\n`;
201
177
  }
202
178
 
203
- const resPath = (rel: string) => `res://${rel.replace(/^res:\/\//, '')}`;
204
-
205
- /** A pack's `exclude_filter`: the base preset's own excludes plus every source the others own. */
206
- function excludesFor(base: string[], packs: Pack[], mine: Pack): string[] {
207
- const own = new Set(mine.owns);
208
- return [
209
- ...base,
210
- ...packs
211
- .flatMap((pack) => pack.owns)
212
- .filter((source) => !own.has(source))
213
- .map((source) => source.replace(/^res:\/\//, '')),
214
- ];
179
+ /**
180
+ * The `res://` path one declared entry names — the subpath's `godot` condition. A pack declares its
181
+ * entries once, in its `package.json`; the bake copies that declaration into the document it emits, so
182
+ * an entry resolves against the same statement the pack was built from. Internal: `resolveEntry` is
183
+ * how a client reaches it, and `packEntries` is the entry vocabulary's public face.
184
+ */
185
+ function godotEntry(exports: Exports, entry: string): string | undefined {
186
+ const value = exports?.[`./${entry.replace(/^\.\//, '')}`];
187
+ return typeof value === 'object' ? value.godot : undefined;
215
188
  }
216
189
 
217
- /** The preset whose `name=` matches, as its raw `[preset.N]` … block (options included). */
218
- function presetBlock(cfg: string, name: string): string {
219
- const m = new RegExp(`^name=${JSON.stringify(name)}$`, 'm').exec(cfg);
220
- if (!m) throw new Error(`godot-assets: no "${name}" preset in export_presets.cfg (see export_presets.example.cfg).`);
221
- const rest = cfg.slice(cfg.lastIndexOf('[preset.', m.index));
222
- // The next *preset* header — not this one's `[preset.N.options]`.
223
- const end = rest.search(/\n\[preset\.\d+\]/);
224
- return (end < 0 ? rest : rest.slice(0, end)).trimEnd();
190
+ /** One declared entry, resolved: its name, the `res://` path it names, and that path's class. */
191
+ export interface PackEntry {
192
+ entry: string;
193
+ res: string;
194
+ cls: string;
225
195
  }
226
196
 
227
- /** A preset block's `exclude_filter` entries. */
228
- function excludeFilter(block: string): string[] {
229
- const line = /^exclude_filter="([^"]*)"/m.exec(block);
230
- return (line?.[1] ?? '').split(',').map((s) => s.trim()).filter(Boolean);
197
+ /**
198
+ * A pack's declared entries, each with the class its path loads as — the one rule a declared entry,
199
+ * the types emitted for it and the module the plugin serves all read (`FORMATS` by extension, so a
200
+ * `.tscn` is a `PackedScene` everywhere). Entries that name no asset are skipped: they exist to
201
+ * declare something else.
202
+ */
203
+ export function packEntries(exports: Exports): PackEntry[] {
204
+ const entries: PackEntry[] = [];
205
+ for (const [subpath, declared] of Object.entries(exports ?? {})) {
206
+ const res = typeof declared === 'object' ? declared.godot : undefined;
207
+ const cls = res ? classOf(res) : undefined;
208
+ if (res && cls) entries.push({ entry: subpath.replace(/^\.\//, ''), res, cls });
209
+ }
210
+ return entries;
231
211
  }
232
212
 
233
- /** A pack's preset: the base preset's own (platform + options), renamed and re-excluded. */
234
- function presetFor(base: string, index: number, name: string, exclude: string[]): string {
235
- return base
236
- .replace(/^\[preset\.\d+\]/, `[preset.${index}]`)
237
- .replace(/\[preset\.\d+\.options\]/, `[preset.${index}.options]`)
238
- .replace(/^name="[^"]*"/m, `name=${JSON.stringify(name)}`)
239
- .replace(/^exclude_filter="[^"]*"/m, `exclude_filter=${JSON.stringify(exclude.join(','))}`);
213
+ /** An entry name as the identifier both the served module and its emitted types use (`my-level` → `my_level`). */
214
+ export const entryId = (entry: string) => entry.replace(/[^A-Za-z0-9_$]/g, '_');
215
+
216
+ /**
217
+ * A loose source's staging descriptor, read from its `.import` sidecar: the product it serves — the
218
+ * first `deps/dest_files` entry, or the single `[remap] path` an importer that writes only that serves
219
+ * it as — and the sidecar text itself. A client has no source tree to read either from, so the pack
220
+ * carries them. The bake and the preload plugin both walk a sidecar with this one rule, so a source
221
+ * can never stage in one place and not the other.
222
+ */
223
+ export function descriptorOf(sidecar: string): { path: string; import: string } | null {
224
+ const dests = /^dest_files=\[([^\]]*)\]/m.exec(sidecar)?.[1];
225
+ const path = dests ? /"([^"]+)"/.exec(dests)?.[1] : /^path="([^"]+)"/m.exec(sidecar)?.[1];
226
+ return path ? { path, import: sidecar } : null;
240
227
  }
241
228
 
242
- /** Drops the presets this tool generated in an earlier run (so re-bakes are idempotent). */
243
- function pruneGenerated(cfg: string, prefix: string): string {
244
- const generated = new RegExp(`^name="${prefix}[^"]*"$`, 'm');
245
- return cfg.split(/(?=\[preset\.\d+\])/).filter((block) => !generated.test(block)).join('');
229
+ /**
230
+ * The two files one loose source stages: the sidecar the engine looks for, and the product path it
231
+ * remaps to. Stated once because the bake (which emits a module's source) and the runtime (which builds
232
+ * the files a loader stages) both derive them from the same descriptor, and a rule told twice is a rule
233
+ * that can be told differently.
234
+ */
235
+ export function stagedKeys(loose: string, descriptor: { path: string }): { sidecar: string; product: string } {
236
+ return { sidecar: `${loose}.import`, product: descriptor.path };
246
237
  }
247
238
 
248
- /** Parses `godot pack` CLI args into {@link BuildAssetPackOptions}. */
249
- export function parsePackArgs(args: string[]): BuildAssetPackOptions {
250
- const opt = (flag: string): string | undefined => {
251
- const i = args.indexOf(flag);
252
- return i >= 0 ? args[i + 1] : undefined;
253
- };
254
- return {
255
- roots: opt('--roots')?.split(',').filter(Boolean),
256
- out: opt('--out'),
257
- preset: opt('--preset'),
258
- godot: opt('--godot'),
239
+ /**
240
+ * A file to stage before loading, keyed by `res://` path: a sidecar (`.import`) arrives as `content`
241
+ * (text), an imported product (`.ctex`/`.scn`) as `path` (fetched).
242
+ */
243
+ export interface StagedFile {
244
+ content?: string;
245
+ path?: string;
246
+ }
247
+
248
+ /** The files one load stages, keyed by `res://` path. */
249
+ export type StagedFiles = Record<string, StagedFile>;
250
+
251
+ /**
252
+ * A declared entry, resolved: the packs to mount, the `res://` path it names, and the loose files to
253
+ * stage before loading it. Nothing here is a URL — addressing is the caller's.
254
+ */
255
+ export interface ResolvedEntry {
256
+ /** The packs to mount, in mount order; version-addressed, so each is immutable and cacheable. */
257
+ mounts: Array<{ name: string; version: string }>;
258
+ /** The `res://` path the entry names. */
259
+ res: string;
260
+ /** What to stage before loading `res`: one entry per loose file the entry needs. */
261
+ files: Array<{
262
+ /** The sidecar's key — where the engine looks for it (`<loose>.import`) — and its text. */
263
+ sidecar: string;
264
+ content: string;
265
+ /** The product's key: the `res://` path it is looked up by. */
266
+ product: string;
267
+ /** The pack the product's bytes come from — its name, or `null` for the app's own pack, which declares none. */
268
+ pack: string | null;
269
+ /** The product's file name inside that pack. */
270
+ file: string;
271
+ }>;
272
+ }
273
+
274
+ /**
275
+ * Resolves one declared entry to what must happen before it loads: the release's closure with the
276
+ * version of each pack, the `res://` path the entry names, and the loose files that path needs, each
277
+ * owned by whichever mounted pack carries its product — the descriptor is looked up across the stated
278
+ * documents, so a pack borrows another's textures without carrying them. `boot` is the app's own pack,
279
+ * which declares no name and is staged from like any other.
280
+ */
281
+ export function resolveEntry(
282
+ release: Release,
283
+ docs: Record<string, PackDoc>,
284
+ pack: string,
285
+ entry: string,
286
+ boot?: PackDoc,
287
+ ): ResolvedEntry {
288
+ const names = mountClosure(release, pack);
289
+ const own = docs[pack];
290
+ if (!own) throw new Error(`godot-assets: no document for ${pack}, which declares the entry.`);
291
+
292
+ // Ownership over the whole mounted set: the app's own pack last, so it wins where both own a file.
293
+ const owned = new Map<string, { path: string; import: string; pack: string | null; file: string }>();
294
+ const claim = (doc: PackDoc, owner: string | null) => {
295
+ for (const [loose, descriptor] of Object.entries(doc.stage)) {
296
+ owned.set(loose, { ...descriptor, pack: owner, file: fileOf(descriptor.path) });
297
+ }
259
298
  };
299
+ for (const name of names) {
300
+ const doc = docs[name];
301
+ if (!doc) throw new Error(`godot-assets: no document for ${name}, which the release mounts.`);
302
+ claim(doc, name);
303
+ }
304
+ if (boot) claim(boot, null);
305
+
306
+ const res = godotEntry(own.exports, entry);
307
+ if (!res) throw new Error(`godot-assets: ${pack} declares no entry ${entry}.`);
308
+ const files = (own.needs[res] ?? []).flatMap((loose) => {
309
+ const descriptor = owned.get(loose);
310
+ // A loose file no mounted pack owns is skipped, not staged as a dangling reference.
311
+ if (!descriptor) return [];
312
+ const { sidecar, product } = stagedKeys(loose, descriptor);
313
+ return [{ sidecar, content: descriptor.import, product, pack: descriptor.pack, file: descriptor.file }];
314
+ });
315
+ return { mounts: names.map((name) => ({ name, version: release[name].version })), res, files };
316
+ }
317
+
318
+ /** The file name a product path ends in — what a pack stages it under in its {@link FILES_DIR}. */
319
+ function fileOf(path: string): string {
320
+ return path.split('/').pop()!;
260
321
  }