@ringozz/godot 4.7.2-657 → 4.7.2-661

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 CHANGED
@@ -91,13 +91,13 @@ const tex = await loadResourceAsync('res://textures/icon.jpg', Texture2D); //
91
91
  ```
92
92
 
93
93
  - `loadResourceAsync(path, cls, files?)` — generic over the Godot class: `cls.name` is the `ResourceLoader` type hint, and the return type is `InstanceType<typeof cls>`. Loads in the background (`loadThreadedRequest` + status polling). Godot default arguments apply: the napi dispatch truncates trailing `undefined` args, so `loadThreadedRequest` uses its default `CACHE_MODE_REUSE` and the resource stays cached in the engine (scene re-references don't re-read the file). On web it only works for paths already staged into MEMFS (generated asset modules handle this). When the asset's `files` map is passed, the staged product files (`.ctex`/`.scn`/native sources) are deleted from MEMFS after the resource loads — only the `.import` sidecars are kept, since they route imported source paths to their products. The decoded texture bytes don't linger in the JS heap. `files` lists overlap (a scene and the model it instantiates can share one loose texture), so a product may need re-staging after another module's cleanup: `materialize` creates the parent directory itself and treats an already-staged file as done, because the engine's `copyToFS` throws on a path that is already there while `FileAccess.fileExists('res://…')` cannot see the Emscripten spelling of it.
94
- - `loadAsset(path, cls, files, deps, data)` — the generated asset modules' entry point (also usable directly): returns `{ materialize, load }`. It checks the engine's `ResourceCache` first (when the asset is already loaded, `load` resolves to the cached instance and staging is skipped), stages the bundled files via Godot's `copyToFS` (zero-copy handover of the fetched bytes; a no-op on desktop where the files already exist), loads via `loadResourceAsync`, and memoizes `load` on `data` (pass `import.meta.hot.data`) so HMR re-evaluations reuse the same fulfilled promise.
94
+ - `loadAsset(path, cls, files, data, ready)` — the generated asset modules' entry point (also usable directly): returns `{ materialize, load }`. It checks the engine's `ResourceCache` first (when the asset is already loaded, `load` resolves to the cached instance and staging is skipped), stages the bundled files via Godot's `copyToFS` (zero-copy handover of the fetched bytes; a no-op on desktop where the files already exist), loads via `loadResourceAsync`, and memoizes `load` on `data` (pass `import.meta.hot.data`) so HMR re-evaluations reuse the same fulfilled promise.
95
95
 
96
96
  ### Asset packs
97
97
 
98
- `godot pack` (the `godot` bin, `@ringozz/godot/pack`) bakes a project's assets for the runtime: it imports them, asks `dump-deps.gd` for each source's packed closure and its loose (WebP-backed) textures, and exports native `.pck`s — the engine-converted binary resources plus a remap table — through the project's committed `export_presets.cfg`. `loadAssetPack(url)` fetches a pack into MEMFS and hands it to `ProjectSettings.loadResourcePack`; packs are add-only, so several can be mounted and `res://` resolves across all of them.
98
+ `godot pack` (the `godot` bin, `@ringozz/godot/pack`) bakes a project's assets for the runtime: it imports them, asks `dump-deps.gd` for each source's packed closure and its loose (WebP-backed) textures — including a level's plan, which names refs no resource edge exposes — and exports native `.pck`s — the engine-converted binary resources plus a remap table — through the project's committed `export_presets.cfg`. `loadAssetPack(url)` fetches a pack into MEMFS and hands it to `ProjectSettings.loadResourcePack`; packs are add-only, so several can be mounted and `res://` resolves across all of them.
99
99
 
