@ringozz/godot 4.7.2-641 → 4.7.2-646

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-641",
4
+ "version": "4.7.2-646",
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-641",
69
- "@ringozz/godot-windows-x86_64": "^4.7.2-641",
70
- "@ringozz/godot-web-wasm32": "^4.7.2-641"
69
+ "@ringozz/godot-macos-arm64": "^4.7.2-646",
70
+ "@ringozz/godot-windows-x86_64": "^4.7.2-646",
71
+ "@ringozz/godot-web-wasm32": "^4.7.2-646"
71
72
  },
72
73
  "peerDependencies": {
73
74
  "@types/bun": "*"
@@ -0,0 +1,109 @@
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
+ const PACKED := ["tscn", "tres", "gltf", "obj", "gd", "gdshader", "gdshaderinc", "ttf", "otf", "po"]
20
+ const SCANNED := ["tscn", "tres", "gd", "gdshader", "gdshaderinc", "gltf", "obj"]
21
+
22
+
23
+ func _initialize() -> void:
24
+ var out := OS.get_environment("GODOT_DUMP_OUT")
25
+ if out.is_empty():
26
+ out = "res://.godot/packs/deps.json"
27
+ var roots := OS.get_environment("GODOT_DUMP_ROOTS").split(",", false)
28
+ if roots.is_empty():
29
+ roots = ["res://"]
30
+ var deps := {}
31
+ for r in roots:
32
+ _scan(String(r).rstrip("/") + "/", deps)
33
+ DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(out.get_base_dir()))
34
+ var f := FileAccess.open(out, FileAccess.WRITE)
35
+ f.store_string(JSON.stringify(deps, " "))
36
+ f.close()
37
+ print("[dump-deps] ", deps.size(), " resources -> ", out)
38
+ quit()
39
+
40
+
41
+ func _scan(path: String, out: Dictionary) -> void:
42
+ var d := DirAccess.open(path)
43
+ if d == null:
44
+ return
45
+ for dir in d.get_directories():
46
+ if dir.begins_with(".") or dir == "node_modules":
47
+ continue
48
+ _scan(path.path_join(dir) + "/", out)
49
+ for file in d.get_files():
50
+ var p := path.path_join(file)
51
+ if p.get_extension().to_lower() in PACKED:
52
+ out[p] = _loose(p)
53
+
54
+
55
+ ## The loose texture sources reachable from `path` (transitive), sorted.
56
+ func _loose(path: String) -> Array:
57
+ var loose := {}
58
+ _collect(path, loose, {})
59
+ var arr := loose.keys()
60
+ arr.sort()
61
+ return arr
62
+
63
+
64
+ func _classify(dep: String, loose: Dictionary, seen: Dictionary) -> void:
65
+ if dep.is_empty():
66
+ return
67
+ if dep.get_extension().to_lower() in PACKED:
68
+ _collect(dep, loose, seen)
69
+ elif FileAccess.file_exists(dep + ".import"):
70
+ loose[dep] = true
71
+
72
+
73
+ func _collect(path: String, loose: Dictionary, seen: Dictionary) -> void:
74
+ if seen.has(path):
75
+ return
76
+ seen[path] = true
77
+ # An imported source's own `get_dependencies` is empty (the importer hides the
78
+ # refs); the imported *product* carries them — recurse through the sidecar.
79
+ var imp := path + ".import"
80
+ if FileAccess.file_exists(imp):
81
+ var cfg := ConfigFile.new()
82
+ if cfg.load(imp) == OK:
83
+ for dst in cfg.get_value("deps", "dest_files", []):
84
+ _collect(String(dst), loose, seen)
85
+ for dep in ResourceLoader.get_dependencies(path):
86
+ _classify(_res_path(String(dep)), loose, seen)
87
+ # String-valued resource paths are invisible to dependency analysis; scan the
88
+ # text for loose refs the engine cannot report.
89
+ if path.get_extension().to_lower() in SCANNED:
90
+ for p in _text_refs(path):
91
+ _classify(p, loose, seen)
92
+
93
+
94
+ ## `res://` path from a dependency entry (`uid://…::::res://…` or plain).
95
+ func _res_path(dep: String) -> String:
96
+ var i := dep.find("res://")
97
+ if i < 0:
98
+ return ""
99
+ return dep.substr(i)
100
+
101
+
102
+ ## Literal `res://` paths appearing in a text source.
103
+ func _text_refs(path: String) -> Array:
104
+ var re := RegEx.new()
105
+ re.compile('res://[^"\']+')
106
+ var refs := []
107
+ for m in re.search_all(FileAccess.get_file_as_string(path)):
108
+ refs.append(m.get_string())
109
+ 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,52 @@ 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
+ tscn: { cls: 'PackedScene' },
35
+ obj: { cls: 'PackedScene' },
36
+ gd: { cls: 'GDScript' },
37
+ gdshader: { cls: 'Shader' },
38
+ gdshaderinc: { cls: 'ShaderInclude' },
39
+ jpg: { cls: 'CompressedTexture2D', loose: true },
40
+ jpeg: { cls: 'CompressedTexture2D', loose: true },
41
+ png: { cls: 'CompressedTexture2D', loose: true },
42
+ webp: { cls: 'CompressedTexture2D', loose: true },
42
43
  // 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' },
