@ringozz/godot 4.7.2-661 → 4.7.2-676
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 +28 -5
- package/package.json +5 -4
- package/src/assets.d.ts +10 -1
- package/src/bake.ts +587 -0
- package/src/dump-deps.gd +140 -66
- package/src/editor.ts +6 -1
- package/src/export_presets.example.cfg +3 -3
- package/src/formats.ts +78 -0
- package/src/load.ts +169 -17
- package/src/pack.ts +276 -220
- package/src/preload.ts +103 -150
- package/src/serve.ts +67 -0
package/src/load.ts
CHANGED
|
@@ -10,6 +10,20 @@ import { ResourceUID } from '../gen/classes/ResourceUID.ts';
|
|
|
10
10
|
import type { Resource } from '../gen/classes/Resource.ts';
|
|
11
11
|
import { cleanupHooks, stageFile } from './runtime.ts';
|
|
12
12
|
import { transcoder } from '@ringozz/godot/transcoder';
|
|
13
|
+
import {
|
|
14
|
+
FILES_DIR,
|
|
15
|
+
PACK_DOC,
|
|
16
|
+
mountClosure,
|
|
17
|
+
parseDoc,
|
|
18
|
+
parseRelease,
|
|
19
|
+
pckOf,
|
|
20
|
+
resolveEntry,
|
|
21
|
+
type PackDoc,
|
|
22
|
+
type Release,
|
|
23
|
+
type ResolvedEntry,
|
|
24
|
+
type StagedFile,
|
|
25
|
+
type StagedFiles,
|
|
26
|
+
} from './pack.ts';
|
|
13
27
|
import { decodeCtex } from './web-image.ts';
|
|
14
28
|
|
|
15
29
|
/**
|
|
@@ -57,18 +71,6 @@ function track<T extends Resource>(res: T): T {
|
|
|
57
71
|
*/
|
|
58
72
|
type ResourceConstructor = { new(...args: any[]): Resource } & Function;
|
|
59
73
|
|
|
60
|
-
/**
|
|
61
|
-
* A file to stage before loading, keyed by `res://` path: sidecars (`.import`)
|
|
62
|
-
* arrive as `content` (text); imported products (`.scn`/`.ctex`) arrive as
|
|
63
|
-
* `path` (fetched).
|
|
64
|
-
*/
|
|
65
|
-
interface AssetFile {
|
|
66
|
-
content?: string;
|
|
67
|
-
path?: string;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
export type AssetFiles = Record<string, AssetFile>;
|
|
71
|
-
|
|
72
74
|
const UID_RE = /uid="(uid:\/\/[\w.]+)"/;
|
|
73
75
|
|
|
74
76
|
// Compressed-texture products whose payloads may need decoding/transcoding:
|
|
@@ -79,7 +81,7 @@ const COMPRESSED_TEXTURE_RE = /\.(ctex|ctexarray|ccube|ccubearray)$/;
|
|
|
79
81
|
|
|
80
82
|
// Textures the runtime must receive *exactly* as the server served them, so they are decoded to raw RGBA8
|
|
81
83
|
// instead of going through the platform transcoder (spark -> BC7/ASTC): the terrain height atlas
|
|
82
|
-
// (`<
|
|
84
|
+
// (`<name>.dat/terrain/height.webp`), whose 12-bit packed R/G the vertex shader decodes per texel, so
|
|
83
85
|
// block quantisation shows as blocky relief on every slope. Everything else may be compressed — the
|
|
84
86
|
// surface arrays, the weight planes and the `_n` tile normal all ship as layered containers the runtime
|
|
85
87
|
// only samples (see `docs/godot.md`'s M4 section). The `.ctex` product is named after its source plus an
|
|
@@ -112,7 +114,27 @@ function registerAssetUid(uid: string, resPath: string): void {
|
|
|
112
114
|
}
|
|
113
115
|
}
|
|
114
116
|
|
|
115
|
-
|
|
117
|
+
// res:// paths already staged, or being staged: MEMFS cannot be read back reliably — the engine's
|
|
118
|
+
// `res://` lookup and the Emscripten path disagree by a leading slash — so `FileAccess.fileExists`
|
|
119
|
+
// cannot answer "is this staged?", and two asset modules needing the same file would fetch and
|
|
120
|
+
// allocate it twice. A path is forgotten when its bytes are removed after a load.
|
|
121
|
+
const staged = new Map<string, Promise<void>>();
|
|
122
|
+
|
|
123
|
+
async function materialize(entry: [string, StagedFile]): Promise<void> {
|
|
124
|
+
const [resPath] = entry;
|
|
125
|
+
const existing = staged.get(resPath);
|
|
126
|
+
if (existing) {
|
|
127
|
+
return existing;
|
|
128
|
+
}
|
|
129
|
+
const p = stage(entry).catch((err) => {
|
|
130
|
+
staged.delete(resPath);
|
|
131
|
+
throw err;
|
|
132
|
+
});
|
|
133
|
+
staged.set(resPath, p);
|
|
134
|
+
return p;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function stage([resPath, entry]: [string, StagedFile]): Promise<void> {
|
|
116
138
|
if (FileAccess.fileExists(resPath)) {
|
|
117
139
|
return;
|
|
118
140
|
}
|
|
@@ -163,7 +185,7 @@ const nextTick = () => new Promise(requestAnimationFrame);
|
|
|
163
185
|
* straight into Emscripten's MEMFS via Godot's `copyToFS` (JS-heap only, no wasm
|
|
164
186
|
* copy); a no-op on desktop where the files already exist.
|
|
165
187
|
*/
|
|
166
|
-
function materializeFiles(files:
|
|
188
|
+
function materializeFiles(files: StagedFiles): Promise<unknown> {
|
|
167
189
|
if (stageFile === undefined) {
|
|
168
190
|
// Desktop: the files already exist on disk (res:// = cwd) and uids come
|
|
169
191
|
// from `.godot/uid_cache.bin`, so there is nothing to stage.
|
|
@@ -199,7 +221,7 @@ function cachedResource<C extends ResourceConstructor>(path: string): Promise<In
|
|
|
199
221
|
export function loadAsset<C extends ResourceConstructor>(
|
|
200
222
|
path: string,
|
|
201
223
|
cls: C,
|
|
202
|
-
files:
|
|
224
|
+
files: StagedFiles,
|
|
203
225
|
data: Record<string, unknown> = {},
|
|
204
226
|
ready?: Promise<unknown>,
|
|
205
227
|
): { materialize: Promise<unknown>; load: Promise<InstanceType<C>> } {
|
|
@@ -230,7 +252,7 @@ export function loadAsset<C extends ResourceConstructor>(
|
|
|
230
252
|
export async function loadResourceAsync<C extends ResourceConstructor>(
|
|
231
253
|
path: string,
|
|
232
254
|
cls: C,
|
|
233
|
-
files?:
|
|
255
|
+
files?: StagedFiles,
|
|
234
256
|
): Promise<InstanceType<C>> {
|
|
235
257
|
const err = ResourceLoader.loadThreadedRequest(path, cls.name);
|
|
236
258
|
if (err) {
|
|
@@ -263,6 +285,8 @@ export async function loadResourceAsync<C extends ResourceConstructor>(
|
|
|
263
285
|
for (const resPath of Object.keys(files)) {
|
|
264
286
|
if (!resPath.endsWith('.import')) {
|
|
265
287
|
DirAccess.removeAbsolute(resPath);
|
|
288
|
+
// The bytes are gone, so the memo must forget the path — a later module stages it again.
|
|
289
|
+
staged.delete(resPath);
|
|
266
290
|
}
|
|
267
291
|
}
|
|
268
292
|
}
|
|
@@ -321,3 +345,131 @@ function packName(url: string): string {
|
|
|
321
345
|
const base = url.split('?')[0].split('/').pop() ?? 'pack.pck';
|
|
322
346
|
return base.replace(/[^A-Za-z0-9._-]/g, '_') || 'pack.pck';
|
|
323
347
|
}
|
|
348
|
+
|
|
349
|
+
// ── Content packs ───────────────────────────────────────────────────────────
|
|
350
|
+
// A content pack is deployed apart from the app, so a client is told where a release-relative path can
|
|
351
|
+
// be read (`at`) and everything else follows: the release graph, each mounted pack's manifest and
|
|
352
|
+
// `.pck`, and the loose files the entry needs. The bake also hands over the boot pack's own config —
|
|
353
|
+
// its `.pck` and a bundled URL per loose product, the one thing only the app can provide, since those
|
|
354
|
+
// bytes ship beside it. The runtime only ever sees URLs, all of them version-addressed and immutable.
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* A pack the runtime has at hand: its `.pck`, its document, and where its staged products are read. The
|
|
358
|
+
* app's own pack is one of these — its bytes ship beside it, so its URLs are the host's, not a
|
|
359
|
+
* version-addressed path — and it is what `configurePacks({ boot })` hands over.
|
|
360
|
+
*/
|
|
361
|
+
export interface MountedPack {
|
|
362
|
+
/** The `.pck` to mount (`loadAssetPack`). */
|
|
363
|
+
pck: string;
|
|
364
|
+
doc: PackDoc;
|
|
365
|
+
/** The URL of one of this pack's products, by the file name `doc.stage` gives it. */
|
|
366
|
+
url: (file: string) => string;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* How the runtime reads content packs: a URL per file, and the release that names the packs (or where
|
|
371
|
+
* to read one). The host owns its base and the way a version is encoded in it, so the protocol never
|
|
372
|
+
* builds a release-relative path — it knows a pack's name and its files (`pack.json`, `<name>.pck`,
|
|
373
|
+
* `files/<product>`), and asks.
|
|
374
|
+
*/
|
|
375
|
+
export interface PackSource {
|
|
376
|
+
/** A pack's file, as a fetchable URL or a local path — how the host addresses a version is its own. */
|
|
377
|
+
at?: (pack: string, version: string, file: string) => string;
|
|
378
|
+
/** The app's own pack, configured by the bake's own code: always mounted, its bytes bundled. */
|
|
379
|
+
boot?: MountedPack;
|
|
380
|
+
/**
|
|
381
|
+
* The packs to mount. The app's own dependencies (pinned by its lockfile) are one way: there is no
|
|
382
|
+
* release document to fetch, and the versions the app was built and tested with are the ones it
|
|
383
|
+
* mounts. A URL is the other: mount what a release document names — a bake's `release.json`, a CDN's.
|
|
384
|
+
*/
|
|
385
|
+
release?: Release | string;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
let packSource: PackSource = {};
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Points the runtime at a pack source. Merges, so the bake's boot config and the app's `at` may land in
|
|
392
|
+
* either order — the boot config rides on the app's own asset imports.
|
|
393
|
+
*/
|
|
394
|
+
export function configurePacks(source: PackSource): void {
|
|
395
|
+
packSource = { ...packSource, ...source };
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/** Reads a document: a URL over the network, anything else off the filesystem. */
|
|
399
|
+
async function readText(url: string): Promise<string> {
|
|
400
|
+
if (/^https?:/i.test(url)) {
|
|
401
|
+
const res = await fetch(url);
|
|
402
|
+
if (!res.ok) throw new Error(`godot-assets: ${url} (${res.status})`);
|
|
403
|
+
return res.text();
|
|
404
|
+
}
|
|
405
|
+
// Desktop and a dev bundle hand over a local path, and `res://` is the working tree there.
|
|
406
|
+
const text = FileAccess.getFileAsString(url.startsWith('res://') ? url : ProjectSettings.globalizePath(url));
|
|
407
|
+
if (!text) throw new Error(`godot-assets: cannot read ${url}`);
|
|
408
|
+
return text;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/** The release to mount: the packs the app pinned, or the document `configurePacks` was pointed at. */
|
|
412
|
+
async function mountedRelease(): Promise<Release> {
|
|
413
|
+
const pinned = packSource.release;
|
|
414
|
+
if (!pinned) throw new Error('godot-assets: no release — configurePacks({ release }) with the packs to mount or its URL.');
|
|
415
|
+
return typeof pinned === 'string' ? parseRelease(await readText(pinned)) : pinned;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* A resolved entry's files, as the loader stages them: the app's own pack addresses a product from its
|
|
420
|
+
* bundled URLs, a mounted pack from the host's addressing. This is the addressing the protocol
|
|
421
|
+
* deliberately does not know — which is why a resolution returns packs and file names, not URLs.
|
|
422
|
+
*/
|
|
423
|
+
function stagedFiles(
|
|
424
|
+
plan: ResolvedEntry,
|
|
425
|
+
boot: MountedPack | undefined,
|
|
426
|
+
fileUrl: (pack: string, file: string) => string,
|
|
427
|
+
): StagedFiles {
|
|
428
|
+
const files: StagedFiles = {};
|
|
429
|
+
for (const { sidecar, content, product, pack, file } of plan.files) {
|
|
430
|
+
files[sidecar] = { content };
|
|
431
|
+
files[product] = { path: pack === null ? boot!.url(file) : fileUrl(pack, `${FILES_DIR}/${file}`) };
|
|
432
|
+
}
|
|
433
|
+
return files;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Loads one declared entry of a content pack: the release, then the packs the entry reaches and the
|
|
438
|
+
* loose files they stage, then the `res://` path the entry names. Nothing about a pack is baked into
|
|
439
|
+
* the app, so it can be republished without one.
|
|
440
|
+
*/
|
|
441
|
+
export async function loadPackEntry<C extends ResourceConstructor>(
|
|
442
|
+
pack: string,
|
|
443
|
+
entry: string,
|
|
444
|
+
cls: C,
|
|
445
|
+
): Promise<InstanceType<C>> {
|
|
446
|
+
const at = packSource.at;
|
|
447
|
+
if (!at) throw new Error('godot-assets: no pack source — call configurePacks({ at }) first.');
|
|
448
|
+
const release = await mountedRelease();
|
|
449
|
+
// Mounting needs only the release — a pack's `.pck` is `<name>.pck` by convention — so the closure's
|
|
450
|
+
// packs are mounted while their documents are read, and only staging waits on those.
|
|
451
|
+
const names = mountClosure(release, pack);
|
|
452
|
+
const url = (name: string, file: string) => at(name, release[name].version, file);
|
|
453
|
+
const boot = packSource.boot;
|
|
454
|
+
const mounts = [...(boot ? [boot.pck] : []), ...names.map((name) => url(name, pckOf(name)))].map((pck) =>
|
|
455
|
+
loadAssetPack(pck),
|
|
456
|
+
);
|
|
457
|
+
const docs = Object.fromEntries(
|
|
458
|
+
await Promise.all(
|
|
459
|
+
names.map(async (name): Promise<[string, PackDoc]> => {
|
|
460
|
+
const doc = parseDoc(await readText(url(name, PACK_DOC)));
|
|
461
|
+
// A base that serves a version it wasn't asked for is the one thing a client cannot see:
|
|
462
|
+
// production's URLs are immutable, and a dev server pointed at a local bake serves content
|
|
463
|
+
// at another version on purpose. Say so; the load still works.
|
|
464
|
+
if (doc.version !== release[name].version) {
|
|
465
|
+
console.warn(`godot-assets: ${name} ${release[name].version} asked, ${doc.version ?? 'no version'} served`);
|
|
466
|
+
}
|
|
467
|
+
return [name, doc];
|
|
468
|
+
}),
|
|
469
|
+
),
|
|
470
|
+
);
|
|
471
|
+
const plan = resolveEntry(release, docs, pack, entry, boot?.doc);
|
|
472
|
+
// The boot is a mounted pack like any other, so an entry borrows its loose files too.
|
|
473
|
+
await Promise.all(mounts);
|
|
474
|
+
return loadAsset(plan.res, cls, stagedFiles(plan, boot, url)).load;
|
|
475
|
+
}
|