100
- The bake always splits: **one pack per level, plus a shared pack**, so a host fetches the shared data and only the level it is playing. A level is its scene — or, in a project that builds levels from their plans at run time, the `.gltfx` its plan lives in; a project with neither under its roots gets a single `shared.pck`. What the runtime reads is `.godot/packs/packs.json` beside the packs: which packs exist, which sources each owns, and which loose sources the host must stage per file (the WebP-backed textures are transcoded; a plan's plain header JSONs are just written). The preload plugin mounts the right packs per asset, so importing a level pulls in the shared pack and that level's, nothing else. How the split is decided — and why it needs no configuration — is in `AGENTS.md`.
100
+ The bake always splits: **one pack per level, plus a shared pack**, so a host fetches the shared data and only the level it is playing. A level is its scene — or, in a project that builds levels from their plans at run time, the `.gltfx` its plan lives in; a project with neither under its roots gets a single `shared.pck`. What the runtime reads is `.godot/packs/packs.json` beside the packs: which packs exist, which sources each owns, and which loose sources the host must stage per file (the WebP-backed textures are transcoded). The preload plugin mounts the right packs per asset, so importing a level pulls in the shared pack and that level's, nothing else. How the split is decided — and why it needs no configuration — is in `AGENTS.md`.
101
101
 
102
102
  ```ts
103
103
  import { loadAssetPack } from '@ringozz/godot/load';
@@ -113,7 +113,7 @@ import foo from './models/foo.gltf';
113
113
  const scene = await foo; // PackedScene
114
114
  ```
115
115
 
116
- Each asset module's default export is its load **promise** (`const { materialize, load } = loadAsset(resPath, Class, files, deps, import.meta.hot.data)` — the default is `load`, memoized on the module's HMR data), started eagerly at module import, and it also exports a `materialize` promise (its own files plus its deps' files). The files Godot reads are bundled into the module — `.import` sidecars and native text sources (`tscn`/`tres`/`po`/`gd`/`gdshader`/`gdshaderinc`) are inlined as text, imported products (`.scn`/`.ctex`) are emitted as files — and the module imports its **dependencies as generated modules too**, waiting only for their `materialize` before loading itself (dep loads start at module evaluation and run concurrently). Text-based source assets (`.gltf`/`.tscn`/`.tres`/`.obj`/`.gd`/`.gdshader`/`.gdshaderinc`) are scanned for references (`res://` paths and relative paths ending in known asset extensions); referenced loadable assets become dep modules, native-text leaves are staged as raw text so the engine loads them by `res://` on web, raw `.mtl` files (OBJ materials) are recursed into, and raw `.bin` buffers are import-time only. The modules are virtual (namespace `godot`), and every import of the same asset resolves to the same one (evaluated once) — so the promise is a stable singleton, read with `await` or React 19's `use()`:
116
+ Each asset module's default export is its load **promise** (`const { materialize, load } = loadAsset(resPath, Class, files, import.meta.hot.data, ready)` — the default is `load`, memoized on the module's HMR data), started eagerly at module import, and it also exports a `materialize` promise (its own files plus its deps' files). The files Godot reads are bundled into the module — `.import` sidecars and native text sources (`tscn`/`tres`/`po`/`gd`/`gdshader`/`gdshaderinc`) are inlined as text, imported products (`.scn`/`.ctex`) are emitted as files — and the module imports its **dependencies as generated modules too**, waiting only for their `materialize` before loading itself (dep loads start at module evaluation and run concurrently). Text-based source assets (`.gltf`/`.tscn`/`.tres`/`.obj`/`.gd`/`.gdshader`/`.gdshaderinc`) are scanned for references (`res://` paths and relative paths ending in known asset extensions); referenced loadable assets become dep modules, native-text leaves are staged as raw text so the engine loads them by `res://` on web, raw `.mtl` files (OBJ materials) are recursed into, and raw `.bin` buffers are import-time only. The modules are virtual (namespace `godot`), and every import of the same asset resolves to the same one (evaluated once) — so the promise is a stable singleton, read with `await` or React 19's `use()`:
117
117
 
118
118
  ```tsx
119
119
  import foo from './models/foo.gltf';
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@ringozz/godot",
3
3
  "author": "Vladimir Davidovich",
4
- "version": "4.7.2-657",
4
+ "version": "4.7.2-661",
5
5
  "description": "Node-API bindings for Godot Engine — call Godot classes from JavaScript",
6
6
  "publishConfig": {
7
7
  "access": "public"
@@ -66,9 +66,9 @@
66
66
  "precision": "single"
67
67
  },
68
68
  "optionalDependencies": {
69
- "@ringozz/godot-macos-arm64": "^4.7.2-657",
70
- "@ringozz/godot-windows-x86_64": "^4.7.2-657",
71
- "@ringozz/godot-web-wasm32": "^4.7.2-657"
69
+ "@ringozz/godot-macos-arm64": "^4.7.2-661",
70
+ "@ringozz/godot-windows-x86_64": "^4.7.2-661",
71
+ "@ringozz/godot-web-wasm32": "^4.7.2-661"
72
72
  },
73
73
  "peerDependencies": {
74
74
  "@types/bun": "*"
package/src/dump-deps.gd CHANGED
@@ -16,6 +16,12 @@ extends SceneTree
16
16
  ## is *loose* when it has an `.import` sidecar but is not a packed resource type — the set is
17
17
  ## derived, not listed.
18
18
  ##
19
+ ## A third source, because neither above can see it: a **level 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.
24
+ ##
19
25
  ## Driven by `@ringozz/godot/pack`'s `buildAssetPacks`, which sets:
20
26
  ## GODOT_DUMP_OUT – output res:// path (default res://.godot/pack-dump.json) — a build
21
27
  ## input: the bake merges it into the manifest beside the packs.
@@ -38,7 +44,8 @@ func _initialize() -> void:
38
44
  roots = ["res://"]
39
45
  var sources := {}
40
46
  for r in roots:
41
- _scan(String(r).rstrip("/") + "/", sources)
47
+ var root := String(r).rstrip("/") + "/"
48
+ _scan(root, root, sources)
42
49
  DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(out.get_base_dir()))
43
50
  var f := FileAccess.open(out, FileAccess.WRITE)
44
51
  f.store_string(JSON.stringify(sources, " "))
@@ -47,19 +54,20 @@ func _initialize() -> void:
47
54
  quit()
48
55
 
49
56
 
50
- func _scan(path: String, sources: Dictionary) -> void:
57
+ ## `root` is the scan root `path` lives under (a plan's refs are relative to it), carried down the walk.
58
+ func _scan(root: String, path: String, sources: Dictionary) -> void:
51
59
  var d := DirAccess.open(path)
52
60
  if d == null:
53
61
  return
54
62
  for dir in d.get_directories():
55
63
  if dir.begins_with(".") or dir == "node_modules":
56
64
  continue
57
- _scan(path.path_join(dir) + "/", sources)
65
+ _scan(root, path.path_join(dir) + "/", sources)
58
66
  for file in d.get_files():
59
67
  var p := path.path_join(file)
60
68
  if p.get_extension().to_lower() in PACKED:
61
69
  var seen := {}
62
- var loose := _loose(p, seen)
70
+ var loose := _loose(root, p, seen)
63
71
  # The graph holds authored packed sources only: the walk also visits the engine's
64
72
  # own products (`res://.godot/imported/…`, reached through the `.import` sidecar),
65
73
  # and a product is carried by its *source*, not owned in its own right.
@@ -70,24 +78,24 @@ func _scan(path: String, sources: Dictionary) -> void:
70
78
 
71
79
  ## The loose texture sources reachable from `path` (transitive), sorted. `seen` collects every
72
80
  ## packed source the walk touches — the graph — including `path` itself.
73
- func _loose(path: String, seen: Dictionary) -> Array:
81
+ func _loose(root: String, path: String, seen: Dictionary) -> Array:
74
82
  var loose := {}
75
- _collect(path, loose, seen)
83
+ _collect(root, path, loose, seen)
76
84
  var arr := loose.keys()
77
85
  arr.sort()
78
86
  return arr
79
87
 
80
88
 
81
- func _classify(dep: String, loose: Dictionary, seen: Dictionary) -> void:
89
+ func _classify(root: String, dep: String, loose: Dictionary, seen: Dictionary) -> void:
82
90
  if dep.is_empty():
83
91
  return
84
92
  if dep.get_extension().to_lower() in PACKED:
85
- _collect(dep, loose, seen)
93
+ _collect(root, dep, loose, seen)
86
94
  elif FileAccess.file_exists(dep + ".import"):
87
95
  loose[dep] = true
88
96
 
89
97
 
90
- func _collect(path: String, loose: Dictionary, seen: Dictionary) -> void:
98
+ func _collect(root: String, path: String, loose: Dictionary, seen: Dictionary) -> void:
91
99
  if seen.has(path):
92
100
  return
93
101
  seen[path] = true
@@ -98,14 +106,17 @@ func _collect(path: String, loose: Dictionary, seen: Dictionary) -> void:
98
106
  var cfg := ConfigFile.new()
99
107
  if cfg.load(imp) == OK:
100
108
  for dst in cfg.get_value("deps", "dest_files", []):
101
- _collect(String(dst), loose, seen)
109
+ _collect(root, String(dst), loose, seen)
102
110
  for dep in ResourceLoader.get_dependencies(path):
103
- _classify(_res_path(String(dep)), loose, seen)
111
+ _classify(root, _res_path(String(dep)), loose, seen)
104
112
  # String-valued resource paths are invisible to dependency analysis; scan the
105
113
  # text for loose refs the engine cannot report.
106
114
  if path.get_extension().to_lower() in SCANNED:
107
115
  for p in _text_refs(path):
108
- _classify(p, loose, seen)
116
+ _classify(root, p, loose, seen)
117
+ # A level built from its plan names nothing above: its refs come from the plan itself.
118
+ if path.get_extension().to_lower() == "gltfx":
119
+ _plan_refs(root, path, loose, seen)
109
120
 
110
121
 
111
122
  ## `res://` path from a dependency entry (`uid://…::::res://…` or plain).
@@ -124,3 +135,65 @@ func _text_refs(path: String) -> Array:
124
135
  for m in re.search_all(FileAccess.get_file_as_string(path)):
125
136
  refs.append(m.get_string())
126
137
  return refs
138
+
139
+
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:
146
+ var doc = JSON.parse_string(FileAccess.get_file_as_string(path))
147
+ 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
149
+ var plan: Variant = stamp.get("plan") if stamp is Dictionary else null
150
+ if not plan is Dictionary:
151
+ return
152
+
153
+ # The terrain: the plan names its directory, and the directory is the atlas list.
154
+ var terrain: Variant = plan.get("terrain")
155
+ if terrain is Dictionary and terrain.get("dir") is String:
156
+ _terrain_refs(root, root.path_join(String(terrain["dir"])), loose, seen)
157
+
158
+ # The rest are texture routes — the ground's patches / the water's maps, and the sky.
159
+ var ground: Variant = plan.get("ground")
160
+ if ground is Array:
161
+ for g in ground:
162
+ if not g is Dictionary:
163
+ continue
164
+ var fields: Variant = g.get("material") if g.get("source") == "overlay" else g.get("water")
165
+ if fields is Dictionary:
166
+ for key in ["diffuse", "normal", "orm"]:
167
+ _classify_route(root, fields.get(key), loose, seen)
168
+ var lighting: Variant = plan.get("lighting")
169
+ if lighting is Dictionary:
170
+ # A host renders one time of day: the authored stage's env, which the plan mirrors as `env`.
171
+ var stages: Variant = lighting.get("stages")
172
+ var env: Variant = stages[0].get("env") if stages is Array and stages.size() > 0 and stages[0] is Dictionary else null
173
+ if not env is Dictionary:
174
+ env = lighting.get("env")
175
+ _classify_route(root, env.get("map") if env is Dictionary else null, loose, seen)
176
+
177
+
178
+ ## A terrain directory **is** its atlas list (every atlas name is top-level in it), so each file is a
179
+ ## ref — except a layered container's *layers*: the manifest names them and their bytes live in its
180
+ ## imported product, so staging them would ship megabytes nothing reads.
181
+ func _terrain_refs(root: String, dir: String, loose: Dictionary, seen: Dictionary) -> void:
182
+ var files := DirAccess.get_files_at(dir)
183
+ var layers := {}
184
+ for name in files:
185
+ if name.to_lower().ends_with(".webparray"):
186
+ for line in FileAccess.get_file_as_string(dir.path_join(name)).split("\n"):
187
+ var ref := line.strip_edges()
188
+ if not ref.is_empty() and not ref.begins_with("#"):
189
+ layers[ref.get_file()] = true
190
+ for name in files:
191
+ if not layers.has(name.get_file()):
192
+ _classify(root, dir.path_join(name), loose, seen)
193
+
194
+
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)
@@ -0,0 +1 @@
1
+ uid://dfbmw3t660p7v
package/src/pack.ts CHANGED
@@ -7,25 +7,23 @@ import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from
7
7
  import { dirname, resolve } from 'node:path';
