@ringozz/godot 4.7.2-659 → 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,261 +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
- import { mergePlanRefs, planRefs } from './planRefs.ts';
11
-
12
- // Bakes a Godot project's assets for the runtime: imports them, then exports one native
13
- // `.pck` **per scene** plus a shared one, so a host fetches the shared data and only the
14
- // level it is playing. `dump-deps.gd` supplies both inputs (no host-side scanning): each
15
- // source's packed closure — which decides what a pack owns — and the loose (WebP-backed)
16
- // 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.
17
11
  //
18
- // The result the runtime reads is one file beside the packs: `<out>/packs.json`, holding
19
- // each pack (its path, whether it mounts always, and the sources it owns) plus those loose
20
- // texture lists. `preload.ts` reads it to mount the right packs per asset.
21
- //
22
- // The project owns `export_presets.cfg` (a committed, editor-managed file), and the scoping
23
- // has to live there: `--export-pack` runs against a preset. Per-pack scoping is therefore an
24
- // `exclude_filter` — the repo's `all_resources` convention, which ignores `include_filter`
25
- // and, unlike the selected modes, never pulls dependencies back in — so each pack excludes
26
- // exactly the sources the others own (see `splitPacks`). Those presets are cloned from the
27
- // project's own base preset, one written at a time, and the file is restored when the bake
28
- // ends: they are derived and the file is tracked.
29
-
30
- const HERE = dirname(fileURLToPath(import.meta.url));
31
-
32
- /** The dump's one build-time output, merged into the manifest beside the packs. */
33
- const DUMP = '.godot/pack-dump.json';
34
-
35
- export interface BuildAssetPackOptions {
36
- /** res:// roots whose assets are baked (default `['res://src']`). */
37
- roots?: string[];
38
- /** Output directory for the packs + manifest, project-relative (default `.godot/packs`). */
39
- out?: string;
40
- /** The `export_presets.cfg` preset the generated pack presets derive from (default `Pack`). */
41
- preset?: string;
42
- /** Editor executable override (default: `resolveEditor()`). */
43
- 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[];
23
+ }
24
+
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).');
44
61
  }
45
62
 
46
- /** A pack's assignment: its name, whether it mounts always, and the sources it owns. */
47
- interface AssetPackSplit {
48
- /** `shared`, or a scene's file stem. */
49
- name: string;
50
- /** Mount for every asset (the shared pack). */
51
- always?: boolean;
52
- /** The packable sources this pack owns — the lookup the plugin mounts by. */
53
- owns: string[];
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';
54
66
  }
55
67
 
56
- /** One baked pack as the runtime sees it. */
57
- interface AssetPackManifestEntry extends AssetPackSplit {
58
- /** `res://` path of the `.pck`, for `loadAssetPack`. */
59
- 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 ?? {});
60
71
  }
61
72
 
62
- /** The runtime manifest beside the packs: what to mount, and what to stage per file. */
63
- export interface AssetPackManifest {
64
- packs: AssetPackManifestEntry[];
65
- /** Packed source → the loose texture sources it needs at runtime (from `dump-deps.gd`). */
66
- deps: 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;
67
88
  }
68
89
 
69
- /** Each packed source's packed closure, as `dump-deps.gd` reports it. */
70
- interface Graph {
71
- [source: string]: string[];
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}`;
72
93
  }
73
94
 
74
- /** The two build-time outputs of `dump-deps.gd`. */
75
- interface Dump {
76
- graph: Graph;
77
- deps: 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`;
78
100
  }
79
101
 
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).
104
+
80
105
  /**
81
- * The ownership partition, from the dependency graph. A **level is a scene** (`.tscn`) among
82
- * the scanned sources — a project's levels are its scenes, so nothing declares them — and:
83
- * **a source exactly one scene reaches is that scene's** (so a model only one level uses
84
- * rides its pack, though it lives in a shared asset tree), while **a source several scenes
85
- * reach, or none does, is shared**. A scene whose own pack would be empty is dropped: every
86
- * source it reaches is reached by another scene too, so it is already in `shared`, and a pack
87
- * is mounted by *owner*.
88
- *
89
- * The graph it reads is the dump's *plus* every level's plan refs (`mergePlanRefs`), so a level's
90
- * terrain, its layered containers and their layers, and its ground/environment textures are part of
91
- * its closure whether or not a baked scene names them. When a project stops baking one scene per
92
- * level — a level built from its plan at run time is a single shared scene plus the plan — the level
93
- * unit becomes the `.gltfx` its plan lives in instead; until then both units exist and this keying
94
- * 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.
95
109
  */
