@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/src/dump-deps.gd CHANGED
@@ -1,38 +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
- ## A third source, because neither above can see it: a **level built from its plan**
19
+ ## A third source, because neither above can see it: a **scene built from its plan**
20
20
  ## (`extras.gltfx.plan`, which a host script reads at run time). It names its terrain by
21
21
  ## *directory* and its ground/environment maps by relative route — no resource edge and no `res://`
22
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.
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.
24
27
  ##
25
- ## Driven by `@ringozz/godot/pack`'s `buildAssetPacks`, which sets:
26
- ## GODOT_DUMP_OUT – output res:// path (default res://.godot/pack-dump.json) — a build
27
- ## input: the bake merges it into the manifest beside the packs.
28
- ## GODOT_DUMP_ROOTS – comma-separated res:// roots to scan (default res://)
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()
29
44
 
30
- ## Imported source extensions: `_classify` recurses into these (an imported source's own deps are
31
- ## empty — its *product* carries the refs) where any other extension with a sidecar is a loose texture.
32
- ## `gltfx` is a scene-composition source and `gltfxmesh` a shared-mesh one: their products reference the
33
- ## meshes and textures they compose.
34
- const PACKED := ["tscn", "tres", "gltf", "gltfx", "gltfxmesh", "obj", "gd", "gdshader", "gdshaderinc", "ttf", "otf", "po"]
35
- const SCANNED := ["tscn", "tres", "gd", "gdshader", "gdshaderinc", "gltf", "obj"]
45
+
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 := ""
36
50
 
37
51
 
38
52
  func _initialize() -> void:
@@ -42,6 +56,16 @@ func _initialize() -> void:
42
56
  var roots := OS.get_environment("GODOT_DUMP_ROOTS").split(",", false)
43
57
  if roots.is_empty():
44
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("/") + "/"
45
69
  var sources := {}
46
70
  for r in roots:
47
71
  var root := String(r).rstrip("/") + "/"
@@ -54,6 +78,11 @@ func _initialize() -> void:
54
78
  quit()
55
79
 
56
80
 
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
+
57
86
  ## `root` is the scan root `path` lives under (a plan's refs are relative to it), carried down the walk.
58
87
  func _scan(root: String, path: String, sources: Dictionary) -> void:
59
88
  var d := DirAccess.open(path)
@@ -65,34 +94,40 @@ func _scan(root: String, path: String, sources: Dictionary) -> void:
65
94
  _scan(root, path.path_join(dir) + "/", sources)
66
95
  for file in d.get_files():
67
96
  var p := path.path_join(file)
68
- if p.get_extension().to_lower() in PACKED:
69
- var seen := {}
70
- var loose := _loose(root, p, seen)
71
- # The graph holds authored packed sources only: the walk also visits the engine's
72
- # own products (`res://.godot/imported/…`, reached through the `.import` sidecar),
73
- # and a product is carried by its *source*, not owned in its own right.
74
- var packed := seen.keys().filter(func(s: String) -> bool: return not s.begins_with("res://.godot/"))
75
- packed.sort()
76
- sources[p] = {"packed": packed, "loose": loose}
77
-
78
-
79
- ## The loose texture sources reachable from `path` (transitive), sorted. `seen` collects every
80
- ## packed source the walk touches — the graph — including `path` itself.
81
- func _loose(root: String, 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:
82
105
  var loose := {}
83
106
  _collect(root, path, loose, seen)
84
107
  var arr := loose.keys()
85
108
  arr.sort()
86
- 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}
87
116
 
88
117
 
89
- func _classify(root: String, 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:
90
122
  if dep.is_empty():
91
- return
92
- if dep.get_extension().to_lower() in PACKED:
123
+ return false
124
+ if dep.get_extension().to_lower() in _packed:
93
125
  _collect(root, dep, loose, seen)
94
- elif FileAccess.file_exists(dep + ".import"):
126
+ return true
127
+ if FileAccess.file_exists(dep + ".import"):
95
128
  loose[dep] = true
129
+ return true
130
+ return false
96
131
 
97
132
 
98
133
  func _collect(root: String, path: String, loose: Dictionary, seen: Dictionary) -> void:
@@ -105,18 +140,29 @@ func _collect(root: String, path: String, loose: Dictionary, seen: Dictionary) -
105
140
  if FileAccess.file_exists(imp):
106
141
  var cfg := ConfigFile.new()
107
142
  if cfg.load(imp) == OK:
108
- for dst in cfg.get_value("deps", "dest_files", []):
143
+ for dst in _dest_files(cfg):
109
144
  _collect(root, String(dst), loose, seen)
110
145
  for dep in ResourceLoader.get_dependencies(path):
111
146
  _classify(root, _res_path(String(dep)), loose, seen)
112
147
  # String-valued resource paths are invisible to dependency analysis; scan the
113
148
  # text for loose refs the engine cannot report.
114
- if path.get_extension().to_lower() in SCANNED:
149
+ if path.get_extension().to_lower() in _scanned:
115
150
  for p in _text_refs(path):
116
151
  _classify(root, p, loose, seen)
117
- # A level built from its plan names nothing above: its refs come from the plan itself.
152
+ # A scene built from its plan names nothing above: its refs come from the plan itself.
118
153
  if path.get_extension().to_lower() == "gltfx":
119
- _plan_refs(root, path, loose, seen)
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 []
120
166
 
121
167
 
122
168
  ## `res://` path from a dependency entry (`uid://…::::res://…` or plain).
@@ -137,23 +183,39 @@ func _text_refs(path: String) -> Array:
137
183
  return refs
138
184
 
139
185
 
140
- ## A level's refs, read from the plan its `.gltfx` carries (`extras.gltfx.plan`). They are
141
- ## invisible to every source above (a *directory* rather than a route, relative routes, and a host
142
- ## script that reads the JSON instead of a resource edge), so the plan is their only statement. Each
143
- ## ref goes through `_classify`: what has an `.import` sidecar is staged, the header JSONs (which the
144
- ## packs carry) are not.
145
- func _plan_refs(root: String, path: String, loose: Dictionary, seen: Dictionary) -> void:
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:
146
198
  var doc = JSON.parse_string(FileAccess.get_file_as_string(path))
147
199
  var extras: Variant = doc.get("extras") if doc is Dictionary else null
148
- var stamp: Variant = extras.get("gltfx") if extras is Dictionary else null
200
+ if not extras is Dictionary:
201
+ return
202
+ var stamp: Variant = extras.get("gltfx")
149
203
  var plan: Variant = stamp.get("plan") if stamp is Dictionary else null
150
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])
151
212
  return
