@ringozz/godot 4.7.2-643 → 4.7.2-649

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,7 +52,8 @@ 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` |
55
+ | `@ringozz/godot/load` | Async resource loading: `loadResourceAsync`, `loadAsset`, `loadAssetPack` (native `.pck` packs) |
56
+ | `@ringozz/godot/pack` | `buildAssetPack()` + the `godot pack` subcommand: bake a project's `.pck` + loose-texture manifest |
56
57
  | `@ringozz/godot/debug` | `initDebug()`, `dumpStr`, `snapshot`, `inspect`, `click`, `find`, `gc` |
57
58
 
58
59
  Class-scoped enums use bare names (import `ProcessMode` from `@ringozz/godot/Node`, not `NodeProcessMode`).
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-643",
4
+ "version": "4.7.2-649",
5
5
  "description": "Node-API bindings for Godot Engine — call Godot classes from JavaScript",
6
6
  "publishConfig": {
7
7
  "access": "public"
@@ -26,6 +26,7 @@
26
26
  "default": "./src/boot.ts"
27
27
  },
28
28
  "./load": "./src/load.ts",
29
+ "./pack": "./src/pack.ts",
29
30
  "./transcoder": {
30
31
  "browser": "./src/transcoder.browser.ts",
31
32
  "default": "./src/transcoder.ts"
@@ -65,9 +66,9 @@
65
66
  "precision": "single"
66
67
  },
67
68
  "optionalDependencies": {
68
- "@ringozz/godot-macos-arm64": "^4.7.2-643",
69
- "@ringozz/godot-windows-x86_64": "^4.7.2-643",
70
- "@ringozz/godot-web-wasm32": "^4.7.2-643"
69
+ "@ringozz/godot-macos-arm64": "^4.7.2-649",
70
+ "@ringozz/godot-windows-x86_64": "^4.7.2-649",
71
+ "@ringozz/godot-web-wasm32": "^4.7.2-649"
71
72
  },
72
73
  "peerDependencies": {
73
74
  "@types/bun": "*"
package/src/assets.d.ts CHANGED
@@ -52,6 +52,13 @@ declare module '*.gltf' {
52
52
  export const materialize: Promise<unknown>;
53
53
  }
54
54
 
55
+ declare module '*.gltfx' {
56
+ import type { PackedScene } from '@ringozz/godot/PackedScene';
57
+ const asset: Promise<PackedScene>;
58
+ export default asset;
59
+ export const materialize: Promise<unknown>;
60
+ }
61
+
55
62
  declare module '*.tscn' {
56
63
  import type { PackedScene } from '@ringozz/godot/PackedScene';
57
64
  const asset: Promise<PackedScene>;
@@ -0,0 +1,112 @@
1
+ @tool
2
+ extends SceneTree
3
+ ## Dumps, for every packed source resource, the set of **loose** (WebP-backed)
4
+ ## texture sources that must stay out of the asset pack and be staged/transcoded
5
+ ## by the host. The preload plugin (`@ringozz/godot/preload`) reads deps.json so
6
+ ## it never scans source text for references.
7
+ ##
8
+ ## Sources of loose refs (union): the engine's `ResourceLoader.get_dependencies`
9
+ ## (for imported sources the real refs live on the imported product, reached via
10
+ ## the `.import` `dest_files`), plus a text scan for string-valued paths
11
+ ## (`albedo_array = "res://.../*.webparray"`, `load("res://...")`) that no static
12
+ ## resource edge exposes. A dep is *loose* when it has an `.import` sidecar but is
13
+ ## not a packed resource type — the set is derived, not listed.
14
+ ##
15
+ ## Driven by `@ringozz/godot/pack`'s `buildAssetPack`, which sets:
16
+ ## GODOT_DUMP_OUT – output res:// path (default res://.godot/packs/deps.json)
17
+ ## GODOT_DUMP_ROOTS – comma-separated res:// roots to scan (default res://)
18
+
19
+ ## Imported source extensions: `_classify` recurses into these (an imported source's own deps are
20
+ ## empty — its *product* carries the refs) where any other extension with a sidecar is a loose texture.
21
+ ## `gltfx` is a scene-composition source: its product references the meshes and textures it composes.
22
+ const PACKED := ["tscn", "tres", "gltf", "gltfx", "obj", "gd", "gdshader", "gdshaderinc", "ttf", "otf", "po"]
23
+ const SCANNED := ["tscn", "tres", "gd", "gdshader", "gdshaderinc", "gltf", "obj"]
24
+
25
+
26
+ func _initialize() -> void:
27
+ var out := OS.get_environment("GODOT_DUMP_OUT")
28
+ if out.is_empty():
29
+ out = "res://.godot/packs/deps.json"
30
+ var roots := OS.get_environment("GODOT_DUMP_ROOTS").split(",", false)
31
+ if roots.is_empty():
32
+ roots = ["res://"]
33
+ var deps := {}
34
+ for r in roots:
35
+ _scan(String(r).rstrip("/") + "/", deps)
36
+ DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(out.get_base_dir()))
37
+ var f := FileAccess.open(out, FileAccess.WRITE)
38
+ f.store_string(JSON.stringify(deps, " "))
39
+ f.close()
40
+ print("[dump-deps] ", deps.size(), " resources -> ", out)
41
+ quit()
42
+
43
+
44
+ func _scan(path: String, out: Dictionary) -> void:
45
+ var d := DirAccess.open(path)
46
+ if d == null:
47
+ return
48
+ for dir in d.get_directories():
49
+ if dir.begins_with(".") or dir == "node_modules":
50
+ continue
51
+ _scan(path.path_join(dir) + "/", out)
52
+ for file in d.get_files():
53
+ var p := path.path_join(file)
54
+ if p.get_extension().to_lower() in PACKED:
55
+ out[p] = _loose(p)
56
+
57
+
58
+ ## The loose texture sources reachable from `path` (transitive), sorted.
59
+ func _loose(path: String) -> Array:
60
+ var loose := {}
61
+ _collect(path, loose, {})
62
+ var arr := loose.keys()
63
+ arr.sort()
64
+ return arr
65
+
66
+
67
+ func _classify(dep: String, loose: Dictionary, seen: Dictionary) -> void:
68
+ if dep.is_empty():
69
+ return
70
+ if dep.get_extension().to_lower() in PACKED:
71
+ _collect(dep, loose, seen)
72
+ elif FileAccess.file_exists(dep + ".import"):
73
+ loose[dep] = true
74
+
75
+
76
+ func _collect(path: String, loose: Dictionary, seen: Dictionary) -> void:
77
+ if seen.has(path):
78
+ return
79
+ seen[path] = true
80
+ # An imported source's own `get_dependencies` is empty (the importer hides the
81
+ # refs); the imported *product* carries them — recurse through the sidecar.
82
+ var imp := path + ".import"
83
+ if FileAccess.file_exists(imp):
84
+ var cfg := ConfigFile.new()
85
+ if cfg.load(imp) == OK:
86
+ for dst in cfg.get_value("deps", "dest_files", []):
87
+ _collect(String(dst), loose, seen)
88
+ for dep in ResourceLoader.get_dependencies(path):
89
+ _classify(_res_path(String(dep)), loose, seen)
90
+ # String-valued resource paths are invisible to dependency analysis; scan the
91
+ # text for loose refs the engine cannot report.
92
+ if path.get_extension().to_lower() in SCANNED:
93
+ for p in _text_refs(path):
94
+ _classify(p, loose, seen)
95
+
96
+
97
+ ## `res://` path from a dependency entry (`uid://…::::res://…` or plain).
98
+ func _res_path(dep: String) -> String:
99
+ var i := dep.find("res://")
100
+ if i < 0:
101
+ return ""
102
+ return dep.substr(i)
103
+
104
+
105
+ ## Literal `res://` paths appearing in a text source.
106
+ func _text_refs(path: String) -> Array:
107
+ var re := RegEx.new()
108
+ re.compile('res://[^"\']+')
109
+ var refs := []
110
+ for m in re.search_all(FileAccess.get_file_as_string(path)):
111
+ refs.append(m.get_string())
112
+ return refs
package/src/editor.ts CHANGED
@@ -94,7 +94,7 @@ async function provision(): Promise<void> {
94
94
  }
