@ringozz/godot 4.7.2-659 → 4.7.2-664

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/dump-deps.gd CHANGED
@@ -1,32 +1,52 @@
1
1
  @tool
2
2
  extends SceneTree
3
- ## Dumps, for every packable (`.import`-less) source under the given roots, two things:
4
- ## - `loose`: the **loose** (WebP-backed) texture sources it needs — those must stay out of
5
- ## the asset packs and be staged/transcoded by the host, and the preload plugin
6
- ## (`@ringozz/godot/preload`) reads them from the manifest beside the packs so it never
7
- ## scans source text for references.
8
- ## - `packed`: the packed sources reachable from it — the graph `@ringozz/godot/pack`'s
9
- ## `splitPacks` uses to decide which pack owns a source (the ones only one scene reaches
10
- ## are that scene's).
3
+ ## Dumps, for every packable (`.import`-less) source under the given roots, two halves. The **loose**
4
+ ## (WebP-backed) texture sources it needs: those must stay out of the pack and be staged/transcoded by
5
+ ## the host, and the preload plugin (`@ringozz/godot/preload`) reads them from the manifest beside the
6
+ ## pack, so it never scans source text for references. And the **external** refs the walk reached
7
+ ## outside the roots: a pack may reference the boot sources and its declared dependencies, never a
8
+ ## sibling, so the bake checks those instead of trusting the tree.
11
9
  ##
12
10
  ## Sources of both (union): the engine's `ResourceLoader.get_dependencies` (for imported
13
- ## sources the real refs live on the imported product, reached via the `.import`
14
- ## `dest_files`), plus a text scan for string-valued paths (`albedo_array =
15
- ## "res://.../*.webparray"`, `load("res://...")`) that no static resource edge exposes. A dep
16
- ## is *loose* when it has an `.import` sidecar but is not a packed resource type — the set is
17
- ## derived, not listed.
11
+ ## sources the real refs live on the imported *product*, reached through the `.import` sidecar —
12
+ ## `deps/dest_files`, or the `[remap] path` an importer that writes only that serves the source as,
13
+ ## exactly as `@ringozz/godot/preload` reads it), plus a text scan for string-valued paths
14
+ ## (`albedo_array = "res://.../*.webparray"`, `load("res://...")`) that no static resource edge
15
+ ## exposes — the engine reports *no* dependency for a script at all (`GDScriptParser::get_dependencies`
16
+ ## is a stub), so a `.gd`'s literal loads are only ever found here. A dep is *loose* when it has an
17
+ ## `.import` sidecar but is not a packed resource type — the set is derived, not listed.
18
18
  ##
19
- ## Driven by `@ringozz/godot/pack`'s `buildAssetPacks`, which sets:
20
- ## GODOT_DUMP_OUT – output res:// path (default res://.godot/pack-dump.json) — a build
21
- ## input: the bake merges it into the manifest beside the packs.
22
- ## GODOT_DUMP_ROOTS – comma-separated res:// roots to scan (default res://)
19
+ ## A third source, because neither above can see it: a **scene built from its plan**
20
+ ## (`extras.gltfx.plan`, which a host script reads at run time). It names its terrain by
21
+ ## *directory* and its ground/environment maps by relative route — no resource edge and no `res://`
22
+ ## literal — so `_plan_refs` reads the plan off the `.gltfx` and classifies its refs. A scene that
23
+ ## instances the `.gltfx` inherits them through `_collect`'s recursion. It reads the plan's own role
24
+ ## names (`terrain.dir`, a patch's `material.{diffuse,normal,orm}` / `water.normal`, the authored
25
+ ## stage's `env.map`), which no engine check can validate — so a plan that resolves *nothing*, or one
26
+ ## stamped under another `extras` namespace, is warned about rather than silently staging nothing.
27
+ ##
28
+ ## Driven by `@ringozz/godot/bake`'s `buildAssetPacks`, which sets:
29
+ ## GODOT_DUMP_OUT – output res:// path (default res://.godot/pack-dump.json) — a build
30
+ ## input: the bake merges it into the document beside the packs.
31
+ ## GODOT_DUMP_ROOTS – comma-separated res:// roots to scan (default res://)
32
+ ## GODOT_DUMP_PACKED – comma-separated extensions whose *product* the pack carries: such a source is
33
+ ## walked (an imported source's own deps are empty — its product carries the
34
+ ## refs) where any other extension with a sidecar is a loose texture to stage
35
+ ## GODOT_DUMP_SCANNED – comma-separated extensions whose text names `res://` paths the engine cannot
36
+ ## report, so it is scanned for literals
37
+ ##
38
+ ## The two extension lists are required rather than defaulted: they are the format table's
39
+ ## (`formats.ts`), and an empty one would classify every texture as packed — staging the wrong set
40
+ ## instead of failing.
41
+
42
+ var _packed := PackedStringArray()
43
+ var _scanned := PackedStringArray()
44
+
23
45
 