8
8
  import { fileURLToPath } from 'node:url';
9
9
  import { resolveEditor } from './editor.ts';
10
- import { mergePlanRefs, planRefs } from './planRefs.ts';
11
10
 
12
- // Bakes a Godot project's assets for the runtime: imports them, then exports one native
13
- // `.pck` **per scene** plus a shared one, so a host fetches the shared data and only the
14
- // level it is playing. `dump-deps.gd` supplies both inputs (no host-side scanning): each
15
- // source's packed closure — which decides what a pack owns — and the loose (WebP-backed)
11
+ // Bakes a Godot project's assets for the runtime: imports them, then exports one native `.pck` **per
12
+ // level** plus a shared one, so a host fetches the shared data and only the level it is playing.
13
+ // `dump-deps.gd` supplies both inputs (no host-side scanning), in the dump's own two words: each
14
+ // source's `packed` closure — which decides what a pack *owns* — and the `loose` (WebP-backed)
16
15
  // texture sources, which the host stages and transcodes per file.
17
16
  //
18
- // The result the runtime reads is one file beside the packs: `<out>/packs.json`, holding
19
- // each pack (its path, whether it mounts always, and the sources it owns) plus those loose
20
- // texture lists. `preload.ts` reads it to mount the right packs per asset.
17
+ // The one file the runtime reads is `<out>/packs.json`, beside the packs: every pack's `pck` and
18
+ // `owns`, plus the `loose` lists. `preload.ts` mounts the reserved `shared` pack and the asset's
19
+ // owner.
21
20
  //
