@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/bake.ts
ADDED
|
@@ -0,0 +1,587 @@
|
|
|
1
|
+
/**********************************************************************
|
|
2
|
+
Copyright (c) Vladimir Davidovich. All rights reserved.
|
|
3
|
+
***********************************************************************/
|
|
4
|
+
|
|
5
|
+
import { spawnSync } from 'node:child_process';
|
|
6
|
+
import { createHash, type Hash } from 'node:crypto';
|
|
7
|
+
import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
8
|
+
import { dirname, extname, join, resolve } from 'node:path';
|
|
9
|
+
import { fileURLToPath } from 'node:url';
|
|
10
|
+
import { resolveEditor } from './editor.ts';
|
|
11
|
+
import { packedExtensions, scannedExtensions } from './formats.ts';
|
|
12
|
+
import {
|
|
13
|
+
BOOT_MANIFEST,
|
|
14
|
+
BOOT_MODULE,
|
|
15
|
+
BOOT_PCK,
|
|
16
|
+
FILES_DIR,
|
|
17
|
+
PACK_DIR,
|
|
18
|
+
PACK_DOC,
|
|
19
|
+
RELEASE,
|
|
20
|
+
descriptorOf,
|
|
21
|
+
entryId,
|
|
22
|
+
packEntries,
|
|
23
|
+
pckOf,
|
|
24
|
+
serializeDoc,
|
|
25
|
+
type Exports,
|
|
26
|
+
type PackDoc,
|
|
27
|
+
type Release,
|
|
28
|
+
} from './pack.ts';
|
|
29
|
+
|
|
30
|
+
// Bakes a Godot project's assets for the runtime. Two kinds of pack, both native `.pck`s (the
|
|
31
|
+
// engine-converted binary resources + remap table) exported through the project's own committed
|
|
32
|
+
// `export_presets.cfg`:
|
|
33
|
+
//
|
|
34
|
+
// - **the boot pack**: the app's own sources (`src/`), exported with the preset, and always mounted
|
|
35
|
+
// by the runtime — a scene may load a path it assembles itself (a script named by a constant, a
|
|
36
|
+
// level chosen at run time), and only a mounted pack resolves those.
|
|
37
|
+
// - **content packs**: one per directory under `dlc/` that declares itself with a `package.json`.
|
|
38
|
+
// Each is exported on its own — the preset's `exclude_filter` is rewritten per pack, so a pack
|
|
39
|
+
// carries its own directory and nothing else — and carries the declared package fields plus the
|
|
40
|
+
// staging data the runtime needs, so it can be published and deployed apart from the app.
|
|
41
|
+
//
|
|
42
|
+
// A pack may reference the boot sources and the packs it declares as `dependencies`, never a sibling,
|
|
43
|
+
// and the bake fails otherwise: the runtime mounts a pack by import, so a reference "upward" or
|
|
44
|
+
// "sideways" is a reference that will not resolve.
|
|
45
|
+
//
|
|
46
|
+
// This is the bake half: everything that spawns the editor or touches the disk. What it writes is the
|
|
47
|
+
// pack protocol's (`pack.ts`), which the plugin and the runtime read without any of this.
|
|
48
|
+
|
|
49
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
50
|
+
|
|
51
|
+
/** The dump's one build-time output, rewritten by every bake. */
|
|
52
|
+
const DUMP = '.godot/pack-dump.json';
|
|
53
|
+
|
|
54
|
+
/** The digest of every bake input, beside the packs: written by a bake that finished, read by the next. */
|
|
55
|
+
const STAMP = '.stamp.json';
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Removes everything a bake derives — the import cache, the documents, the packs — and never a source.
|
|
59
|
+
* `godot clean` is the answer to the two states a bake cannot repair by itself: orphaned products left
|
|
60
|
+
* by sources that moved or were renamed, and a derived file whose import was never forced.
|
|
61
|
+
*/
|
|
62
|
+
export function cleanProject(cwd = process.cwd()): void {
|
|
63
|
+
rmSync(resolve(cwd, '.godot'), { recursive: true, force: true });
|
|
64
|
+
console.log('[pack] cleaned .godot');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Source the engine never imports or packs — the app's own code, sitting in the same tree as its
|
|
69
|
+
* assets. Editing it cannot change a pack, and it is the most common edit there is, so the digest
|
|
70
|
+
* skips it. The list is a deny-list on purpose: an unknown extension re-bakes (harmless) rather than
|
|
71
|
+
* going stale (a pack that silently lags its sources).
|
|
72
|
+
*/
|
|
73
|
+
const SOURCE = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.md', '.yaml', '.yml', '.css']);
|
|
74
|
+
|
|
75
|
+
export interface BuildAssetPackOptions {
|
|
76
|
+
/** The project root the bake reads and writes (default: `process.cwd()`). */
|
|
77
|
+
cwd?: string;
|
|
78
|
+
/** res:// roots whose assets the boot pack carries (default `['res://src']`). */
|
|
79
|
+
roots?: string[];
|
|
80
|
+
/** res:// root to discover content packs under (default `res://dlc`). */
|
|
81
|
+
dlc?: string;
|
|
82
|
+
/** Output directory for the packs + documents, project-relative (default {@link PACK_DIR}). */
|
|
83
|
+
out?: string;
|
|
84
|
+
/** The `export_presets.cfg` preset every pack exports with (default `Pack`). */
|
|
85
|
+
preset?: string;
|
|
86
|
+
/** Editor executable override (default: `resolveEditor()`). */
|
|
87
|
+
godot?: string;
|
|
88
|
+
/** Bake even when nothing the bake reads has changed since the last one (default: false). */
|
|
89
|
+
force?: boolean;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** A content pack: a directory that declares itself with a `package.json`. */
|
|
93
|
+
interface ContentPack {
|
|
94
|
+
/** The declared package name (`@tiv/packs/assets`), or the directory's own name. */
|
|
95
|
+
name: string;
|
|
96
|
+
/** Project-relative directory, no trailing slash (`dlc/assets`). */
|
|
97
|
+
dir: string;
|
|
98
|
+
/** Declared version, if any. */
|
|
99
|
+
version?: string;
|
|
100
|
+
/** Declared pack dependencies: package name → version range. */
|
|
101
|
+
dependencies: Record<string, string>;
|
|
102
|
+
/** The declared `exports` verbatim: how the pack names the entries a client may load. */
|
|
103
|
+
exports?: Exports;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** A `spawnSync` runner for the editor — the bake's one injected dependency. */
|
|
107
|
+
export type Run = (args: string[], env?: Record<string, string>) => void;
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Bakes the boot pack, then one pack per directory under `dlc/` that declares a `package.json`.
|
|
111
|
+
* Three editor runs per pack (import, dump, export); no project file is left changed.
|
|
112
|
+
*
|
|
113
|
+
* `run` is the seam: the editor by default, a stand-in in a test — so what a caller gets (the
|
|
114
|
+
* documents, the products beside them, the preset put back) can be asserted through this interface
|
|
115
|
+
* rather than through the helpers it is made of. Everything else is the filesystem, which `opts.cwd`
|
|
116
|
+
* can point at any root.
|
|
117
|
+
*/
|
|
118
|
+
export async function buildAssetPacks(opts: BuildAssetPackOptions = {}, run?: Run): Promise<void> {
|
|
119
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
120
|
+
const bootRoots = opts.roots ?? ['res://src'];
|
|
121
|
+
const out = opts.out ?? PACK_DIR;
|
|
122
|
+
const preset = opts.preset ?? 'Pack';
|
|
123
|
+
const editor = opts.godot ?? (run ? null : await resolveEditor());
|
|
124
|
+
const exec = run ?? runner(editor!, cwd);
|
|
125
|
+
mkdirSync(resolve(cwd, out), { recursive: true });
|
|
126
|
+
|
|
127
|
+
const dlc = rel(opts.dlc ?? 'res://dlc');
|
|
128
|
+
const packs = discoverPacks(cwd, dlc);
|
|
129
|
+
|
|
130
|
+
const presets = resolve(cwd, 'export_presets.cfg');
|
|
131
|
+
const cfg = readFileSync(presets, 'utf8');
|
|
132
|
+
|
|
133
|
+
// Nothing the bake reads has changed since the last one that finished: an import scan, a dump and
|
|
134
|
+
// one export per pack are the whole cost of a dev server start, and none of it is worth repeating
|
|
135
|
+
// for a project nobody touched. The stamp is the digest of every input, not a timestamp of the
|
|
136
|
+
// outputs — a pack's `.pck` is rewritten on every export, so it can't answer this.
|
|
137
|
+
const stamp = resolve(cwd, out, STAMP);
|
|
138
|
+
const inputs = inputsOf({ cwd, dirs: [...bootRoots.map(rel), ...packs.map((pack) => pack.dir)], cfg, editor });
|
|
139
|
+
if (!opts.force && readStamp(stamp) === inputs) {
|
|
140
|
+
console.log(`[pack] up to date (${out})`);
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// The editor's import scan must not walk the installed dependency tree: a workspace-linked pack
|
|
145
|
+
// would be imported a second time through the link, at a depth its relative paths (a terrain
|
|
146
|
+
// `.webparray`'s layer refs) do not survive. `.gdignore` is the editor's own skip marker; the
|
|
147
|
+
// bundler never looks at it, and the sources are imported once at their real `res://` paths.
|
|
148
|
+
// A pack that declares another pack gets that link *inside itself* (`node_modules` beside its
|
|
149
|
+
// `package.json`), so each pack's tree needs the marker as well as the root one.
|
|
150
|
+
const installed = [resolve(cwd, 'node_modules'), ...packs.map((pack) => resolve(cwd, pack.dir, 'node_modules'))];
|
|
151
|
+
for (const dir of installed.filter((dir) => existsSync(dir))) writeFileSync(join(dir, '.gdignore'), '');
|
|
152
|
+
exec(['--headless', '--path', '.', '--import']);
|
|
153
|
+
|
|
154
|
+
assertScopes(bootRoots.map(rel), packs);
|
|
155
|
+
const dirOf = new Map(packs.map((pack) => [pack.name, pack.dir]));
|
|
156
|
+
const release = releaseOf(packs);
|
|
157
|
+
// Every scoped directory: the boot sources and the pack directories. A pack's export excludes all
|
|
158
|
+
// of them but its own, so no source can belong to two packs.
|
|
159
|
+
const bootDirs = bootRoots.map(rel);
|
|
160
|
+
const all = [...bootDirs, ...packs.map((pack) => pack.dir)];
|
|
161
|
+
|
|
162
|
+
// A plan's routes are relative to the converter's output root, which is where the packs live.
|
|
163
|
+
const ctx: Bake = { run: exec, cwd, cfg, preset, base: excludeFilter(cfg), all, planBase: resPath(dlc) };
|
|
164
|
+
try {
|
|
165
|
+
// **One dump for the whole project**, sliced per scope below (a loose file is staged by the pack
|
|
166
|
+
// that *owns* it, whoever needs it). One dump is also one editor run instead of one per pack.
|
|
167
|
+
const dump = runDump(ctx, [...bootRoots, ...packs.map((pack) => resPath(pack.dir))]);
|
|
168
|
+
|
|
169
|
+
if (bootDirs.length) {
|
|
170
|
+
const boot = manifestOf(dump, cwd, out, bootDirs);
|
|
171
|
+
assertRefs('the boot pack', boot.external, bootRoots);
|
|
172
|
+
exportPack(ctx, bootDirs, `${out}/${BOOT_PCK}`);
|
|
173
|
+
writeFileSync(resolve(cwd, out, BOOT_MANIFEST), serializeDoc({ needs: boot.needs, stage: boot.stage }));
|
|
174
|
+
writeFileSync(resolve(cwd, out, BOOT_MODULE), bootModule(boot));
|
|
175
|
+
console.log(`[pack] ${BOOT_PCK} over ${Object.keys(boot.needs).length} sources`);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
for (const pack of packs) {
|
|
179
|
+
const version = release[pack.name].version;
|
|
180
|
+
// One directory per pack, holding exactly what its npm package holds: the `.pck`, its
|
|
181
|
+
// document, the loose products, the types and a manifest. So a bake's output and an installed
|
|
182
|
+
// pack have the same layout, and a host serves either with one static root. The version is
|
|
183
|
+
// the release's (`release.json`), not a directory's — a bake builds one per pack.
|
|
184
|
+
const dir = `${out}/${pack.name}`;
|
|
185
|
+
mkdirSync(resolve(cwd, dir), { recursive: true });
|
|
186
|
+
const own = manifestOf(dump, cwd, dir, [pack.dir]);
|
|
187
|
+
const entries = packEntries(pack.exports);
|
|
188
|
+
const allowed = [...bootRoots, ...Object.keys(pack.dependencies).map((dep) => resPath(dirOf.get(dep) ?? dep))];
|
|
189
|
+
assertRefs(pack.name, own.external, allowed);
|
|
190
|
+
exportPack(ctx, [pack.dir], `${dir}/${pckOf(pack.name)}`);
|
|
191
|
+
writeFileSync(
|
|
192
|
+
resolve(cwd, dir, PACK_DOC),
|
|
193
|
+
serializeDoc({
|
|
194
|
+
// The pack's own declaration (`name`/`version`/`dependencies`/`exports`) plus what the
|
|
195
|
+
// bake computed: the same document shape the boot pack's is, so a client reads one. The
|
|
196
|
+
// `.pck` is `<name>.pck` beside it, by convention — mounting never waits on this document.
|
|
197
|
+
name: pack.name,
|
|
198
|
+
version,
|
|
199
|
+
dependencies: pack.dependencies,
|
|
200
|
+
exports: withoutTypes(pack.exports),
|
|
201
|
+
needs: own.needs,
|
|
202
|
+
stage: own.stage,
|
|
203
|
+
}),
|
|
204
|
+
);
|
|
205
|
+
// The pack's types, beside its own `package.json` (whose `types` names them) and in its
|
|
206
|
+
// output directory: a client installs the pack by name, so the types have to travel with it.
|
|
207
|
+
const types = entries.length ? packTypes(pack.exports) : null;
|
|
208
|
+
if (types) {
|
|
209
|
+
writeFileSync(resolve(cwd, pack.dir, 'package.d.ts'), types);
|
|
210
|
+
writeFileSync(resolve(cwd, dir, 'package.d.ts'), types);
|
|
211
|
+
} else {
|
|
212
|
+
rmSync(resolve(cwd, pack.dir, 'package.d.ts'), { force: true });
|
|
213
|
+
}
|
|
214
|
+
// The output directory *is* the deployable artifact: `npm publish` of it ships the `.pck`,
|
|
215
|
+
// its document, the loose products and the types, and its declared dependencies — so a
|
|
216
|
+
// level pulls its shared pack in on install, and nothing is assembled later.
|
|
217
|
+
writeFileSync(
|
|
218
|
+
resolve(cwd, dir, 'package.json'),
|
|
219
|
+
`${JSON.stringify(
|
|
220
|
+
{
|
|
221
|
+
name: pack.name,
|
|
222
|
+
version,
|
|
223
|
+
dependencies: Object.keys(pack.dependencies).length ? pack.dependencies : undefined,
|
|
224
|
+
types: types ? './package.d.ts' : undefined,
|
|
225
|
+
files: [PACK_DOC, pckOf(pack.name), `${FILES_DIR}/**`, ...(types ? ['package.d.ts'] : [])],
|
|
226
|
+
},
|
|
227
|
+
null,
|
|
228
|
+
'\t',
|
|
229
|
+
)}\n`,
|
|
230
|
+
);
|
|
231
|
+
console.log(`[pack] ${pack.name}@${version} over ${Object.keys(own.needs).length} sources`);
|
|
232
|
+
}
|
|
233
|
+
if (packs.length) {
|
|
234
|
+
writeFileSync(resolve(cwd, out, RELEASE), serializeDoc(release));
|
|
235
|
+
} else {
|
|
236
|
+
// Nothing to release: a release document left by a previous bake would name packs this
|
|
237
|
+
// project no longer builds.
|
|
238
|
+
rmSync(resolve(cwd, out, RELEASE), { force: true });
|
|
239
|
+
}
|
|
240
|
+
// Only a bake that got this far can say the project is up to date next time.
|
|
241
|
+
writeFileSync(stamp, `${inputs}\n`);
|
|
242
|
+
} finally {
|
|
243
|
+
// The preset is scoped per pack, so it is rewritten per export and put back once.
|
|
244
|
+
writeFileSync(presets, cfg);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** The bake's shared context: the runner, the project, the preset, and every scoped directory. */
|
|
249
|
+
interface Bake {
|
|
250
|
+
run: Run;
|
|
251
|
+
cwd: string;
|
|
252
|
+
cfg: string;
|
|
253
|
+
preset: string;
|
|
254
|
+
base: string[];
|
|
255
|
+
all: string[];
|
|
256
|
+
/** The converter's output root, which a plan's routes are relative to. */
|
|
257
|
+
planBase: string;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Scopes the preset to `mine` and exports that scope to `pck`. */
|
|
261
|
+
function exportPack(ctx: Bake, mine: string[], pck: string): void {
|
|
262
|
+
writeFileSync(resolve(ctx.cwd, 'export_presets.cfg'), withExcludes(ctx.cfg, ctx.preset, excludesFor(ctx.base, ctx.all, mine)));
|
|
263
|
+
ctx.run(['--headless', '--path', '.', '--export-pack', ctx.preset, pck]);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* One scope's half of the dump, as a document's `needs`/`stage` (and the refs the bake checks). A pack's
|
|
268
|
+
* export carries its own directory and nothing else, so `needs` — what each source under `dirs` needs
|
|
269
|
+
* staged — is that scope's slice, while `stage` — the descriptor of each loose file it *owns* — is
|
|
270
|
+
* judged over the **whole project**: a level's sky lives in the shared pack and is named by nothing but
|
|
271
|
+
* the level's plan, and the owner stages it all the same. `needs` stays paths (whoever owns them),
|
|
272
|
+
* which is what lets a pack borrow another's texture without carrying its bytes. `external` is what the
|
|
273
|
+
* scope's sources leak, for the ref check.
|
|
274
|
+
*/
|
|
275
|
+
function manifestOf(
|
|
276
|
+
dump: Record<string, Dumped>,
|
|
277
|
+
cwd: string,
|
|
278
|
+
dir: string,
|
|
279
|
+
dirs: string[],
|
|
280
|
+
): Pick<PackDoc, 'needs' | 'stage'> & { external: string[] } {
|
|
281
|
+
const roots = dirs.map((d) => resPath(d));
|
|
282
|
+
const needs: Record<string, string[]> = {};
|
|
283
|
+
const external: string[] = [];
|
|
284
|
+
for (const [source, d] of Object.entries(dump)) {
|
|
285
|
+
if (!roots.some((root) => source.startsWith(`${root}/`))) continue;
|
|
286
|
+
if (d.loose.length) needs[source] = d.loose;
|
|
287
|
+
external.push(...d.external);
|
|
288
|
+
}
|
|
289
|
+
const stage: Record<string, { path: string; import: string }> = {};
|
|
290
|
+
for (const loose of new Set(Object.values(dump).flatMap((d) => d.loose))) {
|
|
291
|
+
// Only this scope's own: a borrowed loose file is staged from the pack that owns it, so carrying
|
|
292
|
+
// a copy would make every level ship the shared textures — exactly what deploying a level on its
|
|
293
|
+
// own must not do.
|
|
294
|
+
if (!roots.some((root) => loose.startsWith(`${root}/`))) continue;
|
|
295
|
+
const abs = resolve(cwd, rel(loose));
|
|
296
|
+
if (!existsSync(`${abs}.import`)) continue;
|
|
297
|
+
const descriptor = descriptorOf(readFileSync(`${abs}.import`, 'utf8'));
|
|
298
|
+
if (!descriptor || !existsSync(resolve(cwd, rel(descriptor.path)))) continue;
|
|
299
|
+
mkdirSync(resolve(cwd, dir, FILES_DIR), { recursive: true });
|
|
300
|
+
copyFileSync(resolve(cwd, rel(descriptor.path)), resolve(cwd, dir, FILES_DIR, descriptor.path.split('/').pop()!));
|
|
301
|
+
stage[loose] = descriptor;
|
|
302
|
+
}
|
|
303
|
+
return { needs, stage, external: [...new Set(external)].sort() };
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** A `spawnSync` runner that throws on a non-zero editor exit. */
|
|
307
|
+
function runner(editor: string, cwd: string): Run {
|
|
308
|
+
return (args, env) => {
|
|
309
|
+
const r = spawnSync(editor, args, { cwd, stdio: 'inherit', env: { ...process.env, ...env } });
|
|
310
|
+
if ((r.status ?? 1) !== 0) {
|
|
311
|
+
throw new Error(`godot-assets: \`${editor} ${args.join(' ')}\` failed (${r.status})`);
|
|
312
|
+
}
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** One bucket of the dump: the loose textures a source needs, and the refs that left the scan roots. */
|
|
317
|
+
interface Dumped {
|
|
318
|
+
loose: string[];
|
|
319
|
+
external: string[];
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** Runs `dump-deps.gd` over `scope` (res:// roots): per source, its loose textures and its leaks. */
|
|
323
|
+
function runDump(ctx: Bake, scope: string[]): Record<string, Dumped> {
|
|
324
|
+
copyFileSync(resolve(HERE, 'dump-deps.gd'), resolve(ctx.cwd, '.godot', 'pack-dump-deps.gd'));
|
|
325
|
+
ctx.run(['--headless', '--path', '.', '--script', 'res://.godot/pack-dump-deps.gd'], {
|
|
326
|
+
GODOT_DUMP_OUT: resPath(DUMP),
|
|
327
|
+
GODOT_DUMP_ROOTS: scope.join(','),
|
|
328
|
+
GODOT_DUMP_PLAN_BASE: ctx.planBase,
|
|
329
|
+
// The dump holds no extension lists of its own: they come from the format table, so a source
|
|
330
|
+
// cannot be walked by the dump and staged by the plugin for want of one entry.
|
|
331
|
+
GODOT_DUMP_PACKED: packedExtensions.join(','),
|
|
332
|
+
GODOT_DUMP_SCANNED: scannedExtensions.join(','),
|
|
333
|
+
});
|
|
334
|
+
const abs = resolve(ctx.cwd, DUMP);
|
|
335
|
+
if (!existsSync(abs)) throw new Error(`godot-assets: the dump wrote no ${DUMP} (see dump-deps.gd).`);
|
|
336
|
+
return JSON.parse(readFileSync(abs, 'utf8')) as Record<string, Dumped>;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const resPath = (rel: string) => `res://${rel.replace(/^res:\/\//, '')}`;
|
|
340
|
+
const rel = (res: string) => res.replace(/^res:\/\//, '').replace(/\/+$/, '');
|
|
341
|
+
|
|
342
|
+
/** Fails the bake on a reference that leaves the pack (a sibling, or a source no pack owns). */
|
|
343
|
+
function assertRefs(what: string, external: string[], allowed: string[]): void {
|
|
344
|
+
const illegal = external.filter((ref) => !allowed.some((dir) => ref.startsWith(`${dir}/`)));
|
|
345
|
+
if (illegal.length) {
|
|
346
|
+
throw new Error(
|
|
347
|
+
`godot-assets: ${what} references ${illegal.length} source(s) outside itself and ${allowed.join(', ')} — a pack's refs point at the boot sources or a declared dependency:\n ${illegal.slice(0, 10).join('\n ')}`,
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Finds the content packs under `rootAbs` (searching the subdirectory `under`, so the returned `dir`s
|
|
354
|
+
* are `rootAbs`-relative): every directory holding a `package.json`, identified by that file's `name`.
|
|
355
|
+
* Packs do not nest — a pack inside a pack could not be scoped to one export — and a directory holding
|
|
356
|
+
* one is a container, so stray files beside it belong to no pack.
|
|
357
|
+
*/
|
|
358
|
+
function discoverPacks(rootAbs: string, under = ''): ContentPack[] {
|
|
359
|
+
const dirs: string[] = [];
|
|
360
|
+
walk(rootAbs, under, dirs);
|
|
361
|
+
const packs: ContentPack[] = [];
|
|
362
|
+
for (const dir of dirs.sort()) {
|
|
363
|
+
const parent = packs.find((pack) => dir.startsWith(`${pack.dir}/`));
|
|
364
|
+
if (parent) throw new Error(`godot-assets: ${dir}/ is inside the pack ${parent.dir} — packs do not nest.`);
|
|
365
|
+
if (!dir) throw new Error('godot-assets: the discovery root itself declares a package.json.');
|
|
366
|
+
const pkg = JSON.parse(readFileSync(join(rootAbs, dir, 'package.json'), 'utf8')) as {
|
|
367
|
+
name?: string;
|
|
368
|
+
version?: string;
|
|
369
|
+
dependencies?: Record<string, string>;
|
|
370
|
+
exports?: Exports;
|
|
371
|
+
};
|
|
372
|
+
packs.push({
|
|
373
|
+
name: pkg.name ?? dir.split('/').pop()!,
|
|
374
|
+
dir,
|
|
375
|
+
...(pkg.version ? { version: pkg.version } : {}),
|
|
376
|
+
dependencies: pkg.dependencies ?? {},
|
|
377
|
+
// Passed through verbatim: the declaration is what a client resolves an entry against, so
|
|
378
|
+
// the bake copies it rather than restating it (a client reads it through the entry's
|
|
379
|
+
// `godot` condition).
|
|
380
|
+
exports: pkg.exports,
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
return packs;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** Collects every directory (relative, `/`-separated) under `rootAbs` that holds a `package.json`. */
|
|
387
|
+
function walk(rootAbs: string, dir: string, out: string[]): void {
|
|
388
|
+
const abs = dir ? join(rootAbs, dir) : rootAbs;
|
|
389
|
+
let names: string[];
|
|
390
|
+
try {
|
|
391
|
+
names = readdirSync(abs);
|
|
392
|
+
} catch {
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
if (names.includes('package.json')) out.push(dir);
|
|
396
|
+
for (const name of names) {
|
|
397
|
+
if (name.startsWith('.') || name === 'node_modules') continue;
|
|
398
|
+
if (statSync(join(abs, name)).isDirectory()) walk(rootAbs, dir ? `${dir}/${name}` : name, out);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* The release a local bake serves, in the shape `parseRelease` reads: pack name → the version it just
|
|
404
|
+
* wrote and the dependencies it declared. A deploy publishes the same graph (resolved by `bun install`
|
|
405
|
+
* into a lockfile) under one immutable name, which is the only thing that identifies a content release.
|
|
406
|
+
*/
|
|
407
|
+
function releaseOf(packs: ContentPack[]): Release {
|
|
408
|
+
return Object.fromEntries(
|
|
409
|
+
packs.map((pack) => [pack.name, { version: pack.version ?? '0.0.0', deps: Object.keys(pack.dependencies) }]),
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* The boot pack as a module the app's bundle imports (beside the packs it describes): its `.pck`
|
|
415
|
+
* mounted once, however many modules import this, and the same `needs`/`stage` — with each loose
|
|
416
|
+
* product it owns imported as a file, so its URL is the bundle's. A content pack may need one, and the
|
|
417
|
+
* boot is mounted like any other pack, so its files belong in a client's staging lookup. Only the app
|
|
418
|
+
* can name those URLs, which is why the bake renders them here.
|
|
419
|
+
*/
|
|
420
|
+
function bootModule({ needs, stage }: Pick<PackDoc, 'needs' | 'stage'>): string {
|
|
421
|
+
const imports: string[] = [];
|
|
422
|
+
const files: string[] = [];
|
|
423
|
+
for (const descriptor of Object.values(stage)) {
|
|
424
|
+
const name = descriptor.path.split('/').pop()!;
|
|
425
|
+
imports.push(`import file${files.length} from ${JSON.stringify(`./${FILES_DIR}/${name}`)} with { type: 'file' };`);
|
|
426
|
+
files.push(`\t${JSON.stringify(name)}: file${files.length},`);
|
|
427
|
+
}
|
|
428
|
+
return (
|
|
429
|
+
[
|
|
430
|
+
"// Generated by `godot pack`: the app's own pack, as a module (see @ringozz/godot/preload).",
|
|
431
|
+
`import bootPck from ${JSON.stringify(`./${BOOT_PCK}`)} with { type: 'file' };`,
|
|
432
|
+
`import { configurePacks, loadAssetPack } from '@ringozz/godot/load';`,
|
|
433
|
+
...imports,
|
|
434
|
+
files.length ? `const bootFiles = {\n${files.join('\n')}\n};` : 'const bootFiles = {};',
|
|
435
|
+
`configurePacks({ boot: { pck: bootPck, doc: ${JSON.stringify({ needs, stage })}, url: (file) => bootFiles[file] } });`,
|
|
436
|
+
`export const mounting = loadAssetPack(bootPck);`,
|
|
437
|
+
].join('\n') + '\n'
|
|
438
|
+
);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* A pack's type surface, emitted beside the declaration that names it (`types` in its own
|
|
443
|
+
* `package.json`): one typed export per declared entry, so importing the module the plugin serves is
|
|
444
|
+
* type-checked with no generated module — `packEntries` resolves each entry, exactly as the served
|
|
445
|
+
* module's is. The bake's, not the protocol's: it writes it.
|
|
446
|
+
*/
|
|
447
|
+
export function packTypes(exports: Exports): string {
|
|
448
|
+
const entries = packEntries(exports);
|
|
449
|
+
const classes = [...new Set(entries.map((e) => e.cls))];
|
|
450
|
+
return [
|
|
451
|
+
"// Generated by `godot pack` from the pack's `exports` — do not edit.",
|
|
452
|
+
...classes.map((cls) => `import type { ${cls} } from '@ringozz/godot/${cls}';`),
|
|
453
|
+
'',
|
|
454
|
+
...entries.map((e) => `export declare const ${entryId(e.entry)}: Promise<${e.cls}>;`),
|
|
455
|
+
'',
|
|
456
|
+
].join('\n');
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* A pack's declared `exports` minus its `types` condition. The document is emitted beside the `.pck`,
|
|
461
|
+
* while the declarations a pack's `types` names stay in its source directory (the bake writes them
|
|
462
|
+
* there), so the path would not resolve from the document and nothing reads it — `packEntries` reads
|
|
463
|
+
* the `godot` condition. A subpath left with no conditions goes with it: the document says what a
|
|
464
|
+
* client may resolve.
|
|
465
|
+
*/
|
|
466
|
+
function withoutTypes(exports: Exports): Exports {
|
|
467
|
+
if (!exports) return exports;
|
|
468
|
+
const declared: Record<string, string | { [condition: string]: string | undefined }> = {};
|
|
469
|
+
for (const [subpath, value] of Object.entries(exports)) {
|
|
470
|
+
if (typeof value !== 'object') {
|
|
471
|
+
declared[subpath] = value;
|
|
472
|
+
continue;
|
|
473
|
+
}
|
|
474
|
+
const conditions = Object.fromEntries(Object.entries(value).filter(([condition]) => condition !== 'types'));
|
|
475
|
+
if (Object.keys(conditions).length) declared[subpath] = conditions;
|
|
476
|
+
}
|
|
477
|
+
return declared;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* The bake's scopes must not overlap. Each pack's export excludes every *other* scoped directory, so a
|
|
482
|
+
* boot root that contains a pack would exclude that pack's own files from its own export — an empty
|
|
483
|
+
* pack, which shows up much later as a scene whose assets are missing. The mistake is easy to make
|
|
484
|
+
* (`--roots res://src,res://dlc` when the packs live under `dlc/`), so the bake says so instead.
|
|
485
|
+
*/
|
|
486
|
+
function assertScopes(bootDirs: string[], packs: ContentPack[]): void {
|
|
487
|
+
for (const pack of packs) {
|
|
488
|
+
const root = bootDirs.find((dir) => pack.dir.startsWith(`${dir}/`));
|
|
489
|
+
if (root) {
|
|
490
|
+
throw new Error(
|
|
491
|
+
`godot-assets: the boot root ${root} contains the content pack ${pack.dir} — the boot pack would carry it and the pack's own export would exclude it. Point --roots at sources only: a pack's directory is its own export.`,
|
|
492
|
+
);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* A pack's `exclude_filter`: the preset's own entries plus a glob per scoped directory that is not
|
|
499
|
+
* `mine` — so the export carries this pack and nothing else.
|
|
500
|
+
*/
|
|
501
|
+
function excludesFor(base: string[], all: string[], mine: string[]): string[] {
|
|
502
|
+
return [...base, ...all.filter((dir) => !mine.includes(dir)).map((dir) => `${dir}/*`)];
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/** A preset block's `exclude_filter` entries. */
|
|
506
|
+
function excludeFilter(cfg: string): string[] {
|
|
507
|
+
const line = /^exclude_filter="([^"]*)"/m.exec(cfg);
|
|
508
|
+
return (line?.[1] ?? '').split(',').map((s) => s.trim()).filter(Boolean);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/** The config with the preset named `preset`'s `exclude_filter` replaced by `exclude`. */
|
|
512
|
+
function withExcludes(cfg: string, preset: string, exclude: string[]): string {
|
|
513
|
+
const at = new RegExp(`^name=${JSON.stringify(preset)}$`, 'm').exec(cfg)?.index ?? -1;
|
|
514
|
+
if (at < 0) {
|
|
515
|
+
throw new Error(`godot-assets: no "${preset}" preset in export_presets.cfg (see export_presets.example.cfg).`);
|
|
516
|
+
}
|
|
517
|
+
const line = /^exclude_filter="[^"]*"$/m.exec(cfg.slice(at));
|
|
518
|
+
if (!line) throw new Error(`godot-assets: the "${preset}" preset has no exclude_filter.`);
|
|
519
|
+
const start = at + line.index;
|
|
520
|
+
return `${cfg.slice(0, start)}exclude_filter=${JSON.stringify(exclude.join(','))}${cfg.slice(start + line[0].length)}`;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* Everything a bake reads, as one digest: each resource tree's files (`path/size/mtime` — content is the
|
|
525
|
+
* editor's to read, and a checkout that only moves timestamps re-bakes, which is the safe direction),
|
|
526
|
+
* the preset, the dump script, the tool, and the editor it runs.
|
|
527
|
+
*/
|
|
528
|
+
function inputsOf({ cwd, dirs, cfg, editor }: {
|
|
529
|
+
cwd: string;
|
|
530
|
+
dirs: string[];
|
|
531
|
+
cfg: string;
|
|
532
|
+
editor: string | null;
|
|
533
|
+
}): string {
|
|
534
|
+
const hash = createHash('sha1')
|
|
535
|
+
.update(cfg)
|
|
536
|
+
.update(readFileSync(join(HERE, 'dump-deps.gd')))
|
|
537
|
+
.update(readFileSync(join(HERE, '..', 'package.json')));
|
|
538
|
+
// The executable itself, not its `--version`: asking would spawn it, which is the cost the stamp
|
|
539
|
+
// exists to avoid.
|
|
540
|
+
hash.update(editor ? `${statSync(editor).size}:${statSync(editor).mtimeMs}` : 'injected');
|
|
541
|
+
for (const dir of dirs) digestTree(resolve(cwd, dir), '', hash);
|
|
542
|
+
return hash.digest('hex');
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/** Feeds a tree's files into `hash`: the bake's own outputs and the app's own sources are not inputs. */
|
|
546
|
+
function digestTree(abs: string, rel: string, hash: Hash): void {
|
|
547
|
+
let names: string[];
|
|
548
|
+
try {
|
|
549
|
+
names = readdirSync(abs);
|
|
550
|
+
} catch {
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
for (const name of names.sort()) {
|
|
554
|
+
// The bake's own output beside a pack's manifest, the app's own sources, and dependencies are
|
|
555
|
+
// not inputs: editing them cannot change a pack.
|
|
556
|
+
if (name === 'node_modules' || name.startsWith('.') || name === 'package.d.ts' || SOURCE.has(extname(name))) continue;
|
|
557
|
+
const child = join(abs, name);
|
|
558
|
+
const stat = statSync(child);
|
|
559
|
+
if (stat.isDirectory()) digestTree(child, `${rel}/${name}`, hash);
|
|
560
|
+
else hash.update(`${rel}/${name}:${stat.size}:${stat.mtimeMs}\n`);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/** The stamp a bake left, or `null` — a stamp this can't read is a project that gets baked. */
|
|
565
|
+
function readStamp(file: string): string | null {
|
|
566
|
+
try {
|
|
567
|
+
return readFileSync(file, 'utf8').trim();
|
|
568
|
+
} catch {
|
|
569
|
+
return null;
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/** Parses `godot pack` CLI args into {@link BuildAssetPackOptions}. */
|
|
574
|
+
export function parsePackArgs(args: string[]): BuildAssetPackOptions {
|
|
575
|
+
const opt = (flag: string): string | undefined => {
|
|
576
|
+
const i = args.indexOf(flag);
|
|
577
|
+
return i >= 0 ? args[i + 1] : undefined;
|
|
578
|
+
};
|
|
579
|
+
return {
|
|
580
|
+
roots: args.includes('--no-boot') ? [] : opt('--roots')?.split(',').filter(Boolean),
|
|
581
|
+
dlc: opt('--dlc'),
|
|
582
|
+
out: opt('--out'),
|
|
583
|
+
preset: opt('--preset'),
|
|
584
|
+
godot: opt('--godot'),
|
|
585
|
+
force: args.includes('--force'),
|
|
586
|
+
};
|
|
587
|
+
}
|