44
+ // layers are source WebP bytes). It has no runtime deps of its own; the host stages its product.
45
+ webparray: { cls: 'CompressedTexture2DArray', loose: true },
46
+ svg: { cls: 'CompressedTexture2D', loose: true },
47
+ exr: { cls: 'TextureLayered', loose: true },
48
+ hdr: { cls: 'TextureLayered', loose: true },
49
49
  wav: { cls: 'AudioStreamWAV' },
50
50
  ogg: { cls: 'AudioStreamOggVorbis' },
51
51
  mp3: { cls: 'AudioStreamMP3' },
52
- tres: { cls: 'Resource', scan: true, native: true },
53
- po: { cls: 'Translation', native: true },
52
+ tres: { cls: 'Resource' },
53
+ po: { cls: 'Translation' },
54
54
  ttf: { cls: 'FontFile' },
55
55
  otf: { cls: 'FontFile' },
56
- mtl: { scan: true },
56
+ mtl: {},
57
57
  };
58
58
 
59
59
  const ASSET_RE = new RegExp(
@@ -63,14 +63,23 @@ const ASSET_RE = new RegExp(
63
63
  const CLASS_BY_EXT: Record<string, string> = Object.fromEntries(
64
64
  Object.entries(FORMATS).filter(([, f]) => f.cls).map(([k, f]) => [k, f.cls!]),
65
65
  );
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));
66
+ const LOOSE = new Set(Object.entries(FORMATS).filter(([, f]) => f.loose).map(([k]) => k));
68
67
 
69
68
  const extOf = (abs: string) => abs.slice(abs.lastIndexOf('.') + 1).toLowerCase();
70
69
  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);
70
+ const isLoose = (abs: string) => LOOSE.has(extOf(abs));
71
+
72
+ // A project may bake a native `.pck` (`godot --export-pack`, path overridable via
73
+ // `GODOT_PACK`) carrying the engine-converted binary resources + remap table. Its
74
+ // sibling `deps.json` (from `scripts/dump-deps.gd`) maps each packed source to the
75
+ // loose textures it references. Both are optional: without them (desktop, or a
76
+ // project that hasn't baked a pack) modules load source paths directly.
77
+ const PACK_ABS = resolve(ROOT, process.env.GODOT_PACK ?? '.godot/packs/app.pck');
78
+ const HAS_PACK = existsSync(PACK_ABS);
79
+ const DEPS_ABS = resolve(dirname(PACK_ABS), 'deps.json');
80
+ const DEPS: Record<string, string[]> = existsSync(DEPS_ABS)
81
+ ? (JSON.parse(readFileSync(DEPS_ABS, 'utf8')) as Record<string, string[]>)
82
+ : {};
74
83
 