24
- ## Imported source extensions: `_classify` recurses into these (an imported source's own deps are
25
- ## empty — its *product* carries the refs) where any other extension with a sidecar is a loose texture.
26
- ## `gltfx` is a scene-composition source and `gltfxmesh` a shared-mesh one: their products reference the
27
- ## meshes and textures they compose.
28
- const PACKED := ["tscn", "tres", "gltf", "gltfx", "gltfxmesh", "obj", "gd", "gdshader", "gdshaderinc", "ttf", "otf", "po"]
29
- const SCANNED := ["tscn", "tres", "gd", "gdshader", "gdshaderinc", "gltf", "obj"]
46
+ ## A plan's routes are relative to the converter's output root (`--out`), *not* to the scan root — a
47
+ ## pack is scanned on its own, so the two differ. `GODOT_DUMP_PLAN_BASE` names that root; the first
48
+ ## scan root stands in when unset (a project whose content prefix is where it bakes).
49
+ var _plan_base := ""
30
50
 
31
51
 
32
52
  func _initialize() -> void:
@@ -36,9 +56,20 @@ func _initialize() -> void:
36
56
  var roots := OS.get_environment("GODOT_DUMP_ROOTS").split(",", false)
37
57
  if roots.is_empty():
38
58
  roots = ["res://"]
59
+ _packed = _extensions("GODOT_DUMP_PACKED")
60
+ _scanned = _extensions("GODOT_DUMP_SCANNED")
61
+ if _packed.is_empty() or _scanned.is_empty():
62
+ push_error("[dump-deps] GODOT_DUMP_PACKED and GODOT_DUMP_SCANNED are required — the dump is driven by `godot pack`.")
63
+ quit(1)
64
+ return
65
+ _plan_base = OS.get_environment("GODOT_DUMP_PLAN_BASE")
66
+ if _plan_base.is_empty():
67
+ _plan_base = String(roots[0])
68
+ _plan_base = _plan_base.rstrip("/") + "/"
39
69
  var sources := {}
40
70
  for r in roots:
41
- _scan(String(r).rstrip("/") + "/", sources)
71
+ var root := String(r).rstrip("/") + "/"
72
+ _scan(root, root, sources)
42
73
  DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(out.get_base_dir()))
43
74
  var f := FileAccess.open(out, FileAccess.WRITE)
44
75
  f.store_string(JSON.stringify(sources, " "))
@@ -47,47 +78,59 @@ func _initialize() -> void:
47
78
  quit()
48
79
 
49
80
 
50
- func _scan(path: String, sources: Dictionary) -> void:
81
+ ## A comma-separated `GODOT_DUMP_*` extension list.
82
+ func _extensions(name: String) -> PackedStringArray:
83
+ return PackedStringArray(OS.get_environment(name).split(",", false))
84
+
85
+
86
+ ## `root` is the scan root `path` lives under (a plan's refs are relative to it), carried down the walk.
87
+ func _scan(root: String, path: String, sources: Dictionary) -> void:
51
88
  var d := DirAccess.open(path)
52
89
  if d == null:
53
90
  return
54
91
  for dir in d.get_directories():
55
92
  if dir.begins_with(".") or dir == "node_modules":
56
93
  continue
57
- _scan(path.path_join(dir) + "/", sources)
94
+ _scan(root, path.path_join(dir) + "/", sources)
58
95
  for file in d.get_files():
59
96
  var p := path.path_join(file)
60
- if p.get_extension().to_lower() in PACKED:
61
- var seen := {}
62
- var loose := _loose(p, seen)
63
- # The graph holds authored packed sources only: the walk also visits the engine's
64
- # own products (`res://.godot/imported/…`, reached through the `.import` sidecar),
65
- # and a product is carried by its *source*, not owned in its own right.
66
- var packed := seen.keys().filter(func(s: String) -> bool: return not s.begins_with("res://.godot/"))
67
- packed.sort()
68
- sources[p] = {"packed": packed, "loose": loose}
69
-
70
-
71
- ## The loose texture sources reachable from `path` (transitive), sorted. `seen` collects every
72
- ## packed source the walk touches — the graph — including `path` itself.
73
- func _loose(path: String, seen: Dictionary) -> Array:
97
+ if p.get_extension().to_lower() in _packed:
98
+ sources[p] = _loose(root, p, {})
99
+
100
+
101
+ ## The loose texture sources reachable from `path` (transitive), sorted, and the refs the walk reached
102
+ ## outside the scan root. `seen` is the walk's visited set, so a cycle (or a diamond) costs one visit;
103
+ ## the engine's own products (`res://.godot/…`) are carried by their source, so they are not refs.
104
+ func _loose(root: String, path: String, seen: Dictionary) -> Dictionary:
74
105
  var loose := {}