22
- // The project owns `export_presets.cfg` (a committed, editor-managed file), and the scoping
23
- // has to live there: `--export-pack` runs against a preset. Per-pack scoping is therefore an
24
- // `exclude_filter` — the repo's `all_resources` convention, which ignores `include_filter`
25
- // and, unlike the selected modes, never pulls dependencies back in — so each pack excludes
26
- // exactly the sources the others own (see `splitPacks`). Those presets are cloned from the
27
- // project's own base preset, one written at a time, and the file is restored when the bake
28
- // ends: they are derived and the file is tracked.
21
+ // The project owns `export_presets.cfg` (a committed, editor-managed file), and the scoping has to
22
+ // live there: `--export-pack` runs against a preset. Per-pack scoping is therefore an
23
+ // `exclude_filter` — the repo's `all_resources` convention, which ignores `include_filter` and,
24
+ // unlike the selected modes, never pulls dependencies back in — so each pack excludes exactly the
25
+ // sources the others own (see `splitPacks`). Those presets are cloned from the project's own base
26
+ // preset and the file is restored once the bake ends: they are derived, and the file is tracked.
29
27
 
30
28
  const HERE = dirname(fileURLToPath(import.meta.url));
31
29
 
@@ -43,74 +41,63 @@ export interface BuildAssetPackOptions {
43
41
  godot?: string;
44
42
  }
