@ringozz/godot 4.7.1-7 → 4.7.1-8

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/load.ts ADDED
@@ -0,0 +1,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 { 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
+ }
package/src/preload.ts ADDED
@@ -0,0 +1,198 @@
1
+ /**********************************************************************
2
+ Copyright (c) Vladimir Davidovich. All rights reserved.
3
+ ***********************************************************************/
4
+
5
+ import { existsSync, readFileSync } from 'node:fs';
6
+ import { dirname, resolve } from 'node:path';
7
+ import { plugin } from 'bun';
8
+ import type { BunPlugin } from 'bun';
9
+
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.
26
+
27
+ const ROOT = process.cwd();
28
+ const NS = 'godot';
29
+
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
+ jpg: { cls: 'CompressedTexture2D' },
36
+ jpeg: { cls: 'CompressedTexture2D' },
37
+ png: { cls: 'CompressedTexture2D' },
38
+ webp: { cls: 'CompressedTexture2D' },
39
+ svg: { cls: 'Texture2D' },
40
+ exr: { cls: 'TextureLayered' },
41
+ hdr: { cls: 'TextureLayered' },
42
+ wav: { cls: 'AudioStreamWAV' },
43
+ ogg: { cls: 'AudioStreamOggVorbis' },
44
+ mp3: { cls: 'AudioStreamMP3' },
45
+ tres: { cls: 'Resource', scan: true, native: true },
46
+ po: { cls: 'Translation', native: true },
47
+ mtl: { scan: true },
48
+ };
49
+
50
+ const ASSET_RE = new RegExp(
51
+ `\\.(${Object.entries(FORMATS).filter(([, f]) => f.cls).map(([k]) => k).join('|')})$`,
52
+ 'i',
53
+ );
54
+ const CLASS_BY_EXT: Record<string, string> = Object.fromEntries(
55
+ Object.entries(FORMATS).filter(([, f]) => f.cls).map(([k, f]) => [k, f.cls!]),
56
+ );
57
+ const SCANNED = new Set(Object.entries(FORMATS).filter(([, f]) => f.scan).map(([k]) => k));
58
+ const NATIVE = new Set(Object.entries(FORMATS).filter(([, f]) => f.native).map(([k]) => k));
59
+
60
+ const extOf = (abs: string) => abs.slice(abs.lastIndexOf('.') + 1).toLowerCase();
61
+ const resOf = (abs: string) => 'res://' + abs.slice(ROOT.length + 1).replaceAll('\\', '/');
62
+ const isScannableText = (abs: string) => SCANNED.has(extOf(abs));
63
+ const isNativeText = (abs: string) => NATIVE.has(extOf(abs));
64
+ const isSourceAsset = (abs: string) => existsSync(abs + '.import') || isNativeText(abs);
65
+
66
+ /** Reads the imported destination (`dest_files`) out of an `.import` sidecar. */
67
+ function parseSidecarDest(text: string): string | null {
68
+ const deps = text.match(/^dest_files=\[([^\]]*)\]/m);
69
+ if (deps) {
70
+ const first = deps[1].match(/"([^"]+)"/);
71
+ if (first) {
72
+ return first[1];
73
+ }
74
+ }
75
+ const remap = text.match(/^path="([^"]+)"/m);
76
+ return remap ? remap[1] : null;
77
+ }
78
+
79
+ // Generalized reference extraction for text-based source assets: absolute
80
+ // `res://` paths (Godot scenes/resources) and relative path tokens ending in a
81
+ // known asset extension (glTF uris, OBJ/MTL textures). The literal dot before
82
+ // the extension avoids matching MIME types inside `data:` URIs.
83
+ const RES_RE = /res:\/\/[\w./\\-]+/g;
84
+ const REL_RE = new RegExp(`([\\w./\\\\-]+\\.(?:${Object.keys(FORMATS).join('|')}))`, 'g');
85
+
86
+ function extractRefs(text: string): string[] {
87
+ const refs: string[] = [];
88
+ for (const m of text.matchAll(RES_RE)) refs.push(m[0]);
89
+ for (const m of text.matchAll(REL_RE)) refs.push(m[1]);
90
+ return refs;
91
+ }
92
+
93
+ /**
94
+ * Adds a source asset's own files to `files`: imported assets contribute their
95
+ * `.import` sidecar (text) + imported product (file); native text formats
96
+ * (tscn/tres/po) contribute the file itself (text).
97
+ */
98
+ function addSource(srcAbs: string, files: Map<string, 'text' | 'file'>): void {
99
+ const impAbs = srcAbs + '.import';
100
+ if (existsSync(impAbs)) {
101
+ files.set(resOf(srcAbs) + '.import', 'text');
102
+ const dest = parseSidecarDest(readFileSync(impAbs, 'utf8'));
103
+ if (dest) {
104
+ files.set(dest, 'file');
105
+ }
106
+ } else if (isNativeText(srcAbs)) {
107
+ files.set(resOf(srcAbs), 'text');
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Collects the referenced source assets (deps) of a text source: source assets
113
+ * become dep modules (each handles its own transitive deps); raw text formats
114
+ * without a sidecar (`.mtl`) are recursed into. Buffers (`.bin`) are skipped.
115
+ */
116
+ function collectDeps(abs: string, visited: Set<string>, deps: string[]): void {
117
+ if (visited.has(abs)) {
118
+ return;
119
+ }
120
+ visited.add(abs);
121
+ if (!isScannableText(abs)) {
122
+ return;
123
+ }
124
+ for (const ref of extractRefs(readFileSync(abs, 'utf8'))) {
125
+ const refAbs = ref.startsWith('res://')
126
+ ? resolve(ROOT, ref.slice('res://'.length))
127
+ : resolve(dirname(abs), ref);
128
+ if (visited.has(refAbs)) {
129
+ continue;
130
+ }
131
+ if (isSourceAsset(refAbs)) {
132
+ deps.push(refAbs);
133
+ } else if (isScannableText(refAbs)) {
134
+ collectDeps(refAbs, visited, deps);
135
+ }
136
+ }
137
+ }
138
+
139
+ function analyze(abs: string): { files: Map<string, 'text' | 'file'>; deps: string[] } {
140
+ const files = new Map<string, 'text' | 'file'>();
141
+ const deps: string[] = [];
142
+ addSource(abs, files);
143
+ collectDeps(abs, new Set(), deps);
144
+ return { files, deps };
145
+ }
146
+
147
+ function ensureModule(abs: string): string {
148
+ const res = resOf(abs);
149
+ const cls = CLASS_BY_EXT[extOf(abs)] ?? 'Resource';
150
+ const { files, deps } = analyze(abs);
151
+
152
+ const imports = [
153
+ `import { loadAsset } from '@ringozz/godot/load';`,
154
+ `import { ${cls} } from '@ringozz/godot/${cls}';`,
155
+ ];
156
+ const map: string[] = [];
157
+ let ti = 0;
158
+ let fi = 0;
159
+ for (const [resPath, kind] of files) {
160
+ const absP = resolve(ROOT, resPath.slice('res://'.length));
161
+ const v = kind === 'text' ? `c${ti++}` : `f${fi++}`;
162
+ const type = kind === 'text' ? 'text' : 'file';
163
+ const field = kind === 'text' ? 'content' : 'path';
164
+ imports.push(`import ${v} from ${JSON.stringify(absP)} with { type: '${type}' };`);
165
+ map.push(` ${JSON.stringify(resPath)}: { ${field}: ${v} },`);
166
+ }
167
+ deps.forEach((dep, i) => imports.push(`import * as dep${i} from ${JSON.stringify(dep)};`));
168
+
169
+ const filesVar = files.size ? `const files = {\n${map.join('\n')}\n};` : `const files = {};`;
170
+ const loadAsset = `const { materialize, load } = loadAsset(${JSON.stringify(res)}, ${cls}, files, [${deps.map((_, i) => `dep${i}`).join(', ')}], import.meta.hot.data);`;
171
+ return imports.join('\n') + '\n' + filesVar + '\n' + loadAsset + '\n' + `export { materialize, load as default };` + '\n';
172
+ }
173
+
174
+ const assetPlugin: BunPlugin = {
175
+ name: 'godot-assets',
176
+ setup(build) {
177
+ build.onResolve({ filter: ASSET_RE }, (args) => {
178
+ const importerAbs = args.importer?.startsWith(`${NS}:`)
179
+ ? args.importer.slice(NS.length + 1)
180
+ : args.importer;
181
+ const base = importerAbs ? dirname(importerAbs) : (args.resolveDir ?? ROOT);
182
+ const abs = resolve(base, args.path);
183
+ // A module's own native-text source (e.g. `.tscn`) self-imports the
184
+ // same path — let it load as a real file.
185
+ if (abs === importerAbs) {
186
+ return;
187
+ }
188
+ return { path: abs, namespace: NS };
189
+ });
190
+ build.onLoad({ filter: /.*/, namespace: NS }, (args) => ({
191
+ contents: ensureModule(args.path),
192
+ loader: 'js',
193
+ }));
194
+ },
195
+ };
196
+
197
+ export default assetPlugin;
198
+ plugin(assetPlugin);
package/src/runtime.ts CHANGED
@@ -1,68 +1,72 @@
1
- /**********************************************************************
2
- Copyright (c) Vladimir Davidovich. All rights reserved.
3
- ***********************************************************************/
4
-
5
- const { platform, arch } = globalThis.process ?? {};
6
- const mapping: any = {
7
- 'darwin-arm64': 'macos-arm64',
8
- 'win32-x64': 'windows-x86_64',
9
- };
10
- const suffix = mapping[`${platform}-${arch}`];
11
- const { default: _mod } = await (suffix ? import(`@ringozz/godot-${suffix}`) : import('@ringozz/godot-web-wasm32'));
12
-
13
- /** Godot instance */
14
- import type { GodotInstance } from '../gen/classes/GodotInstance.ts';
15
- export const getGodot = _mod.getGodot as () => GodotInstance;
16
-
17
- /** Base constructor for all Godot object wrappers. */
18
- export interface GodotVar {
19
- free(): void;
20
- }
21
- export const GodotVar = _mod.GodotVar as (new (...args: unknown[]) => GodotVar) & {
22
- assign<T extends GodotVar>(target: T, props: Record<string, unknown>): T;
23
- };
24
-
25
- /** Unified entry point:
26
- * - _C(this, methodId, ...args) → instance method
27
- * - _C(classId, methodId, ...args) → static method
28
- * - _C(null, methodId, ...args) → singleton lookup (_getInstance) or internal
29
- * - _C(0xHASHn, methodId, ...args) → utility function (BigInt carries hash + type metadata)
30
- */
31
- export const _C = _mod._C as (...args: unknown[]) => unknown;
32
-
33
- /** Resolve a string name to its integer handle. */
34
- export const _S = _mod._S as (name: string) => number;
35
-
36
- /** Low-level property getter for value type fields. */
37
- export const _get = _mod._get as (target: GodotVar, nameId: number) => unknown;
38
-
39
- /** Low-level property setter for value type fields. */
40
- export const _set = _mod._set as (target: GodotVar, nameId: number, val: unknown) => void;
41
-
42
- /** Register Godot object wrapper. */
43
- export const _R = _mod._R as (typeId: number, ctor: Function) => string;
44
-
45
- /** Get a Signal value from an Object by signal name, or connect a callback when 3rd arg given. */
46
- export const _G = _mod._G as (target: GodotVar, signalNameId: number, callback?: unknown) => unknown;
47
-
48
- /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/DedicatedWorkerGlobalScope/requestAnimationFrame) */
49
- export const requestAnimationFrame = _mod.requestAnimationFrame as typeof globalThis.requestAnimationFrame ?? globalThis.requestAnimationFrame;
50
-
51
- /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/DedicatedWorkerGlobalScope/cancelAnimationFrame) */
52
- export const cancelAnimationFrame = _mod.cancelAnimationFrame as typeof globalThis.cancelAnimationFrame ?? globalThis.cancelAnimationFrame;
53
-
54
- /** Make a Signal PromiseLike: connects a one-shot callback that resolves with the args tuple. */
55
- export function _P(signal: any, onfulfilled?: any, onrejected?: any) {
56
- return new Promise<any>((resolve, reject) => {
57
- if (signal.isNull()) {
58
- reject(new Error('Cannot await a null Signal'));
59
- return;
60
- }
61
- const cb = (...args: any[]) => {
62
- signal.disconnect(cb);
63
- resolve(args);
64
- };
65
- const res = signal.connect(cb);
66
- if (res !== 0) reject(new Error('Failed to connect signal: error code ' + res));
67
- }).then(onfulfilled, onrejected);
68
- }
1
+ /**********************************************************************
2
+ Copyright (c) Vladimir Davidovich. All rights reserved.
3
+ ***********************************************************************/
4
+
5
+ const { platform, arch } = globalThis.process ?? {};
6
+ const mapping: any = {
7
+ 'darwin-arm64': 'macos-arm64',
8
+ 'win32-x64': 'windows-x86_64',
9
+ };
10
+
11
+ import { getNativeModule } from './boot.ts';
12
+ const { default: _mod } = getNativeModule() ?? import.meta.require(`@ringozz/godot-${mapping[`${platform}-${arch}`]}`);
13
+
14
+ /** Godot instance */
15
+ import type { GodotInstance } from '../gen/classes/GodotInstance.ts';
16
+ export const getGodot = _mod.getGodot as () => GodotInstance;
17
+
18
+ /** Base constructor for all Godot object wrappers. */
19
+ export interface GodotVar {
20
+ free(): void;
21
+ }
22
+ export const GodotVar = _mod.GodotVar as (new (...args: unknown[]) => GodotVar) & {
23
+ assign<T extends GodotVar>(target: T, props: Record<string, unknown>): T;
24
+ };
25
+
26
+ /** Unified entry point:
27
+ * - _C(this, methodId, ...args) → instance method
28
+ * - _C(classId, methodId, ...args) → static method
29
+ * - _C(null, methodId, ...args) → singleton lookup (_getInstance) or internal
30
+ * - _C(0xHASHn, methodId, ...args) → utility function (BigInt carries hash + type metadata)
31
+ */
32
+ export const _C = _mod._C as (...args: unknown[]) => unknown;
33
+
34
+ /** Resolve a string name to its integer handle. */
35
+ export const _S = _mod._S as (name: string) => number;
36
+
37
+ /** Low-level property getter for value type fields. */
38
+ export const _get = _mod._get as (target: GodotVar, nameId: number) => unknown;
39
+
40
+ /** Low-level property setter for value type fields. */
41
+ export const _set = _mod._set as (target: GodotVar, nameId: number, val: unknown) => void;
42
+
43
+ /** Register Godot object wrapper. */
44
+ export const _R = _mod._R as (typeId: number, ctor: Function) => string;
45
+
46
+ /** Stage bytes into the web MEMFS (Godot's `copyToFS`); undefined on desktop. */
47
+ export const stageFile = (_mod as any).stageFile as ((path: string, bytes: Uint8Array) => void) | undefined;
48
+
49
+ /** Get a Signal value from an Object by signal name, or connect a callback when 3rd arg given. */
50
+ export const _G = _mod._G as (target: GodotVar, signalNameId: number, callback?: unknown) => unknown;
51
+
52
+ /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/DedicatedWorkerGlobalScope/requestAnimationFrame) */
53
+ export const requestAnimationFrame = _mod.requestAnimationFrame as typeof globalThis.requestAnimationFrame ?? globalThis.requestAnimationFrame;
54
+
55
+ /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/DedicatedWorkerGlobalScope/cancelAnimationFrame) */
56
+ export const cancelAnimationFrame = _mod.cancelAnimationFrame as typeof globalThis.cancelAnimationFrame ?? globalThis.cancelAnimationFrame;
57
+
58
+ /** Make a Signal PromiseLike: connects a one-shot callback that resolves with the args tuple. */
59
+ export function _P(signal: any, onfulfilled?: any, onrejected?: any) {
60
+ return new Promise<any>((resolve, reject) => {
61
+ if (signal.isNull()) {
62
+ reject(new Error('Cannot await a null Signal'));
63
+ return;
64
+ }
65
+ const cb = (...args: any[]) => {
66
+ signal.disconnect(cb);
67
+ resolve(args);
68
+ };
69
+ const res = signal.connect(cb);
70
+ if (res !== 0) reject(new Error('Failed to connect signal: error code ' + res));
71
+ }).then(onfulfilled, onrejected);
72
+ }
@@ -0,0 +1,118 @@
1
+ /**********************************************************************
2
+ Copyright (c) Vladimir Davidovich. All rights reserved.
3
+ ***********************************************************************/
4
+
5
+ // Web-only: decode the embedded PNG/WebP blobs of a `.ctex` CompressedTexture2D
6
+ // container with the browser's `createImageBitmap`, and rebuild the container as
7
+ // a raw RGBA8 `DATA_FORMAT_IMAGE` ctex — so no image codec runs in wasm on web.
8
+ // Anything that isn't a PNG/WebP-embedded ctex is returned unchanged.
9
+
10
+ const CTEX_MAGIC = 'GST2';
11
+ const DATA_FORMAT_IMAGE = 0;
12
+ const DATA_FORMAT_PNG = 1;
13
+ const DATA_FORMAT_WEBP = 2;
14
+ const FORMAT_RGBA8 = 5;
15
+ const FORMAT_BIT_STREAM = 1 << 22;
16
+
17
+ // Outer header: magic(4) version(4) w(4) h(4) df(4) mipmap_limit(4) reserved(12).
18
+ const OUTER_HEADER_SIZE = 36;
19
+ // Sub-header: data_format(4) w(2) h(2) mipmaps(4) format(4).
20
+ const SUB_HEADER_OFFSET = OUTER_HEADER_SIZE;
21
+ const SUB_HEADER_SIZE = 16;
22
+ const BLOB_TABLE_OFFSET = SUB_HEADER_OFFSET + SUB_HEADER_SIZE;
23
+
24
+ interface DecodedBitmap {
25
+ width: number;
26
+ height: number;
27
+ data: Uint8Array;
28
+ }
29
+
30
+ async function decodeWebBitmap(blob: Uint8Array<ArrayBuffer>, type: string): Promise<DecodedBitmap> {
31
+ const bmp = await createImageBitmap(new Blob([blob], { type }), {
32
+ imageOrientation: 'none',
33
+ colorSpaceConversion: 'none',
34
+ premultiplyAlpha: 'none',
35
+ });
36
+ try {
37
+ const canvas = new OffscreenCanvas(bmp.width, bmp.height);
38
+ const ctx = canvas.getContext('2d', { colorSpace: 'srgb', willReadFrequently: true }) as
39
+ | OffscreenCanvasRenderingContext2D
40
+ | null;
41
+ if (!ctx) {
42
+ throw new Error('web-image: no 2d context for OffscreenCanvas');
43
+ }
44
+ ctx.drawImage(bmp, 0, 0);
45
+ const img = ctx.getImageData(0, 0, bmp.width, bmp.height);
46
+ return { width: bmp.width, height: bmp.height, data: new Uint8Array(img.data.buffer, img.data.byteOffset, img.data.byteLength) };
47
+ } finally {
48
+ bmp.close();
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Decodes the embedded PNG/WebP blobs of a `.ctex` file with the browser's
54
+ * `createImageBitmap` and rebuilds it as a raw RGBA8 (`DATA_FORMAT_IMAGE`)
55
+ * container. Non-ctex bytes, streamable textures, and `DATA_FORMAT_IMAGE` /
56
+ * `DATA_FORMAT_BASIS_UNIVERSAL` containers are returned unchanged. Throws if an
57
+ * embedded blob can't be decoded or its dimensions don't match the expected
58
+ * mipmap layout, so a would-be undecodable texture fails loudly at materialize
59
+ * time. A no-op outside browsers (no `createImageBitmap`).
60
+ */
61
+ export async function decodeCtex(bytes: Uint8Array): Promise<Uint8Array> {
62
+ if (
63
+ typeof createImageBitmap !== 'function' ||
64
+ bytes.length < SUB_HEADER_OFFSET + SUB_HEADER_SIZE ||
65
+ String.fromCharCode(...bytes.subarray(0, 4)) !== CTEX_MAGIC
66
+ ) {
67
+ return bytes;
68
+ }
69
+ const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
70
+ if (dv.getUint32(16, true) & FORMAT_BIT_STREAM) {
71
+ return bytes; // streamable layout differs; leave it to the engine
72
+ }
73
+ const dataFormat = dv.getUint32(SUB_HEADER_OFFSET, true);
74
+ if (dataFormat !== DATA_FORMAT_PNG && dataFormat !== DATA_FORMAT_WEBP) {
75
+ return bytes; // raw and BASIS_UNIVERSAL containers pass through
76
+ }
77
+ const w = dv.getUint16(SUB_HEADER_OFFSET + 4, true);
78
+ const h = dv.getUint16(SUB_HEADER_OFFSET + 6, true);
79
+ const mipmaps = dv.getUint32(SUB_HEADER_OFFSET + 8, true);
80
+ const type = dataFormat === DATA_FORMAT_WEBP ? 'image/webp' : 'image/png';
81
+
82
+ let ofs = BLOB_TABLE_OFFSET;
83
+ const levels: Uint8Array[] = [];
84
+ for (let i = 0; i <= mipmaps; i++) {
85
+ if (ofs + 4 > bytes.length) {
86
+ throw new Error(`web-image: truncated ctex at mipmap ${i}`);
87
+ }
88
+ const size = dv.getUint32(ofs, true);
89
+ ofs += 4;
90
+ if (ofs + size > bytes.length) {
91
+ throw new Error(`web-image: truncated ctex blob at mipmap ${i}`);
92
+ }
93
+ const bmp = await decodeWebBitmap(bytes.slice(ofs, ofs + size), type);
94
+ ofs += size;
95
+ const ew = Math.max(w >> i, 1);
96
+ const eh = Math.max(h >> i, 1);
97
+ if (bmp.width !== ew || bmp.height !== eh) {
98
+ throw new Error(`web-image: mipmap ${i} decoded as ${bmp.width}x${bmp.height}, expected ${ew}x${eh}`);
99
+ }
100
+ levels.push(bmp.data);
101
+ }
102
+
103
+ const dataSize = levels.reduce((n, level) => n + level.length, 0);
104
+ const out = new Uint8Array(BLOB_TABLE_OFFSET + dataSize);
105
+ out.set(bytes.subarray(0, OUTER_HEADER_SIZE)); // outer header verbatim
106
+ const odv = new DataView(out.buffer);
107
+ odv.setUint32(SUB_HEADER_OFFSET, DATA_FORMAT_IMAGE, true);
108
+ odv.setUint16(SUB_HEADER_OFFSET + 4, w, true);
109
+ odv.setUint16(SUB_HEADER_OFFSET + 6, h, true);
110
+ odv.setUint32(SUB_HEADER_OFFSET + 8, mipmaps, true);
111
+ odv.setUint32(SUB_HEADER_OFFSET + 12, FORMAT_RGBA8, true);
112
+ let p = BLOB_TABLE_OFFSET;
113
+ for (const level of levels) {
114
+ out.set(level, p);
115
+ p += level.length;
116
+ }
117
+ return out;
118
+ }