@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/README.md CHANGED
@@ -52,8 +52,9 @@ await import('./app.ts'); // may now import @ringozz/godot / react-godot freely
52
52
  | `@ringozz/godot/ClassName` | Classes, one per module — e.g. `@ringozz/godot/Label`, `@ringozz/godot/Node` |
53
53
  | `@ringozz/godot/runtime` | Low-level dispatch: `_C`, `_S`, `_R`, `_G`, `_P`, `_V`, `toValueType`, `getGodot`, `GodotVar` |
54
54
  | `@ringozz/godot/boot` | Platform boot: `getNativeModule()`; web `preloadGodot()` (awaited before importing the app; no-op on desktop) |
55
- | `@ringozz/godot/load` | Async resource loading: `loadResourceAsync`, `loadAsset`, `loadAssetPack` (native `.pck` packs) |
56
- | `@ringozz/godot/pack` | `buildAssetPacks()` + the `godot pack` subcommand: bake a project's `.pck`(s) + loose-texture manifest, one pack per level (its scene, or the `.gltfx` its plan lives in) |
55
+ | `@ringozz/godot/load` | Async resource loading: `loadResourceAsync`, `loadAsset`, `loadAssetPack` (native `.pck` packs), `configurePacks`/`loadPackEntry` (content packs) |
56
+ | `@ringozz/godot/bake` | `buildAssetPacks(opts, run?)` + the `godot pack` subcommand: bake the project's boot `.pck` plus one per declared content pack, with their loose-texture documents. `opts.cwd` picks the project root and `run` replaces the editor, so a test can bake a temp project |
57
+ | `@ringozz/godot/pack` | The pack protocol the bake writes and the plugin/runtime read: `PACK_DOC`/`RELEASE`/`FILES_DIR` and the boot's names, `parseDoc`/`serializeDoc`/`parseRelease`, `packEntries`/`godotEntry`/`entryId`/`packPath`/`pckOf`, and `resolveEntry` (what to mount and stage for one entry) |
57
58
  | `@ringozz/godot/debug` | `initDebug()`, `dumpStr`, `snapshot`, `inspect`, `click`, `find`, `gc` |
58
59
 
59
60
  Class-scoped enums use bare names (import `ProcessMode` from `@ringozz/godot/Node`, not `NodeProcessMode`).
@@ -91,19 +92,37 @@ const tex = await loadResourceAsync('res://textures/icon.jpg', Texture2D); //
91
92
  ```
92
93
 
93
94
  - `loadResourceAsync(path, cls, files?)` — generic over the Godot class: `cls.name` is the `ResourceLoader` type hint, and the return type is `InstanceType<typeof cls>`. Loads in the background (`loadThreadedRequest` + status polling). Godot default arguments apply: the napi dispatch truncates trailing `undefined` args, so `loadThreadedRequest` uses its default `CACHE_MODE_REUSE` and the resource stays cached in the engine (scene re-references don't re-read the file). On web it only works for paths already staged into MEMFS (generated asset modules handle this). When the asset's `files` map is passed, the staged product files (`.ctex`/`.scn`/native sources) are deleted from MEMFS after the resource loads — only the `.import` sidecars are kept, since they route imported source paths to their products. The decoded texture bytes don't linger in the JS heap. `files` lists overlap (a scene and the model it instantiates can share one loose texture), so a product may need re-staging after another module's cleanup: `materialize` creates the parent directory itself and treats an already-staged file as done, because the engine's `copyToFS` throws on a path that is already there while `FileAccess.fileExists('res://…')` cannot see the Emscripten spelling of it.
94
- - `loadAsset(path, cls, files, deps, data)` — the generated asset modules' entry point (also usable directly): returns `{ materialize, load }`. It checks the engine's `ResourceCache` first (when the asset is already loaded, `load` resolves to the cached instance and staging is skipped), stages the bundled files via Godot's `copyToFS` (zero-copy handover of the fetched bytes; a no-op on desktop where the files already exist), loads via `loadResourceAsync`, and memoizes `load` on `data` (pass `import.meta.hot.data`) so HMR re-evaluations reuse the same fulfilled promise.
95
+ - `loadAsset(path, cls, files, data, ready)` — the generated asset modules' entry point (also usable directly): returns `{ materialize, load }`. It checks the engine's `ResourceCache` first (when the asset is already loaded, `load` resolves to the cached instance and staging is skipped), stages the bundled files via Godot's `copyToFS` (zero-copy handover of the fetched bytes; a no-op on desktop where the files already exist), loads via `loadResourceAsync`, and memoizes `load` on `data` (pass `import.meta.hot.data`) so HMR re-evaluations reuse the same fulfilled promise.
95
96
 
96
97
  ### Asset packs
97
98
 
98
- `godot pack` (the `godot` bin, `@ringozz/godot/pack`) bakes a project's assets for the runtime: it imports them, asks `dump-deps.gd` for each source's packed closure and its loose (WebP-backed) textures, and exports native `.pck`s — the engine-converted binary resources plus a remap table — through the project's committed `export_presets.cfg`. `loadAssetPack(url)` fetches a pack into MEMFS and hands it to `ProjectSettings.loadResourcePack`; packs are add-only, so several can be mounted and `res://` resolves across all of them.
99
+ `godot pack` (the `godot` bin, `@ringozz/godot/bake`) bakes a project's assets for the runtime: it imports them, asks `dump-deps.gd` for every source's loose (WebP-backed) textures — including a plan, which names refs no resource edge exposes (it warns when a plan resolves no ref at all, or is stamped under another `extras` namespace — that reading has no static check) — and exports one native `.pck` — the engine-converted binary resources plus a remap table — through the project's committed `export_presets.cfg`. `loadAssetPack(url)` fetches a pack into MEMFS and hands it to `ProjectSettings.loadResourcePack`; packs are add-only, so several can be mounted and `res://` resolves across all of them.
99
100
 