75
- _collect(path, loose, seen)
106
+ _collect(root, path, loose, seen)
76
107
  var arr := loose.keys()
77
108
  arr.sort()
78
- return arr
109
+ var external := []
110
+ for s in seen:
111
+ var ref := String(s)
112
+ if not ref.begins_with(root) and not ref.begins_with("res://.godot/"):
113
+ external.append(ref)
114
+ external.sort()
115
+ return {"loose": arr, "external": external}
79
116
 
80
117
 
81
- func _classify(dep: String, loose: Dictionary, seen: Dictionary) -> void:
118
+ ## Classifies one dep: a packed type is walked, anything else with an `.import` sidecar is a loose
119
+ ## texture to stage. Returns whether the dep named anything real — a plan's routes are probed in
120
+ ## several spellings, so that is what tells `_plan_refs` its shape still resolves.
121
+ func _classify(root: String, dep: String, loose: Dictionary, seen: Dictionary) -> bool:
82
122
  if dep.is_empty():
83
- return
84
- if dep.get_extension().to_lower() in PACKED:
85
- _collect(dep, loose, seen)
86
- elif FileAccess.file_exists(dep + ".import"):
123
+ return false
124
+ if dep.get_extension().to_lower() in _packed:
125
+ _collect(root, dep, loose, seen)
126
+ return true
127
+ if FileAccess.file_exists(dep + ".import"):
87
128
  loose[dep] = true
129
+ return true
130
+ return false
88
131
 
89
132
 
90
- func _collect(path: String, loose: Dictionary, seen: Dictionary) -> void:
133
+ func _collect(root: String, path: String, loose: Dictionary, seen: Dictionary) -> void:
91
134
  if seen.has(path):
92
135
  return
93
136
  seen[path] = true
@@ -97,15 +140,29 @@ func _collect(path: String, loose: Dictionary, seen: Dictionary) -> void:
97
140
  if FileAccess.file_exists(imp):
98
141
  var cfg := ConfigFile.new()
99
142
  if cfg.load(imp) == OK:
100
- for dst in cfg.get_value("deps", "dest_files", []):
101
- _collect(String(dst), loose, seen)
143
+ for dst in _dest_files(cfg):
144
+ _collect(root, String(dst), loose, seen)
102
145
  for dep in ResourceLoader.get_dependencies(path):
103
- _classify(_res_path(String(dep)), loose, seen)
146
+ _classify(root, _res_path(String(dep)), loose, seen)
104
147
  # String-valued resource paths are invisible to dependency analysis; scan the
105
148
  # text for loose refs the engine cannot report.
106
- if path.get_extension().to_lower() in SCANNED:
149
+ if path.get_extension().to_lower() in _scanned:
107
150
  for p in _text_refs(path):
108
- _classify(p, loose, seen)
151
+ _classify(root, p, loose, seen)
152
+ # A scene built from its plan names nothing above: its refs come from the plan itself.
153
+ if path.get_extension().to_lower() == "gltfx":
154
+ _plan_refs(path, loose, seen)
155
+
156
+
157
+ ## An imported source's product(s), read exactly as `@ringozz/godot/preload` reads them: every
158
+ ## `deps/dest_files` entry (the real refs live on the product), or — for an importer that writes only
159
+ ## that — the single `[remap] path` the source is served as.
160
+ func _dest_files(cfg: ConfigFile) -> Array:
161
+ var dests: Variant = cfg.get_value("deps", "dest_files", [])
162
+ if dests is Array and not dests.is_empty():
163
+ return dests
164
+ var served: Variant = cfg.get_value("remap", "path", "")
165
+ return [served] if served is String and not served.is_empty() else []
109
166
 
110
167
 
111
168
  ## `res://` path from a dependency entry (`uid://…::::res://…` or plain).
@@ -124,3 +181,93 @@ func _text_refs(path: String) -> Array:
124
181
  for m in re.search_all(FileAccess.get_file_as_string(path)):
