diff --git a/CLAUDE.md b/CLAUDE.md index e1fd5b4..26592c2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,9 +11,9 @@ knowledge base — see § Quick links spec. |---|---| | Phase | 1 (Etch ↔ ECS) | | Current milestone | (none — between milestones) | -| Last released tag | `v0.10.4-scene-cook` | +| Last released tag | `v0.10.5-scene-load` | | Active branch | `main` | -| Next planned milestone | M1.0.5 — runtime `.scene.bin` loader (to be scoped) | +| Next planned milestone | M1.0.6 — prefab `.prefab.bin` loading + entity→entity cross-references + Entity Extensions Table (to be scoped) | ## Tags @@ -42,6 +42,7 @@ knowledge base — see § Quick links spec. | `v0.10.2-etch-events-observers` | 2026-06-23 | M1.0.2 — Events + structural observers | `emit`/`@on_event` + five lifecycle-annotation observers (`@on_added`/`@on_removed`/`@on_replaced`/`@on_spawned`/`@on_despawned`), annotation-routed; Tier 0 observer registry completed (`on_replaced`, ctx, old/new). Diagnostics E1208/E1209/E1215. Non-POD (`string`) event-field fix folded in via fix-as-you-go. | | `v0.10.3-resource-nonpod-fields` | 2026-06-24 | M1.0.3 — String and enum resource fields | Resource fields reach spec parity for the two scalar non-POD cases; founds the Phase 1 persistent heap (system allocator + atomic refcount + drop-by-`type_id` + immortal-interned sentinel). Resource-only `FieldKind.string_`/`.enum_`; components stay POD-strict (validator-gated). The Option A alignment (the former deferred "tranche 7"). | | `v0.10.4-scene-cook` | 2026-06-27 | M1.0.4 — Cooking `.scene.etch` → `.scene.bin` | Offline, World-free cook of direct-entity scenes. Tier-0 `src/core/scene/` codec — `SceneHeader` (64 B) + §10 Schema Registry + `writer` + zero-copy `accessor` (the read half, reused verbatim by the M1.0.5 loader). Etch driver `src/etch/scene_cook.zig` reuses `compileTypeDecl` (refactored `*World`→`*Registry`) + `evalConst`; groups entities by `ComponentSignature` into flat SoA columns. On-disk component identity is the Schema-Registry index → component **name** (no raw `ComponentId`). Resource `string`/enum materialized; `parent` name→UUID; `instance of` + unsupported field kinds rejected with clear diagnostics. `scene_cook` CLI + re-cook byte-identical determinism. | +| `v0.10.5-scene-load` | 2026-06-27 | M1.0.5 — Runtime `.scene.bin` loader → ECS | Runtime loader `src/core/scene/loader.zig` reusing `accessor.zig` verbatim: `openVerified` (magic/version + `verifyHash` → `CorruptScene`) + `buildSchemaRemap` (Schema-Registry index → runtime `ComponentId` via `idOf`, size/alignment-validated → `SchemaMismatch`/`UnknownComponent`) + per-entity `spawnDynamicWithValues` instantiation + UUID(16 B)→handle map + two-phase `on_spawned` (`ObserverRegistry.dispatchOnSpawned` + `World.dispatchOnSpawned`, all-entities-exist-first ordering) + resource loading (POD + `string` fields interned into the Tier-0 persistent heap, owned by `LoadResult`). `loadFromBytes` (byte-level core) + `loadScene(path)` (mmap). New `error.MalformedScene` (structure invalid, distinct from `CorruptScene` = hash mismatch). Persistent heap moved `src/etch/persistent.zig` → `src/core/memory/persistent.zig` (Tier 0). Bench median ~1.05 ms / 10k entities (M4 Pro, ReleaseFast). | ## Hypotheses validated by spikes @@ -65,6 +66,9 @@ knowledge base — see § Quick links spec. - **M1.0.4 scope boundary (scene cook)**: the cook is offline + **World-free** (no entity instantiation). Runtime `.scene.bin` → ECS `World` (mmap + memcpy-into-chunks + UUID→handle remap + `on_spawned`) is **M1.0.5** — it reuses `src/core/scene/accessor.zig` verbatim. Prefab `instance of` flattening + entity→entity cross-refs + the Entity Extensions Table are **M1.0.6** (M1.0.4 rejects `instance of` and writes both reserved sections empty). On-disk component identity is the §10 Schema-Registry index → component name (Phase-1 identity = name; no comptime schema-hash for Etch components), so M1.0.5 remaps via `idOf(name)`. Registration deviation: `interp.compileTypeDecl` was refactored `*World`→`*Registry` (+ `pub`) so the cook reuses it World-free (traced in `briefs/M1.0.4-scene-cook.md` Accepted deviations). - **Dynamic collections on resource fields (`string[]`, `[K:V]`, `Set`)**: unscheduled-**additive** — they fold into the first consuming milestone, **not** M1.0.4. The `M1.0.4` label in `briefs/M1.0.3-resource-nonpod-fields.md` Context is **superseded** (M1.0.4 is the scene cook, not dynamic collections). The persistent heap's `type_id`→drop dispatch + open `TypeId` set already accommodate them. - **Additive future format sections (design-at-day-1, not deferral)**: M1.0.4's `.scene.bin` reserves the Extensions + Cross-references sections (written empty) and the writer/accessor dispatch on `FieldKind` + name-based schema identity — so M1.0.6 (cross-refs/extensions) and the first milestone adding a 3D/handle `FieldKind` add columns/entries **without** changing the on-disk format or the loader. +- **M1.0.5 scope boundary (runtime loader)**: the loader instantiates **per-entity** via `world.spawnDynamicWithValues` (no chunk/column bulk copy), builds the UUID→handle map and validates parent ordinals but **applies no parent link** (no runtime hierarchy component yet — hierarchy milestone), reads the cross-refs/extensions tables empty (**M1.0.6**), and rejects an unknown component (`error.UnknownComponent`) rather than auto-registering from the on-disk schema (Phase 2+). `loadFromBytes` (byte-level core) + `loadScene(path)` (mmap, `LoadResult` owns the mmap). Out: bulk-spawn, prefab/`.prefab.bin`, save/load, decompression, streaming. +- **Per-entity vs bulk-spawn (decided by M1.0.5 bench)**: per-entity instantiation measured **~1.05 ms / 10k entities** (M4 Pro, ReleaseFast) — far under the ~10–50 ms/10k spec reference → **per-entity confirmed; bulk SoA column-copy is a genuine YAGNI, no bulk-spawn milestone scheduled.** The loader's instantiate step keeps a clean boundary so a bulk path could later swap its body without touching `loadScene`'s signature or call sites. +- **Persistent heap is Tier 0 (since M1.0.5)**: the refcounted string/value heap moved `src/etch/persistent.zig` → **`src/core/memory/persistent.zig`** (re-exported as `weld_core.memory.persistent`) so the `weld_core` scene loader can intern resource `string` fields without importing `weld_etch`. Tier-neutral (`runDrop` is a no-op); `StringSlot` 16-byte layout + API unchanged (no format/ABI impact). The Etch importers (`interp`/`ecs_bridge`/`scene_cook`) now reach it through `weld_core`. Loaded resource strings are `allocImmortal` + owned by `LoadResult`. ## Non-negotiable rules diff --git a/bench/scene_load_bench.zig b/bench/scene_load_bench.zig new file mode 100644 index 0000000..a6c5311 --- /dev/null +++ b/bench/scene_load_bench.zig @@ -0,0 +1,136 @@ +//! M1.0.5 E2 — runtime scene loader benchmark. +//! +//! Measures the wall time of `scene.loader.loadFromBytes` on a ~10k-entity +//! scene. The image is produced in-process by the M1.0.4 `writer` (no +//! `.scene.etch` authoring, no file I/O) so the number isolates the load work: +//! the per-entity `spawnDynamicWithValues` (archetype find/create + slot alloc + +//! component memcpy), the schema-identity remap, and the `on_spawned` pass. +//! +//! **Measurement, not a gate.** It prints a median; the milestone's per-entity- +//! vs-bulk decision is recorded from this number (spec ref ~10–50 ms / 10k). +//! Run in ReleaseFast for a representative figure: +//! `zig build bench-scene-load -Doptimize=ReleaseFast` + +const std = @import("std"); +const builtin = @import("builtin"); +const weld_core = @import("weld_core"); + +const World = weld_core.ecs.World; +const Registry = weld_core.ecs.registry.Registry; +const scene = weld_core.scene; +const format = scene.format; +const writer = scene.writer; +const loader = scene.loader; + +const num_entities: u32 = 10_000; +const warmup_runs: u32 = 3; +const measured_runs: u32 = 50; +const pos_size: u16 = 12; // [3]f32 +const pos_align: u16 = 4; + +/// Cook a single `[Pos]` archetype of `num_entities` entities into `.scene.bin` +/// bytes (caller-owned). The cook registry is local — the writer copies the +/// schema name into the image, so the bytes outlive it. +fn buildSceneBytes(gpa: std.mem.Allocator) ![]u8 { + var reg = Registry.init(); + defer reg.deinit(gpa); + const pos = try reg.registerComponentRaw(gpa, .{ + .name = "Pos", + .size = pos_size, + .alignment = pos_align, + .default_bytes = &[_]u8{0} ** pos_size, + .fields = &.{}, + }); + + var arena = std.heap.ArenaAllocator.init(gpa); + const a = arena.allocator(); + const names = try a.dupe([]const u8, &.{try a.dupe(u8, "E")}); + const uuids = try a.alloc([16]u8, num_entities); + for (0..num_entities) |i| { + uuids[i] = [_]u8{0} ** 16; + std.mem.writeInt(u32, uuids[i][0..4], @intCast(i + 1), .little); + } + const col = try a.alloc(u8, @as(usize, pos_size) * num_entities); + @memset(col, 0); + const cols = try a.dupe([]u8, &.{col}); + const ents = try a.alloc(format.EntityEntry, num_entities); + for (0..num_entities) |i| ents[i] = .{ .name = 0, .uuid = @intCast(i), .parent_uuid = format.no_parent }; + const ids = try a.dupe(format.ComponentId, &.{pos}); + const blocks = try a.dupe(format.ArchetypeBlock, &.{.{ + .component_ids = ids, + .entity_count = num_entities, + .columns = cols, + .entities = ents, + }}); + var model: format.CookModel = .{ + .strings = names, + .uuids = uuids, + .resources = &.{}, + .archetypes = blocks, + .arena = arena, + }; + defer model.deinit(); + return try writer.write(gpa, model, ®); +} + +/// One timed load into a fresh world. Returns the elapsed ns (the load only — +/// world setup and teardown are outside the measured window). +fn timeOneLoad(gpa: std.mem.Allocator, io: std.Io, bytes: []const u8) !u64 { + var world = World.init(); + defer world.deinit(gpa); + _ = try world.registry.registerComponentRaw(gpa, .{ + .name = "Pos", + .size = pos_size, + .alignment = pos_align, + .default_bytes = &[_]u8{0} ** pos_size, + .fields = &.{}, + }); + + const t0 = std.Io.Clock.now(.awake, io); + var result = try loader.loadFromBytes(&world, gpa, bytes); + const t1 = std.Io.Clock.now(.awake, io); + result.deinit(gpa); + const elapsed = t0.durationTo(t1).nanoseconds; + return @intCast(@max(@as(i96, 0), elapsed)); +} + +fn msFromNs(ns: u64) f64 { + return @as(f64, @floatFromInt(ns)) / 1_000_000.0; +} + +pub fn main(init: std.process.Init) !void { + const gpa = init.gpa; + const io = init.io; + + const bytes = try buildSceneBytes(gpa); + defer gpa.free(bytes); + + const samples = try gpa.alloc(u64, measured_runs); + defer gpa.free(samples); + + var run: u32 = 0; + const total = warmup_runs + measured_runs; + while (run < total) : (run += 1) { + const ns = try timeOneLoad(gpa, io, bytes); + if (run >= warmup_runs) samples[run - warmup_runs] = ns; + } + + std.mem.sort(u64, samples, {}, std.sort.asc(u64)); + const median = samples[samples.len / 2]; + + var buf: [512]u8 = undefined; + var w = std.Io.File.stdout().writer(io, &buf); + try w.interface.print( + "scene_load_bench [{s}]: {d} entities, {d} runs\n" ++ + " median {d:.3} ms | min {d:.3} ms | max {d:.3} ms\n", + .{ + @tagName(builtin.mode), + num_entities, + measured_runs, + msFromNs(median), + msFromNs(samples[0]), + msFromNs(samples[samples.len - 1]), + }, + ); + try w.interface.flush(); +} diff --git a/briefs/M1.0.5-scene-load.md b/briefs/M1.0.5-scene-load.md new file mode 100644 index 0000000..498d656 --- /dev/null +++ b/briefs/M1.0.5-scene-load.md @@ -0,0 +1,180 @@ +# M1.0.5 — Runtime loader `.scene.bin` → ECS + +> **Status:** CLOSED +> **Phase:** 1 +> **Branch:** `phase-1/scene/scene-load` +> **Tag (set after merge by Guy):** `v0.10.5-scene-load` +> **Dependencies:** M0.1 (Tier-0 ECS: `World` spawn paths, `Archetype`, `Registry.idOf`, generational identity, `entity_locations`), M0.2 (RTTI), M0.8 (grammar v0.6 + scene descriptors + `compileTypeDecl` — for the E3 Etch integration test), M1.0.2 (`ObserverRegistry`: `on_spawned`/`on_despawned`, command-buffer dispatch via `applyWithObservers`/`flushWithObservers`), M1.0.3 (resource `string`/enum fields + persistent string heap, `writeValueAsBytes`/`readBytesAsValue`), M1.0.4 (`.scene.bin` codec: `src/core/scene/{format,writer,accessor}.zig`) +> **Open date:** 2026-06-27 +> **Close date:** 2026-06-27 + +--- + +# FROZEN SECTION + +*Authored by Claude.ai. Not modifiable by Claude Code outside a Claude.ai round-trip (see § Accepted deviations).* + +## Context + +Second sub-milestone of the scene track (M1.0.4–6) in Phase 1, and **blocker #1** of the track. It delivers the **runtime loader** `.scene.bin` → ECS `World`: open + integrity-check the cooked binary, instantiate every entity into the live `World`, remap on-disk identity to runtime, and fire the `on_spawned` lifecycle. It validates the hypothesis that the M1.0.4 zero-copy accessor plus the existing M0.1 spawn surface and the M1.0.2 observer registry are sufficient to materialize a cooked scene into a running `World` — with no new ECS storage primitive. + +**Verified instantiation surface (governs the loader, differs from the §4 narrative pseudo-code).** Read against the frozen M1.0.4 code, the public ECS entry point for byte-payload instantiation is `world.spawnDynamicWithValues(gpa, component_ids, payloads)` — it sorts the ids, finds-or-creates the archetype, allocates a slot, and copies each payload into the column it resolves **by `ComponentId`** (so payloads may be supplied in the on-disk column order; the function reorders internally). `getOrCreateArchetype` is **private** and there is **no** `appendRowFromBytes`; the loader does **not** touch chunks, `ChunkLayout`, or the archetype internals directly. `on_spawned` is currently dispatched **only** inside the command-buffer flush (`applyWithObservers` → `fireList`), and `fireList` is private and early-returns when the registry's `deferred` buffer is null; the loader therefore needs a small **new public dispatch entry** (`ObserverRegistry.dispatchOnSpawned` + a `World` wrapper) to drive a two-phase load. Instantiation is **per-entity**; bulk SoA column-copy is out of scope (see Out-of-scope and Notes). + +This milestone **assembles** existing bricks — `accessor.zig` (reused verbatim), `world.spawnDynamicWithValues` (M0.1/E6), `Registry.idOf` (name→`ComponentId`), `ObserverRegistry` (M1.0.2), `ResourceStore.addResource` + `persistent.alloc`/`StringSlot` (M1.0.3), `fs.mmapFile` (M0.6). It reinvents none of them. The genuinely new surface is `loader.zig` (the instantiation algorithm over the accessor) and the public `dispatchOnSpawned` entry. + +## Scope + +**E1 — Open + identity remap (`src/core/scene/loader.zig`).** +- `mmapFile(path)` → borrowed bytes; `scene.Accessor.open(bytes)` (validates magic + version, surfaces its `ReadError`); `accessor.verifyHash()` → `error.CorruptScene` on mismatch. +- Build the schema-remap table: for each on-disk schema index `i`, resolve `accessor.schema(i).name` to a runtime `ComponentId` via `world.componentId(name)` (i.e. `Registry.idOf`); `null` → `error.UnknownComponent`. **Validate** the on-disk `SchemaEntry.size`/`alignment` equal the runtime registry's `componentSize`/`componentAlignment` for that id; mismatch → `error.SchemaMismatch` (the scene was cooked against a different component layout — copying its bytes would corrupt storage). Produces `[]ComponentId` indexed by on-disk schema index. +- Inline tests for E1 invariants. + +**E2 — Entity instantiation + UUID map + two-phase `on_spawned`.** +- Add `ObserverRegistry.dispatchOnSpawned(self, gpa, world, eid)` (ensures `deferred`, fires `on_spawned` for one entity) and a `World.dispatchOnSpawned` wrapper. No other observer category is fired by the loader. +- *Phase 1 — instantiate.* For each archetype block: map its `schemaIndex(c)` columns to runtime `ComponentId`s (E1 table); for each entity slot, gather `payloads[c] = block.componentSlot(c, slot)` (raw bytes, borrowed from the mmap, on-disk column order) and `eid = world.spawnDynamicWithValues(gpa, ids, payloads)`. Record `uuid(16 bytes) → eid` in the load map (key on the 16-byte UUID from `block.entityUuid(slot)`; **do not** modify the accessor) and append `eid` to the spawned list. Validate `block.entityParent(slot)` is `no_parent` or a UUID ordinal in `[0, uuidCount)`; **do not apply** the parent link (no runtime hierarchy component exists — see Out-of-scope). +- *Phase 2 — lifecycle.* Drain any pre-existing `deferred` commands; then for each `eid` in the spawned list (in load order) call `world.dispatchOnSpawned(eid)`; then drain `deferred` again (an `on_spawned` rule may queue structural commands), reusing the existing flush/`applyRawCommand` path. The ordering guarantee is: **every loaded entity exists before any `on_spawned` fires**. +- `LoadResult { spawned: []EntityId, uuid_to_entity, mmap }` returned to the caller; ownership defined explicitly (caller frees `spawned`/the map and calls `mmap.close()`; or a `LoadResult.deinit`). The cross-references table and the extensions table are read and asserted **empty** (M1.0.6 owns their population). +- `bench/scene_load_bench.zig`: time `loadScene` on a ~10k-entity scene (bytes produced via the M1.0.4 `writer` in-bench, no need to author a 10k `.scene.etch`). + +**E3 — Persistent heap to Tier 0 + resource loading + Etch integration + CLAUDE.md.** +- **Move the persistent heap to Tier 0 (prerequisite).** Move `src/etch/persistent.zig` (a tier-neutral refcounted heap — `runDrop` is a no-op, no Etch coupling) to `src/core/memory/persistent.zig`; create `src/core/memory/root.zig` (re-export + §13 pin); wire `src/core/memory` into `weld_core` (`build.zig`). Redirect its three `weld_etch` importers (`interp.zig`, `ecs_bridge.zig`, `scene_cook.zig`) and drop the now-duplicate `persistent` pin from `src/etch/root.zig`. The `StringSlot` 16-byte `extern` layout and the heap's API are unchanged — only the tier home moves (no format/ABI impact). This lets the `weld_core` loader intern resource `string` fields without importing `weld_etch`. Rationale: M1.0.3 placed it in `weld_etch` for proximity to the interp, but the directory structure targets `src/core/memory` for persistent pools, and resource `string` fields are a Tier-0 resource capability. +- Load the resources block: for each resource, resolve `schema_index` → `ComponentId` (validated as in E1); reconstruct the resource bytes = copy the POD `data`, then for each `string` field of that resource type (offsets from the runtime `FieldDesc` metadata, M1.0.3) intern `resource.stringField(offset)` into the now-Tier-0 heap (`allocImmortal`, `type_string`) and write the resulting `StringSlot` into the reconstructed bytes at `offset`; install via `world.addResource(gpa, cid, bytes)`. Loaded resource strings are allocated **immortal** and **owned by `LoadResult`** (tracked + `destroy`-ed at `LoadResult.deinit`, alongside the mmap), independent of the interp teardown. This is the load-side mirror of M1.0.3's non-POD resource path. +- Etch integration test (cross-module: `weld_etch` cook + `weld_core` loader): compile a program declaring ≥2 component types, an `@on_spawned` rule that `emit`s an event, and a resource with a `string` field; cook a scene to `.scene.bin`; load it; assert entities are instantiated, the emitted-event count equals the entity count, and the resource `string` round-trips. +- `CLAUDE.md` §3.4 update, applied on the milestone branch via `docs(claude-md): update for M1.0.5`: État courant table row for the scene track (loader landed); Tags row entry `v0.10.5-scene-load`; open-decisions update recording (a) the M1.0.5 scope boundary, (b) the per-entity-vs-bulk decision outcome from the E2 bench measurement, (c) the persistent-heap move to Tier 0 (`src/core/memory`), and (d) Last updated date. + +## Out-of-scope + +- **Bulk SoA column-copy instantiation (bulk-spawn Tier-0)** — reserving a run of N slots in an archetype and copying whole columns with chunk-boundary slicing, instead of per-entity `spawnDynamicWithValues`. It is a multi-consumer Tier-0 capability (loader + prefabs + later Relay snapshots) and is **owned by a dedicated milestone co-designed with M1.0.6 prefabs**. The E2 bench measures whether per-entity is fast enough; the per-entity path and a bulk path are behaviourally identical (same world state), so swapping the internals later touches neither `loadScene`'s signature nor any call site. Do **not** introduce it here, and do **not** try to use/expose `getOrCreateArchetype` or add `appendRowFromBytes`. +- **Parent / child hierarchy application** — writing a runtime hierarchy component from resolved `parent_ordinal`s. No runtime hierarchy component exists (`Transform`/`Velocity` only); `AttachedToSocket`/`DespawnWithParent` are spec-level, not implemented. Introducing a placeholder risks the wrong abstraction. The loader builds the UUID map (the seam) and validates parent ordinals, but applies nothing. **Owned by the hierarchy milestone.** +- **Entity→entity cross-references** — resolving `uuid`-typed component fields to runtime handles. The cross-references table is empty in M1.0.4 output and **owned by M1.0.6**; the loader reads it empty. +- **Extensions table / `on_attach`** — empty in M1.0.4 output, **owned by M1.0.6**; read empty. +- **`scene_source_uuid` auto-component** — the load-time scene-tagging component used by unload. Needs a runtime component type that does not exist; **owned by the streaming milestone**. +- **Auto-registering an unknown component from its on-disk `SchemaEntry`** — loading a scene into a `World` that did not compile its component types is Phase 2+. In Phase 1 every scene type is registered by the running Etch program; an unknown component is `error.UnknownComponent`. +- **World partition** (`.cell.bin`/`.layer.bin`/`.manifest.bin`), HLOD, data layers, scene streaming, async load. +- **`.prefab.bin` loading / prefab instantiation with overrides** — **owned by M1.0.6**. +- **Save/load** (`.sav`), scene hot-reload, and **optional `.scene.bin` decompression** (`engine-scene-serialization.md` §3). +- **Refactoring the Etch-side string codec** (`ecs_bridge.zig` `writeValueAsBytes`/`readBytesAsValue`) beyond redirecting its `persistent` import — only the heap's tier home moves; its API and the codec are otherwise untouched. + +## Specs to read first + +1. `engine-scene-serialization.md` — §4 (`.scene.bin` layout + the runtime loading section — PRIMARY; note the pseudo-code's explicit "ECS instantiation surface reconfirmed at M1.0.5" banner — this brief is that reconfirmation), §2 (UUID + name identity), §5 (prefabs — to see the M1.0.6 boundary). +2. `engine-ecs-internals.md` — §10 (ECS serialization: Schema Registry, the spawn-all-then-remap identity pattern). +3. `engine-spec.md` — §19 (scene serialization), §3.5 (in-tree discipline). +4. `engine-zig-conventions.md` — §13 (test rooting / lazy-analysis guard — mandatory so `tests/scene/` actually runs), §19 (rules summary, POD `extern struct`). + +## Files to create or modify + +- `src/core/scene/loader.zig` — **create** — runtime loader (E1/E2/E3): `loadScene` + `LoadResult`. Reuses `accessor.zig` verbatim; no re-parsing. +- `src/core/scene/root.zig` — **edit** — re-export `loader`; pin it for test rooting (§13). `weld_core` only — never `weld_etch`. +- `src/core/ecs/observers.zig` — **edit** — add public `ObserverRegistry.dispatchOnSpawned` (two-phase load entry). +- `src/core/ecs/world.zig` — **edit** — add `World.dispatchOnSpawned` wrapper. +- `tests/scene/load_roundtrip_test.zig` — **create** — core-level: instantiation + two-phase `on_spawned` + component-bytes fidelity (T1, T2, T3). +- `tests/scene/load_resources_test.zig` — **create** — resource round-trip: POD + interned `string` (T4). +- `tests/scene/load_errors_test.zig` — **create** — `CorruptScene` / `UnknownComponent` / `SchemaMismatch` (T5). +- `tests/scene/load_integration_test.zig` — **create** — Etch cook→load integration: `@on_spawned` emit + resource `string` (T6). May live inline in `src/etch/interp.zig` if cross-module wiring is cleaner there. +- `bench/scene_load_bench.zig` — **create** — `loadScene` on ~10k entities; reports median. +- `src/core/memory/persistent.zig` — **create (moved from `src/etch/persistent.zig`)** — Tier-0 refcounted string/value heap (`StringSlot`, `alloc`/`allocImmortal`/`incref`/`decref`/`destroy`). API/layout unchanged. +- `src/core/memory/root.zig` — **create** — re-exports + §13 pin for `persistent`. +- `src/etch/persistent.zig` — **delete** — moved to `src/core/memory/`. +- `src/etch/interp.zig` — **edit** — redirect the `persistent` import to `weld_core` (`src/core/memory`). +- `src/etch/ecs_bridge.zig` — **edit** — redirect the `persistent` import. +- `src/etch/scene_cook.zig` — **edit** — redirect the `persistent` import. +- `src/etch/root.zig` — **edit** — drop the `persistent` §13 pin (now pinned by `src/core/memory/root.zig`). +- `src/core/ecs/registry.zig` — **edit** — fix the comment referencing the heap path (now `src/core/memory/persistent.zig`). +- `build.zig` — **edit** — wire `loader` into `weld_core`, the `bench/scene_load_bench.zig` artifact, and the `tests/scene/` test target (rooted per §13); the integration test target must see both `weld_core` and `weld_etch`; wire `src/core/memory` into the `weld_core` module; remove `src/etch/persistent.zig` from the `weld_etch` file set if explicitly listed. +- `CLAUDE.md` — **edit** — §3.4 (see Scope E3), via `docs(claude-md): update for M1.0.5` on the branch. + +## Acceptance criteria + +### Tests + +- `tests/scene/load_roundtrip_test.zig` — `test "loading a cooked scene instantiates every entity"` — entity count in the `World` equals the scene's; component bytes match the source. +- `tests/scene/load_roundtrip_test.zig` — `test "on_spawned fires once per loaded entity"` — a registered Zig `on_spawned` observer's counter equals the entity count after load. +- `tests/scene/load_roundtrip_test.zig` — `test "all entities exist before any on_spawned fires"` — the observer, at its first invocation, observes `world.entityCount()` equal to the full loaded count (proves the two-phase ordering). +- `tests/scene/load_resources_test.zig` — `test "resource string fields round-trip through the persistent heap"` — a loaded resource's `string` field reads back the cooked value. +- `tests/scene/load_errors_test.zig` — `test "..."` × 3 — bad/short bytes → `error.CorruptScene`; a scene referencing an unregistered component → `error.UnknownComponent`; a size/alignment-divergent schema → `error.SchemaMismatch`. +- `tests/scene/load_integration_test.zig` — `test "cooked Etch scene loads, on_spawned rules emit, resource string round-trips"` — emitted-event count equals entity count; resource `string` matches. + +### Benchmarks + +- `bench/scene_load_bench.zig` — median `loadScene` wall time on ~10k entities — **measurement, not a blocking threshold.** The bench passes when it runs and reports a median (logged in the Execution log + Closing notes, with the dev machine named). Spec reference is ~10–50 ms / 10k. **Decision rule to record in Closing notes:** comfortably under ~50 ms with linear scaling → per-entity confirmed, bulk-spawn is a genuine YAGNI; at/above ~50 ms or super-linear → flag for Claude.ai to schedule the bulk-spawn milestone. Do not block the PR on the number. + +### Observable behaviour + +- A runnable scenario that cooks a small scene (reuse `tools/scene_cook` from M1.0.4) then loads it and logs the instantiated entity count and `on_spawned` firings — demonstrable by hand (a small harness or the integration test run with output). + +### CI + +- `zig build` clean, zero warnings, on the configured matrix. +- `zig build test` green (Debug + ReleaseSafe). +- `zig fmt --check` green. +- `zig build lint` green (when the custom linter exists). +- `commit-msg` hook green on every commit of the branch. + +## Conventions + +- **Branch**: `phase-1/scene/scene-load` +- **Final tag**: `v0.10.5-scene-load` +- **PR title**: `Phase 1 / Scene / Runtime loader .scene.bin → ECS` +- **Commit convention**: Conventional Commits (cf. `engine-development-workflow.md` §4.3) +- **Merge strategy**: squash-and-merge (cf. `engine-development-workflow.md` §4.6) + +## Notes + +- **Format source of truth = code, not §4 prose.** Read `src/core/scene/format.zig` and `src/core/scene/accessor.zig` (frozen M1.0.4) **before** writing `loader.zig`. The accessor read API is the real, frozen contract: `Accessor.open`, `verifyHash`, `schemaCount`/`schema`, `resourceCount`/`resource` (+ `Resource.stringField`), `archetypeCount`/`archetype` (+ `Archetype.component_count`/`entity_count`/`schemaIndex`/`entityName`/`entityUuid`/`entityParent`/`column`/`componentSlot`). +- **Critical disambiguation.** §4's narrative describes the target algorithm as a per-column SoA `memcpy` into chunks. M1.0.5 ships the **per-entity** path via `world.spawnDynamicWithValues` (identical resulting world state). The bulk per-column copy is **out of scope** (dedicated milestone). Follow this brief for the instantiation strategy, not §4's algorithmic prose. +- **Runtime surfaces to use:** `world.spawnDynamicWithValues` (per-entity; resolves payloads to columns by `ComponentId`, so on-disk column order is fine); `world.componentId(name)` / `Registry.idOf` (name remap); `fs.mmapFile` → `Mmap` (close at scene end-of-life); `world.addResource` + `persistent.alloc`/`StringSlot` (resource strings). +- **`dispatchOnSpawned` must `ensureDeferred` first** — `fireList` early-returns when `deferred` is null. Mirror the M1.0.2 semantics: the deferred queue is drained after the phase-2 dispatch loop (see `flushWithObservers`/`applyRawCommand`). +- **`on_spawned` only on load (not `on_add` per component).** Semantic decision: a scene load fires the spawn lifecycle hook, not per-component-add hooks — the UE5 `BeginPlay`-after-level-load model. An `on_spawned` rule that wants to react to components reads them (all present). +- **UUID map keyed by the 16-byte UUID.** `entityUuid(slot)` returns the bytes; resolve a parent ordinal via `uuidAt(ordinal)`. This avoids touching the verbatim-reused accessor. A dense ordinal-keyed map is a later optimization (would need an accessor getter) — not now. +- **Per-entity is the deliberate baseline, with a clean seam.** Shape the loader so the "instantiate" step has a clean internal boundary (`block + remap → spawned + map`); the bulk path later swaps that boundary's body only. Do not pre-design the `spawnBulk` signature — freeze the boundary, not the internal signature of a two-consumer primitive. +- **Persistent heap is Tier 0 (E3).** `runDrop` is a verified no-op (no Etch callback), so the heap moves to `src/core/memory/` with no inverse dependency; the `weld_etch` importers now reach it through `weld_core` (legal). Loaded resource strings are `allocImmortal` + owned by `LoadResult` (reclaimed at `deinit`), not by the interp teardown. + +--- + +# LIVING SECTION + +*Maintained by Claude Code during the milestone. The log is for review and post-mortem debugging, not a marketing report.* + +## Specs read + +*Tick before writing any production code. Confirms the spec was ingested fully, not skimmed.* + +- [x] `engine-scene-serialization.md` (§4, §2, §5) — read 2026-06-27 12:09 +- [x] `engine-ecs-internals.md` (§10) — read 2026-06-27 12:09 +- [x] `engine-spec.md` (§19, §3.5) — read 2026-06-27 12:09 +- [x] `engine-zig-conventions.md` (§13, §19) — read 2026-06-27 12:09 + +## Execution log + +- 2026-06-27 12:09 — Setup. Branch `phase-1/scene/scene-load` off clean `main`; brief copied verbatim; 4 specs ingested at the sections the brief scopes; `Status: ACTIVE`. Read the frozen M1.0.4 codec (`format.zig`/`accessor.zig`/`writer.zig`) + the ECS surfaces the loader consumes (`World.componentId`/`spawnDynamicWithValues`/`addResource`/`entityCount`, `Registry.idOf`/`componentSize`/`componentAlignment`, `ObserverRegistry`, `fs.Mmap`). +- 2026-06-27 12:25 — **E1 done.** `src/core/scene/loader.zig`: `openVerified(bytes)` (accessor open + `verifyHash` → `error.CorruptScene`) and `buildSchemaRemap(gpa, world, acc)` (on-disk schema index → runtime `ComponentId` via `Registry.idOf`; `null` → `UnknownComponent`; cooked `size`/`alignment` ≠ runtime → `SchemaMismatch`). 5 inline tests: remap success, `UnknownComponent`, `SchemaMismatch`, `CorruptScene`, header `ReadError` (short/bad-magic/bad-version). `scene/root.zig` re-exports + §13-pins `loader`. Green: `zig build`, `zig build lint`, `zig fmt --check`, `zig build test` (debug exit 0); loader tests also pass `-O ReleaseSafe`. +- 2026-06-27 12:25 — **E1/E2 structuring note** (within the gate model, not a frozen-section change): `fs.mmapFile` appears in the E1 bullet, but the mmap's *ownership* is `LoadResult.mmap` + `mmap.close()` — an E2 deliverable. E1 therefore ships the byte-level open+verify+remap units (filesystem-free → deterministic, fast inline tests per §13); the `loadScene(world, gpa, path)` orchestrator that wraps `fs.mmapFile`, runs the per-entity spawn loop + two-phase `on_spawned`, and returns the mmap-owning `LoadResult` lands in E2. Boundary frozen (`buildSchemaRemap`), internal instantiate-signature deliberately not (brief Notes). +- 2026-06-27 17:20 — **E2 done.** `ObserverRegistry.dispatchOnSpawned` (ensureDeferred + fire `on_spawned`) + `World.dispatchOnSpawned` wrapper. `loader.zig`: `loadFromBytes` (open→remap→instantiate→two-phase `on_spawned`) + `loadScene(path)` (= `fs.mmapFile` + `loadFromBytes`); `LoadResult { spawned, uuid_to_entity, mmap: ?Mmap }` + `deinit`. Phase 1 = per-entity `spawnDynamicWithValues` (fires no observers); phase 2 = drain pre-existing deferred → `dispatchOnSpawned` per eid in load order → drain again, all via the existing `flushWithObservers`/`applyRawCommand` path. UUID(16 B)→eid map; parent ordinals validated in `[0, uuidCount)` (count derived from header offsets — accessor untouched). Reserved cross-refs/extensions tables asserted empty. Tests: `tests/scene/load_roundtrip_test.zig` T1/T2/T3 + a `loadScene(path)` mmap test (4 total) + an inline `dispatchOnSpawned` test in `observers.zig`. All green debug + ReleaseSafe; `zig build`/`lint`/`fmt` clean. +- 2026-06-27 17:20 — **Bench measurement** (`bench/scene_load_bench.zig`, `zig build bench-scene-load -Doptimize=ReleaseFast`, Apple M4 Pro): **median 1.050 ms / 10 000 entities** (min 0.907, max 2.111; 50 runs). Far under the spec ~10–50 ms/10k reference → **decision: per-entity confirmed, bulk-spawn is a genuine YAGNI** (not flagged for a dedicated milestone). The instantiate step keeps a clean boundary so a bulk path could swap its body later without touching `loadScene`'s signature. +- 2026-06-27 17:20 — **Two micro-decisions to flag at the E2 gate** (within the brief's surface, not frozen-section changes): (a) `loadFromBytes` exposed as the byte-level public core (the brief names `loadScene`); justified by the bench requirement "bytes produced via writer in-bench" + clean-boundary Note + filesystem-free tests — `loadScene` is the thin mmap wrapper. (b) An out-of-range parent ordinal maps to `error.CorruptScene` (the brief specifies the validation but not an error name; reused the existing corruption error rather than adding a 4th — unreachable for hash-valid M1.0.4 output, defensive only). +- 2026-06-27 19:48 — **E2 GO received**; bulk-spawn YAGNI confirmed (no bulk milestone) + `loadFromBytes` public both validated by Claude.ai. **E3 started.** Step (ii) **done** — correction (b): out-of-range parent ordinal → new `error.MalformedScene` (`StructureError`), distinct from `CorruptScene` (hash mismatch); inline test forges a hash-valid image with a bad parent ordinal. Traced in Accepted deviations (`02f8196`). +- 2026-06-27 20:15 — **E3 done (iii).** (a) **Persistent heap moved** `src/etch/persistent.zig` → `src/core/memory/persistent.zig` (Tier 0, `refactor(memory)` `8627a3e`); 3 Etch importers redirected via `weld_core.memory.persistent`, etch-root §13 pin dropped, registry comment fixed. The "wire `src/core/memory` into `weld_core`" step lives in `src/core/root.zig` (`pub const memory` + §13 pin), NOT `build.zig` — `src/core/*` is a single Zig module, so no `addModule` is needed (justifies touching `core/root.zig`, off the file list). (b) **Resource loading** in `loadFromBytes`: copy POD `data`, intern each `string` field into the Tier-0 heap (`allocImmortal`/`type_string`), write the `StringSlot` byte-compatibly with `ecs_bridge`, owned by `LoadResult.resource_strings` (destroyed at `deinit`). (c) Tests: `tests/scene/load_resources_test.zig` (T4) + the cook→load integration (T6) **inline in `interp.zig`** (3 entities + 3 emitted `on_spawned` events + resource string round-trip). (d) `CLAUDE.md` §3.4 patched. Green: `zig build`/`lint`/`fmt`, `zig build test` debug exit 0 (no leaks). + +## Accepted deviations + +- `f513782` — E3 scope amended (authorized by Claude.ai): the refcounted persistent heap moves `src/etch/persistent.zig` → `src/core/memory/persistent.zig` (Tier 0) so the `weld_core` loader can intern resource `string` fields without importing `weld_etch`. Root cause: M1.0.3 placed a tier-neutral heap (`runDrop` no-op) in `weld_etch`; the directory structure targets `src/core/memory` for persistent pools. `StringSlot` 16-byte layout unchanged — no format/ABI impact. +- `02f8196` — E3 correction (b), authorized by Claude.ai: an out-of-range parent ordinal now returns `error.MalformedScene` (new `StructureError` — "invalid scene structure") instead of `error.CorruptScene` (reserved for content-hash mismatch). Adds a 4th loader error beyond the brief's original `CorruptScene`/`UnknownComponent`/`SchemaMismatch`, and **supersedes** the E2 micro-decision (b) logged 2026-06-27 17:20. Inline test forges a hash-valid `.scene.bin` with a parent ordinal past the UUID table → `error.MalformedScene`. +- `dc5d16e` — E3 resource install refined to **install-or-overwrite**, not a strict `addResource`. Discovered via T6: a running Etch program's `Interpreter.compile` pre-installs declared resources with their defaults, so loading a scene that sets the same resource hit `error.DuplicateResource`. Resolved per `engine-spec.md` §19.1 (scene `resources {…}` are *injected into the resource map at load*): the scene value is authoritative and overrides the compile-time default (`getMutResource` + memcpy when the resource is present, else `addResource`). Ownership stays clean — the overridden value's string blocks remain owned by their installer (the interp frees its compile-time defaults at teardown); the newly-interned blocks are owned by `LoadResult`. **Flag for review:** the brief's word was "addResource"; this is the spec-grounded completion for the program-then-scene runtime flow. +- `dc5d16e` — T6 integration test lives **inline in `src/etch/interp.zig`** (the brief explicitly permits this) rather than as `tests/scene/load_integration_test.zig`. Reason: the assertion counts emitted events by reading the interpreter's private per-tick `EventStore`, only reachable from within `interp.zig`. `tests/scene/load_integration_test.zig` is therefore not created. + +## Blockers encountered + +- — resolved by or + +## Closing notes + +- **What worked**: The central hypothesis held — the M1.0.4 zero-copy `accessor` (reused **verbatim**) + the existing M0.1 spawn surface (`spawnDynamicWithValues`) + the M1.0.2 `ObserverRegistry` were sufficient to materialize a cooked scene into a live `World` with **no new ECS storage primitive**. The genuinely new code is small: `loader.zig` (open/verify + schema remap + per-entity instantiate + UUID map + two-phase lifecycle + resource interning) and one public observer entry (`dispatchOnSpawned`). The two-phase `on_spawned` (phase-1 direct spawn fires no observers; phase-2 dispatches per entity) gives the "all entities exist before any `on_spawned`" ordering cleanly. The persistent-heap move to Tier 0 was mechanical (the heap is tier-neutral, `runDrop` a no-op) and unblocked World-free resource-string interning. The gate split (E1 units → E2 instantiation → E3 resources/integration) kept each review tight. +- **What deviated from the original spec**: All traced in Accepted deviations. (1) E1/E2 structuring — `fs.mmapFile`'s *ownership* (`LoadResult.mmap`) is E2; E1 shipped byte-level units; `loadFromBytes` exposed as the byte-level core alongside `loadScene(path)`. (2) `error.MalformedScene` added (correction b, authorized) for a structurally-invalid scene, distinct from `CorruptScene`. (3) Persistent heap moved `src/etch/persistent.zig` → `src/core/memory/persistent.zig` (Claude.ai amendment). (4) Resource install is **install-or-overwrite** (spec-grounded, `engine-spec.md` §19.1) not strict `addResource`. (5) T6 lives inline in `interp.zig` (brief-permitted). (6) `core/root.zig` wired the `memory` submodule (not `build.zig` — single Zig module). +- **What to flag explicitly in review**: All reviewed and validated across the E1/E2/E3 gates (install-or-overwrite, T6 inline, `core/root.zig` wiring, MalformedScene). Nothing outstanding. +- **Final measurements**: `scene_load_bench` (`zig build bench-scene-load -Doptimize=ReleaseFast`, **Apple M4 Pro**): **median 1.050 ms / 10 000 entities** (min 0.907, max 2.111; 50 runs, 3 warmup). Far under the spec ~10–50 ms/10k reference. **Decision: per-entity instantiation confirmed; bulk SoA column-copy is a genuine YAGNI — no bulk-spawn milestone scheduled.** The instantiate step keeps a clean internal boundary so a bulk path could later swap its body without touching `loadScene`'s signature or call sites. +- **Residual risks / deliberately-left technical debt**: + - **`LoadResult.deinit` == scene unload, not just mmap release** (raised by Guy at the E3 gate): `deinit` `destroy`s the `resource_strings` blocks that the installed resources still reference. Safe in M1.0.5 (no unload path, no post-`deinit` resource reads), but semantically `deinit` discards the loaded scene's resource strings — to be framed when the unload/streaming milestone (`scene_source_uuid` + despawn-by-cell) lands. + - **Non-transactional load**: a mid-load error (e.g. `MalformedScene`, OOM) leaves partially-spawned entities + partially-installed resources in the `World`; only the loader's own heap allocations (spawned list, UUID map, interned-strings list) are reclaimed via `errdefer`. The caller deinits the `World`. Transactional/rollback load is out of scope (Phase 1). + - **Reserved tables asserted, not error-checked**: the cross-refs/extensions empty-count check is `std.debug.assert` (stripped in ReleaseFast). A future M1.0.6 file that populates them, loaded by a ReleaseFast M1.0.5 binary, would not trip it — but a format-version bump (the open() gate) is the real guard, and M1.0.6 owns those tables. + - **Parent links validated but not applied**: parent ordinals are range-checked (`MalformedScene` on violation) and the UUID→handle map is the seam, but no runtime hierarchy component is written — owned by the hierarchy milestone. diff --git a/build.zig b/build.zig index 6099302..9e56148 100644 --- a/build.zig +++ b/build.zig @@ -430,6 +430,14 @@ pub fn build(b: *std.Build) void { .{ .path = "tests/scene/cook_roundtrip_test.zig", .scene = true }, // M1.0.4 / E3 — scene cook negative cases (typed errors, no panic). .{ .path = "tests/scene/cook_errors_test.zig", .scene = true }, + // M1.0.5 / E2 — runtime loader `.scene.bin` → ECS: instantiate every + // entity (component bytes verbatim), two-phase `on_spawned` (fires once + // per entity, after all entities exist). `weld_core` only (no `.scene` + // flag → no `weld_etch`); builds the image in-memory via the writer. + .{ .path = "tests/scene/load_roundtrip_test.zig" }, + // M1.0.5 / E3 — resource `string` fields round-trip through the Tier-0 + // persistent heap (intern on load, owned by `LoadResult`). `weld_core` only. + .{ .path = "tests/scene/load_resources_test.zig" }, // M0.3 — common platform layer tests. .{ .path = "tests/platform/fs_vfs_test.zig" }, .{ .path = "tests/platform/time_test.zig" }, @@ -808,6 +816,31 @@ pub fn build(b: *std.Build) void { ); bench_step.dependOn(&bench_run.step); + // -------------------------------------- M1.0.5 scene loader bench -------- + // + // `loadFromBytes` on a ~10k-entity image synthesized in-bench via the + // writer. Measurement only (median), not a gate — see the bench header. + const scene_load_bench_module = b.createModule(.{ + .root_source_file = b.path("bench/scene_load_bench.zig"), + .target = target, + .optimize = optimize, + }); + scene_load_bench_module.addImport("weld_core", core_module); + const scene_load_bench_exe = b.addExecutable(.{ + .name = "scene-load-bench", + .root_module = scene_load_bench_module, + }); + b.installArtifact(scene_load_bench_exe); + + const scene_load_bench_run = b.addRunArtifact(scene_load_bench_exe); + scene_load_bench_run.step.dependOn(b.getInstallStep()); + if (b.args) |args| scene_load_bench_run.addArgs(args); + const scene_load_bench_step = b.step( + "bench-scene-load", + "Run the M1.0.5 scene loader bench (~10k entities, reports median)", + ); + scene_load_bench_step.dependOn(&scene_load_bench_run.step); + // ------------------------------------- M0.4 render instancing bench ------ // // CPU-side batcher harness for the brief Benchmarks targets diff --git a/src/core/ecs/observers.zig b/src/core/ecs/observers.zig index e529b7b..5bd1421 100644 --- a/src/core/ecs/observers.zig +++ b/src/core/ecs/observers.zig @@ -208,6 +208,24 @@ pub const ObserverRegistry = struct { try entry.value_ptr.append(gpa, .{ .ctx = ctx, .callback = callback }); } + /// Fire `on_spawned` for one already-instantiated entity (M1.0.5 E2). The + /// scene loader drives the spawn lifecycle in a dedicated second pass — + /// after every loaded entity exists — rather than through the + /// command-buffer flush, so the ordering guarantee "all entities present + /// before any `on_spawned` fires" holds. Ensures the shared `deferred` + /// buffer exists first (a `null` `deferred` makes `fireList` early-return), + /// letting an `on_spawned` rule queue structural commands the caller drains + /// afterwards. Only `on_spawned` is fired — never `on_add`/`on_replaced`. + pub fn dispatchOnSpawned( + self: *ObserverRegistry, + gpa: std.mem.Allocator, + world: *World, + eid: EntityId, + ) !void { + self.ensureDeferred(gpa, world); + try self.fireList(self.on_spawned, world, eid, null, null, null); + } + fn fireList( self: *ObserverRegistry, list: Listeners, @@ -479,3 +497,45 @@ test "on_removed receives the pre-removal value (M1.0.2 E3)" { try testing.expectEqual(@as(i32, 99), E3Capture.old); // the pre-removal value try testing.expect(world.componentBytes(e, drop) == null); // component gone } + +// ─── M1.0.5 E2 — two-phase on_spawned dispatch entry ─────────────────────── + +const SpawnCounter = struct { + var count: u32 = 0; + fn reset() void { + count = 0; + } +}; + +fn spawnCountObserver( + _: ?*anyopaque, + _: *World, + _: EntityId, + _: ?ComponentId, + _: ?*const anyopaque, + _: ?*const anyopaque, + _: *CommandBuffer, +) anyerror!void { + SpawnCounter.count += 1; +} + +test "dispatchOnSpawned fires on_spawned once for an already-spawned entity (M1.0.5 E2)" { + const gpa = testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + + const cid = try e3RegisterRawI32(gpa, &world, "Tag"); + SpawnCounter.reset(); + try world.registerOnSpawned(gpa, null, &spawnCountObserver); + + // Direct spawn does NOT fire observers (only a cmd-buffer flush or this + // explicit dispatch does) — the counter is still 0 right after spawning. + var v: i32 = 1; + const e = try world.spawnDynamicWithValues(gpa, &[_]ComponentId{cid}, &[_][]const u8{std.mem.asBytes(&v)}); + try testing.expectEqual(@as(u32, 0), SpawnCounter.count); + + try world.dispatchOnSpawned(gpa, e); + try testing.expectEqual(@as(u32, 1), SpawnCounter.count); + // `dispatchOnSpawned` lazily created the shared deferred buffer. + try testing.expect(world.observer_registry.deferred != null); +} diff --git a/src/core/ecs/registry.zig b/src/core/ecs/registry.zig index 8c965d0..270a671 100644 --- a/src/core/ecs/registry.zig +++ b/src/core/ecs/registry.zig @@ -40,7 +40,7 @@ pub const FieldKind = enum { f32_, f64_, /// A `string` field slot: `{ ptr: u64, len: u32 }` (16 bytes, 8-aligned) - /// pointing into the Etch persistent heap (`src/etch/persistent.zig`, + /// pointing into the Tier-0 persistent heap (`src/core/memory/persistent.zig`, /// `StringSlot`). **Resource-only by construction** (M1.0.3): the Etch /// validator rejects `string` on `component` and `fieldKindFromTypeName` /// only emits this kind for the `.resource` origin, so no component can ever diff --git a/src/core/ecs/world.zig b/src/core/ecs/world.zig index 9ae413c..9edc48e 100644 --- a/src/core/ecs/world.zig +++ b/src/core/ecs/world.zig @@ -253,6 +253,17 @@ pub const World = struct { try self.observer_registry.registerOnReplaced(gpa, self, cid, ctx, callback); } + /// M1.0.5 E2 — fire `on_spawned` for one already-spawned entity. The scene + /// loader's two-phase lifecycle pass calls this directly: entities are + /// instantiated by `spawnDynamicWithValues` (which fires no observers), then + /// `on_spawned` is dispatched per entity in a second pass, guaranteeing + /// every loaded entity exists before any `on_spawned` rule runs. An + /// `on_spawned` rule may queue structural commands into the shared deferred + /// buffer; the caller drains it via the usual flush path. + pub fn dispatchOnSpawned(self: *World, gpa: std.mem.Allocator, eid: EntityId) !void { + try self.observer_registry.dispatchOnSpawned(gpa, self, eid); + } + // ─── Component registration helpers ────────────────────────────────── /// Register a component whose layout is described at runtime. diff --git a/src/etch/persistent.zig b/src/core/memory/persistent.zig similarity index 90% rename from src/etch/persistent.zig rename to src/core/memory/persistent.zig index 2500c0a..27fc615 100644 --- a/src/etch/persistent.zig +++ b/src/core/memory/persistent.zig @@ -1,9 +1,14 @@ -//! Phase-1 persistent heap for the Etch runtime (`etch-memory-model.md` §4 / -//! §11). The keystone for non-POD resource fields: a refcounted, system- -//! allocator-backed heap whose blocks outlive a rule body. M1.0.3 uses it for -//! resource `string` fields; M1.0.4 reuses it unchanged for dynamic -//! collections (the `type_id` → drop dispatch and the open `TypeId` set are -//! exactly what `string[]` / `[K: V]` / `Set` will register against). +//! Persistent heap — Tier 0 (`src/core/memory`, `etch-memory-model.md` §4 / §11). +//! The keystone for non-POD resource fields: a refcounted, system-allocator- +//! backed heap whose blocks outlive a rule body (or a single scene load). M1.0.3 +//! uses it for resource `string` fields; the M1.0.5 scene loader interns loaded +//! resource strings into it; M1.0.4 reuses it unchanged for dynamic collections +//! (the `type_id` → drop dispatch and the open `TypeId` set are exactly what +//! `string[]` / `[K: V]` / `Set` will register against). +//! +//! Moved from `src/etch/` to Tier 0 in M1.0.5 (the heap is tier-neutral — +//! `runDrop` is a no-op, no Etch coupling — and resource `string` fields are a +//! Tier-0 capability). API + on-storage layout unchanged. //! //! Layout (`etch-memory-model.md` §4.3 / §5.1). Each block is one system //! allocation laid out as: @@ -32,8 +37,11 @@ //! `incref` / `decref` are no-ops on it — compile-time string literals (resource //! field defaults) use this path so `addResource` allocates nothing. //! -//! Tier-0-agnostic: imports only `std` (no `src/core` dependency). The Etch -//! runtime owns this heap; the Tier-0 `ResourceStore` stays string-agnostic. +//! Self-contained: imports only `std` (no other `src/core` coupling), so it sits +//! cleanly at Tier 0. Consumers are the scene loader and the Etch runtime +//! (interp / bridge / cook, which reach it through `weld_core.memory`); the +//! Tier-0 `ResourceStore` itself stays string-agnostic (it stores the raw +//! `StringSlot` bytes). const std = @import("std"); diff --git a/src/core/memory/root.zig b/src/core/memory/root.zig new file mode 100644 index 0000000..f6f5547 --- /dev/null +++ b/src/core/memory/root.zig @@ -0,0 +1,14 @@ +//! Public surface of the Tier-0 `memory` submodule (re-exported from `weld_core` +//! as `weld_core.memory`). Owns the refcounted persistent heap used for non-POD +//! resource fields (`persistent.zig`) — consumed by the scene loader and, via +//! `weld_core`, by the Etch runtime (interp / bridge / cook). + +/// Refcounted, system-allocator-backed persistent heap: `StringSlot`, `TypeId`, +/// `alloc`/`allocImmortal`/`incref`/`decref`/`destroy`. +pub const persistent = @import("persistent.zig"); + +comptime { + // §13 lazy-analysis guard: pin the sub-file so its inline `test` blocks run + // under the `core_tests` target (`engine-zig-conventions.md` §13). + _ = persistent; +} diff --git a/src/core/root.zig b/src/core/root.zig index dba6492..98c57e6 100644 --- a/src/core/root.zig +++ b/src/core/root.zig @@ -123,6 +123,12 @@ pub const plugin_loader = @import("plugin_loader/root.zig"); /// `weld_etch` (tier discipline). pub const scene = @import("scene/root.zig"); +/// Memory namespace — Tier 0 persistent pools. `memory.persistent` is the +/// refcounted string/value heap for non-POD resource fields (moved here from +/// `src/etch/` in M1.0.5). Consumed by the scene loader and, via `weld_core`, +/// by the Etch runtime. +pub const memory = @import("memory/root.zig"); + comptime { // Force eager analysis of every IPC sub-file so inline tests are // picked up by `zig build test`. Zig 0.16's lazy semantic analysis @@ -194,6 +200,9 @@ comptime { _ = scene.format; _ = scene.writer; _ = scene.accessor; + _ = scene.loader; + // M1.0.5 — pin the Tier-0 persistent heap (moved from src/etch). + _ = memory.persistent; // M0.3 — pin the new platform sub-files so their inline tests run. _ = platform.once; _ = platform.time; diff --git a/src/core/scene/loader.zig b/src/core/scene/loader.zig new file mode 100644 index 0000000..db2de1e --- /dev/null +++ b/src/core/scene/loader.zig @@ -0,0 +1,522 @@ +//! `.scene.bin` runtime loader — Tier 0 (`engine-scene-serialization.md` §4, +//! "Chargement (loader runtime — M1.0.5)"). +//! +//! Reads a cooked `.scene.bin` back into a live ECS `World`. It **reuses +//! `accessor.zig` verbatim** (the zero-copy read half of the M1.0.4 codec) and +//! layers the runtime-only steps on top: on-disk-identity → runtime remap, +//! per-entity instantiation, the UUID→handle map, and the `on_spawned` +//! lifecycle. No new ECS storage primitive — the loader assembles existing +//! bricks (`world.spawnDynamicWithValues`, `Registry.idOf`, `ObserverRegistry`, +//! `world.addResource`). +//! +//! Tier discipline: imports `weld_core` internals only — never `weld_etch` +//! (`engine-spec.md` §3.5). The cook driver's Etch coupling lives in +//! `src/etch/scene_cook.zig`; the loader consumes only the neutral byte image. +//! +//! ## Stages (gate-split, see `briefs/M1.0.5-scene-load.md`) +//! * **E1 (here)** — open + integrity check + schema-identity remap. The two +//! units below (`openVerified`, `buildSchemaRemap`) are the front of the load +//! pipeline; both operate on a borrowed byte image so they are unit-testable +//! without touching the filesystem. They form the clean internal boundary the +//! E2 instantiation step builds on (and a future bulk path would swap behind). +//! * **E2** — `loadScene(world, gpa, path)`: the `fs.mmapFile` wrapper, the +//! per-entity instantiation loop, the UUID map, two-phase `on_spawned`, and +//! the `LoadResult` that owns the mmap. +//! * **E3** — resource loading (POD bytes + interned `string` fields). + +const std = @import("std"); + +const format = @import("format.zig"); +const accessor_mod = @import("accessor.zig"); +const registry_mod = @import("../ecs/registry.zig"); +const world_mod = @import("../ecs/world.zig"); +const observers_mod = @import("../ecs/observers.zig"); +const command_buffer_mod = @import("../ecs/command_buffer.zig"); +const fs = @import("../platform/fs.zig"); +const persistent = @import("../memory/persistent.zig"); + +const Accessor = accessor_mod.Accessor; +const ComponentId = registry_mod.ComponentId; +const World = world_mod.World; +const EntityId = world_mod.EntityId; + +/// Errors from opening + integrity-checking a `.scene.bin` byte image. +/// `format.ReadError` covers a truncated / wrong-magic / wrong-version file; +/// `CorruptScene` is a content-hash mismatch (the bytes were altered after the +/// cook recorded their `XxHash64`). +pub const OpenError = format.ReadError || error{CorruptScene}; + +/// Errors from mapping on-disk schema identity to the runtime registry. +/// `UnknownComponent`: a scene type the running program never registered +/// (Phase 1 has no auto-registration from the on-disk `SchemaEntry` — +/// `engine-scene-serialization.md` §4). `SchemaMismatch`: the type is +/// registered but its size/alignment diverge from the cooked layout, so +/// byte-copying its columns into storage would corrupt it. +pub const RemapError = error{ UnknownComponent, SchemaMismatch } || std.mem.Allocator.Error; + +/// Raised for a scene that opens and hashes valid but is structurally invalid — +/// e.g. an entity whose parent ordinal points past the UUID table. **Distinct +/// from `error.CorruptScene`** (a content-hash mismatch): the bytes are intact +/// (the cook's `XxHash64` matches), the scene structure is not. A well-formed +/// M1.0.4 cook never produces this — it is a defensive guard on external input. +pub const StructureError = error{MalformedScene}; + +/// Open a `.scene.bin` byte image: validate magic + version (via the accessor, +/// read little-endian field-by-field — never a raw `@ptrCast` off an unaligned +/// buffer), then verify the content hash. Returns a zero-copy `Accessor` +/// borrowing `bytes` for its whole lifetime. +/// +/// Errors: +/// - `error.TooShort` / `error.BadMagic` / `error.BadVersion` — invalid header +/// - `error.CorruptScene` — content hash does not match the header's +pub fn openVerified(bytes: []const u8) OpenError!Accessor { + const acc = try Accessor.open(bytes); + if (!acc.verifyHash()) return error.CorruptScene; + return acc; +} + +/// Build the schema-remap table: on-disk Schema-Registry index → runtime +/// `ComponentId`. Phase-1 schema identity is the component **name** +/// (`engine-ecs-internals.md` §10): each on-disk schema's name is resolved +/// through the world's registry (`Registry.idOf`), and its cooked +/// `size`/`alignment` are validated against the runtime layout so a scene +/// cooked against a different component layout can never feed mismatched bytes +/// into storage. +/// +/// The returned slice is `acc.schemaCount()` long, indexed by on-disk schema +/// index; the caller owns it (`gpa.free`). +/// +/// Errors: +/// - `error.UnknownComponent` — a schema name the world never registered +/// - `error.SchemaMismatch` — registered, but size/alignment diverge +/// - `error.OutOfMemory` +pub fn buildSchemaRemap(gpa: std.mem.Allocator, world: *const World, acc: Accessor) RemapError![]ComponentId { + const count = acc.schemaCount(); + const remap = try gpa.alloc(ComponentId, count); + errdefer gpa.free(remap); + + var i: u32 = 0; + while (i < count) : (i += 1) { + const s = acc.schema(i); + const id = world.componentId(s.name) orelse return error.UnknownComponent; + if (s.size != world.registry.componentSize(id) or + s.alignment != world.registry.componentAlignment(id)) + { + return error.SchemaMismatch; + } + remap[i] = id; + } + return remap; +} + +// ─── E2 — instantiation + UUID map + two-phase on_spawned ──────────────────── + +/// 16-byte UUID → runtime `EntityId`, built as the scene loads. Keyed on the +/// raw UUID bytes (`Archetype.entityUuid`) so the accessor stays untouched; a +/// dense ordinal-keyed map is a later optimization. +pub const UuidMap = std.AutoHashMapUnmanaged([16]u8, EntityId); + +/// What a load produces. `spawned` is every instantiated entity in load order; +/// `uuid_to_entity` resolves a scene UUID to its runtime handle (the seam the +/// hierarchy / cross-reference milestones build on); `mmap` is the backing file +/// mapping for the `loadScene(path)` entry (null for `loadFromBytes`, whose +/// bytes the caller owns). Component data is copied into ECS storage during the +/// load, so the entities outlive the mapping — `mmap` is held only so its +/// lifetime is the caller's to end (`engine-scene-serialization.md` §4). +/// +/// Ownership: the caller ends the load's life with `deinit` (frees `spawned`, +/// the map, the interned resource strings, and closes `mmap` if present). +pub const LoadResult = struct { + spawned: []EntityId, + uuid_to_entity: UuidMap, + /// Tier-0 persistent-heap blocks interned for loaded resource `string` + /// fields (E3), allocated **immortal**. Owned here (not by the interp), so + /// `deinit` `destroy`s them — the resources' `StringSlot`s point into these. + resource_strings: [][*]u8, + mmap: ?fs.Mmap, + + /// Free the loader-owned allocations, reclaim the interned resource strings, + /// and close the backing mmap (if any). Does **not** despawn the loaded + /// entities — they belong to the `World`. + pub fn deinit(self: *LoadResult, gpa: std.mem.Allocator) void { + gpa.free(self.spawned); + self.uuid_to_entity.deinit(gpa); + for (self.resource_strings) |p| persistent.destroy(gpa, p); + gpa.free(self.resource_strings); + if (self.mmap) |*m| m.close(); + self.* = undefined; + } +}; + +/// Count of entries in the on-disk UUID table, derived from the header section +/// offsets (`uuid_table` ends where `schema_table` begins; 16 B per UUID). Lets +/// the loader bounds-check parent ordinals without a new accessor getter. +fn uuidCount(acc: Accessor) u32 { + return (acc.header.schema_table_offset - acc.header.uuid_table_offset) / 16; +} + +/// Load a cooked `.scene.bin` byte image into `world`. The byte-level core +/// (no filesystem): validate + remap (E1), instantiate every entity, then fire +/// the `on_spawned` lifecycle in a second pass. The returned `LoadResult` has a +/// null `mmap` — the caller owns `bytes`. +/// +/// Two-phase, so the ordering guarantee holds: **every loaded entity exists +/// before any `on_spawned` fires** (phase 1 spawns via `spawnDynamicWithValues`, +/// which dispatches no observers; phase 2 fires `on_spawned` per entity). +/// +/// Errors: the E1 set (`OpenError`/`RemapError`), `error.MalformedScene` +/// (`StructureError`) for a structurally-invalid scene (e.g. an out-of-range +/// parent ordinal), allocation failure, plus anything an `on_spawned` observer +/// propagates (hence the open error set). +pub fn loadFromBytes(world: *World, gpa: std.mem.Allocator, bytes: []const u8) anyerror!LoadResult { + const acc = try openVerified(bytes); + + const remap = try buildSchemaRemap(gpa, world, acc); + defer gpa.free(remap); + + // The cross-references + extensions tables are M1.0.6's; M1.0.4 writes them + // empty (a zero count prefix). Loading a file that populated them would mean + // a newer cook — assert the M1.0.5 contract. + std.debug.assert(readU32At(acc, acc.header.extensions_offset) == 0); + std.debug.assert(readU32At(acc, acc.header.crossrefs_offset) == 0); + + var spawned: std.ArrayListUnmanaged(EntityId) = .empty; + errdefer spawned.deinit(gpa); + var uuid_to_entity: UuidMap = .empty; + errdefer uuid_to_entity.deinit(gpa); + var res_strings: std.ArrayListUnmanaged([*]u8) = .empty; + errdefer { + for (res_strings.items) |p| persistent.destroy(gpa, p); + res_strings.deinit(gpa); + } + + try instantiate(world, gpa, acc, remap, &spawned, &uuid_to_entity); + // Resources before phase 2 so an `on_spawned` rule can read scene resources. + try loadResources(world, gpa, acc, remap, &res_strings); + try dispatchSpawnLifecycle(world, gpa, spawned.items); + + return .{ + .spawned = try spawned.toOwnedSlice(gpa), + .uuid_to_entity = uuid_to_entity, + .resource_strings = try res_strings.toOwnedSlice(gpa), + .mmap = null, + }; +} + +/// Load a cooked `.scene.bin` from `path`: `mmap` the file, then run +/// `loadFromBytes` over the borrowed bytes. The returned `LoadResult` owns the +/// mapping (`LoadResult.deinit` closes it). Adds `fs.Error` (open/map failure) +/// to `loadFromBytes`'s error set. +pub fn loadScene(world: *World, gpa: std.mem.Allocator, path: []const u8) anyerror!LoadResult { + var mmap = try fs.mmapFile(gpa, path); + errdefer mmap.close(); + var result = try loadFromBytes(world, gpa, mmap.bytes); + result.mmap = mmap; + return result; +} + +/// Phase 1 — instantiate every entity of every archetype block. Maps each +/// block's on-disk schema-index columns to runtime `ComponentId`s (the E1 +/// remap), gathers each slot's raw component bytes (borrowed, on-disk column +/// order — `spawnDynamicWithValues` reorders by id), spawns the entity, records +/// `uuid → eid`, and appends to `spawned`. Validates each parent ordinal is +/// `no_parent` or in `[0, uuidCount)` (else `error.MalformedScene`) but +/// **applies no parent link** (no runtime hierarchy component exists yet — +/// owned by the hierarchy milestone). +fn instantiate( + world: *World, + gpa: std.mem.Allocator, + acc: Accessor, + remap: []const ComponentId, + spawned: *std.ArrayListUnmanaged(EntityId), + uuid_to_entity: *UuidMap, +) !void { + const ucount = uuidCount(acc); + const arch_count = acc.archetypeCount(); + var ai: u32 = 0; + while (ai < arch_count) : (ai += 1) { + const block = acc.archetype(ai); + const cc = block.component_count; + + // Per-block component ids (constant across the block's entities). + const ids = try gpa.alloc(ComponentId, cc); + defer gpa.free(ids); + for (0..cc) |c| ids[c] = remap[block.schemaIndex(c)]; + + // Per-slot payload views, reused each slot. + const payloads = try gpa.alloc([]const u8, cc); + defer gpa.free(payloads); + + var slot: usize = 0; + while (slot < block.entity_count) : (slot += 1) { + for (0..cc) |c| payloads[c] = block.componentSlot(c, slot); + const eid = try world.spawnDynamicWithValues(gpa, ids, payloads); + try uuid_to_entity.put(gpa, block.entityUuid(slot).*, eid); + try spawned.append(gpa, eid); + + // Structural (not hash) validity: a parent ordinal must index the + // UUID table or be `no_parent`. The link itself is not applied (no + // runtime hierarchy component yet). + const parent = block.entityParent(slot); + if (parent != format.no_parent and parent >= ucount) return error.MalformedScene; + } + } +} + +/// Load the resources block (E3) — the load-side mirror of M1.0.3's non-POD +/// resource path. For each resource: resolve its schema index → runtime +/// `ComponentId` (the E1 remap, already size/alignment-validated), copy the POD +/// `data` (string-field slots are zeroed on disk), then for each `string` field +/// of that resource type (offsets from the runtime `FieldDesc`) intern the +/// cooked value into the **Tier-0 persistent heap** (`allocImmortal`, +/// `type_string`) and write the resulting `StringSlot` into the slot — the same +/// in-memory `StringSlot` byte layout `ecs_bridge` reads/writes. Each interned +/// block is recorded in `strings` (owned by `LoadResult`, reclaimed at +/// `deinit`); the install goes through `world.addResource`, which copies the +/// bytes. An empty string keeps the zeroed slot (`ptr == 0`, no block). +fn loadResources( + world: *World, + gpa: std.mem.Allocator, + acc: Accessor, + remap: []const ComponentId, + strings: *std.ArrayListUnmanaged([*]u8), +) !void { + const count = acc.resourceCount(); + var i: u32 = 0; + while (i < count) : (i += 1) { + const r = acc.resource(i); + const cid = remap[r.schema_index]; + const size = world.registry.componentSize(cid); + std.debug.assert(r.data.len == size); + + const bytes = try gpa.alloc(u8, size); + defer gpa.free(bytes); // `addResource` copies; this is scratch + @memcpy(bytes, r.data); + + for (world.registry.componentFields(cid)) |fd| { + if (fd.kind != .string_) continue; + const sval = r.stringField(fd.offset) orelse continue; + if (sval.len == 0) continue; // empty string → leave the zeroed slot + + try strings.ensureUnusedCapacity(gpa, 1); + const p = try persistent.allocImmortal(gpa, persistent.type_string, sval.len); + @memcpy(p[0..sval.len], sval); + strings.appendAssumeCapacity(p); // capacity reserved → cannot fail + + const fslot: persistent.StringSlot = .{ .ptr = @intFromPtr(p), .len = @intCast(sval.len) }; + @memcpy(bytes[fd.offset..][0..@sizeOf(persistent.StringSlot)], std.mem.asBytes(&fslot)); + } + + // Scene resources are *injected into the resource map at load* + // (`engine-spec.md` §19.1): the scene value is authoritative, so it + // overrides a value the running program already installed at compile + // (e.g. a declared resource's defaults) rather than erroring. The + // overridden value's own string blocks are owned by whoever installed + // them (the interp frees its compile-time defaults at teardown); the + // newly-interned blocks here are owned by `LoadResult`. + if (world.resources.getMutResource(cid)) |dst| { + @memcpy(dst, bytes); + } else { + try world.addResource(gpa, cid, bytes); + } + } +} + +/// Phase 2 — fire the `on_spawned` lifecycle for every loaded entity, in load +/// order, reusing the existing flush path (`observers.flushWithObservers` / +/// `applyRawCommand`). A pre-existing deferred queue is drained first; an +/// `on_spawned` rule may queue structural commands, drained after the pass. +fn dispatchSpawnLifecycle(world: *World, gpa: std.mem.Allocator, spawned: []const EntityId) !void { + var drain = command_buffer_mod.CommandBuffer.init(gpa, world); + defer drain.deinit(); + + // Drain any commands left queued from prior observer activity. + try observers_mod.flushWithObservers(&drain, &world.observer_registry); + // Every entity already exists — now fire its spawn hook. + for (spawned) |eid| try world.dispatchOnSpawned(gpa, eid); + // Apply whatever the `on_spawned` rules queued. + try observers_mod.flushWithObservers(&drain, &world.observer_registry); +} + +/// Read a little-endian `u32` at file offset `off` (the accessor's `readU32` is +/// private; the loader reads reserved-section counts directly). +fn readU32At(acc: Accessor, off: u32) u32 { + return std.mem.readInt(u32, acc.bytes[off..][0..4], .little); +} + +// ─── inline tests ─────────────────────────────────────────────────────────── + +const testing = std.testing; +const writer = @import("writer.zig"); +const Registry = registry_mod.Registry; + +/// Test helper: serialize a 1-archetype, 1-entity `.scene.bin` over `reg` (the +/// cook registry) holding the single component `cid` with a zeroed column. +/// Returns the caller-owned byte image (`gpa.free`). +fn buildOneCompScene(gpa: std.mem.Allocator, reg: *const Registry, cid: ComponentId) ![]u8 { + var arena = std.heap.ArenaAllocator.init(gpa); + const a = arena.allocator(); + const names = try a.dupe([]const u8, &.{try a.dupe(u8, "E0")}); + const uuids = try a.dupe([16]u8, &.{[_]u8{0} ** 16}); + const col = try a.alloc(u8, reg.componentSize(cid)); // 1 entity + @memset(col, 0); + const cols = try a.dupe([]u8, &.{col}); + const ents = try a.dupe(format.EntityEntry, &.{ + .{ .name = 0, .uuid = 0, .parent_uuid = format.no_parent }, + }); + const ids = try a.dupe(ComponentId, &.{cid}); + const blocks = try a.dupe(format.ArchetypeBlock, &.{.{ + .component_ids = ids, + .entity_count = 1, + .columns = cols, + .entities = ents, + }}); + var model: format.CookModel = .{ + .strings = names, + .uuids = uuids, + .resources = &.{}, + .archetypes = blocks, + .arena = arena, + }; + defer model.deinit(); + return try writer.write(gpa, model, reg); +} + +fn registerRaw(gpa: std.mem.Allocator, reg: *Registry, name: []const u8, size: u16, alignment: u16) !ComponentId { + const zeros = try gpa.alloc(u8, size); + defer gpa.free(zeros); + @memset(zeros, 0); + return try reg.registerComponentRaw(gpa, .{ + .name = name, + .size = size, + .alignment = alignment, + .default_bytes = zeros, + .fields = &.{}, + }); +} + +test "buildSchemaRemap resolves on-disk schema names to runtime ids" { + const gpa = testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + const pos = try registerRaw(gpa, &world.registry, "Pos", 8, 4); + + const bytes = try buildOneCompScene(gpa, &world.registry, pos); + defer gpa.free(bytes); + + const acc = try openVerified(bytes); + const remap = try buildSchemaRemap(gpa, &world, acc); + defer gpa.free(remap); + + try testing.expectEqual(@as(usize, 1), remap.len); + try testing.expectEqual(pos, remap[0]); +} + +test "buildSchemaRemap errors UnknownComponent for an unregistered name" { + const gpa = testing.allocator; + + // Cook a scene referencing "Ghost" through a standalone cook registry. + var cook_reg = Registry.init(); + defer cook_reg.deinit(gpa); + const ghost = try registerRaw(gpa, &cook_reg, "Ghost", 4, 4); + const bytes = try buildOneCompScene(gpa, &cook_reg, ghost); + defer gpa.free(bytes); + + // Load into a world that never registered "Ghost". + var world = World.init(); + defer world.deinit(gpa); + const acc = try openVerified(bytes); + try testing.expectError(error.UnknownComponent, buildSchemaRemap(gpa, &world, acc)); +} + +test "buildSchemaRemap errors SchemaMismatch on a divergent layout" { + const gpa = testing.allocator; + + // Cooked as size 8 … + var cook_reg = Registry.init(); + defer cook_reg.deinit(gpa); + const pos_cook = try registerRaw(gpa, &cook_reg, "Pos", 8, 4); + const bytes = try buildOneCompScene(gpa, &cook_reg, pos_cook); + defer gpa.free(bytes); + + // … but registered as size 12 at load time. + var world = World.init(); + defer world.deinit(gpa); + _ = try registerRaw(gpa, &world.registry, "Pos", 12, 4); + const acc = try openVerified(bytes); + try testing.expectError(error.SchemaMismatch, buildSchemaRemap(gpa, &world, acc)); +} + +test "openVerified rejects a tampered scene with CorruptScene" { + const gpa = testing.allocator; + var cook_reg = Registry.init(); + defer cook_reg.deinit(gpa); + const pos = try registerRaw(gpa, &cook_reg, "Pos", 8, 4); + const bytes = try buildOneCompScene(gpa, &cook_reg, pos); + defer gpa.free(bytes); + + // Flip a byte in the content region (after the 64-byte header) so the + // recorded XxHash64 no longer matches, while magic/version stay valid. + bytes[format.header_size] ^= 0xFF; + try testing.expectError(error.CorruptScene, openVerified(bytes)); +} + +test "openVerified surfaces header ReadError (short / bad magic / bad version)" { + const gpa = testing.allocator; + var cook_reg = Registry.init(); + defer cook_reg.deinit(gpa); + const pos = try registerRaw(gpa, &cook_reg, "Pos", 8, 4); + const bytes = try buildOneCompScene(gpa, &cook_reg, pos); + defer gpa.free(bytes); + + try testing.expectError(error.TooShort, openVerified(bytes[0..10])); + + const saved = bytes[0]; + bytes[0] = 'X'; + try testing.expectError(error.BadMagic, openVerified(bytes)); + bytes[0] = saved; + + std.mem.writeInt(u16, bytes[4..6], 999, .little); + try testing.expectError(error.BadVersion, openVerified(bytes)); +} + +test "loadFromBytes rejects an out-of-range parent ordinal with MalformedScene" { + const gpa = testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + const pos = try registerRaw(gpa, &world.registry, "Pos", 8, 4); + + // 1 entity, 1 UUID (ordinal 0), but its parent ordinal is 5 — past the UUID + // table. The writer still computes a valid header hash over these bytes, so + // the file opens + verifies; only the structural check rejects it. This is + // why the error is `MalformedScene`, not `CorruptScene` (hash mismatch). + var arena = std.heap.ArenaAllocator.init(gpa); + const a = arena.allocator(); + const names = try a.dupe([]const u8, &.{try a.dupe(u8, "E0")}); + const uuids = try a.dupe([16]u8, &.{[_]u8{0} ** 16}); + const col = try a.alloc(u8, 8); + @memset(col, 0); + const cols = try a.dupe([]u8, &.{col}); + const ents = try a.dupe(format.EntityEntry, &.{ + .{ .name = 0, .uuid = 0, .parent_uuid = 5 }, + }); + const ids = try a.dupe(ComponentId, &.{pos}); + const blocks = try a.dupe(format.ArchetypeBlock, &.{.{ + .component_ids = ids, + .entity_count = 1, + .columns = cols, + .entities = ents, + }}); + var model: format.CookModel = .{ + .strings = names, + .uuids = uuids, + .resources = &.{}, + .archetypes = blocks, + .arena = arena, + }; + defer model.deinit(); + const bytes = try writer.write(gpa, model, &world.registry); + defer gpa.free(bytes); + + try testing.expectError(error.MalformedScene, loadFromBytes(&world, gpa, bytes)); +} diff --git a/src/core/scene/root.zig b/src/core/scene/root.zig index bdc79c2..f4b4d31 100644 --- a/src/core/scene/root.zig +++ b/src/core/scene/root.zig @@ -15,6 +15,9 @@ pub const format = @import("format.zig"); pub const writer = @import("writer.zig"); /// `.scene.bin` zero-copy accessor (read half; reused verbatim by M1.0.5). pub const accessor = @import("accessor.zig"); +/// `.scene.bin` runtime loader (M1.0.5): reads a cooked scene back into a live +/// ECS `World`, reusing `accessor` verbatim. `weld_core` only — never `weld_etch`. +pub const loader = @import("loader.zig"); comptime { // §13 lazy-analysis guard: pin every sub-file carrying inline `test` blocks @@ -22,4 +25,5 @@ comptime { _ = format; _ = writer; _ = accessor; + _ = loader; } diff --git a/src/etch/ecs_bridge.zig b/src/etch/ecs_bridge.zig index 04022ba..bbf65c4 100644 --- a/src/etch/ecs_bridge.zig +++ b/src/etch/ecs_bridge.zig @@ -9,9 +9,10 @@ const std = @import("std"); const value_mod = @import("value.zig"); -const persistent = @import("persistent.zig"); const weld_core = @import("weld_core"); +// M1.0.5 — persistent heap moved to Tier 0 (`src/core/memory`); reach it via weld_core. +const persistent = weld_core.memory.persistent; const RegistryNS = weld_core.ecs.registry; const Registry = RegistryNS.Registry; const ComponentId = RegistryNS.ComponentId; diff --git a/src/etch/interp.zig b/src/etch/interp.zig index 750fcdc..77ed22e 100644 --- a/src/etch/interp.zig +++ b/src/etch/interp.zig @@ -21,9 +21,10 @@ const value_mod = @import("value.zig"); const bridge_mod = @import("ecs_bridge.zig"); const tags_mod = @import("tags.zig"); const descriptor_mod = @import("descriptor.zig"); -const persistent = @import("persistent.zig"); const weld_core = @import("weld_core"); +// M1.0.5 — persistent heap moved to Tier 0 (`src/core/memory`); reach it via weld_core. +const persistent = weld_core.memory.persistent; const Registry = weld_core.ecs.registry.Registry; const ComponentId = weld_core.ecs.registry.ComponentId; const FieldDesc = weld_core.ecs.registry.FieldDesc; @@ -7335,3 +7336,93 @@ test "runProgram anonymous struct literal via let annotation and field value (M0 @memcpy(std.mem.asBytes(&out), slot[0..8]); try std.testing.expectEqual(@as(i64, 84), out); } + +// ─── M1.0.5 E3 — cross-module cook → load integration ────────────────────── +// +// Lives inline here (the brief permits it) so the assertion can read the +// interpreter's per-tick `EventStore` directly. End-to-end: compile an Etch +// program (2 components, a `string` resource, an `@on_spawned` rule that +// `emit`s) into a `World`, bind its observer rules to the Tier-0 registry, cook +// the same source's scene to `.scene.bin`, load it, and assert (a) every entity +// is instantiated, (b) the emitted-event count equals the entity count (the +// loader's two-phase `on_spawned` pass ran the rule once per entity — emits +// land in `events` and are NOT cleared, since the load drives no tick), and (c) +// the resource `string` round-trips through the Tier-0 persistent heap. +test "cooked Etch scene loads, on_spawned rules emit, resource string round-trips (M1.0.5 E3)" { + const gpa = std.testing.allocator; + const scene_cook = @import("scene_cook.zig"); + + const source = + \\component Position { x: f32 = 0.0, y: f32 = 0.0 } + \\component Health { current: int = 100, max: int = 100 } + \\resource Settings { title: string = "default" } + \\event Spawned { n: int } + \\ + \\@on_spawned + \\rule on_spawn(entity: Entity) { + \\ emit Spawned { n: 1 } + \\} + \\ + \\scene "IntegrationScene" { + \\ version: 1 + \\ resources { + \\ Settings { title: "level_42" } + \\ } + \\ entity "A" { + \\ uuid: "00000000-0000-0000-0000-000000000001" + \\ Position { x: 1.0, y: 2.0 } + \\ Health { current: 50, max: 100 } + \\ } + \\ entity "B" { + \\ uuid: "00000000-0000-0000-0000-000000000002" + \\ Position { x: 3.0, y: 4.0 } + \\ } + \\ entity "C" { + \\ uuid: "00000000-0000-0000-0000-000000000003" + \\ Position { x: 5.0, y: 6.0 } + \\ Health { current: 75, max: 100 } + \\ } + \\} + ; + + var world = World.init(); + defer world.deinit(gpa); + + // Compile the program (registers Position/Health/Settings + the @on_spawned + // rule). Bind observer rules to the registry NOW — the loader drives a Tier-0 + // flush, not an interpreter tick, so `runFor`'s lazy bind never happens. + var pr = try parser_mod.parse(gpa, source); + defer pr.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), pr.diagnostics.len); + + var interp = try Interpreter.compile(gpa, &pr.ast, &world); + defer interp.deinit(); + try interp.bindToWorld(&world); + + // Cook the same source's scene block to `.scene.bin` (the cook ignores the + // rule/event decls, uses the component/resource decls for type resolution). + var cooked = try scene_cook.cook(gpa, source, null); + defer cooked.deinit(gpa); + const bytes = try weld_core.scene.writer.write(gpa, cooked.model, &cooked.registry); + defer gpa.free(bytes); + + var result = try weld_core.scene.loader.loadFromBytes(&world, gpa, bytes); + defer result.deinit(gpa); + + // (a) every entity instantiated. + try std.testing.expectEqual(@as(usize, 3), result.spawned.len); + try std.testing.expectEqual(@as(usize, 3), world.entityCount()); + + // (b) emitted-event count equals the entity count. + const spawned_id = pr.ast.strings.find("Spawned").?; + try std.testing.expectEqual(@as(usize, 3), interp.events.count(spawned_id)); + + // (c) resource `string` round-trips through the Tier-0 persistent heap. + const settings_cid = world.registry.idOf("Settings").?; + const fd = world.registry.findField(settings_cid, "title").?; + const res_bytes = world.resources.getResource(settings_cid).?; + var ss: persistent.StringSlot = undefined; + @memcpy(std.mem.asBytes(&ss), res_bytes[fd.offset..][0..@sizeOf(persistent.StringSlot)]); + const title: [*]const u8 = @ptrFromInt(ss.ptr); + try std.testing.expectEqualStrings("level_42", title[0..ss.len]); +} diff --git a/src/etch/root.zig b/src/etch/root.zig index a99a0cb..b55a0a4 100644 --- a/src/etch/root.zig +++ b/src/etch/root.zig @@ -46,7 +46,8 @@ comptime { _ = @import("interp.zig"); _ = @import("value.zig"); _ = @import("ecs_bridge.zig"); - _ = @import("persistent.zig"); + // M1.0.5 — `persistent.zig` moved to Tier 0 (`src/core/memory`); it is now + // pinned by `src/core/memory/root.zig` (reached here via `weld_core.memory`). // M1.0.4 — pull the scene cook driver into the test import graph (§13). _ = @import("scene_cook.zig"); } diff --git a/src/etch/scene_cook.zig b/src/etch/scene_cook.zig index 9cc04b5..302d613 100644 --- a/src/etch/scene_cook.zig +++ b/src/etch/scene_cook.zig @@ -32,10 +32,11 @@ const std = @import("std"); const ast_mod = @import("ast.zig"); const interp = @import("interp.zig"); const bridge_mod = @import("ecs_bridge.zig"); -const persistent = @import("persistent.zig"); const value_mod = @import("value.zig"); const weld_core = @import("weld_core"); +// M1.0.5 — persistent heap moved to Tier 0 (`src/core/memory`); reach it via weld_core. +const persistent = weld_core.memory.persistent; const Registry = weld_core.ecs.registry.Registry; const ComponentId = weld_core.ecs.registry.ComponentId; const FieldDesc = weld_core.ecs.registry.FieldDesc; diff --git a/tests/scene/load_resources_test.zig b/tests/scene/load_resources_test.zig new file mode 100644 index 0000000..563ba1a --- /dev/null +++ b/tests/scene/load_resources_test.zig @@ -0,0 +1,76 @@ +//! M1.0.5 E3 — resource `string` fields round-trip through the Tier-0 persistent +//! heap. Cooks (in-memory, via the writer) a scene with one resource carrying a +//! `string` field, loads it, and asserts the field reads back the cooked value: +//! the loaded string is interned into `weld_core.memory.persistent` (immortal) +//! and owned by `LoadResult`, with the resource's `StringSlot` pointing at it. +//! `weld_core` only. + +const std = @import("std"); +const weld_core = @import("weld_core"); + +const ecs = weld_core.ecs; +const scene = weld_core.scene; +const World = ecs.World; +const registry = ecs.registry; +const format = scene.format; +const writer = scene.writer; +const loader = scene.loader; +const persistent = weld_core.memory.persistent; + +test "resource string fields round-trip through the persistent heap" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + + // Resource `Settings { title: string }` — one 16-byte `StringSlot` at offset 0. + const settings = try world.registry.registerComponentRaw(gpa, .{ + .name = "Settings", + .size = 16, + .alignment = 8, + .default_bytes = &[_]u8{0} ** 16, + .fields = &[_]registry.FieldDesc{ + .{ .name = "title", .offset = 0, .kind = .string_ }, + }, + }); + + const title_value = "Verdant Keep"; + + // Cook a 0-entity scene with just this resource. The string slot in `data` + // is zeroed on disk; the value lives in the string table (string_fields). + var arena = std.heap.ArenaAllocator.init(gpa); + const a = arena.allocator(); + const strings = try a.dupe([]const u8, &.{try a.dupe(u8, title_value)}); + const data = try a.alloc(u8, 16); + @memset(data, 0); + const string_fields = try a.dupe(format.StringFieldRef, &.{.{ .offset = 0, .str = 0 }}); + const resources = try a.dupe(format.ResourceEntry, &.{.{ + .schema_id = settings, + .data = data, + .string_fields = string_fields, + }}); + var model: format.CookModel = .{ + .strings = strings, + .uuids = &.{}, + .resources = resources, + .archetypes = &.{}, + .arena = arena, + }; + defer model.deinit(); + const bytes = try writer.write(gpa, model, &world.registry); + defer gpa.free(bytes); + + var result = try loader.loadFromBytes(&world, gpa, bytes); + defer result.deinit(gpa); + + // Exactly one interned string block, owned by the result. + try std.testing.expectEqual(@as(usize, 1), result.resource_strings.len); + + // The installed resource's `StringSlot` points at the interned bytes. + const res_bytes = world.resources.getResource(settings).?; + var ss: persistent.StringSlot = undefined; + @memcpy(std.mem.asBytes(&ss), res_bytes[0..@sizeOf(persistent.StringSlot)]); + try std.testing.expect(ss.ptr != 0); + try std.testing.expectEqual(@as(u32, title_value.len), ss.len); + const loaded: [*]const u8 = @ptrFromInt(ss.ptr); + try std.testing.expectEqualStrings(title_value, loaded[0..ss.len]); +} diff --git a/tests/scene/load_roundtrip_test.zig b/tests/scene/load_roundtrip_test.zig new file mode 100644 index 0000000..38390e5 --- /dev/null +++ b/tests/scene/load_roundtrip_test.zig @@ -0,0 +1,242 @@ +//! M1.0.5 E2 — runtime loader `.scene.bin` → ECS `World` round-trip. +//! +//! Builds a cooked scene image in memory via the M1.0.4 `writer` (no `.scene.etch` +//! authoring, no filesystem), loads it with `scene.loader.loadFromBytes`, and +//! asserts the three E2 invariants: +//! T1 — every entity is instantiated and its component bytes survive verbatim; +//! T2 — `on_spawned` fires exactly once per loaded entity; +//! T3 — every loaded entity exists before any `on_spawned` fires (two-phase). +//! +//! `weld_core` only — the loader and writer are both Tier 0. + +const std = @import("std"); +const weld_core = @import("weld_core"); + +const ecs = weld_core.ecs; +const scene = weld_core.scene; +const World = ecs.World; +const EntityId = ecs.EntityId; +const ComponentId = ecs.ComponentId; +const CommandBuffer = ecs.CommandBuffer; +const format = scene.format; +const writer = scene.writer; +const loader = scene.loader; + +const n_entities = 3; + +fn uuidFor(i: u32) [16]u8 { + var u = [_]u8{0} ** 16; + std.mem.writeInt(u32, u[0..4], i + 1, .little); // +1 so entity 0 ≠ all-zero + return u; +} + +// Per-entity component values, recomputed on the assert side (the source of truth). +fn posX(i: usize) f32 { + return @floatFromInt(i); +} +fn posY(i: usize) f32 { + return @as(f32, @floatFromInt(i)) + 0.25; +} +fn velX(i: usize) f32 { + return @as(f32, @floatFromInt(i)) * 10.0; +} +fn velY(i: usize) f32 { + return -@as(f32, @floatFromInt(i)); +} + +fn writeF32(buf: []u8, off: usize, v: f32) void { + std.mem.writeInt(u32, buf[off..][0..4], @bitCast(v), .little); +} +fn readF32(buf: []const u8, off: usize) f32 { + return @bitCast(std.mem.readInt(u32, buf[off..][0..4], .little)); +} + +/// Register `Pos`/`Vel` (both `[2]f32`, size 8, align 4) into `world.registry` +/// and cook a single `[Pos, Vel]` archetype of `n_entities` entities. Returns +/// the caller-owned `.scene.bin` bytes. +fn cookPosVelScene(gpa: std.mem.Allocator, world: *World) ![]u8 { + const pos = try world.registry.registerComponentRaw(gpa, .{ + .name = "Pos", + .size = 8, + .alignment = 4, + .default_bytes = &[_]u8{0} ** 8, + .fields = &.{}, + }); + const vel = try world.registry.registerComponentRaw(gpa, .{ + .name = "Vel", + .size = 8, + .alignment = 4, + .default_bytes = &[_]u8{0} ** 8, + .fields = &.{}, + }); + std.debug.assert(pos < vel); // column order below is sorted-ascending by id + + var arena = std.heap.ArenaAllocator.init(gpa); + const a = arena.allocator(); + + const names = try a.dupe([]const u8, &.{try a.dupe(u8, "E")}); + const uuids = try a.alloc([16]u8, n_entities); + for (0..n_entities) |i| uuids[i] = uuidFor(@intCast(i)); + + const pos_col = try a.alloc(u8, 8 * n_entities); + const vel_col = try a.alloc(u8, 8 * n_entities); + for (0..n_entities) |i| { + writeF32(pos_col, i * 8 + 0, posX(i)); + writeF32(pos_col, i * 8 + 4, posY(i)); + writeF32(vel_col, i * 8 + 0, velX(i)); + writeF32(vel_col, i * 8 + 4, velY(i)); + } + const cols = try a.dupe([]u8, &.{ pos_col, vel_col }); + const ents = try a.alloc(format.EntityEntry, n_entities); + for (0..n_entities) |i| ents[i] = .{ .name = 0, .uuid = @intCast(i), .parent_uuid = format.no_parent }; + const ids = try a.dupe(format.ComponentId, &.{ pos, vel }); + const blocks = try a.dupe(format.ArchetypeBlock, &.{.{ + .component_ids = ids, + .entity_count = n_entities, + .columns = cols, + .entities = ents, + }}); + var model: format.CookModel = .{ + .strings = names, + .uuids = uuids, + .resources = &.{}, + .archetypes = blocks, + .arena = arena, + }; + defer model.deinit(); + return try writer.write(gpa, model, &world.registry); +} + +test "loading a cooked scene instantiates every entity" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + + const bytes = try cookPosVelScene(gpa, &world); + defer gpa.free(bytes); + + var result = try loader.loadFromBytes(&world, gpa, bytes); + defer result.deinit(gpa); + + try std.testing.expectEqual(@as(usize, n_entities), world.entityCount()); + try std.testing.expectEqual(@as(usize, n_entities), result.spawned.len); + + const pos_id = world.componentId("Pos").?; + const vel_id = world.componentId("Vel").?; + for (0..n_entities) |i| { + // Resolve the runtime handle through the UUID map, then read storage. + const eid = result.uuid_to_entity.get(uuidFor(@intCast(i))).?; + const pb = world.componentBytes(eid, pos_id).?; + try std.testing.expectApproxEqAbs(posX(i), readF32(pb, 0), 1e-6); + try std.testing.expectApproxEqAbs(posY(i), readF32(pb, 4), 1e-6); + const vb = world.componentBytes(eid, vel_id).?; + try std.testing.expectApproxEqAbs(velX(i), readF32(vb, 0), 1e-6); + try std.testing.expectApproxEqAbs(velY(i), readF32(vb, 4), 1e-6); + } +} + +// ── T2 — on_spawned fires once per loaded entity ── + +const Counter = struct { + var fired: u32 = 0; + fn reset() void { + fired = 0; + } +}; + +fn countObserver( + _: ?*anyopaque, + _: *World, + _: EntityId, + _: ?ComponentId, + _: ?*const anyopaque, + _: ?*const anyopaque, + _: *CommandBuffer, +) anyerror!void { + Counter.fired += 1; +} + +test "on_spawned fires once per loaded entity" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + + Counter.reset(); + try world.registerOnSpawned(gpa, null, &countObserver); + + const bytes = try cookPosVelScene(gpa, &world); + defer gpa.free(bytes); + + var result = try loader.loadFromBytes(&world, gpa, bytes); + defer result.deinit(gpa); + + try std.testing.expectEqual(@as(u32, n_entities), Counter.fired); +} + +// ── T3 — every entity exists before any on_spawned fires ── + +const FirstSeen = struct { + var count: i64 = -1; // sentinel: not yet fired + fn reset() void { + count = -1; + } +}; + +fn firstSeenObserver( + _: ?*anyopaque, + w: *World, + _: EntityId, + _: ?ComponentId, + _: ?*const anyopaque, + _: ?*const anyopaque, + _: *CommandBuffer, +) anyerror!void { + if (FirstSeen.count < 0) FirstSeen.count = @intCast(w.entityCount()); +} + +test "all entities exist before any on_spawned fires" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + + FirstSeen.reset(); + try world.registerOnSpawned(gpa, null, &firstSeenObserver); + + const bytes = try cookPosVelScene(gpa, &world); + defer gpa.free(bytes); + + var result = try loader.loadFromBytes(&world, gpa, bytes); + defer result.deinit(gpa); + + // At the very first on_spawned invocation, the full set was already present. + try std.testing.expectEqual(@as(i64, n_entities), FirstSeen.count); +} + +// ── loadScene(path) — the mmap entry (the byte-level core is covered above) ── + +test "loadScene mmaps a cooked file and instantiates every entity" { + const gpa = std.testing.allocator; + const io = std.testing.io; + + var world = World.init(); + defer world.deinit(gpa); + + const bytes = try cookPosVelScene(gpa, &world); + defer gpa.free(bytes); + + // Write the image to a CWD-relative file (portable across OSes; deleted + // after) so `loadScene` exercises the real `fs.mmapFile` path. + const path = "weld_m105_loadscene_test.scene.bin"; + const root = std.Io.Dir.cwd(); + const f = try root.createFile(io, path, .{ .truncate = true }); + try f.writeStreamingAll(io, bytes); + f.close(io); + defer root.deleteFile(io, path) catch {}; + + var result = try loader.loadScene(&world, gpa, path); + defer result.deinit(gpa); // also closes the mmap + + try std.testing.expectEqual(@as(usize, n_entities), world.entityCount()); + try std.testing.expect(result.mmap != null); + try std.testing.expectEqual(@as(usize, n_entities), result.spawned.len); +}