152
213
 
214
+ var refs := 0
153
215
  # The terrain: the plan names its directory, and the directory is the atlas list.
154
216
  var terrain: Variant = plan.get("terrain")
155
217
  if terrain is Dictionary and terrain.get("dir") is String:
156
- _terrain_refs(root, root.path_join(String(terrain["dir"])), loose, seen)
218
+ refs += _terrain_refs(_plan_base, _plan_base.path_join(String(terrain["dir"])), loose, seen)
157
219
 
158
220
  # The rest are texture routes — the ground's patches / the water's maps, and the sky.
159
221
  var ground: Variant = plan.get("ground")
@@ -164,7 +226,8 @@ func _plan_refs(root: String, path: String, loose: Dictionary, seen: Dictionary)
164
226
  var fields: Variant = g.get("material") if g.get("source") == "overlay" else g.get("water")
165
227
  if fields is Dictionary:
166
228
  for key in ["diffuse", "normal", "orm"]:
167
- _classify_route(root, fields.get(key), loose, seen)
229
+ if _classify_route(_plan_base, fields.get(key), loose, seen):
230
+ refs += 1
168
231
  var lighting: Variant = plan.get("lighting")
169
232
  if lighting is Dictionary:
170
233
  # A host renders one time of day: the authored stage's env, which the plan mirrors as `env`.
@@ -172,13 +235,17 @@ func _plan_refs(root: String, path: String, loose: Dictionary, seen: Dictionary)
172
235
  var env: Variant = stages[0].get("env") if stages is Array and stages.size() > 0 and stages[0] is Dictionary else null
173
236
  if not env is Dictionary:
174
237
  env = lighting.get("env")
175
- _classify_route(root, env.get("map") if env is Dictionary else null, loose, seen)
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)
176
243
 
177
244
 
178
245
  ## A terrain directory **is** its atlas list (every atlas name is top-level in it), so each file is a
179
246
  ## ref — except a layered container's *layers*: the manifest names them and their bytes live in its
180
247
  ## imported product, so staging them would ship megabytes nothing reads.
181
- func _terrain_refs(root: String, dir: String, loose: Dictionary, seen: Dictionary) -> void:
248
+ func _terrain_refs(root: String, dir: String, loose: Dictionary, seen: Dictionary) -> int:
182
249
  var files := DirAccess.get_files_at(dir)
183
250
  var layers := {}
184
251
  for name in files:
@@ -187,13 +254,20 @@ func _terrain_refs(root: String, dir: String, loose: Dictionary, seen: Dictionar
187
254
  var ref := line.strip_edges()
188
255
  if not ref.is_empty() and not ref.begins_with("#"):
189
256
  layers[ref.get_file()] = true
257
+ var refs := 0
190
258
  for name in files:
191
- if not layers.has(name.get_file()):
192
- _classify(root, dir.path_join(name), loose, seen)
259
+ if not layers.has(name.get_file()) and _classify(root, dir.path_join(name), loose, seen):
260
+ refs += 1
261
+ return refs
193
262
 
194
263
 
195
- ## Classifies a plan's route: relative and unsuffixed, so the served `.webp` spelling is tried too.
196
- func _classify_route(root: String, route: Variant, loose: Dictionary, seen: Dictionary) -> void:
197
- if route is String:
198
- for suffix in ["", ".webp"]:
199
- _classify(root, root.path_join(route + suffix), loose, seen)
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,10 +107,15 @@ 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
  }
114
+ if (command === 'clean') {
115
+ const { cleanProject } = await import('./bake.ts');
116
+ cleanProject();
117
+ process.exit(0);
118
+ }
114
119
  const exe = await resolveEditor();
115
120
  const result = spawnSync(exe, process.argv.slice(2), { stdio: 'inherit', env: { ...process.env, GODOT_NODE_WRAPPER: '1' } });
116
121
  process.exit(result.status ?? 1);
@@ -7,9 +7,9 @@
7
7
  ; (see `dump-deps.gd`), not read from the pack. `export_filter="all_resources"`
8
8
  ; ignores `include_filter` — scope with `exclude_filter` only.
9
9
  ;
10
- ; The bake always splits — one pack per scene (`Pack: <scene>`) plus a shared one — and
11
- ; derives those presets from this one, pruning them again after exporting. Don't write
12
- ; them by hand.
10
+ ; The bake rewrites this preset's `exclude_filter` once per pack (the boot pack, then one per
11
+ ; content pack) and puts the file back when it finishes, so the export carries exactly one scope.
12
+ ; Don't write those scopes by hand.
13
13
 
14
14
  [preset.0]
15
15
 
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);