75
84
  /** Reads the imported destination (`dest_files`) out of an `.import` sidecar. */
76
85
  function parseSidecarDest(text: string): string | null {
@@ -85,120 +94,52 @@ function parseSidecarDest(text: string): string | null {
85
94
  return remap ? remap[1] : null;
86
95
  }
87
96
 
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 {
97
+ /** Adds a loose texture source to `files`: its `.import` sidecar (text) + product (file). */
98
+ function addLoose(srcAbs: string, files: Map<string, 'text' | 'file'>): void {
120
99
  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)) {
100
+ if (!existsSync(impAbs)) {
150
101
  return;
151
102
  }
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
- }
103
+ files.set(resOf(srcAbs) + '.import', 'text');
104
+ const dest = parseSidecarDest(readFileSync(impAbs, 'utf8'));
105
+ if (dest) {
106
+ files.set(dest, 'file');
176
107
  }
177
108
  }
178
109
 
179
- function analyze(abs: string): { files: Map<string, 'text' | 'file'>; deps: string[] } {
110
+ function analyze(abs: string): Map<string, 'text' | 'file'> {
180
111
  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 };
112
+ // A directly imported loose texture stages itself; a packed resource stages the
113
+ // loose textures the engine's dependency dump found for it.
114
+ if (isLoose(abs)) {
115
+ addLoose(abs, files);
116
+ }
117
+ for (const loose of DEPS[resOf(abs)] ?? []) {
118
+ addLoose(resolve(ROOT, loose.slice('res://'.length)), files);
119
+ }
120
+ return files;
185
121
  }
186
122
 
