@ringozz/godot 4.7.1-8 → 4.7.2-570

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts CHANGED
@@ -1,40 +1,52 @@
1
- /**********************************************************************
2
- Copyright (c) Vladimir Davidovich. All rights reserved.
3
- ***********************************************************************/
4
-
5
- export * from '../gen/index.ts';
6
- import * as ValueTypes from '../gen/value-types/index.ts';
7
- import * as HeapTypes from '../gen/heap-types/index.ts';
8
- import { Engine } from '../gen/classes/Engine.ts';
9
- import { GodotInstance } from '../gen/classes/GodotInstance.ts';
10
- import { SceneTree } from '../gen/classes/SceneTree.ts';
11
- import { Window } from '../gen/classes/Window.ts';
12
- import { gc } from './debug.ts';
13
- import { cancelAnimationFrame, getGodot, requestAnimationFrame } from './runtime.ts';
14
-
15
- // ---- make sure these classes are not tree-shaked ----
16
- void ValueTypes;
17
- void HeapTypes;
18
- void GodotInstance;
19
- void SceneTree;
20
- void Window;
21
-
22
- // ---- globalThis browser-compat API ----
23
- Object.assign(globalThis as any, { requestAnimationFrame, cancelAnimationFrame });
24
-
25
- // ---- GodotInstance frame pump (engine already started by C++ InitModule) ----
26
- export async function runGodot(signal?: AbortSignal, unmount?: () => PromiseLike<void>) {
27
- signal?.throwIfAborted();
28
- signal?.addEventListener('abort', () => {
29
- (Engine.getMainLoop() as SceneTree)?.quit();
30
- }, { once: true });
31
-
32
- const godot = getGodot();
33
- while (!godot.iteration())
34
- await new Promise(requestAnimationFrame);
35
-
36
- await unmount?.();
37
- console.log(gc());
38
- return godot.free();
39
- }
40
-
1
+ /**********************************************************************
2
+ Copyright (c) Vladimir Davidovich. All rights reserved.
3
+ ***********************************************************************/
4
+
5
+ export * from '../gen/index.ts';
6
+ import * as ValueTypes from '../gen/value-types/index.ts';
7
+ import * as HeapTypes from '../gen/heap-types/index.ts';
8
+ import { Engine } from '../gen/classes/Engine.ts';
9
+ import { GodotInstance } from '../gen/classes/GodotInstance.ts';
10
+ import { SceneTree } from '../gen/classes/SceneTree.ts';
11
+ import { Window } from '../gen/classes/Window.ts';
12
+ import { gc } from './debug.ts';
13
+ import { cancelAnimationFrame, cleanupHooks, getGodot, requestAnimationFrame } from './runtime.ts';
14
+
15
+ // ---- make sure these classes are not tree-shaked ----
16
+ void ValueTypes;
17
+ void HeapTypes;
18
+ void GodotInstance;
19
+ void SceneTree;
20
+ void Window;
21
+
22
+ // ---- globalThis browser-compat API ----
23
+ Object.assign(globalThis as any, { requestAnimationFrame, cancelAnimationFrame });
24
+
25
+ // ---- GodotInstance frame pump (engine already started by C++ InitModule) ----
26
+ export async function runGodot(signal?: AbortSignal, unmount?: () => PromiseLike<void>) {
27
+ signal?.throwIfAborted();
28
+ signal?.addEventListener('abort', () => {
29
+ (Engine.getMainLoop() as SceneTree)?.quit();
30
+ }, { once: true });
31
+
32
+ const godot = getGodot();
33
+ while (!godot.iteration())
34
+ await new Promise(requestAnimationFrame);
35
+
36
+ await unmount?.();
37
+ while (cleanupHooks.size) {
38
+ const hooks = Array.from(cleanupHooks); cleanupHooks.clear();
39
+ await Promise.all(hooks.map((hook) => hook()));
40
+ }
41
+
42
+ // Let pending Napi wrapper finalizers run before tearing down the engine:
43
+ // Bun.gc(true) marks unreachable wrappers but their ~GodotVar/~Variant
44
+ // (which unrefs RefCounted objects) only runs on later macrotasks — and one
45
+ // wrapper's finalizer can free others. Freeing the engine first would report
46
+ // every still-referenced RefCounted as leaked, so drain a few gc+tick rounds.
47
+ for (let i = 0; i < 4; i++) {
48
+ console.log(gc());
49
+ await new Promise((resolve) => setTimeout(resolve, 0));
50
+ }
51
+ return godot.free();
52
+ }
package/src/load.ts CHANGED
@@ -1,210 +1,231 @@
1
- /**********************************************************************
2
- Copyright (c) Vladimir Davidovich. All rights reserved.
3
- ***********************************************************************/
4
-
5
- import { DirAccess } from '../gen/classes/DirAccess.ts';
6
- import { FileAccess } from '../gen/classes/FileAccess.ts';
7
- import { ProjectSettings } from '../gen/classes/ProjectSettings.ts';
8
- import { ResourceLoader, ThreadLoadStatus } from '../gen/classes/ResourceLoader.ts';
9
- import { ResourceUID } from '../gen/classes/ResourceUID.ts';
10
- import type { Resource } from '../gen/classes/Resource.ts';
11
- import { stageFile } from './runtime.ts';
12
- import { decodeCtex } from './web-image.ts';
13
-
14
- /**
15
- * A Godot `Resource` subclass constructor: its `.name` is the registered class
16
- * name (used as the `ResourceLoader` type hint), and `InstanceType<C>` is the
17
- * loaded resource type.
18
- */
19
- type ResourceConstructor = { new(...args: any[]): Resource } & Function;
20
-
21
- /**
22
- * A file to stage before loading, keyed by `res://` path: sidecars (`.import`)
23
- * arrive as `content` (text); imported products (`.scn`/`.ctex`) arrive as
24
- * `path` (fetched).
25
- */
26
- interface AssetFile {
27
- content?: string;
28
- path?: string;
29
- }
30
-
31
- export type AssetFiles = Record<string, AssetFile>;
32
-
33
- const UID_RE = /uid="(uid:\/\/[\w.]+)"/;
34
-
35
- // Godot encodes `uid://` numbers in base 34 over `a-y` then `0-8`
36
- // (core/io/resource_uid.cpp). Decode in JS with BigInt: `ResourceUID.textToId`
37
- // returns a JS number, and these ids exceed `Number.MAX_SAFE_INTEGER`, so the
38
- // low digits would be lost. BigInt flows through `_C` losslessly (int64).
39
- const UID_CHARS = 'abcdefghijklmnopqrstuvwxy012345678';
40
-
41
- function uidToId(uid: string): bigint {
42
- let id = 0n;
43
- for (let i = 6; i < uid.length; i++) {
44
- id = id * 34n + BigInt(UID_CHARS.indexOf(uid[i]));
45
- }
46
- return id;
47
- }
48
-
49
- /**
50
- * Registers an asset's `uid://` → `res://` path so scene ext_resource references
51
- * resolve it (Godot's `ResourceUID` map) instead of warning and falling back to
52
- * the stored text path. Idempotent; skips unknown/malformed uids.
53
- */
54
- function registerAssetUid(uid: string, resPath: string): void {
55
- const id = uidToId(uid);
56
- if (id !== 0n && !ResourceUID.hasId(id as unknown as number)) {
57
- ResourceUID.addId(id as unknown as number, resPath);
58
- }
59
- }
60
-
61
- async function materialize([resPath, entry]: [string, AssetFile]): Promise<void> {
62
- if (FileAccess.fileExists(resPath)) {
63
- return;
64
- }
65
- let bytes: Uint8Array;
66
- if (entry.content !== undefined) {
67
- // Sidecars (`.import`) and native text sources carry the asset's `uid=`;
68
- // register it so scenes resolve by uid. `.import` maps to the source path
69
- // (resPath minus the suffix); native files map to themselves.
70
- const uid = entry.content.match(UID_RE)?.[1];
71
- if (uid) {
72
- registerAssetUid(uid, resPath.endsWith('.import') ? resPath.slice(0, -'.import'.length) : resPath);
73
- }
74
- bytes = new TextEncoder().encode(entry.content);
75
- } else if (entry.path) {
76
- const res = await fetch(entry.path);
77
- if (!res.ok) {
78
- return;
79
- }
80
- bytes = new Uint8Array(await res.arrayBuffer());
81
- if (resPath.endsWith('.ctex')) {
82
- // On web, decode embedded PNG/WebP blobs with the browser before
83
- // staging, so the engine never runs an image codec (see web-image.ts).
84
- bytes = await decodeCtex(bytes);
85
- }
86
- } else {
87
- return;
88
- }
89
- stageFile?.(ProjectSettings.globalizePath(resPath), bytes);
90
- }
91
-
92
- const nextTick = () => new Promise(requestAnimationFrame);
93
-
94
- /**
95
- * An asset module as generated by `@ringozz/godot/preload`: `default` is the
96
- * load promise, `materialize` resolves once its bundled files (plus its deps'
97
- * files) are staged on the engine's filesystem.
98
- */
99
- interface AssetModule {
100
- default: Promise<unknown>;
101
- materialize: Promise<unknown>;
102
- }
103
-
104
- /**
105
- * Stages the asset's bundled files before loading: `.import` sidecars as text
106
- * and imported products (`.scn`/`.ctex`) as fetched paths. On web the bytes are
107
- * written straight into Emscripten's MEMFS via Godot's `copyToFS` (JS-heap only,
108
- * no wasm copy); a no-op on desktop where the files already exist. `deps`
109
- * supplies referenced asset modules whose file-staging is awaited (so Godot can
110
- * resolve them) and whose load failures are logged.
111
- */
112
- function materializeFiles(files: AssetFiles, deps: AssetModule[] = []): Promise<unknown> {
113
- for (const dep of deps) {
114
- dep.default.catch((err) => console.error('[godot] dependency load failed:', err));
115
- }
116
- if (stageFile === undefined) {
117
- // Desktop: the files already exist on disk (res:// = cwd) and uids come
118
- // from `.godot/uid_cache.bin`, so there is nothing to stage.
119
- return Promise.resolve();
120
- }
121
- return Promise.all([...Object.entries(files).map(materialize), ...deps.map((dep) => dep.materialize)]);
122
- }
123
-
124
- /**
125
- * Returns a promise resolving to the resource already cached in the engine at
126
- * `path` (`ResourceCache`), or `null` when it isn't loaded yet. Used by
127
- * {@link loadAsset} to skip file staging and the threaded load when a
128
- * re-evaluated module (e.g. web HMR) references an asset that is still cached.
129
- * `getCachedRef` returns the same JS wrapper as `loadThreadedGet` (instance
130
- * binding), so identity is preserved.
131
- */
132
- function cachedResource<C extends ResourceConstructor>(path: string): Promise<InstanceType<C>> | null {
133
- const result = ResourceLoader.getCachedRef(path) as InstanceType<C>;
134
- return result ? Promise.resolve(result) : null;
135
- }
136
-
137
- /**
138
- * Entry point for the generated asset modules (`@ringozz/godot/preload`): checks
139
- * the engine's `ResourceCache` once (`cachedResource`), stages the bundled files
140
- * via `materializeFiles` when needed, then loads via {@link loadResourceAsync},
141
- * memoizing the resulting load promise on `data` (the module's
142
- * `import.meta.hot.data`, carried across HMR re-evaluations; `{}` on desktop
143
- * where modules evaluate once). Memoization keeps `use()` seeing the **same**
144
- * fulfilled promise object across re-evaluations — no Suspense fallback flash on
145
- * hot reload; a rejected load is evicted so the next evaluation retries. Returns
146
- * the `materialize` promise (own + deps' files staged) and the load promise.
147
- */
148
- export function loadAsset<C extends ResourceConstructor>(
149
- path: string,
150
- cls: C,
151
- files: AssetFiles,
152
- deps: AssetModule[] = [],
153
- data: Record<string, unknown> = {},
154
- ): { materialize: Promise<unknown>; load: Promise<InstanceType<C>> } {
155
- const cached = cachedResource<C>(path);
156
- const materialize = cached ? Promise.resolve() : materializeFiles(files, deps);
157
- const existing = data[path] as Promise<InstanceType<C>> | undefined;
158
- const load = existing ?? cached ?? materialize.then(() => loadResourceAsync(path, cls, files));
159
- data[path] = load;
160
- load.catch(() => {
161
- if (data[path] === load) delete data[path];
162
- });
163
- return { materialize, load };
164
- }
165
-
166
- /**
167
- * Loads a resource in the background. `cls` supplies both the `ResourceLoader`
168
- * type hint (its registered `.name`) and the return type. On web, call
169
- * {@link materializeFiles} with the asset's bundled files first; on desktop the
170
- * files already exist. Pass the same `files` map to have the staged files
171
- * deleted from MEMFS once the resource is loaded (the resource stays cached in
172
- * the engine, so the bytes are no longer needed).
173
- */
174
- export async function loadResourceAsync<C extends ResourceConstructor>(
175
- path: string,
176
- cls: C,
177
- files?: AssetFiles,
178
- ): Promise<InstanceType<C>> {
179
- const err = ResourceLoader.loadThreadedRequest(path, cls.name);
180
- if (err) {
181
- throw new Error(`loadResourceAsync(${path}): loadThreadedRequest failed (${err})`);
182
- }
183
-
184
- let result: InstanceType<C>;
185
- while (true) {
186
- const status = ResourceLoader.loadThreadedGetStatus(path);
187
- if (status === ThreadLoadStatus.THREAD_LOAD_LOADED) {
188
- result = ResourceLoader.loadThreadedGet(path) as InstanceType<C>;
189
- break;
190
- }
191
- if (status === ThreadLoadStatus.THREAD_LOAD_FAILED || status === ThreadLoadStatus.THREAD_LOAD_INVALID_RESOURCE) {
192
- throw new Error(`loadResourceAsync(${path}): load failed (status ${status})`);
193
- }
194
- await nextTick();
195
- }
196
- if (files && stageFile !== undefined) {
197
- // Keep `.import` sidecars — they route imported source paths to their
198
- // products (ResourceFormatImporter recognizes a path by its sidecar).
199
- // Delete everything else: the products are read only on a cache miss, and
200
- // the resource stays cached after loading, so the bytes are dead weight.
201
- // Best-effort: a file may already be gone (another module's cleanup or a
202
- // cache-miss re-stage).
203
- for (const resPath of Object.keys(files)) {
204
- if (!resPath.endsWith('.import')) {
205
- DirAccess.removeAbsolute(resPath);
206
- }
207
- }
208
- }
209
- return result;
210
- }
1
+ /**********************************************************************
2
+ Copyright (c) Vladimir Davidovich. All rights reserved.
3
+ ***********************************************************************/
4
+
5
+ import { DirAccess } from '../gen/classes/DirAccess.ts';
6
+ import { FileAccess } from '../gen/classes/FileAccess.ts';
7
+ import { ProjectSettings } from '../gen/classes/ProjectSettings.ts';
8
+ import { ResourceLoader, ThreadLoadStatus } from '../gen/classes/ResourceLoader.ts';
9
+ import { ResourceUID } from '../gen/classes/ResourceUID.ts';
10
+ import type { Resource } from '../gen/classes/Resource.ts';
11
+ import { cleanupHooks, stageFile } from './runtime.ts';
12
+ import { decodeCtex } from './web-image.ts';
13
+
14
+ // Each loaded wrapper is released at shutdown via a `cleanupHooks` entry
15
+ // holding a WeakRef: module-scope asset imports (the preload modules' memoized
16
+ // load promises) keep their wrappers reachable, so GC alone can't collect them
17
+ // before the engine stops. WeakRef keeps the entries from pinning resources
18
+ // during the app's lifetime.
19
+ function track<T extends Resource>(res: T): T {
20
+ const ref = new WeakRef<Resource>(res);
21
+ cleanupHooks.add(() => {
22
+ const r = ref.deref();
23
+ if (r) {
24
+ try { r.free(); } catch {}
25
+ }
26
+ });
27
+ return res;
28
+ }
29
+
30
+ /**
31
+ * A Godot `Resource` subclass constructor: its `.name` is the registered class
32
+ * name (used as the `ResourceLoader` type hint), and `InstanceType<C>` is the
33
+ * loaded resource type.
34
+ */
35
+ type ResourceConstructor = { new(...args: any[]): Resource } & Function;
36
+
37
+ /**
38
+ * A file to stage before loading, keyed by `res://` path: sidecars (`.import`)
39
+ * arrive as `content` (text); imported products (`.scn`/`.ctex`) arrive as
40
+ * `path` (fetched).
41
+ */
42
+ interface AssetFile {
43
+ content?: string;
44
+ path?: string;
45
+ }
46
+
47
+ export type AssetFiles = Record<string, AssetFile>;
48
+
49
+ const UID_RE = /uid="(uid:\/\/[\w.]+)"/;
50
+
51
+ // Godot encodes `uid://` numbers in base 34 over `a-y` then `0-8`
52
+ // (core/io/resource_uid.cpp). Decode in JS with BigInt: `ResourceUID.textToId`
53
+ // returns a JS number, and these ids exceed `Number.MAX_SAFE_INTEGER`, so the
54
+ // low digits would be lost. BigInt flows through `_C` losslessly (int64).
55
+ const UID_CHARS = 'abcdefghijklmnopqrstuvwxy012345678';
56
+
57
+ function uidToId(uid: string): bigint {
58
+ let id = 0n;
59
+ for (let i = 6; i < uid.length; i++) {
60
+ id = id * 34n + BigInt(UID_CHARS.indexOf(uid[i]));
61
+ }
62
+ return id;
63
+ }
64
+
65
+ /**
66
+ * Registers an asset's `uid://` → `res://` path so scene ext_resource references
67
+ * resolve it (Godot's `ResourceUID` map) instead of warning and falling back to
68
+ * the stored text path. Idempotent; skips unknown/malformed uids.
69
+ */
70
+ function registerAssetUid(uid: string, resPath: string): void {
71
+ const id = uidToId(uid);
72
+ if (id !== 0n && !ResourceUID.hasId(id as unknown as number)) {
73
+ ResourceUID.addId(id as unknown as number, resPath);
74
+ }
75
+ }
76
+
77
+ async function materialize([resPath, entry]: [string, AssetFile]): Promise<void> {
78
+ if (FileAccess.fileExists(resPath)) {
79
+ return;
80
+ }
81
+ let bytes: Uint8Array;
82
+ if (entry.content !== undefined) {
83
+ // Sidecars (`.import`) and native text sources carry the asset's `uid=`;
84
+ // register it so scenes resolve by uid. `.import` maps to the source path
85
+ // (resPath minus the suffix); native files map to themselves.
86
+ const uid = entry.content.match(UID_RE)?.[1];
87
+ if (uid) {
88
+ registerAssetUid(uid, resPath.endsWith('.import') ? resPath.slice(0, -'.import'.length) : resPath);
89
+ }
90
+ bytes = new TextEncoder().encode(entry.content);
91
+ } else if (entry.path) {
92
+ const res = await fetch(entry.path);
93
+ if (!res.ok) {
94
+ return;
95
+ }
96
+ bytes = new Uint8Array(await res.arrayBuffer());
97
+ if (resPath.endsWith('.ctex')) {
98
+ // On web, decode embedded PNG/WebP blobs with the browser before
99
+ // staging, so the engine never runs an image codec (see web-image.ts).
100
+ bytes = await decodeCtex(bytes);
101
+ }
102
+ } else {
103
+ return;
104
+ }
105
+ stageFile?.(ProjectSettings.globalizePath(resPath), bytes);
106
+ }
107
+
108
+ const nextTick = () => new Promise(requestAnimationFrame);
109
+
110
+ /**
111
+ * An asset module as generated by `@ringozz/godot/preload`: `default` is the
112
+ * load promise, `materialize` resolves once its bundled files (plus its deps'
113
+ * files) are staged on the engine's filesystem.
114
+ */
115
+ interface AssetModule {
116
+ default: Promise<unknown>;
117
+ materialize: Promise<unknown>;
118
+ }
119
+
120
+ /**
121
+ * Stages the asset's bundled files before loading: `.import` sidecars as text
122
+ * and imported products (`.scn`/`.ctex`) as fetched paths. On web the bytes are
123
+ * written straight into Emscripten's MEMFS via Godot's `copyToFS` (JS-heap only,
124
+ * no wasm copy); a no-op on desktop where the files already exist. `deps`
125
+ * supplies referenced asset modules whose file-staging is awaited (so Godot can
126
+ * resolve them) and whose load failures are logged.
127
+ */
128
+ function materializeFiles(files: AssetFiles, deps: AssetModule[] = []): Promise<unknown> {
129
+ for (const dep of deps) {
130
+ dep.default.catch((err) => console.error('[godot] dependency load failed:', err));
131
+ }
132
+ if (stageFile === undefined) {
133
+ // Desktop: the files already exist on disk (res:// = cwd) and uids come
134
+ // from `.godot/uid_cache.bin`, so there is nothing to stage.
135
+ return Promise.resolve();
136
+ }
137
+ return Promise.all([...Object.entries(files).map(materialize), ...deps.map((dep) => dep.materialize)]);
138
+ }
139
+
140
+ /**
141
+ * Returns a promise resolving to the resource already cached in the engine at
142
+ * `path` (`ResourceCache`), or `null` when it isn't loaded yet. Used by
143
+ * {@link loadAsset} to skip file staging and the threaded load when a
144
+ * re-evaluated module (e.g. web HMR) references an asset that is still cached.
145
+ * `getCachedRef` returns the same JS wrapper as `loadThreadedGet` (instance
146
+ * binding), so identity is preserved.
147
+ */
148
+ function cachedResource<C extends ResourceConstructor>(path: string): Promise<InstanceType<C>> | null {
149
+ const result = ResourceLoader.getCachedRef(path) as InstanceType<C>;
150
+ return result ? Promise.resolve(result) : null;
151
+ }
152
+
153
+ /**
154
+ * Entry point for the generated asset modules (`@ringozz/godot/preload`): checks
155
+ * the engine's `ResourceCache` once (`cachedResource`), stages the bundled files
156
+ * via `materializeFiles` when needed, then loads via {@link loadResourceAsync},
157
+ * memoizing the resulting load promise on `data` (the module's
158
+ * `import.meta.hot.data`, carried across HMR re-evaluations; `{}` on desktop
159
+ * where modules evaluate once). Memoization keeps `use()` seeing the **same**
160
+ * fulfilled promise object across re-evaluations — no Suspense fallback flash on
161
+ * hot reload; a rejected load is evicted so the next evaluation retries. Returns
162
+ * the `materialize` promise (own + deps' files staged) and the load promise.
163
+ */
164
+ export function loadAsset<C extends ResourceConstructor>(
165
+ path: string,
166
+ cls: C,
167
+ files: AssetFiles,
168
+ deps: AssetModule[] = [],
169
+ data: Record<string, unknown> = {},
170
+ ): { materialize: Promise<unknown>; load: Promise<InstanceType<C>> } {
171
+ const cached = cachedResource<C>(path);
172
+ if (cached) cached.then(track);
173
+ const materialize = cached ? Promise.resolve() : materializeFiles(files, deps);
174
+ const existing = data[path] as Promise<InstanceType<C>> | undefined;
175
+ const load = existing ?? cached ?? materialize.then(() => loadResourceAsync(path, cls, files));
176
+ data[path] = load;
177
+ load.catch(() => {
178
+ if (data[path] === load) delete data[path];
179
+ });
180
+ return { materialize, load };
181
+ }
182
+
183
+ /**
184
+ * Loads a resource in the background. `cls` supplies both the `ResourceLoader`
185
+ * type hint (its registered `.name`) and the return type. On web, call
186
+ * {@link materializeFiles} with the asset's bundled files first; on desktop the
187
+ * files already exist. Pass the same `files` map to have the staged files
188
+ * deleted from MEMFS once the resource is loaded (the resource stays cached in
189
+ * the engine, so the bytes are no longer needed).
190
+ */
191
+ export async function loadResourceAsync<C extends ResourceConstructor>(
192
+ path: string,
193
+ cls: C,
194
+ files?: AssetFiles,
195
+ ): Promise<InstanceType<C>> {
196
+ const err = ResourceLoader.loadThreadedRequest(path, cls.name);
197
+ if (err) {
198
+ throw new Error(`loadResourceAsync(${path}): loadThreadedRequest failed (${err})`);
199
+ }
200
+
201
+ let result: InstanceType<C>;
202
+ while (true) {
203
+ const status = ResourceLoader.loadThreadedGetStatus(path);
204
+ if (status === ThreadLoadStatus.THREAD_LOAD_LOADED) {
205
+ result = ResourceLoader.loadThreadedGet(path) as InstanceType<C>;
206
+ break;
207
+ }
208
+ if (status === ThreadLoadStatus.THREAD_LOAD_FAILED || status === ThreadLoadStatus.THREAD_LOAD_INVALID_RESOURCE) {
209
+ // Collect the engine's LoadToken so a failed load doesn't leave it
210
+ // registered (a bare `RefCounted` leaked at exit). Safe no-op when
211
+ // no token exists.
212
+ ResourceLoader.loadThreadedGet(path);
213
+ throw new Error(`loadResourceAsync(${path}): load failed (status ${status})`);
214
+ }
215
+ await nextTick();
216
+ }
217
+ if (files && stageFile !== undefined) {
218
+ // Keep `.import` sidecars — they route imported source paths to their
219
+ // products (ResourceFormatImporter recognizes a path by its sidecar).
220
+ // Delete everything else: the products are read only on a cache miss, and
221
+ // the resource stays cached after loading, so the bytes are dead weight.
222
+ // Best-effort: a file may already be gone (another module's cleanup or a
223
+ // cache-miss re-stage).
224
+ for (const resPath of Object.keys(files)) {
225
+ if (!resPath.endsWith('.import')) {
226
+ DirAccess.removeAbsolute(resPath);
227
+ }
228
+ }
229
+ }
230
+ return track(result);
231
+ }