45
43
 
46
- /** A pack's assignment: its name, whether it mounts always, and the sources it owns. */
47
- interface AssetPackSplit {
48
- /** `shared`, or a scene's file stem. */
44
+ /** A pack: `shared`, or a level's file stem, and the sources it owns. */
45
+ export interface Pack {
46
+ /** `shared`, or a level's file stem. */
49
47
  name: string;
50
- /** Mount for every asset (the shared pack). */
51
- always?: boolean;
52
48
  /** The packable sources this pack owns — the lookup the plugin mounts by. */
53
49
  owns: string[];
54
50
  }
55
51
 
56
- /** One baked pack as the runtime sees it. */
57
- interface AssetPackManifestEntry extends AssetPackSplit {
52
+ /** A pack as the manifest carries it: a {@link Pack} plus its `.pck`. */
53
+ interface PackFile extends Pack {
58
54
  /** `res://` path of the `.pck`, for `loadAssetPack`. */
59
55
  pck: string;
60
56
  }
61
57
 
62
58
  /** The runtime manifest beside the packs: what to mount, and what to stage per file. */
63
- export interface AssetPackManifest {
64
- packs: AssetPackManifestEntry[];
59
+ export interface PackManifest {
60
+ packs: PackFile[];
65
61
  /** Packed source → the loose texture sources it needs at runtime (from `dump-deps.gd`). */
66
- deps: Record<string, string[]>;
62
+ loose: Record<string, string[]>;
67
63
  }
68
64
 
69
- /** Each packed source's packed closure, as `dump-deps.gd` reports it. */
70
- interface Graph {
71
- [source: string]: string[];
72
- }
65
+ /** One bucket of the dump: a source → its closure (`dump-deps.gd`'s `packed` or `loose` half). */
66
+ type Closures = Record<string, string[]>;
73
67
 
74
- /** The two build-time outputs of `dump-deps.gd`. */
75
- interface Dump {
76
- graph: Graph;
77
- deps: Record<string, string[]>;
78
- }
68
+ /** A `spawnSync` runner for the editor. */
69
+ type Run = (args: string[], env?: Record<string, string>) => void;
79
70
 
80
71
  /**
81
- * The ownership partition, from the dependency graph. A **level is a scene** (`.tscn`) among
82
- * the scanned sources — a project's levels are its scenes, so nothing declares them — and:
83
- * **a source exactly one scene reaches is that scene's** (so a model only one level uses
84
- * rides its pack, though it lives in a shared asset tree), while **a source several scenes
85
- * reach, or none does, is shared**. A scene whose own pack would be empty is dropped: every
86
- * source it reaches is reached by another scene too, so it is already in `shared`, and a pack
87
- * is mounted by *owner*.
72
+ * The ownership partition, from the packed closure of every source. A **level is a scene**
73
+ * (`.tscn`) among the scanned sources — a project's levels are its scenes, so nothing declares them
74
+ * — and: **a source exactly one level reaches is that level's** (so a model only one level uses
75
+ * rides its pack, though it lives in a shared asset tree), while **a source several levels reach, or
76
+ * none does, is shared**. A level whose own pack would be empty is dropped: every source it reaches
77
+ * is reached by another level too, so it is already in `shared`, and a pack is mounted by *owner*.
88
78
  *
89
- * The graph it reads is the dump's *plus* every level's plan refs (`mergePlanRefs`), so a level's
90
- * terrain, its layered containers and their layers, and its ground/environment textures are part of
91
- * its closure whether or not a baked scene names them. When a project stops baking one scene per
92
- * level — a level built from its plan at run time is a single shared scene plus the plan — the level
93
- * unit becomes the `.gltfx` its plan lives in instead; until then both units exist and this keying
94
- * keeps the scenes.
79
+ * When a project stops baking one scene per level — a level built from its plan at run time is a
80
+ * single shared scene plus the plan — the level unit becomes the `.gltfx` its plan lives in instead;
81
+ * until then both units exist and this keying keeps the scenes.
95
82
  */
96
- export function splitPacks(graph: Graph): AssetPackSplit[] {
83
+ export function splitPacks(packed: Closures): Pack[] {
97
84
  const stem = (asset: string) =>
98
85
  asset.slice(asset.lastIndexOf('/') + 1).replace(/\.(prefab|level)\.[^.]+$/i, '').replace(/\.[^.]+$/, '');
99
86
  // A level is its scene — and, in a project that builds levels from their plans at run time, the
100
87
  // `.gltfx` its plan lives in. A `.gltfx` a scene **reaches** is that scene's composition, not a
101
88
  // second level: keying both would make every source of a baked level look shared.
102
- const scenes = Object.keys(graph).filter((s) => s.toLowerCase().endsWith('.tscn')).sort();
103
- const reachedByScene = new Set(scenes.flatMap((s) => graph[s] ?? []));
89
+ const scenes = Object.keys(packed).filter((s) => s.toLowerCase().endsWith('.tscn')).sort();
90
+ const reachedByScene = new Set(scenes.flatMap((s) => packed[s] ?? []));
104
91
  const levels = [
105
92
  ...scenes,
106
- ...Object.keys(graph).filter((s) => s.toLowerCase().endsWith('.gltfx') && !reachedByScene.has(s)),
93
+ ...Object.keys(packed).filter((s) => s.toLowerCase().endsWith('.gltfx') && !reachedByScene.has(s)),
107
94
  ].sort();
108
- const closure = new Map(levels.map((s) => [s, new Set([s, ...(graph[s] ?? [])])]));
95
+ const closure = new Map(levels.map((s) => [s, new Set([s, ...(packed[s] ?? [])])]));
109
96
 
110
97
  // One pack per level, named after its file stem (`shared` is reserved; a repeated stem —
111
98
  // two `level.tscn` in different directories — gets a suffix).
112
99
  const used = new Set(['shared']);
113
- const packOf = new Map<string, AssetPackSplit>();
100
+ const packOf = new Map<string, Pack>();
114
101
  for (const level of levels) {
115
102
  let name = stem(level);
116
103
  for (let n = 2; used.has(name); n++) name = `${stem(level)}-${n}`;
@@ -119,15 +106,14 @@ export function splitPacks(graph: Graph): AssetPackSplit[] {
119
106
  }
120
107
 
121
108
  const shared: string[] = [];
122
- const sources = new Set([...Object.keys(graph), ...Object.values(graph).flat()]);
109
+ const sources = new Set([...Object.keys(packed), ...Object.values(packed).flat()]);
123
110
  for (const source of sources) {
124
111
  const owners = levels.filter((level) => closure.get(level)!.has(source));
125
112
  if (owners.length === 1) packOf.get(owners[0])!.owns.push(source);
126
113
  else shared.push(source);
127
114
  }
128
- shared.sort();
129
115
  return [
130
- { name: 'shared', always: true, owns: shared },
116
+ { name: 'shared', owns: shared.sort() },
131
117
  ...[...packOf.values()]
132
118
  .filter((pack) => pack.owns.length > 0)
133
119
  .map((pack) => ({ ...pack, owns: pack.owns.sort() })),
@@ -135,8 +121,9 @@ export function splitPacks(graph: Graph): AssetPackSplit[] {
135
121
  }
136
122
 
137
123
  /**
138
- * Bakes the packs (see {@link splitPacks}) and writes the runtime manifest. A project with no
139
- * scene under its roots gets a single `shared.pck` and the same manifest.
124
+ * Bakes the packs (see {@link splitPacks}) and writes the runtime manifest: import, dump, split,
125
+ * then one preset and one `.pck` per pack. A project with no scene under its roots gets a single
126
+ * `shared.pck` and the same manifest.
140
127
  */
141
128
  export async function buildAssetPacks(opts: BuildAssetPackOptions = {}): Promise<void> {
142
129
  const editor = opts.godot ?? (await resolveEditor());
@@ -146,50 +133,49 @@ export async function buildAssetPacks(opts: BuildAssetPackOptions = {}): Promise
146
133
  const base = opts.preset ?? 'Pack';
147
134
  const prefix = `${base}: `;
148
135
  const presets = resolve(cwd, 'export_presets.cfg');
149
-
150
136
  const cfg = readFileSync(presets, 'utf8');
151
137
  const basePreset = presetBlock(cfg, base);
152
- const baseExcludes = excludeFilter(basePreset);
153
138
  mkdirSync(resolve(cwd, out), { recursive: true });
154
139
 
155
140
  const run = runner(editor, cwd);
156
141
  run(['--headless', '--path', '.', '--import']);
157
- const { graph, deps } = dump(run, cwd, roots);
158
- // A level's plan names what no scene does any more — its terrain directory, its layered containers
159
- // and their layers, its ground/environment textures (`planRefs`). Merged here so the graph and the
160
- // loose lists carry the level's whole closure with or without a baked scene.
161
- mergePlanRefs(graph, deps, planRefs(cwd, roots));
162
-
163
- const packs = splitPacks(graph);
164
- const owned = packs.flatMap((pack) => pack.owns);
165
- const manifest: AssetPackManifestEntry[] = [];
142
+ // `dump-deps.gd` reads a level's plan for the refs no resource edge exposes, so its `loose` lists
143
+ // carry the level's whole closure with or without a baked scene.
144
+ const { packed, loose } = dump(run, cwd, roots);
145
+ const packs = splitPacks(packed);
166
146
 
167
- // The project's own presets, with any this tool generated in an earlier run dropped.
147
+ // One preset per pack, all written from the project's own base preset before any export: each
148
+ // excludes exactly the sources the other packs own (see the module comment).
168
149
  const original = pruneGenerated(cfg, prefix);
169
- const index = (original.match(/^\[preset\.\d+\]/gm) ?? []).length;
150
+ const first = (original.match(/^\[preset\.\d+\]/gm) ?? []).length;
151
+ const baseExcludes = excludeFilter(basePreset);
152
+ const generated = packs.map((pack, i) =>
153
+ presetFor(basePreset, first + i, `${prefix}${pack.name}`, excludesFor(baseExcludes, packs, pack)),
154
+ );
170
155
  try {
171
- for (const { name, always, owns } of packs) {
172
- const exclude = [
173
- ...baseExcludes,
174
- ...owned.filter((source) => !owns.includes(source)).map((source) => source.replace(/^res:\/\//, '')),
175
- ];
176
- writeFileSync(presets, `${original.trimEnd()}\n\n${presetFor(basePreset, index, `${prefix}${name}`, exclude)}\n`);
156
+ writeFileSync(presets, `${original.trimEnd()}\n\n${generated.join('\n\n')}\n`);
157
+ const manifest: PackFile[] = [];
158
+ for (const { name, owns } of packs) {
177
159
  const pck = `${out}/${name}.pck`;
178
160
  run(['--headless', '--path', '.', '--export-pack', `${prefix}${name}`, pck]);
179
- manifest.push({ name, pck: resPath(pck), ...(always ? { always } : {}), owns });
161
+ manifest.push({ name, pck: resPath(pck), owns });
180
162
  }
163
+ writeFileSync(
164
+ resolve(cwd, out, 'packs.json'),
165
+ `${JSON.stringify({ packs: manifest, loose } satisfies PackManifest, null, '\t')}\n`,
166
+ );
167
+ const sources = manifest.reduce((n, pack) => n + pack.owns.length, 0);
168
+ console.log(`[pack] ${manifest.length} packs (${manifest.map((p) => `${p.name}:${p.owns.length}`).join(', ')}) over ${sources} sources`);
181
169
  } finally {
182
170
  // Derived — leave the project's own file as it was, so a bake does not churn a tracked,
183
171
  // editor-managed file.
184
172
  writeFileSync(presets, original);
185
173
  }
186
- writeFileSync(resolve(cwd, out, 'packs.json'), `${JSON.stringify({ packs: manifest, deps } satisfies AssetPackManifest, null, '\t')}\n`);
187
- console.log(`[pack] ${manifest.length} packs (${manifest.map((p) => `${p.name}:${p.owns.length}`).join(', ')}) over ${owned.length} sources`);
188
174
  }
189
175
 
190
176
  /** A `spawnSync` runner that throws on a non-zero editor exit. */
191
- function runner(editor: string, cwd: string) {
192
- return (args: string[], env?: Record<string, string>) => {
177
+ function runner(editor: string, cwd: string): Run {
178
+ return (args, env) => {
193
179
  const r = spawnSync(editor, args, { cwd, stdio: 'inherit', env: { ...process.env, ...env } });
194
180
  if ((r.status ?? 1) !== 0) {
195
181
  throw new Error(`godot-assets: \`${editor} ${args.join(' ')}\` failed (${r.status})`);
@@ -197,8 +183,8 @@ function runner(editor: string, cwd: string) {
197
183
  };
198
184
  }
199
185
 
200
- /** Runs `dump-deps.gd` and splits its output into the graph and the loose-texture lists. */
201
- function dump(run: (args: string[], env?: Record<string, string>) => void, cwd: string, roots: string[]): Dump {
186
+ /** Runs `dump-deps.gd`: every source's packed closure, and the loose textures it needs. */
187
+ function dump(run: Run, cwd: string, roots: string[]): { packed: Closures; loose: Closures } {
202
188
  copyFileSync(resolve(HERE, 'dump-deps.gd'), resolve(cwd, '.godot', 'pack-dump-deps.gd'));
203
189
  run(['--headless', '--path', '.', '--script', 'res://.godot/pack-dump-deps.gd'], {
204
190
  GODOT_DUMP_OUT: resPath(DUMP),
@@ -209,13 +195,25 @@ function dump(run: (args: string[], env?: Record<string, string>) => void, cwd:
209
195
  const sources = JSON.parse(readFileSync(abs, 'utf8')) as Record<string, { packed: string[]; loose: string[] }>;
210
196
  const entries = Object.entries(sources);
211
197
  return {
212
- graph: Object.fromEntries(entries.map(([source, s]) => [source, s.packed])),
213
- deps: Object.fromEntries(entries.map(([source, s]) => [source, s.loose])),
198
+ packed: Object.fromEntries(entries.map(([source, s]) => [source, s.packed])),
199
+ loose: Object.fromEntries(entries.map(([source, s]) => [source, s.loose])),
214
200
  };
215
201
  }
216
202
 
217
203
  const resPath = (rel: string) => `res://${rel.replace(/^res:\/\//, '')}`;
218
204
 
205
+ /** A pack's `exclude_filter`: the base preset's own excludes plus every source the others own. */
206
+ function excludesFor(base: string[], packs: Pack[], mine: Pack): string[] {
207
+ const own = new Set(mine.owns);
208
+ return [
209
+ ...base,
210
+ ...packs
211
+ .flatMap((pack) => pack.owns)
212
+ .filter((source) => !own.has(source))
213
+ .map((source) => source.replace(/^res:\/\//, '')),
214
+ ];
215
+ }
216
+
219
217
  /** The preset whose `name=` matches, as its raw `[preset.N]` … block (options included). */
220
218
  function presetBlock(cfg: string, name: string): string {
221
219
  const m = new RegExp(`^name=${JSON.stringify(name)}$`, 'm').exec(cfg);
package/src/preload.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  import { existsSync, readFileSync } from 'node:fs';
6
6
  import { dirname, resolve } from 'node:path';
7
7
  import { plugin } from 'bun';
8
- import type { AssetPackManifest } from './pack.ts';
8
+ import type { PackManifest } from './pack.ts';
9
9
  import type { BunPlugin } from 'bun';
10
10
 
11
11
  // Registers the godot-assets plugin: imports of Godot source assets become
@@ -75,25 +75,28 @@ const isLoose = (abs: string) => LOOSE.has(extOf(abs));
75
75
 
76
76
  // A project bakes native `.pck`s (`godot --export-pack`) — the engine-converted binary
77
77
  // resources + remap tables — through `@ringozz/godot/pack`'s `buildAssetPacks`: one per
78
- // scene, plus a shared one, so a host fetches the shared data and only the level it is
78
+ // level, plus a shared one, so a host fetches the shared data and only the level it is
79
79
  // playing. The manifest beside them (`<pack dir>/packs.json`) is the whole runtime contract:
80
80
  // which packs exist, which sources each owns, and which loose (WebP-backed) textures the host
81
- // must stage per file. A module mounts the packs marked `always` (the shared pack) plus its
82
- // asset's owner, so importing a level brings in exactly what it needs.
81
+ // must stage per file. A module mounts the reserved `shared` pack plus its asset's owner, so
82
+ // importing a level brings in exactly what it needs.
83
83
  //
84
84
  // Everything is optional: with no manifest (desktop, or a project that has not baked) modules
85
85
  // load source paths directly.
86
86
  const readJson = <T>(file: string): T | null =>
87
87
  existsSync(file) ? (JSON.parse(readFileSync(file, 'utf8')) as T) : null;
88
88
 
89
+ /** The pack every asset mounts, as `splitPacks` names it (its `name` is reserved for this). */
90
+ const SHARED_PACK = 'shared';
91
+
89
92
  /** Where the packs were baked — `godot pack`'s `--out` (`GODOT_PACKS` overrides for a host). */
90
93
  const PACK_DIR = resolve(ROOT, process.env.GODOT_PACKS ?? '.godot/packs');
91
- const MANIFEST = readJson<AssetPackManifest>(resolve(PACK_DIR, 'packs.json'));
94
+ const MANIFEST = readJson<PackManifest>(resolve(PACK_DIR, 'packs.json'));
92
95
 
93
- /** The packs an asset needs mounted: the always-mounted ones plus its owner. */
96
+ /** The packs an asset needs mounted: the shared one plus its owner. */
94
97
  function packsFor(resPath: string): string[] {
95
98
  return (MANIFEST?.packs ?? [])
96
- .filter((pack) => pack.always || pack.owns.includes(resPath))
99
+ .filter((pack) => pack.name === SHARED_PACK || pack.owns.includes(resPath))
97
100
  .map((pack) => resolve(ROOT, pack.pck.replace(/^res:\/\//, '')));
98
101
  }
99
102
 
@@ -130,7 +133,7 @@ function analyze(abs: string): Map<string, 'text' | 'file'> {
130
133
  if (isLoose(abs)) {
131
134
  addLoose(abs, files);
132
135
  }
133
- for (const loose of MANIFEST?.deps[resOf(abs)] ?? []) {
136
+ for (const loose of MANIFEST?.loose[resOf(abs)] ?? []) {
134
137
  addLoose(resolve(ROOT, loose.slice('res://'.length)), files);
135
138
  }
136
139
  return files;