95
95
  }
96
96
 
97
- async function resolveEditor(): Promise<string> {
97
+ export async function resolveEditor(): Promise<string> {
98
98
  if (process.env.GODOT_PATH) return process.env.GODOT_PATH;
99
99
  if (!process.env.GODOT_NODE_WRAPPER) {
100
100
  const system = systemGodot();
@@ -105,7 +105,13 @@ async function resolveEditor(): Promise<string> {
105
105
  }
106
106
 
107
107
  if (import.meta.main) {
108
- const exe = await resolveEditor();
109
- const result = spawnSync(exe, process.argv.slice(2), { stdio: 'inherit', env: { ...process.env, GODOT_NODE_WRAPPER: '1' } });
110
- process.exit(result.status ?? 1);
108
+ const command = process.argv[2];
109
+ if (command === 'pack') {
110
+ const { buildAssetPack, parsePackArgs } = await import('./pack.ts');
111
+ await buildAssetPack(parsePackArgs(process.argv.slice(3)));
112
+ process.exit(0);
113
+ }
114
+ const exe = await resolveEditor();
115
+ const result = spawnSync(exe, process.argv.slice(2), { stdio: 'inherit', env: { ...process.env, GODOT_NODE_WRAPPER: '1' } });
116
+ process.exit(result.status ?? 1);
111
117
  }
@@ -0,0 +1,37 @@
1
+ ; Example Godot export preset for `@ringozz/godot/pack`'s `buildAssetPack()`.
2
+ ; Copy to the project root as `export_presets.cfg` (the project owns it) and add
3
+ ; your own non-asset trees to `exclude_filter`.
4
+ ;
5
+ ; The texture sources (`*.webp`/`*.png`/… below) MUST stay excluded: the wasm
6
+ ; engine has no WebP decoder, so those products are staged/transcoded by the host
7
+ ; (see `dump-deps.gd`), not read from the pack. `export_filter="all_resources"`
8
+ ; ignores `include_filter` — scope with `exclude_filter` only.
9
+
10
+ [preset.0]
11
+
12
+ name="Pack"
13
+ platform="Web"
14
+ runnable=true
15
+ dedicated_server=false
16
+ custom_features=""
17
+ export_filter="all_resources"
18
+ include_filter=""
19
+ exclude_filter="node_modules/*,.git/*,.godot/packs/*,*.webp,*.png,*.svg,*.jpg,*.jpeg,*.hdr,*.exr,*.webparray"
20
+ export_path=""
21
+ script_export_mode=2
22
+
23
+ [preset.0.options]
24
+
25
+ custom_template/debug=""
26
+ custom_template/release=""
27
+ variant/extensions_support=false
28
+ variant/thread_support=false
29
+ vram_texture_compression/for_desktop=false
30
+ vram_texture_compression/for_mobile=false
31
+ html/export_icon=true
32
+ html/custom_html_shell=""
33
+ html/head_include=""
34
+ html/canvas_resize_policy=2
35
+ html/focus_canvas_on_start=true
36
+ html/experimental_virtual_keyboard=false
37
+ progressive_web_app/enabled=false
package/src/load.ts CHANGED
@@ -148,34 +148,19 @@ async function materialize([resPath, entry]: [string, AssetFile]): Promise<void>
148
148
 
149
149
  const nextTick = () => new Promise(requestAnimationFrame);
150
150
 
151
- /**
152
- * An asset module as generated by `@ringozz/godot/preload`: `default` is the
153
- * load promise, `materialize` resolves once its bundled files (plus its deps'
154
- * files) are staged on the engine's filesystem.
155
- */
156
- interface AssetModule {
157
- default: Promise<unknown>;
158
- materialize: Promise<unknown>;
159
- }
160
-
161
151
  /**
162
152
  * Stages the asset's bundled files before loading: `.import` sidecars as text
163
- * and imported products (`.scn`/`.ctex`) as fetched paths. On web the bytes are
164
- * written straight into Emscripten's MEMFS via Godot's `copyToFS` (JS-heap only,
165
- * no wasm copy); a no-op on desktop where the files already exist. `deps`
166
- * supplies referenced asset modules whose file-staging is awaited (so Godot can
167
- * resolve them) and whose load failures are logged.
153
+ * and imported products (`.ctex`) as fetched paths. On web the bytes are written
154
+ * straight into Emscripten's MEMFS via Godot's `copyToFS` (JS-heap only, no wasm
155
+ * copy); a no-op on desktop where the files already exist.
168
156
  */
169
- function materializeFiles(files: AssetFiles, deps: AssetModule[] = []): Promise<unknown> {
170
- for (const dep of deps) {
171
- dep.default.catch((err) => console.error('[godot] dependency load failed:', err));
172
- }
157
+ function materializeFiles(files: AssetFiles): Promise<unknown> {
173
158
  if (stageFile === undefined) {
174
159
  // Desktop: the files already exist on disk (res:// = cwd) and uids come
175
160
  // from `.godot/uid_cache.bin`, so there is nothing to stage.
176
161
  return Promise.resolve();
177
162
  }
178
- return Promise.all([...Object.entries(files).map(materialize), ...deps.map((dep) => dep.materialize)]);
163
+ return Promise.all(Object.entries(files).map(materialize));
179
164
  }
180
165
 
181
166
  /**
@@ -200,18 +185,22 @@ function cachedResource<C extends ResourceConstructor>(path: string): Promise<In
200
185
  * where modules evaluate once). Memoization keeps `use()` seeing the **same**
201
186
  * fulfilled promise object across re-evaluations — no Suspense fallback flash on
202
187
  * hot reload; a rejected load is evicted so the next evaluation retries. Returns
203
- * the `materialize` promise (own + deps' files staged) and the load promise.
188
+ * the `materialize` promise (bundled files staged) and the load promise.
204
189
  */
205
190
  export function loadAsset<C extends ResourceConstructor>(
206
191
  path: string,
207
192
  cls: C,
208
193
  files: AssetFiles,
209
- deps: AssetModule[] = [],
210
194
  data: Record<string, unknown> = {},
195
+ ready?: Promise<unknown>,
211
196
  ): { materialize: Promise<unknown>; load: Promise<InstanceType<C>> } {
212
197
  const cached = cachedResource<C>(path);
213
198
  if (cached) cached.then(track);
214
- const materialize = cached ? Promise.resolve() : materializeFiles(files, deps);
199
+ // `ready` mounts the pack(s) holding this asset (see `loadAssetPack`);
200
+ // staging waits for that so the pack's remaps are in place when the resource
201
+ // resolves. A no-op when omitted (desktop / no pack).
202
+ const mounted = ready ?? Promise.resolve();
203
+ const materialize = cached ? Promise.resolve() : mounted.then(() => materializeFiles(files));
215
204
  const existing = data[path] as Promise<InstanceType<C>> | undefined;
216
205
  const load = existing ?? cached ?? materialize.then(() => loadResourceAsync(path, cls, files));
217
206
  data[path] = load;
@@ -270,3 +259,56 @@ export async function loadResourceAsync<C extends ResourceConstructor>(
270
259
  }
271
260
  return track(result);
272
261
  }
262
+
263
+ // ── Asset packs ──────────────────────────────────────────────────────────────
264
+ // Mounting for Godot `.pck` packs. A pack is fetched, its bytes staged into the
265
+ // engine's filesystem (web: MEMFS via `copyToFS`; desktop: nothing — the working
266
+ // tree is `res://`), then handed to `ProjectSettings.loadResourcePack`, whose
267
+ // remap table resolves a source path (`res://scene.tscn`) to the packed binary.
268
+ // Memoized per URL (shared by every module that references it); a failed mount is
269
+ // evicted so a later call retries. PackedData is add-only — packs are never unloaded.
270
+
271
+ const mounted = new Map<string, Promise<void>>();
272
+
273
+ /** Mounts the `.pck` at `url` (fetch + stage + `loadResourcePack`). */
274
+ export function loadAssetPack(url: string): Promise<void> {
275
+ const existing = mounted.get(url);
276
+ if (existing) {
277
+ return existing;
278
+ }
279
+ const p = mount(url);
280
+ mounted.set(url, p);
281
+ p.catch(() => {
282
+ if (mounted.get(url) === p) {
283
+ mounted.delete(url);
284
+ }
285
+ });
286
+ return p;
287
+ }
288
+
289
+ async function mount(url: string): Promise<void> {
290
+ if (stageFile === undefined) {
291
+ // Desktop: the app's assets exist on disk. External packs (a path) mount directly.
292
+ const external = /^([A-Za-z]:[\\/]|\/)/.test(url) || url.startsWith('res://');
293
+ if (external && !ProjectSettings.loadResourcePack(url)) {
294
+ throw new Error(`loadAssetPack(${url}): loadResourcePack failed`);
295
+ }
296
+ return;
297
+ }
298
+ const res = await fetch(url);
299
+ if (!res.ok) {
300
+ throw new Error(`loadAssetPack(${url}): fetch failed (${res.status})`);
301
+ }
302
+ const bytes = new Uint8Array(await res.arrayBuffer());
303
+ const resPath = `res://.packs/${packName(url)}`;
304
+ stageFile(ProjectSettings.globalizePath(resPath), bytes);
305
+ if (!ProjectSettings.loadResourcePack(resPath)) {
306
+ throw new Error(`loadAssetPack(${url}): loadResourcePack failed for ${resPath}`);
307
+ }
308
+ }
309
+
310
+ /** A filesystem-safe pack name derived from the URL (basename, query stripped). */
311
+ function packName(url: string): string {
312
+ const base = url.split('?')[0].split('/').pop() ?? 'pack.pck';
313
+ return base.replace(/[^A-Za-z0-9._-]/g, '_') || 'pack.pck';
314
+ }
package/src/pack.ts ADDED
@@ -0,0 +1,87 @@
1
+ /**********************************************************************
2
+ Copyright (c) Vladimir Davidovich. All rights reserved.
3
+ ***********************************************************************/
4
+
5
+ import { spawnSync } from 'node:child_process';
6
+ import { copyFileSync, existsSync, mkdirSync, readFileSync } 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, dumps the loose
12
+ // (WebP-backed) texture dependency manifest (`dump-deps.gd`, via
13
+ // `ResourceLoader.get_dependencies` + a text scan for string-valued paths — no
14
+ // host-side scanning), and exports a native `.pck` carrying the engine-converted
15
+ // binary resources + remap table. The preload plugin (`preload.ts`) consumes both.
16
+ //
17
+ // The project owns `export_presets.cfg` (a committed, editor-managed file): this
18
+ // runs `--export-pack <preset>` against it. That preset must exclude the texture
19
+ // sources (`*.webp`/`*.png`/…): the wasm build has no WebP decoder, so those
20
+ // products stay loose and are staged/transcoded by the host. See
21
+ // `export_presets.example.cfg` for a canonical preset.
22
+
23
+ const HERE = dirname(fileURLToPath(import.meta.url));
24
+
25
+ export interface BuildAssetPackOptions {
26
+ /** res:// roots whose assets are scanned for loose textures (default `['res://src']`). */
27
+ roots?: string[];
28
+ /** Output `.pck` path, project-relative (default `.godot/packs/app.pck`). */
29
+ pack?: string;
30
+ /** Output `deps.json` path, project-relative (default `.godot/packs/deps.json`). */
31
+ deps?: string;
32
+ /** `export_presets.cfg` preset name to export (default `Pack`). */
33
+ preset?: string;
34
+ /** Editor executable override (default: `resolveEditor()`). */
35
+ godot?: string;
36
+ }
37
+
38
+ export async function buildAssetPack(opts: BuildAssetPackOptions = {}): Promise<void> {
39
+ const roots = opts.roots ?? ['res://src'];
40
+ const pack = opts.pack ?? '.godot/packs/app.pck';
41
+ const deps = opts.deps ?? '.godot/packs/deps.json';
42
+ const preset = opts.preset ?? 'Pack';
43
+ const editor = opts.godot ?? (await resolveEditor());
44
+ const cwd = process.cwd();
45
+
46
+ const presets = resolve(cwd, 'export_presets.cfg');
47
+ if (!existsSync(presets) || !new RegExp(`^name=${JSON.stringify(preset)}$`, 'm').test(readFileSync(presets, 'utf8'))) {
48
+ throw new Error(`godot-assets: no "${preset}" preset in export_presets.cfg (see export_presets.example.cfg).`);
49
+ }
50
+
51
+ const run = (args: string[], env?: Record<string, string>) => {
52
+ const r = spawnSync(editor, args, { cwd, stdio: 'inherit', env: { ...process.env, ...env } });
53
+ if ((r.status ?? 1) !== 0) {
54
+ throw new Error(`godot-assets: \`${editor} ${args.join(' ')}\` failed (${r.status})`);
55
+ }
56
+ };
57
+
58
+ mkdirSync(resolve(cwd, dirname(pack)), { recursive: true });
59
+ mkdirSync(resolve(cwd, dirname(deps)), { recursive: true });
60
+
61
+ run(['--headless', '--path', '.', '--import']);
62
+
63
+ const dump = resolve(cwd, '.godot', 'pack-dump-deps.gd');
64
+ copyFileSync(resolve(HERE, 'dump-deps.gd'), dump);
65
+ run(['--headless', '--path', '.', '--script', 'res://.godot/pack-dump-deps.gd'], {
66
+ GODOT_DUMP_OUT: `res://${deps.replace(/^res:\/\//, '')}`,
67
+ GODOT_DUMP_ROOTS: roots.join(','),
68
+ });
69
+
70
+ run(['--headless', '--path', '.', '--export-pack', preset, pack]);
71
+ }
72
+
73
+ /** Parses `godot pack` CLI args into {@link BuildAssetPackOptions}. */
74
+ export function parsePackArgs(args: string[]): BuildAssetPackOptions {
75
+ const opt = (flag: string): string | undefined => {
76
+ const i = args.indexOf(flag);
77
+ return i >= 0 ? args[i + 1] : undefined;
78
+ };
79
+ const list = (flag: string): string[] | undefined => opt(flag)?.split(',').filter(Boolean);
80
+ return {
81
+ roots: list('--roots'),
82
+ pack: opt('--pack'),
83
+ deps: opt('--deps'),
84
+ preset: opt('--preset'),
85
+ godot: opt('--godot'),
86
+ };
87
+ }
package/src/preload.ts CHANGED
@@ -8,52 +8,55 @@ import { plugin } from 'bun';
8
8
  import type { BunPlugin } from 'bun';
9
9
 
10
10
  // Registers the godot-assets plugin: imports of Godot source assets become
11
- // virtual modules (namespace `godot`) that call `loadAsset` (`@ringozz/godot/load`)
12
- // and export the resulting `materialize` (own + deps' files) and load promise.
13
- // `loadAsset` checks the engine's `ResourceCache` once; when the asset is
14
- // already loaded (e.g. a re-evaluated module under web HMR) the load resolves to
15
- // the cached instance and `materialize` short-circuits, skipping file staging
16
- // and the threaded load. The load promise persists on the module's
17
- // `import.meta.hot.data` (per-module state carried across hot replacements), so
18
- // HMR re-evaluations return the *same* fulfilled promise object — React's `use()`
19
- // keeps its tracked status and never re-suspends (no Suspense fallback flash); a
20
- // rejected load is evicted from `data` so the next evaluation retries; on
21
- // desktop `data` is `{}` and modules evaluate once. Dependencies are generated
22
- // modules too, so a dep's load starts at module evaluation while the parent only
23
- // waits for dep *files*. The default export is the single plugin — registered
24
- // globally via bunfig preload and loaded by `[serve.static] plugins` for
25
- // `Bun.serve` HTML routes.
11
+ // virtual modules (namespace `godot`) that mount the project's asset pack and
12
+ // load the resource.
13
+ //
14
+ // A packed source asset (`tscn`/`tres`/`gltf`/`gd`/font/…) is loaded from the
15
+ // `.pck` baked by `godot pack`: the module mounts the pack (`loadAssetPack`),
16
+ // whose remap table resolves the source path to the packed binary (`.scn`/`.res`)
17
+ // with refs the engine resolves itself. The
18
+ // plugin never scans source text for references — the only per-file staging is
19
+ // for *loose* (WebP-backed) textures the wasm engine cannot decode, whose list
20
+ // is computed by the engine (`@ringozz/godot/pack`'s `dump-deps.gd` ->
21
+ // `.godot/packs/deps.json`, via `ResourceLoader.get_dependencies`).
22
+ //
23
+ // The load promise persists on `import.meta.hot.data` (carried across HMR
24
+ // re-evaluations) so `use()` never re-suspends; a rejected load is evicted so
25
+ // the next evaluation retries. The default export is the single plugin —
26
+ // registered globally via bunfig preload and loaded by `[serve.static] plugins`.
26
27
 
27
28
  const ROOT = process.cwd();
28
29
  const NS = 'godot';
29
30
 
30
- /** Format metadata: `cls` = Godot class (importable), `scan` = scan text refs, `native` = no `.import` sidecar. */
31
- const FORMATS: Record<string, { cls?: string; scan?: boolean; native?: boolean }> = {
32
- gltf: { cls: 'PackedScene', scan: true },
33
- tscn: { cls: 'PackedScene', scan: true, native: true },
34
- obj: { cls: 'PackedScene', scan: true },
35
- gd: { cls: 'GDScript', scan: true, native: true },
36
- gdshader: { cls: 'Shader', scan: true, native: true },
37
- gdshaderinc: { cls: 'ShaderInclude', scan: true, native: true },
38
- jpg: { cls: 'CompressedTexture2D' },
39
- jpeg: { cls: 'CompressedTexture2D' },
40
- png: { cls: 'CompressedTexture2D' },
41
- webp: { cls: 'CompressedTexture2D' },
31
+ /** Extension -> class. `loose` = WebP-backed texture excluded from the pack, staged/transcoded per-file. */
32
+ const FORMATS: Record<string, { cls?: string; loose?: boolean }> = {
33
+ gltf: { cls: 'PackedScene' },
34
+ // A scene-composition document (a glTF External Reference): an imported source whose product is a
35
+ // `PackedScene`, like a `.gltf`'s — and whose product is where its meshes/textures are referenced.
36
+ gltfx: { cls: 'PackedScene' },
37
+ tscn: { cls: 'PackedScene' },
38
+ obj: { cls: 'PackedScene' },
39
+ gd: { cls: 'GDScript' },
40
+ gdshader: { cls: 'Shader' },
41
+ gdshaderinc: { cls: 'ShaderInclude' },
42
+ jpg: { cls: 'CompressedTexture2D', loose: true },
43
+ jpeg: { cls: 'CompressedTexture2D', loose: true },
44
+ png: { cls: 'CompressedTexture2D', loose: true },
45
+ webp: { cls: 'CompressedTexture2D', loose: true },
42
46
  // The layer manifest `godot/addons/sourcewebp` imports into a `.ctexarray` (a texture array whose
43
- // layers are source WebP bytes). No `scan`: the layer paths it lists are import-time inputs, not
44
- // runtime deps, so the host must not stage them.
45
- webparray: { cls: 'CompressedTexture2DArray' },
46
- svg: { cls: 'CompressedTexture2D' },
47
- exr: { cls: 'TextureLayered' },
48
- hdr: { cls: 'TextureLayered' },
47
+ // layers are source WebP bytes). It has no runtime deps of its own; the host stages its product.
48
+ webparray: { cls: 'CompressedTexture2DArray', loose: true },
49
+ svg: { cls: 'CompressedTexture2D', loose: true },
50
+ exr: { cls: 'TextureLayered', loose: true },
51
+ hdr: { cls: 'TextureLayered', loose: true },
49
52
  wav: { cls: 'AudioStreamWAV' },
50
53
  ogg: { cls: 'AudioStreamOggVorbis' },
51
54
  mp3: { cls: 'AudioStreamMP3' },
52
- tres: { cls: 'Resource', scan: true, native: true },
53
- po: { cls: 'Translation', native: true },
55
+ tres: { cls: 'Resource' },
56
+ po: { cls: 'Translation' },
54
57
  ttf: { cls: 'FontFile' },
55
58
  otf: { cls: 'FontFile' },
56
- mtl: { scan: true },
59
+ mtl: {},
57
60
  };
58
61
 
59
62
  const ASSET_RE = new RegExp(
@@ -63,14 +66,23 @@ const ASSET_RE = new RegExp(
63
66
  const CLASS_BY_EXT: Record<string, string> = Object.fromEntries(
64
67
  Object.entries(FORMATS).filter(([, f]) => f.cls).map(([k, f]) => [k, f.cls!]),
65
68
  );
66
- const SCANNED = new Set(Object.entries(FORMATS).filter(([, f]) => f.scan).map(([k]) => k));
67
- const NATIVE = new Set(Object.entries(FORMATS).filter(([, f]) => f.native).map(([k]) => k));
69
+ const LOOSE = new Set(Object.entries(FORMATS).filter(([, f]) => f.loose).map(([k]) => k));
68
70
 
69
71
  const extOf = (abs: string) => abs.slice(abs.lastIndexOf('.') + 1).toLowerCase();
70
72
  const resOf = (abs: string) => 'res://' + abs.slice(ROOT.length + 1).replaceAll('\\', '/');
71
- const isScannableText = (abs: string) => SCANNED.has(extOf(abs));
72
- const isNativeText = (abs: string) => NATIVE.has(extOf(abs));
73
- const isSourceAsset = (abs: string) => existsSync(abs + '.import') || isNativeText(abs);
73
+ const isLoose = (abs: string) => LOOSE.has(extOf(abs));
74
+
75
+ // A project may bake a native `.pck` (`godot --export-pack`, path overridable via
76
+ // `GODOT_PACK`) carrying the engine-converted binary resources + remap table. Its
77
+ // sibling `deps.json` (from `scripts/dump-deps.gd`) maps each packed source to the
78
+ // loose textures it references. Both are optional: without them (desktop, or a
79
+ // project that hasn't baked a pack) modules load source paths directly.
80
+ const PACK_ABS = resolve(ROOT, process.env.GODOT_PACK ?? '.godot/packs/app.pck');
81
+ const HAS_PACK = existsSync(PACK_ABS);
82
+ const DEPS_ABS = resolve(dirname(PACK_ABS), 'deps.json');
83
+ const DEPS: Record<string, string[]> = existsSync(DEPS_ABS)
84
+ ? (JSON.parse(readFileSync(DEPS_ABS, 'utf8')) as Record<string, string[]>)
85
+ : {};
74
86
 
75
87
  /** Reads the imported destination (`dest_files`) out of an `.import` sidecar. */
76
88
  function parseSidecarDest(text: string): string | null {
@@ -85,120 +97,52 @@ function parseSidecarDest(text: string): string | null {
85
97
  return remap ? remap[1] : null;
86
98
  }
87
99
 
88
- // Reference extraction for text-based source assets: absolute `res://` paths
89
- // (Godot scenes/resources) and relative path tokens ending in a known asset
90
- // extension (glTF uris, OBJ/MTL textures). The literal dot before the extension
91
- // avoids matching MIME types inside `data:` URIs. Regexes are built fresh per
92
- // call: a `/g` instance is stateful, and reusing one module-level regex across
93
- // many files misbehaves under the dev server's bundler (a stale lastIndex
94
- // shifted a scan's results). The `//` fragment right after `res:` is a mere
95
- // path remainder of the `res://` match, not a real relative ref, so it's
96
- // skipped (otherwise it mis-resolves as a Windows UNC `\\…` path).
97
- //
98
- // The extension must TERMINATE a token: `(?![\w.])` rejects matches where the
99
- // extension is only the head of a longer identifier or a dotted sub-field, e.g.
100
- // `X.po` in `X.position`, `foo.gd` in `foo.gd.position`, or `scene.tscn2`. A
101
- // real relative ref (`foo.po`, `x.gd`, `ui/scene.tscn`) always ends its token.
102
- // `String.raw` keeps the `\w` escapes intact — a plain template literal would
103
- // drop the backslashes, turning `\w` into `w` (a wrong guard `(?![w.])`).
104
- const REL_TERM = String.raw`(?![\w.])`;
105
- const REL_PATTERN = String.raw`([\w./\\-]+\.(?:` + Object.keys(FORMATS).join('|') + `))` + REL_TERM;
106
- function extractRefs(text: string): string[] {
107
- const refs: string[] = [];
108
- for (const m of text.matchAll(/res:\/\/[\w./\\-]+/g)) refs.push(m[0]);
109
- const rel = new RegExp(REL_PATTERN, 'g');
110
- for (const m of text.matchAll(rel)) if (!m[1].startsWith('/')) refs.push(m[1]);
111
- return refs;
112
- }
113
-
114
- /**
115
- * Adds a source asset's own files to `files`: imported assets contribute their
116
- * `.import` sidecar (text) + imported product (file); native text formats
117
- * (tscn/tres/po/gd) contribute the file itself (text).
118
- */
119
- function addSource(srcAbs: string, files: Map<string, 'text' | 'file'>): void {
100
+ /** Adds a loose texture source to `files`: its `.import` sidecar (text) + product (file). */
101
+ function addLoose(srcAbs: string, files: Map<string, 'text' | 'file'>): void {
120
102
  const impAbs = srcAbs + '.import';
121
- if (existsSync(impAbs)) {
122
- files.set(resOf(srcAbs) + '.import', 'text');
123
- const dest = parseSidecarDest(readFileSync(impAbs, 'utf8'));
124
- if (dest) {
125
- files.set(dest, 'file');
126
- }
127
- } else if (isNativeText(srcAbs)) {
128
- stageNativeText(srcAbs, files);
129
- }
130
- }
131
-
132
- /** Stages a native text source (`res://` path → `text`) so web staging + inlining picks it up. */
133
- function stageNativeText(srcAbs: string, files: Map<string, 'text' | 'file'>): void {
134
- files.set(resOf(srcAbs), 'text');
135
- }
136
-
137
- /**
138
- * Collects the referenced source assets (deps) of a text source: every referenced
139
- * source asset is staged (native text → `files`, products → dep modules), and
140
- * loadable source assets also become dep modules so their own files + transitive
141
- * deps are handled. Raw text formats without a sidecar (`.mtl`) are recursed into.
142
- * Buffers (`.bin`) are skipped.
143
- */
144
- function collectDeps(abs: string, visited: Set<string>, deps: string[], files: Map<string, 'text' | 'file'>): void {
145
- if (visited.has(abs)) {
146
- return;
147
- }
148
- visited.add(abs);
149
- if (!isScannableText(abs)) {
103
+ if (!existsSync(impAbs)) {
150
104
  return;
151
105
  }
152
- for (const ref of extractRefs(readFileSync(abs, 'utf8'))) {
153
- const refAbs = ref.startsWith('res://')
154
- ? resolve(ROOT, ref.slice('res://'.length))
155
- : resolve(dirname(abs), ref);
156
- if (visited.has(refAbs)) {
157
- continue;
158
- }
159
- // Stage native text (e.g. `.gd`, nested `.tscn`) so the engine loads it
160
- // by `res://` on web (idempotent); loadable assets also become dep
161
- // modules for their own files + transitive deps.
162
- if (isSourceAsset(refAbs)) {
163
- if (isNativeText(refAbs)) {
164
- visited.add(refAbs);
165
- stageNativeText(refAbs, files);
166
- }
167
- if (CLASS_BY_EXT[extOf(refAbs)]) {
168
- deps.push(refAbs);
169
- }
170
- }
171
- // Any scannable ref (a source's own text, or a raw leaf like `.mtl`) is
172
- // recursed into so its internal `res://` refs are traced/staged too.
173
- if (isScannableText(refAbs)) {
174
- collectDeps(refAbs, visited, deps, files);
175
- }
106
+ files.set(resOf(srcAbs) + '.import', 'text');
107
+ const dest = parseSidecarDest(readFileSync(impAbs, 'utf8'));
108
+ if (dest) {
109
+ files.set(dest, 'file');
176
110
  }
177
111
  }
178
112
 
179
- function analyze(abs: string): { files: Map<string, 'text' | 'file'>; deps: string[] } {
113
+ function analyze(abs: string): Map<string, 'text' | 'file'> {
180
114
  const files = new Map<string, 'text' | 'file'>();
181
- const deps: string[] = [];
182
- addSource(abs, files);
183
- collectDeps(abs, new Set(), deps, files);
184
- return { files, deps };
115
+ // A directly imported loose texture stages itself; a packed resource stages the
116
+ // loose textures the engine's dependency dump found for it.
117
+ if (isLoose(abs)) {
118
+ addLoose(abs, files);
119
+ }
120
+ for (const loose of DEPS[resOf(abs)] ?? []) {
121
+ addLoose(resolve(ROOT, loose.slice('res://'.length)), files);
122
+ }
123
+ return files;
185
124
  }
186
125
 
187
126
  function ensureModule(abs: string): string {
188
127
  const res = resOf(abs);
189
128
  const cls = CLASS_BY_EXT[extOf(abs)] ?? 'Resource';
190
- const { files, deps } = analyze(abs);
129
+ const files = analyze(abs);
191
130
 
192
131
  const imports = [
193
- `import { loadAsset } from '@ringozz/godot/load';`,
132
+ `import { loadAsset, loadAssetPack } from '@ringozz/godot/load';`,
194
133
  `import { ${cls} } from '@ringozz/godot/${cls}';`,
195
134
  ];
135
+ let ready = 'undefined';
136
+ if (HAS_PACK) {
137
+ imports.push(`import pack0 from ${JSON.stringify(PACK_ABS)} with { type: 'file' };`);
138
+ ready = 'loadAssetPack(pack0)';
139
+ }
196
140
  const map: string[] = [];
197
141
  let fi = 0;
198
- // Text files (own `.tscn`/`.tres`/`.po`, `.import` sidecars, staged `.gd`
199
- // leaves) are inlined as string literals — Bun's `with { type: 'text' }`
200
- // import resolves to a null/default under the dev server's HMR wrapper for
201
- // a module's own path; products (`.scn`/`.ctex`) are fetched by path.
142
+ // Loose texture files: sidecars as inlined string literals (Bun's
143
+ // `with { type: 'text' }` import resolves to a null/default under the dev
144
+ // server's HMR wrapper for a module's own path), products fetched by path so
145
+ // the host can transcode them.
202
146
  for (const [resPath, kind] of files) {
203
147
  const absP = resolve(ROOT, resPath.slice('res://'.length));
204
148
  if (kind === 'text') {
@@ -209,10 +153,9 @@ function ensureModule(abs: string): string {
209
153
  imports.push(`import ${v} from ${JSON.stringify(absP)} with { type: 'file' };`);
210
154
  map.push(` ${JSON.stringify(resPath)}: { path: ${v} },`);
211
155
  }
212
- deps.forEach((dep, i) => imports.push(`import * as dep${i} from ${JSON.stringify(dep)};`));
213
156
 
214
157
  const filesVar = files.size ? `const files = {\n${map.join('\n')}\n};` : `const files = {};`;
215
- const loadAsset = `const { materialize, load } = loadAsset(${JSON.stringify(res)}, ${cls}, files, [${deps.map((_, i) => `dep${i}`).join(', ')}], import.meta.hot.data);`;
158
+ const loadAsset = `const { materialize, load } = loadAsset(${JSON.stringify(res)}, ${cls}, files, import.meta.hot.data, ${ready});`;
216
159
  return imports.join('\n') + '\n' + filesVar + '\n' + loadAsset + '\n' + `export { materialize, load as default };` + '\n';
217
160
  }
218
161
 
@@ -220,13 +163,13 @@ const assetPlugin: BunPlugin = {
220
163
  name: 'godot-assets',
221
164
  setup(build) {
222
165
  build.onResolve({ filter: ASSET_RE }, (args) => {
223
- // Only JS module imports of Godot source assets become `godot`
224
- // modules. `Bun.build` reports JS imports as `import-statement`
225
- // (and `dynamic-import`/`require-*`) but HTML asset references
226
- // (<link>/<img>) and CSS url() refs as `internal`/`url-token` —
227
- // those fall through so Bun copies/hashes the file normally. The
228
- // runtime preload (bun run / bun test) reports JS imports with no
229
- // kind (`undefined`), so treat that as a JS import too.
166
+ // Only JS module imports of Godot source assets become `godot` modules.
167
+ // `Bun.build` reports JS imports as `import-statement` (and
168
+ // `dynamic-import`/`require-*`) but HTML asset references (<link>/<img>)
169
+ // and CSS url() refs as `internal`/`url-token` — those fall through so
170
+ // Bun copies/hashes the file normally. The runtime preload (bun run /
171
+ // bun test) reports JS imports with no kind (`undefined`), so treat that
172
+ // as a JS import too.
230
173
  if (
231
174
  args.kind !== undefined &&
232
175
  args.kind !== 'import-statement' &&
@@ -236,17 +179,8 @@ const assetPlugin: BunPlugin = {
236
179
  ) {
237
180
  return;
238
181
  }
239
- const importerAbs = args.importer?.startsWith(`${NS}:`)
240
- ? args.importer.slice(NS.length + 1)
241
- : args.importer;
242
- const base = importerAbs ? dirname(importerAbs) : (args.resolveDir ?? ROOT);
243
- const abs = resolve(base, args.path);
244
- // A module's own native-text source (e.g. `.tscn`) self-imports the
245
- // same path — let it load as a real file.
246
- if (abs === importerAbs) {
247
- return;
248
- }
249
- return { path: abs, namespace: NS };
182
+ const base = args.importer ? dirname(args.importer) : (args.resolveDir ?? ROOT);
183
+ return { path: resolve(base, args.path), namespace: NS };
250
184
  });
251
185
  build.onLoad({ filter: /.*/, namespace: NS }, (args) => ({
252
186
  contents: ensureModule(args.path),