100
- The bake always splits: **one pack per level, plus a shared pack**, so a host fetches the shared data and only the level it is playing. A level is its scene — or, in a project that builds levels from their plans at run time, the `.gltfx` its plan lives in; a project with neither under its roots gets a single `shared.pck`. What the runtime reads is `.godot/packs/packs.json` beside the packs: which packs exist, which sources each owns, and which loose sources the host must stage per file (the WebP-backed textures are transcoded; a plan's plain header JSONs are just written). The preload plugin mounts the right packs per asset, so importing a level pulls in the shared pack and that level's, nothing else. How the split is decided — and why it needs no configuration — is in `AGENTS.md`.
101
+ The bake produces **one boot pack** — the project's own sources, exported through its committed `export_presets.cfg` preset, and always mounted by the preload plugin, which is what makes a scene's own run-time paths (a script named by a constant, a level chosen at run time) resolvable — plus **one pack per directory under `dlc/` that declares itself with a `package.json`**: that file gives the pack its name, its dependencies on other packs and its `exports` entries, the pack is that directory and nothing else (the bake rewrites the preset's one `exclude_filter` line per pack), and the bake fails if a pack references anything but the boot sources and its declared dependencies. The boot pack's document is `.godot/packs/packs.json` (`needs` = which loose files each source needs staged, `stage` = the descriptor and product of each loose file the pack owns); a content pack's is a `pack.json` emitted beside its `.pck` (`PACK_DOC` — not `package.json`, which is the pack's own manifest in its source directory, an input the bake reads) (its declared fields plus `needs`/`stage` — the same document the boot's `packs.json` is). Deploying those packs independently — versioning, an index, a CDN — is the host's business, not the bake's.
101
102
 
102
103
  ```ts
103
104
  import { loadAssetPack } from '@ringozz/godot/load';
104
105
  await loadAssetPack('/packs/shared.pck'); // usually the preload plugin does this per asset
105
106
  ```
106
107
 
108
+ ### Content packs
109
+
110
+ A content pack is deployed apart from the app, so nothing about it is in the bundle: the runtime reads a **release** (which version of which pack to mount — a `bun.lock`, or the plain map a local bake emits) and each mounted pack's document, then loads a declared entry. Point the runtime at the packs once, at start-up:
111
+
112
+ ```ts
113
+ import { configurePacks } from '@ringozz/godot/load';
114
+ configurePacks({ at: (path) => `https://cdn.example/packs/${path}` }); // '<name>/<version>/<file>'
115
+ ```
116
+
117
+ The boot pack — the app's own sources, which ship beside it — needs no config: the bake renders it into a module (`.godot/packs/boot-pack.js`: its `.pck`, plus a bundled URL per loose product it owns), which every imported asset pulls in, and `configurePacks` merges, so the app's `at` and the boot's config may land in either order. A pack declares its entries (`exports` → the `godot` condition, the `res://` path) and ships **no entry module**: the preload plugin serves one, built from the bake's documents, with one named export per entry. So importing a pack is an ordinary import — and the bake emits the types it resolves to (`package.d.ts`, named by the pack's `types`):
118
+
119
+ ```ts
120
+ import { level } from '@tiv/dlc-beach'; // → a PackedScene, via loadPackEntry(pack, 'level', …)
121
+ ```
122
+
123
+ Mounting is add-only, so every pack in the closure stays mounted for the page's lifetime.
124
+
125
+
107
126
  ### Asset imports
108
127
 
109
128
  Asset imports are resolved by `@ringozz/godot/preload` (a Bun plugin, registered via `bunfig.toml` preload for `bun run`/`bun test` and via `[serve.static] plugins` for `Bun.serve`'s fullstack HTML routes; the module's default export is the single plugin object) into a module per asset. Only **JS imports** are virtualized — HTML asset references (`<link rel="icon">`, `<img src>`) and CSS `url()` refs fall through to Bun's normal (content-hashed) asset handling, so favicons and other web assets co-exist with Godot imports:
@@ -113,7 +132,7 @@ import foo from './models/foo.gltf';
113
132
  const scene = await foo; // PackedScene
114
133
  ```
115
134
 
116
- Each asset module's default export is its load **promise** (`const { materialize, load } = loadAsset(resPath, Class, files, deps, import.meta.hot.data)` — the default is `load`, memoized on the module's HMR data), started eagerly at module import, and it also exports a `materialize` promise (its own files plus its deps' files). The files Godot reads are bundled into the module — `.import` sidecars and native text sources (`tscn`/`tres`/`po`/`gd`/`gdshader`/`gdshaderinc`) are inlined as text, imported products (`.scn`/`.ctex`) are emitted as files — and the module imports its **dependencies as generated modules too**, waiting only for their `materialize` before loading itself (dep loads start at module evaluation and run concurrently). Text-based source assets (`.gltf`/`.tscn`/`.tres`/`.obj`/`.gd`/`.gdshader`/`.gdshaderinc`) are scanned for references (`res://` paths and relative paths ending in known asset extensions); referenced loadable assets become dep modules, native-text leaves are staged as raw text so the engine loads them by `res://` on web, raw `.mtl` files (OBJ materials) are recursed into, and raw `.bin` buffers are import-time only. The modules are virtual (namespace `godot`), and every import of the same asset resolves to the same one (evaluated once) — so the promise is a stable singleton, read with `await` or React 19's `use()`:
135
+ Each asset module's default export is its load **promise** (`const { materialize, load } = loadAsset(resPath, Class, files, import.meta.hot.data, mounting)` — the default is `load`, memoized on the module's HMR data), started eagerly at module import, and it also exports a `materialize` promise (its files staged). The files Godot reads are bundled into the module — `.import` sidecars are inlined as text, imported products (`.ctex`) are emitted as files — from the bake's document, never from scanning the source text: `dump-deps.gd` walked the project at bake time and wrote, per source, the loose (WebP-backed) texture sources the wasm engine cannot decode, and the pack's `stage` gives each of those its sidecar and its product. A packed source resolves inside the mounted `.pck` (the engine-converted resources plus a remap table); only its loose textures are staged per file, transcoded in the browser. The `mounting` promise comes from the bake's boot pack — the module the bake generates beside the packs (`.godot/packs/boot-pack.js`): its `.pck`, plus a bundled URL for every loose product it owns — so importing any asset is also what mounts the app's own pack, and the runtime can stage a content pack's loose files even when the boot owns them. The modules are virtual (namespace `godot`), and every import of the same asset resolves to the same one (evaluated once) — so the promise is a stable singleton, read with `await` or React 19's `use()`:
117
136
 
118
137
  ```tsx
119
138
  import foo from './models/foo.gltf';
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@ringozz/godot",
3
3
  "author": "Vladimir Davidovich",
4
- "version": "4.7.2-659",
4
+ "version": "4.7.2-664",
5
5
  "description": "Node-API bindings for Godot Engine — call Godot classes from JavaScript",
6
6
  "publishConfig": {
7
7
  "access": "public"
@@ -27,6 +27,7 @@
27
27
  },
28
28
  "./load": "./src/load.ts",
29
29
  "./pack": "./src/pack.ts",
30
+ "./bake": "./src/bake.ts",
30
31
  "./transcoder": {
31
32
  "browser": "./src/transcoder.browser.ts",
32
33
  "default": "./src/transcoder.ts"
@@ -66,9 +67,9 @@
66
67
  "precision": "single"
67
68
  },
68
69
  "optionalDependencies": {
69
- "@ringozz/godot-macos-arm64": "^4.7.2-659",
70
- "@ringozz/godot-windows-x86_64": "^4.7.2-659",
71
- "@ringozz/godot-web-wasm32": "^4.7.2-659"
70
+ "@ringozz/godot-macos-arm64": "^4.7.2-664",
71
+ "@ringozz/godot-windows-x86_64": "^4.7.2-664",
72
+ "@ringozz/godot-web-wasm32": "^4.7.2-664"
72
73
  },
73
74
  "peerDependencies": {
74
75
  "@types/bun": "*"
package/src/assets.d.ts CHANGED
@@ -30,7 +30,7 @@ declare module '*.gdshaderinc' {
30
30
  export default asset;
31
31
  export const materialize: Promise<unknown>;
32
32
  }
33
-
33
+
34
34
  declare module '*.tres' {
35
35
  import type { Resource } from '@ringozz/godot/Resource';
36
36
  const asset: Promise<Resource>;
@@ -108,6 +108,15 @@ declare module '*.svg' {
108
108
  export const materialize: Promise<unknown>;
109
109
  }
110
110
 
111
+ // The layer manifest `godot/addons/sourcewebp` imports into a `.ctexarray` (a texture array whose
112
+ // layers are source WebP bytes); its product is what is staged.
113
+ declare module '*.webparray' {
114
+ import type { CompressedTexture2DArray } from '@ringozz/godot/CompressedTexture2DArray';
115
+ const asset: Promise<CompressedTexture2DArray>;
116
+ export default asset;
117
+ export const materialize: Promise<unknown>;
118
+ }
119
+
111
120
  declare module '*.exr' {
112
121
  import type { TextureLayered } from '@ringozz/godot/TextureLayered';
113
122
  const asset: Promise<TextureLayered>;
package/src/bake.ts ADDED
@@ -0,0 +1,469 @@
1
+ /**********************************************************************
2
+ Copyright (c) Vladimir Davidovich. All rights reserved.
3
+ ***********************************************************************/
4
+
5
+ import { spawnSync } from 'node:child_process';
6
+ import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
7
+ import { dirname, join, resolve } from 'node:path';
8
+ import { fileURLToPath } from 'node:url';
9
+ import { resolveEditor } from './editor.ts';
10
+ import { packedExtensions, scannedExtensions } from './formats.ts';
11
+ import {
12
+ BOOT_MANIFEST,
13
+ BOOT_MODULE,
14
+ BOOT_PCK,
15
+ FILES_DIR,
16
+ PACK_DIR,
17
+ PACK_DOC,
18
+ RELEASE,
19
+ descriptorOf,
20
+ entryId,
21
+ packEntries,
22
+ pckOf,
23
+ serializeDoc,
24
+ type Exports,
25
+ type PackDoc,
26
+ type Release,
27
+ } from './pack.ts';
28
+
29
+ // Bakes a Godot project's assets for the runtime. Two kinds of pack, both native `.pck`s (the
30
+ // engine-converted binary resources + remap table) exported through the project's own committed
31
+ // `export_presets.cfg`:
32
+ //
33
+ // - **the boot pack**: the app's own sources (`src/`), exported with the preset, and always mounted
34
+ // by the runtime — a scene may load a path it assembles itself (a script named by a constant, a
35
+ // level chosen at run time), and only a mounted pack resolves those.
36
+ // - **content packs**: one per directory under `dlc/` that declares itself with a `package.json`.
37
+ // Each is exported on its own — the preset's `exclude_filter` is rewritten per pack, so a pack
38
+ // carries its own directory and nothing else — and carries the declared package fields plus the
39
+ // staging data the runtime needs, so it can be published and deployed apart from the app.
40
+ //
41
+ // A pack may reference the boot sources and the packs it declares as `dependencies`, never a sibling,
42
+ // and the bake fails otherwise: the runtime mounts a pack by import, so a reference "upward" or
43
+ // "sideways" is a reference that will not resolve.
44
+ //
45
+ // This is the bake half: everything that spawns the editor or touches the disk. What it writes is the
46
+ // pack protocol's (`pack.ts`), which the plugin and the runtime read without any of this.
47
+
48
+ const HERE = dirname(fileURLToPath(import.meta.url));
49
+
50
+ /** The dump's one build-time output, rewritten by every bake. */
51
+ const DUMP = '.godot/pack-dump.json';
52
+
53
+ export interface BuildAssetPackOptions {
54
+ /** The project root the bake reads and writes (default: `process.cwd()`). */
55
+ cwd?: string;
56
+ /** res:// roots whose assets the boot pack carries (default `['res://src']`). */
57
+ roots?: string[];
58
+ /** res:// root to discover content packs under (default `res://dlc`). */
59
+ dlc?: string;
60
+ /** Output directory for the packs + documents, project-relative (default {@link PACK_DIR}). */
61
+ out?: string;
62
+ /** The `export_presets.cfg` preset every pack exports with (default `Pack`). */
63
+ preset?: string;
64
+ /** Editor executable override (default: `resolveEditor()`). */
65
+ godot?: string;
66
+ }
67
+
68
+ /** A content pack: a directory that declares itself with a `package.json`. */
69
+ interface ContentPack {
70
+ /** The declared package name (`@tiv/packs/assets`), or the directory's own name. */
71
+ name: string;
72
+ /** Project-relative directory, no trailing slash (`dlc/assets`). */
73
+ dir: string;
74
+ /** Declared version, if any. */
75
+ version?: string;
76
+ /** Declared pack dependencies: package name → version range. */
77
+ dependencies: Record<string, string>;
78
+ /** The declared `exports` verbatim: how the pack names the entries a client may load. */
79
+ exports?: Exports;
80
+ }
81
+
82
+ /** A `spawnSync` runner for the editor — the bake's one injected dependency. */
83
+ export type Run = (args: string[], env?: Record<string, string>) => void;
84
+
85
+ /**
86
+ * Bakes the boot pack, then one pack per directory under `dlc/` that declares a `package.json`.
87
+ * Three editor runs per pack (import, dump, export); no project file is left changed.
88
+ *
89
+ * `run` is the seam: the editor by default, a stand-in in a test — so what a caller gets (the
90
+ * documents, the products beside them, the preset put back) can be asserted through this interface
91
+ * rather than through the helpers it is made of. Everything else is the filesystem, which `opts.cwd`
92
+ * can point at any root.
93
+ */
94
+ export async function buildAssetPacks(opts: BuildAssetPackOptions = {}, run?: Run): Promise<void> {
95
+ const cwd = opts.cwd ?? process.cwd();
96
+ const bootRoots = opts.roots ?? ['res://src'];
97
+ const out = opts.out ?? PACK_DIR;
98
+ const preset = opts.preset ?? 'Pack';
99
+ const exec = run ?? runner(opts.godot ?? (await resolveEditor()), cwd);
100
+ mkdirSync(resolve(cwd, out), { recursive: true });
101
+
102
+ // The editor's import scan must not walk the installed dependency tree: a workspace-linked pack
103
+ // would be imported a second time through the link, at a depth its relative paths (a terrain
104
+ // `.webparray`'s layer refs) do not survive. `.gdignore` is the editor's own skip marker; the
105
+ // bundler never looks at it, and the sources are imported once at their real `res://` paths.
106
+ const installed = resolve(cwd, 'node_modules');
107
+ if (existsSync(installed)) writeFileSync(join(installed, '.gdignore'), '');
108
+ exec(['--headless', '--path', '.', '--import']);
109
+
110
+ const dlc = rel(opts.dlc ?? 'res://dlc');
111
+ const packs = discoverPacks(cwd, dlc);
112
+ assertScopes(bootRoots.map(rel), packs);
113
+ const dirOf = new Map(packs.map((pack) => [pack.name, pack.dir]));
114
+ const release = releaseOf(packs);
115
+ // Every scoped directory: the boot sources and the pack directories. A pack's export excludes all
116
+ // of them but its own, so no source can belong to two packs.
117
+ const bootDirs = bootRoots.map(rel);
118
+ const all = [...bootDirs, ...packs.map((pack) => pack.dir)];
119
+
120
+ const presets = resolve(cwd, 'export_presets.cfg');
121
+ const cfg = readFileSync(presets, 'utf8');
122
+ // A plan's routes are relative to the converter's output root, which is where the packs live.
123
+ const ctx: Bake = { run: exec, cwd, cfg, preset, base: excludeFilter(cfg), all, planBase: resPath(dlc) };
124
+ try {
125
+ // **One dump for the whole project**, sliced per scope below (a loose file is staged by the pack
126
+ // that *owns* it, whoever needs it). One dump is also one editor run instead of one per pack.
127
+ const dump = runDump(ctx, [...bootRoots, ...packs.map((pack) => resPath(pack.dir))]);
128
+
129
+ const boot = manifestOf(dump, cwd, out, bootDirs);
130
+ assertRefs('the boot pack', boot.external, bootRoots);
131
+ exportPack(ctx, bootDirs, `${out}/${BOOT_PCK}`);
132
+ writeFileSync(resolve(cwd, out, BOOT_MANIFEST), serializeDoc({ needs: boot.needs, stage: boot.stage }));
133
+ writeFileSync(resolve(cwd, out, BOOT_MODULE), bootModule(boot));
134
+ console.log(`[pack] ${BOOT_PCK} over ${Object.keys(boot.needs).length} sources`);
135
+
136
+ for (const pack of packs) {
137
+ const version = release[pack.name].version;
138
+ // Version-addressed, exactly as a client fetches it — a local bake then exercises the same
139
+ // URLs a CDN serves (`packPath`), and nothing is ever rewritten in place.
140
+ const dir = `${out}/${pack.name}/${version}`;
141
+ mkdirSync(resolve(cwd, dir), { recursive: true });
142
+ const own = manifestOf(dump, cwd, dir, [pack.dir]);
143
+ const entries = packEntries(pack.exports);
144
+ const allowed = [...bootRoots, ...Object.keys(pack.dependencies).map((dep) => resPath(dirOf.get(dep) ?? dep))];
145
+ assertRefs(pack.name, own.external, allowed);
146
+ exportPack(ctx, [pack.dir], `${dir}/${pckOf(pack.name)}`);
147
+ writeFileSync(
148
+ resolve(cwd, dir, PACK_DOC),
149
+ serializeDoc({
150
+ // The pack's own declaration (`name`/`version`/`dependencies`/`exports`) plus what the
151
+ // bake computed: the same document shape the boot pack's is, so a client reads one. The
152
+ // `.pck` is `<name>.pck` beside it, by convention — mounting never waits on this document.
153
+ name: pack.name,
154
+ version,
155
+ dependencies: pack.dependencies,
156
+ exports: withoutTypes(pack.exports),
157
+ needs: own.needs,
158
+ stage: own.stage,
159
+ }),
160
+ );
161
+ // The pack's types, emitted beside its own `package.json` (whose `types` names them): a
162
+ // build-time plugin can serve the module, not its types, so the one declaration it already
163
+ // carries becomes a declaration file too.
164
+ const types = resolve(cwd, pack.dir, 'package.d.ts');
165
+ if (entries.length) {
166
+ writeFileSync(types, packTypes(pack.exports));
167
+ } else {
168
+ rmSync(types, { force: true });
169
+ }
170
+ console.log(`[pack] ${pack.name}@${version} over ${Object.keys(own.needs).length} sources`);
171
+ }
172
+ if (packs.length) {
173
+ writeFileSync(resolve(cwd, out, RELEASE), serializeDoc(release));
174
+ }
175
+ } finally {
176
+ // The preset is scoped per pack, so it is rewritten per export and put back once.
177
+ writeFileSync(presets, cfg);
178
+ }
179
+ }
180
+
181
+ /** The bake's shared context: the runner, the project, the preset, and every scoped directory. */
182
+ interface Bake {
183
+ run: Run;
184
+ cwd: string;
185
+ cfg: string;
186
+ preset: string;
187
+ base: string[];
188
+ all: string[];
189
+ /** The converter's output root, which a plan's routes are relative to. */
190
+ planBase: string;
191
+ }
192
+
193
+ /** Scopes the preset to `mine` and exports that scope to `pck`. */
194
+ function exportPack(ctx: Bake, mine: string[], pck: string): void {
195
+ writeFileSync(resolve(ctx.cwd, 'export_presets.cfg'), withExcludes(ctx.cfg, ctx.preset, excludesFor(ctx.base, ctx.all, mine)));
196
+ ctx.run(['--headless', '--path', '.', '--export-pack', ctx.preset, pck]);
197
+ }
198
+
199
+ /**
200
+ * One scope's half of the dump, as a document's `needs`/`stage` (and the refs the bake checks). A pack's
201
+ * export carries its own directory and nothing else, so `needs` — what each source under `dirs` needs
202
+ * staged — is that scope's slice, while `stage` — the descriptor of each loose file it *owns* — is
203
+ * judged over the **whole project**: a level's sky lives in the shared pack and is named by nothing but
204
+ * the level's plan, and the owner stages it all the same. `needs` stays paths (whoever owns them),
205
+ * which is what lets a pack borrow another's texture without carrying its bytes. `external` is what the
206
+ * scope's sources leak, for the ref check.
207
+ */
208
+ function manifestOf(
209
+ dump: Record<string, Dumped>,
210
+ cwd: string,
211
+ dir: string,
212
+ dirs: string[],
213
+ ): Pick<PackDoc, 'needs' | 'stage'> & { external: string[] } {
214
+ const roots = dirs.map((d) => resPath(d));
215
+ const needs: Record<string, string[]> = {};
216
+ const external: string[] = [];
217
+ for (const [source, d] of Object.entries(dump)) {
218
+ if (!roots.some((root) => source.startsWith(`${root}/`))) continue;
219
+ if (d.loose.length) needs[source] = d.loose;
220
+ external.push(...d.external);
221
+ }
222
+ const stage: Record<string, { path: string; import: string }> = {};
223
+ for (const loose of new Set(Object.values(dump).flatMap((d) => d.loose))) {
224
+ // Only this scope's own: a borrowed loose file is staged from the pack that owns it, so carrying
225
+ // a copy would make every level ship the shared textures — exactly what deploying a level on its
226
+ // own must not do.
227
+ if (!roots.some((root) => loose.startsWith(`${root}/`))) continue;
228
+ const abs = resolve(cwd, rel(loose));
229
+ if (!existsSync(`${abs}.import`)) continue;
230
+ const descriptor = descriptorOf(readFileSync(`${abs}.import`, 'utf8'));
231
+ if (!descriptor || !existsSync(resolve(cwd, rel(descriptor.path)))) continue;
232
+ mkdirSync(resolve(cwd, dir, FILES_DIR), { recursive: true });
233
+ copyFileSync(resolve(cwd, rel(descriptor.path)), resolve(cwd, dir, FILES_DIR, descriptor.path.split('/').pop()!));
234
+ stage[loose] = descriptor;
235
+ }
236
+ return { needs, stage, external: [...new Set(external)].sort() };
237
+ }
238
+
239
+ /** A `spawnSync` runner that throws on a non-zero editor exit. */
240
+ function runner(editor: string, cwd: string): Run {
241
+ return (args, env) => {
242
+ const r = spawnSync(editor, args, { cwd, stdio: 'inherit', env: { ...process.env, ...env } });
243
+ if ((r.status ?? 1) !== 0) {
244
+ throw new Error(`godot-assets: \`${editor} ${args.join(' ')}\` failed (${r.status})`);
245
+ }
246
+ };
247
+ }
248
+
249
+ /** One bucket of the dump: the loose textures a source needs, and the refs that left the scan roots. */
250
+ interface Dumped {
251
+ loose: string[];
252
+ external: string[];
253
+ }
254
+
255
+ /** Runs `dump-deps.gd` over `scope` (res:// roots): per source, its loose textures and its leaks. */
256
+ function runDump(ctx: Bake, scope: string[]): Record<string, Dumped> {
257
+ copyFileSync(resolve(HERE, 'dump-deps.gd'), resolve(ctx.cwd, '.godot', 'pack-dump-deps.gd'));
258
+ ctx.run(['--headless', '--path', '.', '--script', 'res://.godot/pack-dump-deps.gd'], {
259
+ GODOT_DUMP_OUT: resPath(DUMP),
260
+ GODOT_DUMP_ROOTS: scope.join(','),
261
+ GODOT_DUMP_PLAN_BASE: ctx.planBase,
262
+ // The dump holds no extension lists of its own: they come from the format table, so a source
263
+ // cannot be walked by the dump and staged by the plugin for want of one entry.
264
+ GODOT_DUMP_PACKED: packedExtensions.join(','),
265
+ GODOT_DUMP_SCANNED: scannedExtensions.join(','),
266
+ });
267
+ const abs = resolve(ctx.cwd, DUMP);
268
+ if (!existsSync(abs)) throw new Error(`godot-assets: the dump wrote no ${DUMP} (see dump-deps.gd).`);
269
+ return JSON.parse(readFileSync(abs, 'utf8')) as Record<string, Dumped>;
270
+ }
271
+
272
+ const resPath = (rel: string) => `res://${rel.replace(/^res:\/\//, '')}`;
273
+ const rel = (res: string) => res.replace(/^res:\/\//, '').replace(/\/+$/, '');
274
+
275
+ /** Fails the bake on a reference that leaves the pack (a sibling, or a source no pack owns). */
276
+ function assertRefs(what: string, external: string[], allowed: string[]): void {
277
+ const illegal = external.filter((ref) => !allowed.some((dir) => ref.startsWith(`${dir}/`)));
278
+ if (illegal.length) {
279
+ throw new Error(
280
+ `godot-assets: ${what} references ${illegal.length} source(s) outside itself and ${allowed.join(', ')} — a pack's refs point at the boot sources or a declared dependency:\n ${illegal.slice(0, 10).join('\n ')}`,
281
+ );
282
+ }
283
+ }
284
+
285
+ /**
286
+ * Finds the content packs under `rootAbs` (searching the subdirectory `under`, so the returned `dir`s
287
+ * are `rootAbs`-relative): every directory holding a `package.json`, identified by that file's `name`.
288
+ * Packs do not nest — a pack inside a pack could not be scoped to one export — and a directory holding
289
+ * one is a container, so stray files beside it belong to no pack.
290
+ */
291
+ function discoverPacks(rootAbs: string, under = ''): ContentPack[] {
292
+ const dirs: string[] = [];
293
+ walk(rootAbs, under, dirs);
294
+ const packs: ContentPack[] = [];
295
+ for (const dir of dirs.sort()) {
296
+ const parent = packs.find((pack) => dir.startsWith(`${pack.dir}/`));
297
+ if (parent) throw new Error(`godot-assets: ${dir}/ is inside the pack ${parent.dir} — packs do not nest.`);
298
+ if (!dir) throw new Error('godot-assets: the discovery root itself declares a package.json.');
299
+ const pkg = JSON.parse(readFileSync(join(rootAbs, dir, 'package.json'), 'utf8')) as {
300
+ name?: string;
301
+ version?: string;
302
+ dependencies?: Record<string, string>;
303
+ exports?: Exports;
304
+ };
305
+ packs.push({
306
+ name: pkg.name ?? dir.split('/').pop()!,
307
+ dir,
308
+ ...(pkg.version ? { version: pkg.version } : {}),
309
+ dependencies: pkg.dependencies ?? {},
310
+ // Passed through verbatim: the declaration is what a client resolves an entry against, so
311
+ // the bake copies it rather than restating it (a client reads it through the entry's
312
+ // `godot` condition).
313
+ exports: pkg.exports,
314
+ });
315
+ }
316
+ return packs;
317
+ }
318
+
319
+ /** Collects every directory (relative, `/`-separated) under `rootAbs` that holds a `package.json`. */
320
+ function walk(rootAbs: string, dir: string, out: string[]): void {
321
+ const abs = dir ? join(rootAbs, dir) : rootAbs;
322
+ let names: string[];
323
+ try {
324
+ names = readdirSync(abs);
325
+ } catch {
326
+ return;
327
+ }
328
+ if (names.includes('package.json')) out.push(dir);
329
+ for (const name of names) {
330
+ if (name.startsWith('.') || name === 'node_modules') continue;
331
+ if (statSync(join(abs, name)).isDirectory()) walk(rootAbs, dir ? `${dir}/${name}` : name, out);
332
+ }
333
+ }
334
+
335
+ /**
336
+ * The release a local bake serves, in the shape `parseRelease` reads: pack name → the version it just
337
+ * wrote and the dependencies it declared. A deploy publishes the same graph (resolved by `bun install`
338
+ * into a lockfile) under one immutable name, which is the only thing that identifies a content release.
339
+ */
340
+ function releaseOf(packs: ContentPack[]): Release {
341
+ return Object.fromEntries(
342
+ packs.map((pack) => [pack.name, { version: pack.version ?? '0.0.0', deps: Object.keys(pack.dependencies) }]),
343
+ );
344
+ }
345
+
346
+ /**
347
+ * The boot pack as a module the app's bundle imports (beside the packs it describes): its `.pck`
348
+ * mounted once, however many modules import this, and the same `needs`/`stage` — with each loose
349
+ * product it owns imported as a file, so its URL is the bundle's. A content pack may need one, and the
350
+ * boot is mounted like any other pack, so its files belong in a client's staging lookup. Only the app
351
+ * can name those URLs, which is why the bake renders them here.
352
+ */
353
+ function bootModule({ needs, stage }: Pick<PackDoc, 'needs' | 'stage'>): string {
354
+ const imports: string[] = [];
355
+ const files: string[] = [];
356
+ for (const descriptor of Object.values(stage)) {
357
+ const name = descriptor.path.split('/').pop()!;
358
+ imports.push(`import file${files.length} from ${JSON.stringify(`./${FILES_DIR}/${name}`)} with { type: 'file' };`);
359
+ files.push(`\t${JSON.stringify(name)}: file${files.length},`);
360
+ }
361
+ return (
362
+ [
363
+ "// Generated by `godot pack`: the app's own pack, as a module (see @ringozz/godot/preload).",
364
+ `import bootPck from ${JSON.stringify(`./${BOOT_PCK}`)} with { type: 'file' };`,
365
+ `import { configurePacks, loadAssetPack } from '@ringozz/godot/load';`,
366
+ ...imports,
367
+ files.length ? `const bootFiles = {\n${files.join('\n')}\n};` : 'const bootFiles = {};',
368
+ `configurePacks({ boot: { pck: bootPck, doc: ${JSON.stringify({ needs, stage })}, url: (file) => bootFiles[file] } });`,
369
+ `export const mounting = loadAssetPack(bootPck);`,
370
+ ].join('\n') + '\n'
371
+ );
372
+ }
373
+
374
+ /**
375
+ * A pack's type surface, emitted beside the declaration that names it (`types` in its own
376
+ * `package.json`): one typed export per declared entry, so importing the module the plugin serves is
377
+ * type-checked with no generated module — `packEntries` resolves each entry, exactly as the served
378
+ * module's is. The bake's, not the protocol's: it writes it.
379
+ */
380
+ export function packTypes(exports: Exports): string {
381
+ const entries = packEntries(exports);
382
+ const classes = [...new Set(entries.map((e) => e.cls))];
383
+ return [
384
+ "// Generated by `godot pack` from the pack's `exports` — do not edit.",
385
+ ...classes.map((cls) => `import type { ${cls} } from '@ringozz/godot/${cls}';`),
386
+ '',
387
+ ...entries.map((e) => `export declare const ${entryId(e.entry)}: Promise<${e.cls}>;`),
388
+ '',
389
+ ].join('\n');
390
+ }
391
+
392
+ /**
393
+ * A pack's declared `exports` minus its `types` condition. The document is emitted beside the `.pck`,
394
+ * while the declarations a pack's `types` names stay in its source directory (the bake writes them
395
+ * there), so the path would not resolve from the document and nothing reads it — `packEntries` reads
396
+ * the `godot` condition. A subpath left with no conditions goes with it: the document says what a
397
+ * client may resolve.
398
+ */
399
+ function withoutTypes(exports: Exports): Exports {
400
+ if (!exports) return exports;
401
+ const declared: Record<string, string | { [condition: string]: string | undefined }> = {};
402
+ for (const [subpath, value] of Object.entries(exports)) {
403
+ if (typeof value !== 'object') {
404
+ declared[subpath] = value;
405
+ continue;
406
+ }
407
+ const conditions = Object.fromEntries(Object.entries(value).filter(([condition]) => condition !== 'types'));
408
+ if (Object.keys(conditions).length) declared[subpath] = conditions;
409
+ }
410
+ return declared;
411
+ }
412
+
413
+ /**
414
+ * The bake's scopes must not overlap. Each pack's export excludes every *other* scoped directory, so a
415
+ * boot root that contains a pack would exclude that pack's own files from its own export — an empty
416
+ * pack, which shows up much later as a scene whose assets are missing. The mistake is easy to make
417
+ * (`--roots res://src,res://dlc` when the packs live under `dlc/`), so the bake says so instead.
418
+ */
419
+ function assertScopes(bootDirs: string[], packs: ContentPack[]): void {
420
+ for (const pack of packs) {
421
+ const root = bootDirs.find((dir) => pack.dir.startsWith(`${dir}/`));
422
+ if (root) {
423
+ throw new Error(
424
+ `godot-assets: the boot root ${root} contains the content pack ${pack.dir} — the boot pack would carry it and the pack's own export would exclude it. Point --roots at sources only: a pack's directory is its own export.`,
425
+ );
426
+ }
427
+ }
428
+ }
429
+
430
+ /**
431
+ * A pack's `exclude_filter`: the preset's own entries plus a glob per scoped directory that is not
432
+ * `mine` — so the export carries this pack and nothing else.
433
+ */
434
+ function excludesFor(base: string[], all: string[], mine: string[]): string[] {
435
+ return [...base, ...all.filter((dir) => !mine.includes(dir)).map((dir) => `${dir}/*`)];
436
+ }
437
+
438
+ /** A preset block's `exclude_filter` entries. */
439
+ function excludeFilter(cfg: string): string[] {
440
+ const line = /^exclude_filter="([^"]*)"/m.exec(cfg);
441
+ return (line?.[1] ?? '').split(',').map((s) => s.trim()).filter(Boolean);
442
+ }
443
+
444
+ /** The config with the preset named `preset`'s `exclude_filter` replaced by `exclude`. */
445
+ function withExcludes(cfg: string, preset: string, exclude: string[]): string {
446
+ const at = new RegExp(`^name=${JSON.stringify(preset)}$`, 'm').exec(cfg)?.index ?? -1;
447
+ if (at < 0) {
448
+ throw new Error(`godot-assets: no "${preset}" preset in export_presets.cfg (see export_presets.example.cfg).`);
449
+ }
450
+ const line = /^exclude_filter="[^"]*"$/m.exec(cfg.slice(at));
451
+ if (!line) throw new Error(`godot-assets: the "${preset}" preset has no exclude_filter.`);
452
+ const start = at + line.index;
453
+ return `${cfg.slice(0, start)}exclude_filter=${JSON.stringify(exclude.join(','))}${cfg.slice(start + line[0].length)}`;
454
+ }
455
+
456
+ /** Parses `godot pack` CLI args into {@link BuildAssetPackOptions}. */
457
+ export function parsePackArgs(args: string[]): BuildAssetPackOptions {
458
+ const opt = (flag: string): string | undefined => {
459
+ const i = args.indexOf(flag);
460
+ return i >= 0 ? args[i + 1] : undefined;
461
+ };
462
+ return {
463
+ roots: opt('--roots')?.split(',').filter(Boolean),
464
+ dlc: opt('--dlc'),
465
+ out: opt('--out'),
466
+ preset: opt('--preset'),
467
+ godot: opt('--godot'),
468
+ };
469
+ }