125
182
  refs.append(m.get_string())
126
183
  return refs
184
+
185
+
186
+ ## A plan's refs, read from the `.gltfx` that carries it (`extras.gltfx.plan`). They are invisible to
187
+ ## every source above (a *directory* rather than a route, relative routes, and a host script that
188
+ ## reads the JSON instead of a resource edge), so the plan is their only statement. Each ref goes
189
+ ## through `_classify`: what has an `.import` sidecar is staged, the header JSONs (which the packs
190
+ ## carry) are not.
191
+ ##
192
+ ## The role names are the plan's own (`core/ground.ts` + `core/heapsMaterial.ts`): an overlay patch's
193
+ ## `material.{diffuse,normal,orm}`, a lake/ocean's `water.normal`, and the authored stage's `env.map`,
194
+ ## every one served as `<route>.webp` (the host's `_texture_route`). Nothing static can check that
195
+ ## reading, so a plan that resolves no ref at all is warned about: that is a schema change, and it
196
+ ## would otherwise show up as a scene with no terrain on a host that stages.
197
+ func _plan_refs(path: String, loose: Dictionary, seen: Dictionary) -> void:
198
+ var doc = JSON.parse_string(FileAccess.get_file_as_string(path))
199
+ var extras: Variant = doc.get("extras") if doc is Dictionary else null
200
+ if not extras is Dictionary:
201
+ return
202
+ var stamp: Variant = extras.get("gltfx")
203
+ var plan: Variant = stamp.get("plan") if stamp is Dictionary else null
204
+ if not plan is Dictionary:
205
+ # A `.gltfx` with no plan is normal (a plain composition). A plan under *another* namespace
206
+ # is not: nothing here would stage, and the host would build a scene whose textures were
207
+ # never shipped — so name the namespace that was stamped instead.
208
+ for ns in extras:
209
+ var other: Variant = extras[ns]
210
+ if ns != "gltfx" and other is Dictionary and other.get("plan") is Dictionary:
211
+ push_warning("[dump-deps] %s stamps a plan under extras.%s, not extras.gltfx — its refs will not be staged." % [path, ns])
212
+ return
213
+
214
+ var refs := 0
215
+ # The terrain: the plan names its directory, and the directory is the atlas list.
216
+ var terrain: Variant = plan.get("terrain")
217
+ if terrain is Dictionary and terrain.get("dir") is String:
218
+ refs += _terrain_refs(_plan_base, _plan_base.path_join(String(terrain["dir"])), loose, seen)
219
+
220
+ # The rest are texture routes — the ground's patches / the water's maps, and the sky.
221
+ var ground: Variant = plan.get("ground")
222
+ if ground is Array:
223
+ for g in ground:
224
+ if not g is Dictionary:
225
+ continue
226
+ var fields: Variant = g.get("material") if g.get("source") == "overlay" else g.get("water")
227
+ if fields is Dictionary:
228
+ for key in ["diffuse", "normal", "orm"]:
229
+ if _classify_route(_plan_base, fields.get(key), loose, seen):
230
+ refs += 1
231
+ var lighting: Variant = plan.get("lighting")
232
+ if lighting is Dictionary:
233
+ # A host renders one time of day: the authored stage's env, which the plan mirrors as `env`.
234
+ var stages: Variant = lighting.get("stages")
235
+ var env: Variant = stages[0].get("env") if stages is Array and stages.size() > 0 and stages[0] is Dictionary else null
236
+ if not env is Dictionary:
237
+ env = lighting.get("env")
238
+ if _classify_route(_plan_base, env.get("map") if env is Dictionary else null, loose, seen):
239
+ refs += 1
240
+
241
+ if refs == 0:
242
+ push_warning("[dump-deps] %s carries a plan, but no ref in it resolved — its shape (terrain.dir / a patch's material / a stage's env) may have changed." % path)
243
+
244
+
245
+ ## A terrain directory **is** its atlas list (every atlas name is top-level in it), so each file is a
246
+ ## ref — except a layered container's *layers*: the manifest names them and their bytes live in its
247
+ ## imported product, so staging them would ship megabytes nothing reads.
248
+ func _terrain_refs(root: String, dir: String, loose: Dictionary, seen: Dictionary) -> int:
249
+ var files := DirAccess.get_files_at(dir)
250
+ var layers := {}
251
+ for name in files:
252
+ if name.to_lower().ends_with(".webparray"):
253
+ for line in FileAccess.get_file_as_string(dir.path_join(name)).split("\n"):
254
+ var ref := line.strip_edges()
255
+ if not ref.is_empty() and not ref.begins_with("#"):
256
+ layers[ref.get_file()] = true
257
+ var refs := 0
258
+ for name in files:
259
+ if not layers.has(name.get_file()) and _classify(root, dir.path_join(name), loose, seen):
260
+ refs += 1
261
+ return refs
262
+
263
+
264
+ ## Classifies a plan's route: relative and unsuffixed, so the served `.webp` spelling is tried too
265
+ ## (the host's `_texture_route`). Returns whether it resolved.
266
+ func _classify_route(root: String, route: Variant, loose: Dictionary, seen: Dictionary) -> bool:
267
+ if not route is String:
268
+ return false
269
+ var found := false
270
+ for suffix in ["", ".webp"]:
271
+ if _classify(root, root.path_join(route + suffix), loose, seen):
272
+ found = true
273
+ return found
package/src/editor.ts CHANGED
@@ -107,7 +107,7 @@ export async function resolveEditor(): Promise<string> {
107
107
  if (import.meta.main) {
108
108
  const command = process.argv[2];
109
109
  if (command === 'pack') {
110
- const { buildAssetPacks, parsePackArgs } = await import('./pack.ts');
110
+ const { buildAssetPacks, parsePackArgs } = await import('./bake.ts');
111
111
  await buildAssetPacks(parsePackArgs(process.argv.slice(3)));
112
112
  process.exit(0);
113
113
  }
package/src/formats.ts ADDED
@@ -0,0 +1,78 @@
1
+ /**********************************************************************
2
+ Copyright (c) Vladimir Davidovich. All rights reserved.
3
+ ***********************************************************************/
4
+
5
+ // One row per importable source extension, holding everything its four readers need — so adding an
6
+ // extension is one edit here rather than an edit per list, and the lists cannot disagree:
7
+ //
8
+ // - `cls` — the class the module the preload plugin serves loads it as, and the class the ambient
9
+ // types for that import resolve to (`assets.d.ts`, the one hand-kept projection, which
10
+ // `dev/tests/formats.test.ts` compares against these rows). A row with no class is a source nothing
11
+ // imports: `.gltfxmesh`, which a `.gltfx`'s product references.
12
+ // - `packed` — its *product* travels in the `.pck`, so the dump recurses through it (an imported
13
+ // source's own `get_dependencies` is empty — the product carries the refs) instead of staging it
14
+ // loose. That is why the texture formats are rows here and *not* `packed`: the wasm build decodes no
15
+ // WebP, so their bytes must stay out of the pack and be staged and transcoded per file.
16
+ // - `scanned` — its *text* names `res://` paths no resource edge exposes (the engine reports no
17
+ // dependencies for a script at all), so the dump scans its text for literals.
18
+ //
19
+ // `moduleExtensions` / `packedExtensions` / `scannedExtensions` are those rows projected, which is what
20
+ // the plugin's filter and the dump's two environment lists are built from.
21
+
22
+ /** One source extension, and what that says about it. */
23
+ export interface Format {
24
+ /** The class its module loads as; absent when nothing imports one. */
25
+ cls?: string;
26
+ /** Its product travels in the `.pck`: walk it, rather than staging it loose. */
27
+ packed?: boolean;
28
+ /** Its text names `res://` paths worth a literal scan. */
29
+ scanned?: boolean;
30
+ }
31
+
32
+ /** Imported source extension → what it is, and how it is carried. */
33
+ export const FORMATS: Record<string, Format> = {
34
+ gltf: { cls: 'PackedScene', packed: true, scanned: true },
35
+ // A scene-composition document (a glTF External Reference): an imported source whose product is a
36
+ // `PackedScene`, like a `.gltf`'s — and whose product is where its meshes/textures are referenced.
37
+ gltfx: { cls: 'PackedScene', packed: true },
38
+ // A shared-mesh document: what one or more `.gltfx` compositions reference. Nothing imports one —
39
+ // a `.gltfx`'s product is what names it — so it has no class, and is carried by a pack all the same.
40
+ gltfxmesh: { packed: true },
41
+ tscn: { cls: 'PackedScene', packed: true, scanned: true },
42
+ obj: { cls: 'PackedScene', packed: true, scanned: true },
43
+ gd: { cls: 'GDScript', packed: true, scanned: true },
44
+ gdshader: { cls: 'Shader', packed: true, scanned: true },
45
+ gdshaderinc: { cls: 'ShaderInclude', packed: true, scanned: true },
46
+ tres: { cls: 'Resource', packed: true, scanned: true },
47
+ po: { cls: 'Translation', packed: true },
48
+ ttf: { cls: 'FontFile', packed: true },
49
+ otf: { cls: 'FontFile', packed: true },
50
+ jpg: { cls: 'CompressedTexture2D' },
51
+ jpeg: { cls: 'CompressedTexture2D' },
52
+ png: { cls: 'CompressedTexture2D' },
53
+ webp: { cls: 'CompressedTexture2D' },
54
+ svg: { cls: 'CompressedTexture2D' },
55
+ // The layer manifest `godot/addons/sourcewebp` imports into a `.ctexarray` (a texture array whose
56
+ // layers are source WebP bytes). It has no runtime deps of its own; its product is what is staged.
57
+ webparray: { cls: 'CompressedTexture2DArray' },
58
+ exr: { cls: 'TextureLayered' },
59
+ hdr: { cls: 'TextureLayered' },
60
+ wav: { cls: 'AudioStreamWAV' },
61
+ ogg: { cls: 'AudioStreamOggVorbis' },
62
+ mp3: { cls: 'AudioStreamMP3' },
63
+ };
64
+
65
+ /** A path's extension, lowercased (`res://…/Map.TSCN` -> `tscn`). */
66
+ const extOf = (path: string) => path.slice(path.lastIndexOf('.') + 1).toLowerCase();
67
+
68
+ /** The class an asset path loads as, or `undefined` when it is not an importable asset. */
69
+ export const classOf = (path: string) => FORMATS[extOf(path)]?.cls;
70
+
71
+ /** The extensions the preload plugin serves a module for: the rows with a class. */
72
+ export const moduleExtensions = Object.keys(FORMATS).filter((ext) => FORMATS[ext].cls);
73
+
74
+ /** The extensions whose product the `.pck` carries — what the dump walks (`GODOT_DUMP_PACKED`). */
75
+ export const packedExtensions = Object.keys(FORMATS).filter((ext) => FORMATS[ext].packed);
76
+
77
+ /** The extensions whose text the dump scans for `res://` literals (`GODOT_DUMP_SCANNED`). */
78
+ export const scannedExtensions = Object.keys(FORMATS).filter((ext) => FORMATS[ext].scanned);
package/src/load.ts CHANGED
@@ -10,6 +10,22 @@ 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
+ RELEASE,
17
+ mountClosure,
18
+ packPath,
19
+ parseDoc,
20
+ parseRelease,
21
+ pckOf,
22
+ resolveEntry,
23
+ type PackDoc,
24
+ type Release,
25
+ type ResolvedEntry,
26
+ type StagedFile,
27
+ type StagedFiles,
28
+ } from './pack.ts';
13
29
  import { decodeCtex } from './web-image.ts';
14
30
 
15
31
  /**
@@ -57,18 +73,6 @@ function track<T extends Resource>(res: T): T {
57
73
  */
58
74
  type ResourceConstructor = { new(...args: any[]): Resource } & Function;
59
75
 
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
76
  const UID_RE = /uid="(uid:\/\/[\w.]+)"/;
73
77
 
74
78
  // Compressed-texture products whose payloads may need decoding/transcoding:
@@ -79,7 +83,7 @@ const COMPRESSED_TEXTURE_RE = /\.(ctex|ctexarray|ccube|ccubearray)$/;
79
83
 
80
84
  // Textures the runtime must receive *exactly* as the server served them, so they are decoded to raw RGBA8
81
85
  // instead of going through the platform transcoder (spark -> BC7/ASTC): the terrain height atlas
82
- // (`<level>.dat/terrain/height.webp`), whose 12-bit packed R/G the vertex shader decodes per texel, so
86
+ // (`<name>.dat/terrain/height.webp`), whose 12-bit packed R/G the vertex shader decodes per texel, so
83
87
  // block quantisation shows as blocky relief on every slope. Everything else may be compressed — the
84
88
  // surface arrays, the weight planes and the `_n` tile normal all ship as layered containers the runtime
85
89
  // only samples (see `docs/godot.md`'s M4 section). The `.ctex` product is named after its source plus an
@@ -112,7 +116,27 @@ function registerAssetUid(uid: string, resPath: string): void {
112
116
  }
113
117
  }