96
- export function splitPacks(graph: Graph): AssetPackSplit[] {
97
- const stem = (asset: string) =>
98
- asset.slice(asset.lastIndexOf('/') + 1).replace(/\.(prefab|level)\.[^.]+$/i, '').replace(/\.[^.]+$/, '');
99
- // A level is its scene — and, in a project that builds levels from their plans at run time, the
100
- // `.gltfx` its plan lives in. A `.gltfx` a scene **reaches** is that scene's composition, not a
101
- // second level: keying both would make every source of a baked level look shared.
102
- const scenes = Object.keys(graph).filter((s) => s.toLowerCase().endsWith('.tscn')).sort();
103
- const reachedByScene = new Set(scenes.flatMap((s) => graph[s] ?? []));
104
- const levels = [
105
- ...scenes,
106
- ...Object.keys(graph).filter((s) => s.toLowerCase().endsWith('.gltfx') && !reachedByScene.has(s)),
107
- ].sort();
108
- const closure = new Map(levels.map((s) => [s, new Set([s, ...(graph[s] ?? [])])]));
109
-
110
- // One pack per level, named after its file stem (`shared` is reserved; a repeated stem —
111
- // two `level.tscn` in different directories — gets a suffix).
112
- const used = new Set(['shared']);
113
- const packOf = new Map<string, AssetPackSplit>();
114
- for (const level of levels) {
115
- let name = stem(level);
116
- for (let n = 2; used.has(name); n++) name = `${stem(level)}-${n}`;
117
- used.add(name);
118
- packOf.set(level, { name, owns: [] });
119
- }
110
+ export const PACK_DOC = 'pack.json';
120
111
 
121
- const shared: string[] = [];
122
- const sources = new Set([...Object.keys(graph), ...Object.values(graph).flat()]);
123
- for (const source of sources) {
124
- const owners = levels.filter((level) => closure.get(level)!.has(source));
125
- if (owners.length === 1) packOf.get(owners[0])!.owns.push(source);
126
- else shared.push(source);
127
- }
128
- shared.sort();
129
- return [
130
- { name: 'shared', always: true, owns: shared },
131
- ...[...packOf.values()]
132
- .filter((pack) => pack.owns.length > 0)
133
- .map((pack) => ({ ...pack, owns: pack.owns.sort() })),
134
- ];
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 }>;
135
153
  }
136
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
+
137
158
  /**
138
- * Bakes the packs (see {@link splitPacks}) and writes the runtime manifest. A project with no
139
- * scene under its roots gets a single `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.
140
161
  */