187
123
  function ensureModule(abs: string): string {
188
124
  const res = resOf(abs);
189
125
  const cls = CLASS_BY_EXT[extOf(abs)] ?? 'Resource';
190
- const { files, deps } = analyze(abs);
126
+ const files = analyze(abs);
191
127
 
192
128
  const imports = [
193
- `import { loadAsset } from '@ringozz/godot/load';`,
129
+ `import { loadAsset, loadAssetPack } from '@ringozz/godot/load';`,
194
130
  `import { ${cls} } from '@ringozz/godot/${cls}';`,
195
131
  ];
132
+ let ready = 'undefined';
133
+ if (HAS_PACK) {
134
+ imports.push(`import pack0 from ${JSON.stringify(PACK_ABS)} with { type: 'file' };`);
135
+ ready = 'loadAssetPack(pack0)';
136
+ }
196
137
  const map: string[] = [];
197
138
  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.
139
+ // Loose texture files: sidecars as inlined string literals (Bun's
140
+ // `with { type: 'text' }` import resolves to a null/default under the dev
141
+ // server's HMR wrapper for a module's own path), products fetched by path so
142
+ // the host can transcode them.
202
143
  for (const [resPath, kind] of files) {
203
144
  const absP = resolve(ROOT, resPath.slice('res://'.length));
204
145
  if (kind === 'text') {
@@ -209,10 +150,9 @@ function ensureModule(abs: string): string {
209
150
  imports.push(`import ${v} from ${JSON.stringify(absP)} with { type: 'file' };`);
210
151
  map.push(` ${JSON.stringify(resPath)}: { path: ${v} },`);
211
152
  }
212
- deps.forEach((dep, i) => imports.push(`import * as dep${i} from ${JSON.stringify(dep)};`));
213
153
 
214
154
  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);`;
155
+ const loadAsset = `const { materialize, load } = loadAsset(${JSON.stringify(res)}, ${cls}, files, import.meta.hot.data, ${ready});`;
216
156
  return imports.join('\n') + '\n' + filesVar + '\n' + loadAsset + '\n' + `export { materialize, load as default };` + '\n';
217
157
  }
218
158
 
@@ -220,13 +160,13 @@ const assetPlugin: BunPlugin = {
220
160
  name: 'godot-assets',
221
161
  setup(build) {
222
162
  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.
163
+ // Only JS module imports of Godot source assets become `godot` modules.
164
+ // `Bun.build` reports JS imports as `import-statement` (and
165
+ // `dynamic-import`/`require-*`) but HTML asset references (<link>/<img>)
166
+ // and CSS url() refs as `internal`/`url-token` — those fall through so
167
+ // Bun copies/hashes the file normally. The runtime preload (bun run /
168
+ // bun test) reports JS imports with no kind (`undefined`), so treat that
169
+ // as a JS import too.
230
170
  if (
231
171
  args.kind !== undefined &&
232
172
  args.kind !== 'import-statement' &&
@@ -236,17 +176,8 @@ const assetPlugin: BunPlugin = {
236
176
  ) {
237
177
  return;
238
178
  }
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 };
179
+ const base = args.importer ? dirname(args.importer) : (args.resolveDir ?? ROOT);
180
+ return { path: resolve(base, args.path), namespace: NS };
250
181
  });
251
182
  build.onLoad({ filter: /.*/, namespace: NS }, (args) => ({
252
183
  contents: ensureModule(args.path),
package/src/web-image.ts CHANGED
@@ -12,8 +12,8 @@
12
12
  // a single parse/allocate pair covers everything — a container is just a list of
13
13
  // layers.
14
14
  //
15
- // Exposes the container/format helpers, `pickGodotFormats` (probing the WebGL2
16
- // extensions GLES3 checks) and `decodeCtex` — the default staging path, which
15
+ // Exposes the container/format helpers, `pickGodotFormats` (the driver's
16
+ // compressed-format list) and `decodeCtex` — the default staging path, which
17
17
  // decodes the PNG/WebP payloads with the browser and rebuilds a raw RGBA8
18
18
  // container so no image codec runs in wasm. The optional compressed-transcode
19
19
  // hook lives in load.ts.
@@ -209,16 +209,50 @@ export function allocateCtex(
209
209
  }
210
210
 
211
211
  // ---------------------------------------------------------------------------
212
- // Capability probing. Godot's Compatibility (GLES3) renderer is what the web
213
- // wasm build uses, and it only uploads a compressed format natively when the
214
- // matching GL extension is present; this build compiles in NO decompressors, so
215
- // a format the device can't upload breaks the texture rather than degrading.
216
- // It selects formats for layered/3D textures exactly as for 2D — the
217
- // `astc_layered_supported`/`astc_3d_supported` flags it computes are only used
218
- // to derive `astc_supported`/`astc_hdr_supported` — so this probe covers texture
219
- // arrays and cubemaps too.
212
+ // Capability probing. GLES3 uploads a compressed format only when its config
213
+ // reports it supported, and this build compiles in NO decompressors, so a format
214
+ // the device can't upload breaks the texture rather than degrading. Ask the
215
+ // driver (`COMPRESSED_TEXTURE_FORMATS`) rather than reading extension names:
216
+ // Chrome on Windows lists no ETC2/EAC and rejects those uploads, while
217
+ // `config.cpp` would still route one to it. RGTC arrays are the one place 2D and
218
+ // layered differ — `_texture_set_data` force-decompresses them for
219
+ // `TEXTURE_2D_ARRAY`, gated here on `Image::can_decompress("s3tc")`.
220
220
  // ---------------------------------------------------------------------------
221
221
 
222
+ // `CompressedTexture` GL enums -> Godot `Image::Format`, for the targets the
223
+ // encoder can emit; only the unsigned-normal variants (their siblings share it).
224
+ const GL_TO_GODOT_FORMAT: Record<number, number> = {
225
+ 0x83f0: FORMAT_DXT1, // COMPRESSED_RGB_S3TC_DXT1_EXT
226
+ 0x8dbb: FORMAT_RGTC_R, // COMPRESSED_RED_RGTC1_EXT
227
+ 0x8dbd: FORMAT_RGTC_RG, // COMPRESSED_RED_GREEN_RGTC2_EXT
228
+ 0x8e8c: FORMAT_BPTC_RGBA, // COMPRESSED_RGBA_BPTC_UNORM_EXT
229
+ 0x9270: FORMAT_ETC2_R11, // COMPRESSED_R11_EAC
230
+ 0x9272: FORMAT_ETC2_RG11, // COMPRESSED_RG11_EAC
231
+ 0x9274: FORMAT_ETC2_RGB8, // COMPRESSED_RGB8_ETC2
232
+ 0x93b0: FORMAT_ASTC_4x4, // COMPRESSED_RGBA_ASTC_4x4_KHR
233
+ };
234
+
235
+ /** Enabled before reading the list — a browser may only list a format once its extension is enabled. */
236
+ const PROBED_EXTENSIONS = [
237
+ 'EXT_texture_compression_bptc',
238
+ 'EXT_texture_compression_rgtc',
239
+ 'WEBGL_compressed_texture_s3tc',
240
+ 'WEBGL_compressed_texture_astc',
241
+ 'WEBGL_compressed_texture_etc',
242
+ ];
243
+
244
+ /** The Godot targets among a driver's `COMPRESSED_TEXTURE_FORMATS` list, in list order and deduplicated (pure). */
245
+ export function godotFormatsFor(glFormats: ArrayLike<number>): number[] {
246
+ const formats = new Set<number>();
247
+ for (let i = 0; i < glFormats.length; i++) {
248
+ const godot = GL_TO_GODOT_FORMAT[glFormats[i]!];
249
+ if (godot !== undefined) {
250
+ formats.add(godot);
251
+ }
252
+ }
253
+ return [...formats];
254
+ }
255
+
222
256
  /**
223
257
  * Compressed `Image::Format` targets the running device can upload. A list
224
258
  * because the encoder may also reject a format the browser exposes (e.g. spark
@@ -248,31 +282,15 @@ export function pickGodotFormats(): number[] {
248
282
  if (!gl) {
249
283
  return [];
250
284
  }
251
- const formats: number[] = [];
252
285
  try {
253
- const exts = new Set(gl.getSupportedExtensions() ?? []);
254
- if (exts.has('EXT_texture_compression_bptc')) {
255
- formats.push(FORMAT_BPTC_RGBA);
256
- }
257
- if (exts.has('EXT_texture_compression_rgtc')) {
258
- formats.push(FORMAT_RGTC_RG);
259
- formats.push(FORMAT_RGTC_R);
260
- }
261
- if (exts.has('WEBGL_compressed_texture_astc')) {
262
- formats.push(FORMAT_ASTC_4x4);
263
- }
264
- if (exts.has('WEBGL_compressed_texture_s3tc')) {
265
- formats.push(FORMAT_DXT1);
286
+ for (const name of PROBED_EXTENSIONS) {
287
+ gl.getExtension(name);
266
288
  }
267
- // ETC2/EAC is core WebGL2 (ES 3.0), which is how Godot's web config
268
- // reports it (`etc2_supported = true` for non-GLES-over-GL) — so these
269
- // are always uploadable, independent of any extension.
270
- formats.push(FORMAT_ETC2_RGB8, FORMAT_ETC2_RG11, FORMAT_ETC2_R11);
289
+ return godotFormatsFor(gl.getParameter(gl.COMPRESSED_TEXTURE_FORMATS) as ArrayLike<number>);
271
290
  } finally {
272
291
  // Don't retain the probe context for the page's lifetime.
273
292
  gl.getExtension('WEBGL_lose_context')?.loseContext();
274
293
  }
275
- return formats;
276
294
  }
277
295
 
278
296
  /**