@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/README.md +25 -6
- package/package.json +5 -4
- package/src/assets.d.ts +10 -1
- package/src/bake.ts +469 -0
- package/src/dump-deps.gd +200 -53
- package/src/editor.ts +1 -1
- package/src/formats.ts +78 -0
- package/src/load.ts +153 -17
- package/src/pack.ts +280 -221
- package/src/preload.ts +165 -208
- package/src/serve.ts +67 -0
- package/src/planRefs.ts +0 -209
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
|
|
4
|
-
##
|
|
5
|
-
##
|
|
6
|
-
##
|
|
7
|
-
##
|
|
8
|
-
##
|
|
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
|
|
14
|
-
## `dest_files`
|
|
15
|
-
##
|
|
16
|
-
##
|
|
17
|
-
##
|
|
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
|
-
##
|
|
20
|
-
##
|
|
21
|
-
##
|
|
22
|
-
##
|
|
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
|
-
##
|
|
25
|
-
##
|
|
26
|
-
##
|
|
27
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
85
|
-
_collect(dep, loose, seen)
|
|
86
|
-
|
|
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
|
|
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
|
|
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('./
|
|
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
|
-
// (`<
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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?:
|
|
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
|
+
}
|