141
- export async function buildAssetPacks(opts: BuildAssetPackOptions = {}): Promise<void> {
142
- const editor = opts.godot ?? (await resolveEditor());
143
- const cwd = process.cwd();
144
- const roots = opts.roots ?? ['res://src'];
145
- const out = opts.out ?? '.godot/packs';
146
- const base = opts.preset ?? 'Pack';
147
- const prefix = `${base}: `;
148
- const presets = resolve(cwd, 'export_presets.cfg');
149
-
150
- const cfg = readFileSync(presets, 'utf8');
151
- const basePreset = presetBlock(cfg, base);
152
- const baseExcludes = excludeFilter(basePreset);
153
- mkdirSync(resolve(cwd, out), { recursive: true });
154
-
155
- const run = runner(editor, cwd);
156
- run(['--headless', '--path', '.', '--import']);
157
- const { graph, deps } = dump(run, cwd, roots);
158
- // A level's plan names what no scene does any more — its terrain directory, its layered containers
159
- // and their layers, its ground/environment textures (`planRefs`). Merged here so the graph and the
160
- // loose lists carry the level's whole closure with or without a baked scene.
161
- mergePlanRefs(graph, deps, planRefs(cwd, roots));
162
-
163
- const packs = splitPacks(graph);
164
- const owned = packs.flatMap((pack) => pack.owns);
165
- const manifest: AssetPackManifestEntry[] = [];
166
-
167
- // The project's own presets, with any this tool generated in an earlier run dropped.
168
- const original = pruneGenerated(cfg, prefix);
169
- const index = (original.match(/^\[preset\.\d+\]/gm) ?? []).length;
170
- try {
171
- for (const { name, always, owns } of packs) {
172
- const exclude = [
173
- ...baseExcludes,
174
- ...owned.filter((source) => !owns.includes(source)).map((source) => source.replace(/^res:\/\//, '')),
175
- ];
176
- writeFileSync(presets, `${original.trimEnd()}\n\n${presetFor(basePreset, index, `${prefix}${name}`, exclude)}\n`);
177
- const pck = `${out}/${name}.pck`;
178
- run(['--headless', '--path', '.', '--export-pack', `${prefix}${name}`, pck]);
179
- manifest.push({ name, pck: resPath(pck), ...(always ? { always } : {}), owns });
180
- }
181
- } finally {
182
- // Derived — leave the project's own file as it was, so a bake does not churn a tracked,
183
- // editor-managed file.
184
- writeFileSync(presets, original);
185
- }
186
- writeFileSync(resolve(cwd, out, 'packs.json'), `${JSON.stringify({ packs: manifest, deps } satisfies AssetPackManifest, null, '\t')}\n`);
187
- console.log(`[pack] ${manifest.length} packs (${manifest.map((p) => `${p.name}:${p.owns.length}`).join(', ')}) over ${owned.length} sources`);
162
+ function packDoc(document: unknown): PackDoc | null {
163
+ const doc = document as PackDoc | null;
164
+ return doc && doc.needs && doc.stage ? doc : null;
188
165
  }
189
166
 
190
- /** A `spawnSync` runner that throws on a non-zero editor exit. */
191
- function runner(editor: string, cwd: string) {
192
- return (args: string[], env?: Record<string, string>) => {
193
- const r = spawnSync(editor, args, { cwd, stdio: 'inherit', env: { ...process.env, ...env } });
194
- if ((r.status ?? 1) !== 0) {
195
- throw new Error(`godot-assets: \`${editor} ${args.join(' ')}\` failed (${r.status})`);
196
- }
197
- };
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;
198
172
  }
199
173
 
200
- /** Runs `dump-deps.gd` and splits its output into the graph and the loose-texture lists. */
201
- function dump(run: (args: string[], env?: Record<string, string>) => void, cwd: string, roots: string[]): Dump {
202
- copyFileSync(resolve(HERE, 'dump-deps.gd'), resolve(cwd, '.godot', 'pack-dump-deps.gd'));
203
- run(['--headless', '--path', '.', '--script', 'res://.godot/pack-dump-deps.gd'], {
204
- GODOT_DUMP_OUT: resPath(DUMP),
205
- GODOT_DUMP_ROOTS: roots.join(','),
206
- });
207
- const abs = resolve(cwd, DUMP);
208
- if (!existsSync(abs)) throw new Error(`godot-assets: the dump wrote no ${DUMP} (see dump-deps.gd).`);
209
- const sources = JSON.parse(readFileSync(abs, 'utf8')) as Record<string, { packed: string[]; loose: string[] }>;
210
- const entries = Object.entries(sources);
211
- return {
212
- graph: Object.fromEntries(entries.map(([source, s]) => [source, s.packed])),
213
- deps: Object.fromEntries(entries.map(([source, s]) => [source, s.loose])),
214
- };
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`;
215
177
  }
216
178
 
217
- const resPath = (rel: string) => `res://${rel.replace(/^res:\/\//, '')}`;
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;
188
+ }
218
189
 
219
- /** The preset whose `name=` matches, as its raw `[preset.N]` … block (options included). */
220
- function presetBlock(cfg: string, name: string): string {
221
- const m = new RegExp(`^name=${JSON.stringify(name)}$`, 'm').exec(cfg);
222
- if (!m) throw new Error(`godot-assets: no "${name}" preset in export_presets.cfg (see export_presets.example.cfg).`);
223
- const rest = cfg.slice(cfg.lastIndexOf('[preset.', m.index));
224
- // The next *preset* header — not this one's `[preset.N.options]`.
225
- const end = rest.search(/\n\[preset\.\d+\]/);
226
- 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;
227
195
  }
228
196
 
229
- /** A preset block's `exclude_filter` entries. */
230
- function excludeFilter(block: string): string[] {
231
- const line = /^exclude_filter="([^"]*)"/m.exec(block);
232
- 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;
233
211
  }
234
212
 
235
- /** A pack's preset: the base preset's own (platform + options), renamed and re-excluded. */
236
- function presetFor(base: string, index: number, name: string, exclude: string[]): string {
237
- return base
238
- .replace(/^\[preset\.\d+\]/, `[preset.${index}]`)
239
- .replace(/\[preset\.\d+\.options\]/, `[preset.${index}.options]`)
240
- .replace(/^name="[^"]*"/m, `name=${JSON.stringify(name)}`)
241
- .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;
242
227
  }
243
228
 
244
- /** Drops the presets this tool generated in an earlier run (so re-bakes are idempotent). */
245
- function pruneGenerated(cfg: string, prefix: string): string {
246
- const generated = new RegExp(`^name="${prefix}[^"]*"$`, 'm');
247
- 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 };
248
237
  }
249
238
 
250
- /** Parses `godot pack` CLI args into {@link BuildAssetPackOptions}. */
251
- export function parsePackArgs(args: string[]): BuildAssetPackOptions {
252
- const opt = (flag: string): string | undefined => {
253
- const i = args.indexOf(flag);
254
- return i >= 0 ? args[i + 1] : undefined;
255
- };
256
- return {
257
- roots: opt('--roots')?.split(',').filter(Boolean),
258
- out: opt('--out'),
259
- preset: opt('--preset'),
260
- 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
+ }
261
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()!;
262
321
  }