114
118
 
115
- async function materialize([resPath, entry]: [string, AssetFile]): Promise<void> {
119
+ // res:// paths already staged, or being staged: MEMFS cannot be read back reliably — the engine's
120
+ // `res://` lookup and the Emscripten path disagree by a leading slash — so `FileAccess.fileExists`
121
+ // cannot answer "is this staged?", and two asset modules needing the same file would fetch and
122
+ // allocate it twice. A path is forgotten when its bytes are removed after a load.
123
+ const staged = new Map<string, Promise<void>>();
124
+
125
+ async function materialize(entry: [string, StagedFile]): Promise<void> {
126
+ const [resPath] = entry;
127
+ const existing = staged.get(resPath);
128
+ if (existing) {
129
+ return existing;
130
+ }
131
+ const p = stage(entry).catch((err) => {
132
+ staged.delete(resPath);
133
+ throw err;
134
+ });
135
+ staged.set(resPath, p);
136
+ return p;
137
+ }
138
+
139
+ async function stage([resPath, entry]: [string, StagedFile]): Promise<void> {
116
140
  if (FileAccess.fileExists(resPath)) {
117
141
  return;
118
142
  }
@@ -163,7 +187,7 @@ const nextTick = () => new Promise(requestAnimationFrame);
163
187
  * straight into Emscripten's MEMFS via Godot's `copyToFS` (JS-heap only, no wasm
164
188
  * copy); a no-op on desktop where the files already exist.
165
189
  */
166
- function materializeFiles(files: AssetFiles): Promise<unknown> {
190
+ function materializeFiles(files: StagedFiles): Promise<unknown> {
167
191
  if (stageFile === undefined) {
168
192
  // Desktop: the files already exist on disk (res:// = cwd) and uids come
169
193
  // from `.godot/uid_cache.bin`, so there is nothing to stage.
@@ -199,7 +223,7 @@ function cachedResource<C extends ResourceConstructor>(path: string): Promise<In
199
223
  export function loadAsset<C extends ResourceConstructor>(
200
224
  path: string,
201
225
  cls: C,
202
- files: AssetFiles,
226
+ files: StagedFiles,
203
227
  data: Record<string, unknown> = {},
204
228
  ready?: Promise<unknown>,
205
229
  ): { materialize: Promise<unknown>; load: Promise<InstanceType<C>> } {
@@ -230,7 +254,7 @@ export function loadAsset<C extends ResourceConstructor>(
230
254
  export async function loadResourceAsync<C extends ResourceConstructor>(
231
255
  path: string,
232
256
  cls: C,
233
- files?: AssetFiles,
257
+ files?: StagedFiles,
234
258
  ): Promise<InstanceType<C>> {
235
259
  const err = ResourceLoader.loadThreadedRequest(path, cls.name);
236
260
  if (err) {
@@ -263,6 +287,8 @@ export async function loadResourceAsync<C extends ResourceConstructor>(
263
287
  for (const resPath of Object.keys(files)) {
264
288
  if (!resPath.endsWith('.import')) {
265
289
  DirAccess.removeAbsolute(resPath);
290
+ // The bytes are gone, so the memo must forget the path — a later module stages it again.
291
+ staged.delete(resPath);
266
292
  }
267
293
  }
268
294
  }
@@ -321,3 +347,113 @@ function packName(url: string): string {
321
347
  const base = url.split('?')[0].split('/').pop() ?? 'pack.pck';
322
348
  return base.replace(/[^A-Za-z0-9._-]/g, '_') || 'pack.pck';
323
349
  }
350
+
351
+ // ── Content packs ───────────────────────────────────────────────────────────
352
+ // A content pack is deployed apart from the app, so a client is told where a release-relative path can
353
+ // be read (`at`) and everything else follows: the release graph, each mounted pack's manifest and
354
+ // `.pck`, and the loose files the entry needs. The bake also hands over the boot pack's own config —
355
+ // its `.pck` and a bundled URL per loose product, the one thing only the app can provide, since those
356
+ // bytes ship beside it. The runtime only ever sees URLs, all of them version-addressed and immutable.
357
+
358
+ /**
359
+ * A pack the runtime has at hand: its `.pck`, its document, and where its staged products are read. The
360
+ * app's own pack is one of these — its bytes ship beside it, so its URLs are the host's, not a
361
+ * version-addressed path — and it is what `configurePacks({ boot })` hands over.
362
+ */
363
+ export interface MountedPack {
364
+ /** The `.pck` to mount (`loadAssetPack`). */
365
+ pck: string;
366
+ doc: PackDoc;
367
+ /** The URL of one of this pack's products, by the file name `doc.stage` gives it. */
368
+ url: (file: string) => string;
369
+ }
370
+
371
+ /** How the runtime reads content packs: `at('<name>/<version>/<file>')` → a fetchable URL. */
372
+ export interface PackSource {
373
+ /** Where a release document and its packs are read (a CDN prefix, or a dev server's directory). */
374
+ at?: (path: string) => string;
375
+ /** The app's own pack, configured by the bake's own code: always mounted, its bytes bundled. */
376
+ boot?: MountedPack;
377
+ }
378
+
379
+ let packSource: PackSource = {};
380
+
381
+ /**
382
+ * Points the runtime at a pack source. Merges, so the bake's boot config and the app's `at` may land in
383
+ * either order — the boot config rides on the app's own asset imports.
384
+ */
385
+ export function configurePacks(source: PackSource): void {
386
+ packSource = { ...packSource, ...source };
387
+ }
388
+
389
+ /** Reads a pack document: a URL (a release) over the network, anything else off the filesystem. */
390
+ async function fetchPack(path: string): Promise<string> {
391
+ const at = packSource.at;
392
+ if (!at) throw new Error('godot-assets: no pack source — call configurePacks({ at }) first.');
393
+ const url = at(path);
394
+ if (/^https?:/i.test(url)) {
395
+ const res = await fetch(url);
396
+ if (!res.ok) throw new Error(`godot-assets: ${url} (${res.status})`);
397
+ return res.text();
398
+ }
399
+ // Desktop and a dev bundle hand over a local path, and `res://` is the working tree there.
400
+ const text = FileAccess.getFileAsString(url.startsWith('res://') ? url : ProjectSettings.globalizePath(url));
401
+ if (!text) throw new Error(`godot-assets: cannot read ${url}`);
402
+ return text;
403
+ }
404
+
405
+ /**
406
+ * A resolved entry's files, as the loader stages them: the app's own pack addresses a product from its
407
+ * bundled URLs, a release pack from the version-addressed path the entry was resolved against. This is
408
+ * the addressing the protocol deliberately does not know — which is why a resolution returns packs and
409
+ * file names rather than URLs.
410
+ */
411
+ function stagedFiles(
412
+ plan: ResolvedEntry,
413
+ release: Release,
414
+ boot: MountedPack | undefined,
415
+ at: (path: string) => string,
416
+ ): StagedFiles {
417
+ const files: StagedFiles = {};
418
+ for (const { sidecar, content, product, pack, file } of plan.files) {
419
+ files[sidecar] = { content };
420
+ files[product] = {
421
+ path: pack === null ? boot!.url(file) : at(packPath(pack, release[pack].version, `${FILES_DIR}/${file}`)),
422
+ };
423
+ }
424
+ return files;
425
+ }
426
+
427
+ /**
428
+ * Loads one declared entry of a content pack: the release, then the packs the entry reaches and the
429
+ * loose files they stage, then the `res://` path the entry names. Nothing about a pack is baked into
430
+ * the app, so it can be republished without one.
431
+ */
432
+ export async function loadPackEntry<C extends ResourceConstructor>(
433
+ pack: string,
434
+ entry: string,
435
+ cls: C,
436
+ ): Promise<InstanceType<C>> {
437
+ const at = packSource.at;
438
+ if (!at) throw new Error('godot-assets: no pack source — call configurePacks({ at }) first.');
439
+ // Mounting needs only the release — a pack's `.pck` is `<name>.pck` by convention — so the closure's
440
+ // packs are mounted while their documents are fetched, and only staging waits on those.
441
+ const release = parseRelease(await fetchPack(RELEASE));
442
+ const names = mountClosure(release, pack);
443
+ const pckUrl = (name: string) => at(packPath(name, release[name].version, pckOf(name)));
444
+ const boot = packSource.boot;
445
+ const mounts = [...(boot ? [boot.pck] : []), ...names.map(pckUrl)].map((url) => loadAssetPack(url));
446
+ const docs = Object.fromEntries(
447
+ await Promise.all(
448
+ names.map(async (name): Promise<[string, PackDoc]> => {
449
+ const { version } = release[name];
450
+ return [name, parseDoc(await fetchPack(packPath(name, version, PACK_DOC)))];
451
+ }),
452
+ ),
453
+ );
454
+ const plan = resolveEntry(release, docs, pack, entry, boot?.doc);
455
+ // The boot is a mounted pack like any other, so an entry borrows its loose files too.
456
+ const files = stagedFiles(plan, release, boot, at);
457
+ await Promise.all(mounts);
458
+ return loadAsset(plan.res, cls, files).load;
459
+ }