From 6e9baa4ae307cabf3dfba40a9e45e1e3698fd64e Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Tue, 30 Jun 2026 00:13:27 +0200 Subject: [PATCH 01/16] docs(brief): add M1.0.9 milestone brief Co-Authored-By: Claude Opus 4.8 --- briefs/M1.0.9-extension-hooks.md | 157 +++++++++++++++++++++++++++++++ 1 file changed, 157 insertions(+) create mode 100644 briefs/M1.0.9-extension-hooks.md diff --git a/briefs/M1.0.9-extension-hooks.md b/briefs/M1.0.9-extension-hooks.md new file mode 100644 index 0000000..f1becdf --- /dev/null +++ b/briefs/M1.0.9-extension-hooks.md @@ -0,0 +1,157 @@ +# M1.0.9 — Execute extension hooks (`on_attach` / `on_detach`) + +> **Status:** PLANNED +> **Phase:** 1.0 +> **Branch:** `phase-1/etch/extension-hooks` +> **Planned tag:** `v0.10.9-extension-hooks` +> **Dependencies:** M1.0.6 (`v0.10.6-prefabs-crossrefs-extensions`) for the dispatch seam + cooked hook text; base tag M1.0.8 (`v0.10.8-const-private-test`) +> **Opened:** 2026-06-29 +> **Closed:** — + +--- + +# FROZEN SECTION + +*Produced by Claude.ai. Not modifiable by Claude Code outside a Claude.ai round-trip (cf. § Recorded deviations).* + +## Context + +M1.0.9 founds the runtime text-execution surface that M1.0.6 deferred. At M1.0.6, extension `on_attach`/`on_detach` bodies were cooked as canonical Etch text into the `.prefab.bin` hooks sub-section, and the loader wired the Tier-0 dispatch seam (`registerOnAttach`/`dispatchOnAttach`) — but body **execution** was re-scoped out, because the interpreter is compile-once from a single immutable AST arena with no runtime fragment-execution surface. This milestone builds that surface (parse a statement-block fragment, then walk it with the existing tree-walker against the live world), wires it behind the seam so `on_attach` executes at load, adds the symmetric `on_detach` seam, and exposes the runtime extension API (`entity.activate_extension` / `deactivate_extension` / `has_extension` / `active_extensions`). **Decision settled at scoping: text re-parse, not bytecode.** The bytecode VM is a Phase-2 subsystem (`etch-bytecode.md §17`; `etch-visual-scripting.md §4`); the cooked text already exists and the tree-walker is the Phase-1 backend, so migration cost is zero now and bounded later (a backend swap behind the backend-agnostic seam). + +## Scope + +- **E1 — Parser fragment entry.** `parseStmtBlock` in `src/etch/parser.zig`: parse a bare statement run (the exact shape `scene_cook.zig::renderStmtRunAlloc` emits for a hook body) into a fresh `AstArena`, exposing the body statement range (`body_start` / `body_len`, the same encoding rule bodies use in `extra`). Reuses the existing statement parser used for rule/`fn` bodies — this is a new ENTRY POINT, not a new statement grammar. An empty body parses to a zero-statement block with no diagnostics. +- **E2 — Hook-execution primitive + `on_attach` at load.** `Interpreter.execHookText(world, entity, text)` in `src/etch/interp.zig`: parse `text` via `parseStmtBlock` into a transient `AstArena`; rebind `self.ast` to that arena for the duration (`const saved = self.ast; self.ast = &hook_ast; defer self.ast = saved;`); bind the implicit `entity` into a fresh `Locals` scope as a `Value.entity_id`; run the body with the existing `execStmtRun`; then drain hook-issued deferred structural commands via the existing command-buffer flush path. The real `ExtensionAttachFn` lives in `src/etch/ecs_bridge.zig` with `ctx = *Interpreter`, calling `execHookText`; the loader's `dispatchOnAttach` now reaches execution. The extension-activation pass drains deferred commands AFTER the pass, BEFORE `on_spawned` (mirror `dispatchSpawnLifecycle`'s drain). +- **E3 — `on_detach` seam + runtime activate/deactivate.** `src/core/ecs/world.zig` gains `registerOnDetach` / `dispatchOnDetach` (mirror of the `on_attach` pair: an `ExtensionDetachFn` type + a `detach_hook` field, last-registration-wins). A runtime activation entry reuses the shared `activateExtension` (add components + execute `on_attach`); a runtime deactivation entry fires `on_detach` via the new seam, then removes the extension's declared components via `removeComponentDynamic`. `src/etch/interp.zig::dispatchMethodOnValue` gains entity-receiver method branches: `activate_extension(name)` / `deactivate_extension(name)` routed to the runtime entries. The bridge holds the `ExtensionResolver` (name → cooked `.prefab.bin` bytes, the same interface the loader receives) so a name-only Etch call resolves at runtime; absent resolver → a clear error. +- **E4 — Introspection + active-set.** `src/core/ecs/world.zig` tracks per-entity active extensions in a Tier-0 side-table (`EntityId` → owned list of extension-name byte-slices), populated INSIDE the shared `activateExtension` (so load AND runtime activation both track for free) and pruned on deactivate; freed at `World.deinit`. `src/etch/interp.zig::dispatchMethodOnValue` exposes `has_extension(name) -> bool` and `active_extensions() -> [string]` reading the side-table (wrapping owned names as Etch string values on the read path). +- **E5 — CLAUDE.md update (§3.4).** On the milestone branch, within the closing PR: update the current-state table; add one Tags row (`v0.10.9-extension-hooks`); close the **"text-vs-bytecode for hook execution"** entry in *Open / deferred decisions* (decided: text); update the "Last updated" date. No narrative prose. + +## Out of scope + +- **Bytecode / IR for hooks.** Phase 2 (`etch-bytecode.md §17`). The seam is backend-agnostic by design; introduce NO bytecode, NO lowering, NO `.etchc` here. +- **Runtime conflict resolution beyond the existing reject policy.** The shared `activateExtension` ALREADY rejects a component the entity already carries (`error.ExtensionComponentConflict`, §30.5). Consequence: no two active extensions can share a component, so deactivate removal is unambiguous — NO provenance tracking, NO refcount, NO "last-wins". Do NOT implement last-wins. (The `§30.5` doc says "last-wins"; the code says "reject". This doc drift is reconciled by **Claude.ai out-of-band** to match the code — KB spec files are NOT in the repo; do not attempt to edit them.) +- **`@exclusive_with` attribute.** Additive, future. +- **Multi-entity extensions.** `activateExtension` already errors `MultiEntityExtensionUnsupported`; unchanged. +- **Hot-reload re-attach (`weld extension reapply`).** The editor command described in `engine-scene-serialization.md` is out of Phase-1 runtime scope. +- **Re-type-checking the re-parsed fragment.** NOT needed and NOT to be added: component/resource field access and enum shorthand resolve at RUNTIME via the registry/descriptor by name (`execAssign` → `Bridge.readComponentField`/`writeComponentField`, `world.registry.findField` for the enum case), not via type-checker annotations on AST nodes. The cookable subset (the descriptor-renderer surface that bounds `renderStmtRunAlloc`, gated by `HookRenderFailed`) equals the executable subset, so a parsed-but-unchecked fragment suffices. +- **`StableId` / hot-reload identity for hooks.** Phase-2 concern (`etch-ast-ir.md`); untouched. + +## Specs to read first + +1. `etch-reference-part2.md` — §30.3 (`extends` / `on_attach` / `on_detach` semantics; `entity` is an implicit binding; runtime API `activate_extension`/`deactivate_extension`/`has_extension`/`active_extensions`), §30.4–§30.5 (`of` vs `extends`; additive-conflict policy — note the reject-vs-last-wins drift, the code is **reject**) +2. `engine-scene-serialization.md` — extension serialization + the load sequence (base entity → per extension: add components → execute `on_attach` → apply per-field overrides), Entity Extensions Table, hot-reload propagation cases (the last for context only — out of scope) +3. `etch-grammar.md` — `on_attach_block` / `on_detach_block` (`"{" , { statement } , "}"`), `prefab_decl` with `extends` / `requires` +4. `etch-bytecode.md` — §17 (phasing: the bytecode VM is Phase 2 — this CONFIRMS the text decision; do NOT implement bytecode) +5. `etch-visual-scripting.md` — §4 (Phase-1 backend = tree-walking interpreter, no VM) +6. `engine-ecs-internals.md` — deferred command buffer / flush semantics (the drain point for hook-issued structural changes) +7. `engine-phase-1-plan.md` — the M1.0.9 line + +## Files to create or modify + +(Tests live inline in the source `.zig` files per repo convention. The cross-cutting scene-load integration test belongs in the canonical scene/loader test location — reconfirm at clone; record in *Recorded deviations* if it lands elsewhere for a tier-dependency reason, as in M1.0.8.) + +- `src/etch/parser.zig` — modify — `parseStmtBlock` fragment entry (statement run → fresh `AstArena`, expose body range); inline parse tests +- `src/etch/interp.zig` — modify — `execHookText` (transient arena + `self.ast` rebind + `Locals` `entity` bind + `execStmtRun` + deferred drain); `dispatchMethodOnValue` entity-method branches (`activate_extension` / `deactivate_extension` / `has_extension` / `active_extensions`); `active_extensions` read path; inline tests +- `src/etch/ecs_bridge.zig` — modify — real `ExtensionAttachFn` + `ExtensionDetachFn` callbacks (`ctx = *Interpreter` → `execHookText`); an `ExtensionResolver` field for runtime name→bytes resolution; registration helpers +- `src/core/ecs/world.zig` — modify — `registerOnDetach` / `dispatchOnDetach` (`ExtensionDetachFn` + `detach_hook`, mirror `on_attach`); per-entity extension side-table + API (`addEntityExtension` / `removeEntityExtension` / `hasEntityExtension` / `entityExtensions`); freed in `deinit` +- `src/core/scene/loader.zig` — modify — runtime activate/deactivate entry reusing `activateExtension` (+ `removeComponentDynamic` and `dispatchOnDetach` on detach); insert `addEntityExtension` into the shared `activateExtension`; drain the deferred command buffer after the extension-activation pass, before `on_spawned` +- `CLAUDE.md` — modify — §3.4 update (current-state table, +1 Tags row, close the text-vs-bytecode open decision, "Last updated" date) + +## Acceptance criteria + +### Tests + +- `src/etch/parser.zig` — `test "parseStmtBlock parses a bare statement run"` — `entity.get_mut(Health).max += 50` + `emit ExtensionAttached { entity }` parses to a 2-statement block +- `src/etch/parser.zig` — `test "parseStmtBlock on empty body"` — empty text → zero-statement block, no diagnostics +- `src/etch/interp.zig` — `test "execHookText mutates a component on the live world"` — hand-built world + entity `Health { max: 100 }`; `execHookText("entity.get_mut(Health).max += 50")` → `Health.max == 150` +- `src/etch/interp.zig` — `test "execHookText restores self.ast"` — after a hook runs, the program AST pointer is unchanged and the program still steps +- `src/etch/interp.zig` — `test "execHookText emit enqueues into the dynamic event store"` — a hook `emit X { … }` lands in the per-tick `EventStore` +- `src/etch/interp.zig` — `test "entity.activate_extension executes on_attach"` — resolver returns a cooked extension whose `on_attach` adjusts `Health.max`; after the call, `Health.max` reflects it +- `src/etch/interp.zig` — `test "entity.deactivate_extension executes on_detach and removes components"` — after deactivate, the `on_detach` effect is applied and the extension's component is gone +- `src/etch/interp.zig` — `test "has_extension / active_extensions reflect activation"` — after activate, `has_extension(name) == true` and `active_extensions()` contains `name`; after deactivate, both reflect removal +- `src/core/ecs/world.zig` — `test "registerOnDetach/dispatchOnDetach fires the seam"` — a Tier-0 stand-in callback receives the cooked `on_detach` text (mirror of the M1.0.6 `on_attach` seam test) +- `src/core/scene/loader.zig` (or canonical scene test) — `test "scene with active extension executes on_attach at load"` — a cooked scene with `extensions: ["CombatModule"]` adding to `Health`; load → `Health.max` adjusted (**the headline criterion**) +- `src/core/scene/loader.zig` (or canonical scene test) — `test "on_attach structural command is drained before on_spawned"` — a hook that issues a deferred structural change (`entity.add(...)` / spawn) is visible after load + +### Benchmarks + +- N/A. Correctness milestone; hooks run once per activation, not per tick. + +### Observable behavior + +- A cooked scene fixture: a `BaseCharacter` instance carrying `extensions: ["CombatModule"]`, where `CombatModule.on_attach` does `entity.get_mut(Health).max += 50`, loaded by a runtime with `Health` registered → after load, `Health.max == base + 50`. Demonstrable via the scene-load integration test or a small driver. +- An Etch round-trip: activate then deactivate `CombatModule` on a spawned `BaseCharacter` → `Health.max` returns to base, the extension's component is added then removed, `has_extension` flips `true`→`false`. + +### CI + +- `zig build` clean, zero warnings, on the configured matrix +- `zig build test` green (debug + ReleaseSafe) +- `zig fmt --check` green +- `zig build lint` green (once the custom linter exists) +- `commit-msg` hook green on every commit of the branch + +## Conventions + +- **Branch:** `phase-1/etch/extension-hooks` +- **Final tag:** `v0.10.9-extension-hooks` +- **PR title:** `Phase 1 / Etch / Execute extension hooks` +- **Commit convention:** Conventional Commits (cf. `engine-development-workflow.md §4.3`) +- **Merge strategy:** squash-and-merge (cf. `engine-development-workflow.md §4.6`) + +## Notes + +- **Decision frozen at scoping — TEXT, not bytecode.** The bytecode VM is Phase 2 (`etch-bytecode.md §17`; `etch-visual-scripting.md §4`). The Tier-0 seam (`register`/`dispatch` `On{Attach,Detach}`) is backend-agnostic: the Phase-2 migration swaps the bridge callback (text → bytecode) and the cook side (render-text → emit-bytecode) plus a format-version bump — all already anticipated. Nothing built here is throwaway: `execHookText` reuses the SAME tree-walker (`execStmtRun`) that runs all Phase-1 gameplay; it is not hook-specific scaffolding. +- **`self.ast` rebind is safe.** `Interpreter.ast` is a mutable field (`ast: *const AstArena`, ~l.537). During a hook, no executor path dereferences a *program*-arena `NodeId`: component/resource field access and enum shorthand resolve by NAME via the registry/descriptor (`execAssign`, ~l.2019), `emit` enqueues by event-name id into the dynamic `EventStore`, and hook-arena-local `StringId`s resolve through `self.ast.strings` while rebound. No re-entrancy in M1.0.9 — the Etch `activate_extension` method runs at a tick/flush boundary, never nested inside another running hook. +- **Conflict policy is already "reject".** `activateExtension` (`loader.zig`, ~l.363) returns `error.ExtensionComponentConflict` when the entity already carries a component the extension adds. Runtime activate inherits it via the shared path. Therefore deactivate's `removeComponentDynamic` over the extension's declared components is unambiguous. No new conflict machinery. The `§30.5` "last-wins" doc drift → reconciled by Claude.ai (KB op), NOT by Claude Code. +- **`on_detach` has NO seam at `v0.10.8`** — only the `on_attach` pair exists (`world.zig` ~l.293/302). E3 ADDS the `on_detach` seam. `on_detach` is never fired at load (load only activates); it fires on deactivate. +- **Deferred structural changes from hooks** route through the shared deferred command buffer (the Tier-0 "deferred-structural-change primitive", `world.zig` ~l.854). Drain via `observers.flushWithObservers` / `applyRawCommand` at the existing flush point — `loader.zig` ~l.437 (`CommandBuffer.init` + `flushWithObservers`) is the template — after the extension-activation pass, before `on_spawned`. +- **Method dispatch site:** `dispatchMethodOnValue` (`interp.zig` ~l.2284); the entity-receiver arm matches method-name strings (like `get`/`get_mut`). Add the four new branches there. Reconfirm line numbers at clone — they drift. +- **Runtime resolver:** the bridge gains an optional `ExtensionResolver` (same interface the loader receives), set when the interpreter is created/bound. A name-only Etch `activate_extension` resolves through it; if absent, a clear error (sibling to `MissingExtensionResolver`). +- **Corrupt-hook defensive case:** a cooked hook that fails to re-parse is corrupt-asset (cook validated it via `renderStmtRunAlloc` → `HookRenderFailed`). Surface it as a clear extension-family error (e.g. `MalformedExtensionHook`), sibling to `UnknownExtension` / `ExtensionComponentConflict`; should be unreachable in practice. +- **Active-set ownership:** store owned copies of extension-name byte-slices on the `World` side-table (count is tiny — a few per entity). Inserting in the shared `activateExtension` means load-time extensions are tracked with no separate populate-at-load code. Not serialized — rebuilt at load from the Entity Extensions Table via the same shared path. +- **§3.6.1 closing audit (REPO, not `/mnt/project/`):** `grep -rn` over `src/` for the modified terms: `on_attach` / `on_detach`, `dispatchOnAttach` / `dispatchOnDetach`, `registerOnDetach`, `activate_extension` / `deactivate_extension`, `execHookText`, `parseStmtBlock`. Patch orphan references in-session or record as residual debt. Language audit on the diff + brief (no French in code/comments). +- **Surface verified @ `v0.10.8` during scoping; reconfirm at clone** (M1.0.6/M1.0.7 lesson — the surface is the source of truth): `execStmtRun` (`interp.zig` ~l.1755), `execBody` (~l.1663), `dispatchMethodOnValue` (~l.2284), `execAssign` (~l.2019), parser `parse`/`parseFile` (~l.60/591), `loader.activateExtension` (~l.345), `dispatchSpawnLifecycle` (~l.436), `world.registerOnAttach`/`dispatchOnAttach` (~l.293/302), `removeComponentDynamic` (~l.792), `accessor.Hook` (~l.212, `{ on_attach, on_detach }`). + +--- + +# LIVING SECTION + +*Maintained by Claude Code during the milestone. The log is not a marketing report: it serves review and post-mortem debugging.* + +## Specs read + +*Check before writing any production code. Confirms the spec was ingested in full, not merely skimmed.* + +- [ ] `etch-reference-part2.md` (§30.3–§30.5) — read +- [ ] `engine-scene-serialization.md` (extension serialization + load sequence) — read +- [ ] `etch-grammar.md` (`on_attach_block` / `on_detach_block`, `prefab_decl`) — read +- [ ] `etch-bytecode.md` (§17) — read +- [ ] `etch-visual-scripting.md` (§4) — read +- [ ] `engine-ecs-internals.md` (deferred command buffer / flush) — read +- [ ] `engine-phase-1-plan.md` (M1.0.9) — read + +## Execution log + +- + +## Recorded deviations + +*Changes to the FROZEN SECTION made mid-milestone after a Claude.ai round-trip. Each deviation references the commit that records it. If empty at milestone end: nominal case.* + +- + +## Blockers encountered + +*Blocking points that required a return to Claude.ai (cf. `engine-development-workflow.md §2.4`). If 2+ distinct blockers: re-scope signal.* + +- — resolved by or + +## Closing notes + +*Fill in at Status → CLOSED, just before opening the PR.* + +- **What worked:** +- **What deviated from the original spec:** +- **What to flag explicitly in review:** +- **Final measurements** (perf, binary size, compile time, whatever is relevant to the milestone): +- **Residual risks / tech debt left intentionally:** From bf76fdb361cb58274a5223ad8369ba70ef4a533e Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Tue, 30 Jun 2026 00:16:07 +0200 Subject: [PATCH 02/16] docs(brief): confirm specs read for M1.0.9 Co-Authored-By: Claude Opus 4.8 --- briefs/M1.0.9-extension-hooks.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/briefs/M1.0.9-extension-hooks.md b/briefs/M1.0.9-extension-hooks.md index f1becdf..26514ff 100644 --- a/briefs/M1.0.9-extension-hooks.md +++ b/briefs/M1.0.9-extension-hooks.md @@ -122,13 +122,13 @@ M1.0.9 founds the runtime text-execution surface that M1.0.6 deferred. At M1.0.6 *Check before writing any production code. Confirms the spec was ingested in full, not merely skimmed.* -- [ ] `etch-reference-part2.md` (§30.3–§30.5) — read -- [ ] `engine-scene-serialization.md` (extension serialization + load sequence) — read -- [ ] `etch-grammar.md` (`on_attach_block` / `on_detach_block`, `prefab_decl`) — read -- [ ] `etch-bytecode.md` (§17) — read -- [ ] `etch-visual-scripting.md` (§4) — read -- [ ] `engine-ecs-internals.md` (deferred command buffer / flush) — read -- [ ] `engine-phase-1-plan.md` (M1.0.9) — read +- [x] `etch-reference-part2.md` (§30.3–§30.5) — read 2026-06-30 00:15 (full doc, §1–§32) +- [x] `engine-scene-serialization.md` (extension serialization + load sequence) — read 2026-06-30 00:15 (full doc) +- [x] `etch-grammar.md` (`on_attach_block` / `on_detach_block`, `prefab_decl`) — read 2026-06-30 00:15 (full doc; `prefab_decl` + hook blocks in §15) +- [x] `etch-bytecode.md` (§17) — read 2026-06-30 00:15 (§1 overview + §17 phasing + §18 architectural reminders; §2–§16 are the Phase-2 ISA, explicitly out of scope per "do NOT implement bytecode") +- [x] `etch-visual-scripting.md` (§4) — read 2026-06-30 00:15 (full doc) +- [x] `engine-ecs-internals.md` (deferred command buffer / flush) — read 2026-06-30 00:15 (full doc) +- [x] `engine-phase-1-plan.md` (M1.0.9) — read 2026-06-30 00:15 (full doc) ## Execution log From d63cb3584e1ee80d573958ea1cc303e6de35ec0c Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Tue, 30 Jun 2026 00:16:16 +0200 Subject: [PATCH 03/16] docs(brief): activate M1.0.9 Co-Authored-By: Claude Opus 4.8 --- briefs/M1.0.9-extension-hooks.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/briefs/M1.0.9-extension-hooks.md b/briefs/M1.0.9-extension-hooks.md index 26514ff..ac14b7d 100644 --- a/briefs/M1.0.9-extension-hooks.md +++ b/briefs/M1.0.9-extension-hooks.md @@ -1,6 +1,6 @@ # M1.0.9 — Execute extension hooks (`on_attach` / `on_detach`) -> **Status:** PLANNED +> **Status:** ACTIVE > **Phase:** 1.0 > **Branch:** `phase-1/etch/extension-hooks` > **Planned tag:** `v0.10.9-extension-hooks` From 064bc93333948f69e194c478b18ff24809246c5e Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Tue, 30 Jun 2026 00:36:04 +0200 Subject: [PATCH 04/16] docs(brief): journal surface reconnaissance for M1.0.9 Co-Authored-By: Claude Opus 4.8 --- briefs/M1.0.9-extension-hooks.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/briefs/M1.0.9-extension-hooks.md b/briefs/M1.0.9-extension-hooks.md index ac14b7d..32cb4d7 100644 --- a/briefs/M1.0.9-extension-hooks.md +++ b/briefs/M1.0.9-extension-hooks.md @@ -132,7 +132,9 @@ M1.0.9 founds the runtime text-execution surface that M1.0.6 deferred. At M1.0.6 ## Execution log -- +- 2026-06-30 00:35 — Surface reconnaissance (read-only) complete; all brief-named symbols confirmed first-hand. Line numbers from the brief are accurate (no drift): `Interpreter.ast` interp.zig:537, `execBody` :1663, `execStmtRun` :1755, `execAssign` :2019, `dispatchMethodOnValue` :2284; `world.registerOnAttach` :293 / `dispatchOnAttach` :302 / `ExtensionAttachFn` :100 / `AttachHook` :109 / `attach_hook` field :185; `removeComponentDynamic` :792 / `addComponentDynamic` :726 / `World.deinit` :202; `loader.activateExtension` :345 / `dispatchSpawnLifecycle` :436 / `applyExtensions` :316 / `ExtensionResolver` loader.zig:69; `accessor.Hook` accessor.zig:213 + `hook(i)` :215; `renderStmtRunAlloc` descriptor.zig:980. +- 2026-06-30 00:35 — Five surface findings that refine (not contradict) the frozen design: (1) the brief is RIGHT that `self.ast` is rebindable — the field is `ast: *const AstArena` (pointer-to-const, reassignable when `self` is `*Interpreter`); rebind is also NECESSARY because `execStmt`/`evalExpr` resolve identifiers via `self.ast.strings`, so the hook body must execute with `self.ast` pointing at the hook arena. (2) `renderStmtRunAlloc` emits statements separated by `"; "` with NO braces; the lexer has a `.semicolon` token but the parser only consumes it inside a fill-array `[v; n]` (parser.zig:6157), NEVER between statements → `parseStmtBlock` (E1) needs its OWN loop: `parseStmt` then skip an optional `.semicolon`, until `.eof` (reuses `parseStmt`, the statement parser — new entry point, not new grammar). (3) Two `EntityId` types: core `packed struct(u64){index,generation}` (entity.zig:30) vs interp `u64` (value.zig:21); the bridge bitcasts (mirror `runObserverBody` interp.zig:1077). (4) Deferred structural changes from a body route via `self.observer_deferred` → `world.observer_registry.deferred` (`?CommandBuffer`, lazily `ensureDeferred`); `flushWithObservers` drains it first → `dispatchSpawnLifecycle`'s opening flush already applies hook-issued deferred cmds before `on_spawned`. `execHookText` mirrors `runObserverBody` (fresh `Locals`, reset stores, set `observer_deferred`) + the `self.ast` rebind. (5) Interp→loader is a legal dependency (`weld_etch` depends on `weld_core`; `weld_core.scene.loader` + `ExtensionResolver` are exported), so the runtime activate/deactivate entries live in `loader.zig` and the interp calls them. +- 2026-06-30 00:35 — RISK noted for E2 deferred-drain test: the interp has NO `entity.add(T)`/`spawn`/`despawn` structural mutation in bodies (S4 boundary, interp.zig:11), and the only deferred structural producer (tag mutation) is NOT in the cookable subset `{let,emit,expr,assign,return}` (descriptor.zig renderStmt). Headline test (`Health.max += 50`) is an IMMEDIATE field write (no deferral). The brief NOTE "the surface is the source of truth" pre-authorizes adapting the "structural command drained before on_spawned" test to the actual deferred mechanism; will resolve its concrete realization during E2 and record any deviation, escalating only if no in-scope realization exists. ## Recorded deviations From f0da1919ed860de3a44d81562f200ad78d7497f5 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Tue, 30 Jun 2026 00:43:27 +0200 Subject: [PATCH 05/16] feat(etch): parseStmtBlock fragment entry for hook bodies (M1.0.9) Parse a bare statement-run fragment (the canonical text a cooked extension hook body carries: statements joined by "; ", no enclosing braces) into a fresh AstArena, exposing the body statement range (body_start/body_len) in the same encoding rule/fn bodies use. Reuses the existing parseStmt via a new parseStmtFragment loop that skips one optional .semicolon between statements (the parser otherwise only consumes ; inside a fill-array literal). New entry point parseStmtBlock + StmtBlockResult, not a new statement grammar. Co-Authored-By: Claude Opus 4.8 --- src/etch/parser.zig | 111 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) diff --git a/src/etch/parser.zig b/src/etch/parser.zig index 19a2358..d3c5d09 100644 --- a/src/etch/parser.zig +++ b/src/etch/parser.zig @@ -53,6 +53,25 @@ pub const ParseResult = struct { } }; +/// Result of `parseStmtBlock` (M1.0.9 E1): a bare statement-run fragment parsed +/// into its own `AstArena`. `body_start` / `body_len` index the parsed run in +/// `ast.extra` using the same encoding rule / `fn` bodies use (a slice of raw +/// `StmtId` values), so the interpreter walks it with the existing +/// `execStmtRun`. Owns the arena and diagnostics exactly like `ParseResult`. +pub const StmtBlockResult = struct { + ast: AstArena, + body_start: u32, + body_len: u32, + diagnostics: []Diagnostic, + + /// Free the arena and every diagnostic plus the backing slice. + pub fn deinit(self: *StmtBlockResult, gpa: std.mem.Allocator) void { + for (self.diagnostics) |*d| d.deinit(gpa); + gpa.free(self.diagnostics); + self.ast.deinit(gpa); + } +}; + /// Entry point for the Etch parser. Lexes `source`, builds the /// tabular SoA `AstArena`, and returns it together with an optional /// first-error `Diagnostic`. Caller owns the arena and must call @@ -111,6 +130,52 @@ pub fn parse(gpa: std.mem.Allocator, source: []const u8) !ParseResult { return .{ .ast = arena, .diagnostics = diags }; } +/// Parse a bare statement-run fragment (M1.0.9 E1) into a fresh `AstArena`. +/// The fragment is the canonical text a cooked extension hook body carries +/// (`descriptor.renderStmtRunAlloc`): zero or more statements separated by a +/// `";"`, with NO enclosing braces. This is a new ENTRY POINT over the same +/// `parseStmt` rule and `fn` bodies use — not a new statement grammar. The one +/// fragment-specific rule is the separator: `renderStmtRunAlloc` joins +/// statements with `"; "`, and the parser only ever consumes `;` inside a +/// fill-array literal, so `parseStmtFragment` skips one optional `.semicolon` +/// between statements. An empty fragment yields a zero-statement block with no +/// diagnostics. Caller owns the arena + diagnostics (`StmtBlockResult.deinit`). +pub fn parseStmtBlock(gpa: std.mem.Allocator, source: []const u8) !StmtBlockResult { + var lexer = Lexer.init(source); + errdefer lexer.deinit(gpa); + var arena = try AstArena.init(gpa); + errdefer arena.deinit(gpa); + + const c0 = try lexer.next(gpa); + const c1 = try lexer.next(gpa); + const c2 = try lexer.next(gpa); + var parser: Parser = .{ + .gpa = gpa, + .source = source, + .lexer = &lexer, + .arena = &arena, + .current = c0, + .next_tok = c1, + .next2_tok = c2, + }; + errdefer { + for (parser.diagnostics.items) |*d| d.deinit(gpa); + parser.diagnostics.deinit(gpa); + } + defer parser.active_labels.deinit(gpa); + + const body = try parser.parseStmtFragment(); + + const diags = try parser.diagnostics.toOwnedSlice(gpa); + lexer.deinit(gpa); + return .{ + .ast = arena, + .body_start = body.start, + .body_len = body.len, + .diagnostics = diags, + }; +} + /// Bucket the arena's source-ordered comment / doc-comment slabs onto the /// top-level items they precede (M0.8 D-S3-trivia / D-S3-doccomment). /// @@ -5174,6 +5239,27 @@ pub const Parser = struct { return .{ .start = start, .len = @intCast(stmts.items.len) }; } + /// Parse a brace-less statement run terminated by EOF (M1.0.9 E1, used by + /// `parseStmtBlock`). Like `parseStmtRun` but: (a) the run ends at `.eof` + /// (there is no closing `}`), and (b) a single `.semicolon` separator is + /// skipped after each statement — the cooked hook text + /// (`descriptor.renderStmtRunAlloc`) joins statements with `"; "`, whereas + /// rule / `fn` bodies are newline-delimited. The inner `;` of a fill-array + /// literal `[v; n]` is consumed during expression parsing, so only the + /// top-level statement separator is seen here. + fn parseStmtFragment(self: *Parser) ParseError!struct { start: u32, len: u32 } { + var stmts: std.ArrayListUnmanaged(u32) = .empty; + defer stmts.deinit(self.gpa); + while (self.peek() != .eof) { + try self.surfaceTokenErrors(); + try stmts.append(self.gpa, (try self.parseStmt()).raw()); + _ = try self.match(.semicolon); + } + const start: u32 = @intCast(self.arena.extra.items.len); + try self.arena.extra.appendSlice(self.gpa, stmts.items); + return .{ .start = start, .len = @intCast(stmts.items.len) }; + } + /// True when the current token starts a keyword-led statement that can /// never be a block's trailing value (`let` / `assert` / `for` / `while` / /// `break` / `continue` / `throw` / `try`, plus the `IDENT ":" loop` @@ -9008,3 +9094,28 @@ test "parser recovers and a valid prefab after a broken construct survives (M0.8 try std.testing.expect(result.diagnostics.len > 0); try std.testing.expectEqual(@as(usize, 1), result.ast.prefab_decls.items.len); } + +test "parseStmtBlock parses a bare statement run (M1.0.9 E1)" { + const gpa = std.testing.allocator; + // Exactly the shape `descriptor.renderStmtRunAlloc` emits for an extension + // hook body: statements joined by "; ", no enclosing braces. The emit body + // uses `field: expr` form — Etch struct-literal fields have no bare-name + // shorthand (`etch-grammar.md` §4.3 `field_init`), so the brief's spec-style + // `{ entity }` is written `{ source: entity }` here (same 2-statement shape). + var result = try parseStmtBlock(gpa, "entity.get_mut(Health).max += 50; emit ExtensionAttached { source: entity }"); + defer result.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), result.diagnostics.len); + try std.testing.expectEqual(@as(u32, 2), result.body_len); + const s0: NodeId = @bitCast(result.ast.extra.items[result.body_start + 0]); + const s1: NodeId = @bitCast(result.ast.extra.items[result.body_start + 1]); + try std.testing.expectEqual(ast_mod.StmtKind.assign_stmt, result.ast.stmtKind(s0)); + try std.testing.expectEqual(ast_mod.StmtKind.emit_stmt, result.ast.stmtKind(s1)); +} + +test "parseStmtBlock on empty body yields a zero-statement block (M1.0.9 E1)" { + const gpa = std.testing.allocator; + var result = try parseStmtBlock(gpa, ""); + defer result.deinit(gpa); + try std.testing.expectEqual(@as(u32, 0), result.body_len); + try std.testing.expectEqual(@as(usize, 0), result.diagnostics.len); +} From b7bec52320a4d62c0a9494c4a712992c86724dbb Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Tue, 30 Jun 2026 00:50:12 +0200 Subject: [PATCH 06/16] feat(ecs): on_detach seam + per-entity extension side-table (M1.0.9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit world.zig gains the on_detach dispatch seam (ExtensionDetachFn + detach_hook + registerOnDetach/dispatchOnDetach), a mirror of the M1.0.6 on_attach pair — fired by the runtime deactivate path before removing an extension's components. Plus a per-entity active-extension side-table (entity_extensions: EntityId -> owned name slices) with addEntityExtension/removeEntityExtension/ hasEntityExtension/entityExtensions, populated by the shared activate path and freed in deinit. Backs the interpreter's has_extension/active_extensions. Inline tests cover the detach seam (register/dispatch/no-op) and the side-table (add/has/remove/order + leak-freedom). Co-Authored-By: Claude Opus 4.8 --- src/core/ecs/world.zig | 181 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 180 insertions(+), 1 deletion(-) diff --git a/src/core/ecs/world.zig b/src/core/ecs/world.zig index 8a71233..6cc55c2 100644 --- a/src/core/ecs/world.zig +++ b/src/core/ecs/world.zig @@ -108,6 +108,22 @@ pub const ExtensionAttachFn = *const fn ( /// A registered `on_attach` callback + its opaque context. const AttachHook = struct { ctx: ?*anyopaque, func: ExtensionAttachFn }; +/// M1.0.9 — the `on_detach` extension dispatch seam, mirror of +/// `ExtensionAttachFn`. Fired by the runtime `deactivate_extension` path BEFORE +/// removing the extension's components (so the hook still sees them), passing +/// the cooked `on_detach` Etch source text (`null` if absent). Never fired at +/// load — load only activates. +pub const ExtensionDetachFn = *const fn ( + ctx: ?*anyopaque, + world: *World, + entity: EntityId, + extension_name: []const u8, + on_detach_text: ?[]const u8, +) anyerror!void; + +/// A registered `on_detach` callback + its opaque context. +const DetachHook = struct { ctx: ?*anyopaque, func: ExtensionDetachFn }; + /// Top-level ECS world — single archetype list, shared identity, shared /// registry, shared resources. pub const World = struct { @@ -181,9 +197,25 @@ pub const World = struct { /// callback the Etch bridge registers; the scene loader fires it after adding /// an extension's components. `loader.zig` never calls the Etch VM directly — /// it goes through this hook. **M1.0.6 wires + fires the seam only**; the - /// actual execution of `on_attach_text` (Etch code) is **M1.0.9**. + /// actual execution of `on_attach_text` (Etch code) is **M1.0.9** (wired in + /// the Etch bridge's registered callback, not here — the seam still just + /// fires). attach_hook: ?AttachHook = null, + /// M1.0.9 — the `on_detach` extension dispatch seam, mirror of `attach_hook`. + /// Registered by the Etch bridge; fired by the runtime deactivate path before + /// removing an extension's components. `null` until registered (last wins). + detach_hook: ?DetachHook = null, + + /// M1.0.9 — per-entity active-extension set: an entity → the OWNED copies of + /// the names of the extensions currently active on it, in activation order. + /// Populated by `addEntityExtension` inside the shared activate path (so load + /// AND runtime activation both track for free), pruned by + /// `removeEntityExtension` on deactivate, freed in `deinit`. Not serialized — + /// rebuilt at load from the Entity Extensions Table via the same path. Backs + /// the interpreter's `has_extension` / `active_extensions`. + entity_extensions: std.AutoHashMapUnmanaged(EntityId, std.ArrayListUnmanaged([]const u8)) = .empty, + pub fn init() World { return .{ .identity = EntityIdentityStore.init(), @@ -213,6 +245,15 @@ pub const World = struct { self.registry.deinit(gpa); self.identity.deinit(gpa); self.observer_registry.deinit(gpa); + { + // Free each entity's owned extension-name copies + its list (M1.0.9). + var it = self.entity_extensions.valueIterator(); + while (it.next()) |list| { + for (list.items) |name| gpa.free(name); + list.deinit(gpa); + } + self.entity_extensions.deinit(gpa); + } self.* = undefined; } @@ -303,6 +344,71 @@ pub const World = struct { if (self.attach_hook) |h| try h.func(h.ctx, self, entity, extension_name, on_attach_text); } + /// M1.0.9 — register the `on_detach` extension dispatch callback (mirror of + /// `registerOnAttach`). One hook per world (last registration wins). + pub fn registerOnDetach(self: *World, ctx: ?*anyopaque, callback: ExtensionDetachFn) void { + self.detach_hook = .{ .ctx = ctx, .func = callback }; + } + + /// M1.0.9 — fire the `on_detach` seam for `entity`'s extension being + /// deactivated, passing the cooked `on_detach_text` (`null` if absent). The + /// runtime deactivate path calls this BEFORE removing the extension's + /// components, so the hook still sees them. No-op if no hook is registered. + pub fn dispatchOnDetach(self: *World, entity: EntityId, extension_name: []const u8, on_detach_text: ?[]const u8) anyerror!void { + if (self.detach_hook) |h| try h.func(h.ctx, self, entity, extension_name, on_detach_text); + } + + /// M1.0.9 — record `name` as an active extension on `entity` (storing an + /// OWNED copy). Called inside the shared activate path after the extension's + /// components are added. A name already present is not duplicated (the + /// activate path rejects a re-activation via component conflict first, so + /// this is belt-and-braces). + pub fn addEntityExtension(self: *World, gpa: std.mem.Allocator, entity: EntityId, name: []const u8) !void { + const gop = try self.entity_extensions.getOrPut(gpa, entity); + if (!gop.found_existing) gop.value_ptr.* = .empty; + for (gop.value_ptr.items) |existing| { + if (std.mem.eql(u8, existing, name)) return; + } + const owned = try gpa.dupe(u8, name); + errdefer gpa.free(owned); + try gop.value_ptr.append(gpa, owned); + } + + /// M1.0.9 — drop `name` from `entity`'s active-extension set, freeing the + /// owned copy. No-op if absent. Removes the map entry once the set empties. + pub fn removeEntityExtension(self: *World, gpa: std.mem.Allocator, entity: EntityId, name: []const u8) void { + const list = self.entity_extensions.getPtr(entity) orelse return; + var i: usize = 0; + while (i < list.items.len) : (i += 1) { + if (std.mem.eql(u8, list.items[i], name)) { + gpa.free(list.items[i]); + _ = list.orderedRemove(i); + break; + } + } + if (list.items.len == 0) { + list.deinit(gpa); + _ = self.entity_extensions.remove(entity); + } + } + + /// M1.0.9 — whether `name` is currently active on `entity`. + pub fn hasEntityExtension(self: *const World, entity: EntityId, name: []const u8) bool { + const list = self.entity_extensions.getPtr(entity) orelse return false; + for (list.items) |existing| { + if (std.mem.eql(u8, existing, name)) return true; + } + return false; + } + + /// M1.0.9 — the OWNED names of the extensions active on `entity`, in + /// activation order (empty slice if none). Borrowed view — valid until the + /// entity's set is next mutated. + pub fn entityExtensions(self: *const World, entity: EntityId) []const []const u8 { + const list = self.entity_extensions.getPtr(entity) orelse return &.{}; + return list.items; + } + // ─── Component registration helpers ────────────────────────────────── /// Register a component whose layout is described at runtime. @@ -1113,3 +1219,76 @@ fn setTagBit(bytes: []u8, bit: u32, set: bool) void { } @memcpy(bytes[off .. off + 8], std.mem.asBytes(&word)); } + +test "registerOnDetach / dispatchOnDetach fires the on_detach seam (M1.0.9)" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + + const Spy = struct { + var fired: u32 = 0; + var saw_name: bool = false; + var saw_text: bool = false; + fn cb(_: ?*anyopaque, _: *World, _: EntityId, name: []const u8, text: ?[]const u8) anyerror!void { + fired += 1; + if (std.mem.eql(u8, name, "CombatModule")) saw_name = true; + if (text != null and std.mem.indexOf(u8, text.?, "Health") != null) saw_text = true; + } + }; + Spy.fired = 0; + Spy.saw_name = false; + Spy.saw_text = false; + + const e = EntityId{ .index = 1, .generation = 1 }; + const detach_text = "entity.get_mut(Health).max -= 50"; + + // No hook registered → dispatch is a no-op (mirror of the on_attach seam). + try world.dispatchOnDetach(e, "CombatModule", detach_text); + try std.testing.expectEqual(@as(u32, 0), Spy.fired); + + world.registerOnDetach(null, &Spy.cb); + try world.dispatchOnDetach(e, "CombatModule", detach_text); + try std.testing.expectEqual(@as(u32, 1), Spy.fired); + try std.testing.expect(Spy.saw_name); + try std.testing.expect(Spy.saw_text); +} + +test "per-entity extension side-table tracks add / has / remove (M1.0.9)" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + + const e1 = EntityId{ .index = 1, .generation = 1 }; + const e2 = EntityId{ .index = 2, .generation = 1 }; + + try std.testing.expect(!world.hasEntityExtension(e1, "Combat")); + try std.testing.expectEqual(@as(usize, 0), world.entityExtensions(e1).len); + + try world.addEntityExtension(gpa, e1, "Combat"); + try world.addEntityExtension(gpa, e1, "Merchant"); + try world.addEntityExtension(gpa, e2, "Combat"); + // Re-adding the same name is a no-op (belt-and-braces dedup). + try world.addEntityExtension(gpa, e1, "Combat"); + + try std.testing.expect(world.hasEntityExtension(e1, "Combat")); + try std.testing.expect(world.hasEntityExtension(e1, "Merchant")); + try std.testing.expect(world.hasEntityExtension(e2, "Combat")); + + const e1_exts = world.entityExtensions(e1); + try std.testing.expectEqual(@as(usize, 2), e1_exts.len); + try std.testing.expectEqualStrings("Combat", e1_exts[0]); // activation order + try std.testing.expectEqualStrings("Merchant", e1_exts[1]); + + world.removeEntityExtension(gpa, e1, "Combat"); + try std.testing.expect(!world.hasEntityExtension(e1, "Combat")); + try std.testing.expect(world.hasEntityExtension(e1, "Merchant")); + try std.testing.expectEqual(@as(usize, 1), world.entityExtensions(e1).len); + + // e2 still has Combat — the set is per-entity. + try std.testing.expect(world.hasEntityExtension(e2, "Combat")); + + // Draining the last extension drops the map entry; `deinit` frees the rest + // (the testing allocator flags any leak of the owned name copies). + world.removeEntityExtension(gpa, e1, "Merchant"); + try std.testing.expectEqual(@as(usize, 0), world.entityExtensions(e1).len); +} From 3adc8c44416d3e9ab07630a857316d4849965244 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Tue, 30 Jun 2026 01:15:08 +0200 Subject: [PATCH 07/16] feat(etch): execute extension hooks at runtime (M1.0.9) The M1.0.6 on_attach seam now runs the cooked Etch text. interp.zig gains execHookText: parse the hook statement-run (parseStmtBlock) into a transient AstArena, rebind self.ast to it, bind the implicit entity, run via execStmtRun, and route deferred structural changes through the world's shared observer- deferred buffer (mirrors runObserverBody). bindToWorld registers the real on_attach/on_detach trampolines (ctx = *Interpreter); dispatchMethodOnValue's entity arm gains activate_extension/deactivate_extension/has_extension/ active_extensions. The Bridge holds an optional ExtensionResolver (setExtensionResolver) for name-only runtime activation. loader.zig: shared activateExtension now records the active extension (addEntityExtension) before firing on_attach; runtimeActivate/runtimeDeactivate are the runtime entries (deactivate fires on_detach first, then removes the extension's components); the load sequence drains hook-issued deferred commands after the activation pass, before on_spawned. ecs_bridge.zig: ext_resolver field. Decision frozen at scoping: TEXT re-parse, not bytecode. Co-Authored-By: Claude Opus 4.8 --- src/core/scene/loader.zig | 64 +++++++++- src/etch/ecs_bridge.zig | 10 ++ src/etch/interp.zig | 244 +++++++++++++++++++++++++++++++++++++- 3 files changed, 316 insertions(+), 2 deletions(-) diff --git a/src/core/scene/loader.zig b/src/core/scene/loader.zig index a7ccbe3..8b4e48d 100644 --- a/src/core/scene/loader.zig +++ b/src/core/scene/loader.zig @@ -207,6 +207,16 @@ pub fn loadFromBytes(world: *World, gpa: std.mem.Allocator, bytes: []const u8, e // Extension activation (M1.0.6 E6): add each active extension's components + // fire the `on_attach` seam. After resources, before `on_spawned`. try applyExtensions(world, gpa, acc, uuid_to_entity, ext_resolver); + // M1.0.9 — drain the structural commands the `on_attach` hooks queued, AFTER + // the whole activation pass and BEFORE `on_spawned`, so a spawn observer sees + // a fully-materialised entity. `dispatchSpawnLifecycle` also opens with a + // drain; this explicit one keeps the ordering contract local to the load + // sequence (it does not depend on a downstream function's internal drain). + { + var hook_drain = command_buffer_mod.CommandBuffer.init(gpa, world); + defer hook_drain.deinit(); + try observers_mod.flushWithObservers(&hook_drain, &world.observer_registry); + } try dispatchSpawnLifecycle(world, gpa, spawned.items); return .{ @@ -365,11 +375,63 @@ fn activateExtension(world: *World, gpa: std.mem.Allocator, entity: EntityId, na } } - // Fire the `on_attach` dispatch seam (D-E). Executing the text is M1.0.9. + // Record the extension as active on the entity BEFORE firing `on_attach`, so + // a hook that queries `has_extension` / `active_extensions` sees it (M1.0.9). + // Tracked here means load AND runtime activation both track for free. + try world.addEntityExtension(gpa, entity, name); + + // Fire the `on_attach` dispatch seam (D-E). M1.0.9 — the Etch bridge's + // registered callback re-parses + executes `on_attach_text` against the live + // world; with no bridge registered (Tier-0 tests) the seam is a no-op. const on_attach_text: ?[]const u8 = if (ext.hookCount() > 0) ext.hook(0).on_attach else null; try world.dispatchOnAttach(entity, name, on_attach_text); } +/// M1.0.9 — runtime extension activation entry, reached from Etch +/// `entity.activate_extension("X")` (the interpreter resolves the name through +/// the bridge's `ExtensionResolver`). Reuses the shared `activateExtension` +/// path: add components → record the active extension → fire `on_attach`. +/// Unknown name → `error.UnknownExtension`; a component the entity already +/// carries → `error.ExtensionComponentConflict` (§30.5 reject policy). +pub fn runtimeActivate(world: *World, gpa: std.mem.Allocator, entity: EntityId, name: []const u8, resolver: ExtensionResolver) !void { + const bytes = resolver.resolve(name) orelse return error.UnknownExtension; + try activateExtension(world, gpa, entity, name, bytes); +} + +/// M1.0.9 — runtime extension deactivation entry, reached from Etch +/// `entity.deactivate_extension("X")`. Fires the `on_detach` seam FIRST (so the +/// hook still reads the extension's components), then removes the extension's +/// declared components and drops the entity's active-extension record. The +/// extension must be active (`error.ExtensionNotActive` otherwise). The §30.5 +/// reject conflict policy makes the component set unambiguous — no two active +/// extensions share a component — so removal needs no provenance tracking. +pub fn runtimeDeactivate(world: *World, gpa: std.mem.Allocator, entity: EntityId, name: []const u8, resolver: ExtensionResolver) !void { + if (!world.hasEntityExtension(entity, name)) return error.ExtensionNotActive; + const bytes = resolver.resolve(name) orelse return error.UnknownExtension; + const ext = try openVerified(bytes); + + // `on_detach` before the components go away (the hook can still read them). + const on_detach_text: ?[]const u8 = if (ext.hookCount() > 0) ext.hook(0).on_detach else null; + try world.dispatchOnDetach(entity, name, on_detach_text); + + // Remove the extension's declared components (mono-entity, like activate). + var ai: u32 = 0; + while (ai < ext.archetypeCount()) : (ai += 1) { + const arch = ext.archetype(ai); + if (arch.entity_count == 0) continue; + var c: usize = 0; + while (c < arch.component_count) : (c += 1) { + const sch = ext.schema(arch.schemaIndex(c)); + const cid = world.componentId(sch.name) orelse return error.UnknownComponent; + if (world.componentBytes(entity, cid) != null) { + try world.removeComponentDynamic(gpa, entity, cid); + } + } + } + + world.removeEntityExtension(gpa, entity, name); +} + /// 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 diff --git a/src/etch/ecs_bridge.zig b/src/etch/ecs_bridge.zig index e0f9ac3..15b54b9 100644 --- a/src/etch/ecs_bridge.zig +++ b/src/etch/ecs_bridge.zig @@ -24,6 +24,10 @@ const Chunk = weld_core.ecs.archetype_dynamic.Chunk; const ResourceStore = weld_core.ecs.resources.ResourceStore; const CoreEntityId = weld_core.ecs.entity.EntityId; const Tick = weld_core.ecs.tick.Tick; +// M1.0.9 — runtime extension resolution (name → cooked `.prefab.bin` bytes), the +// same interface the scene loader receives. Held (optional, borrowed) by the +// bridge so a name-only Etch `entity.activate_extension("X")` resolves at runtime. +const ExtensionResolver = weld_core.scene.loader.ExtensionResolver; // Module-private aliases shadowing the value module — `EntityId`, // `Value`, `ComponentRef` are not exported because no external caller @@ -66,6 +70,12 @@ pub const Bridge = struct { /// Etch resource name → registry id. resources: std.StringHashMapUnmanaged(ComponentId) = .empty, + /// M1.0.9 — optional runtime extension resolver (name → cooked `.prefab.bin` + /// bytes). Borrowed, not owned — set when the interpreter is bound, used by + /// `entity.activate_extension` / `deactivate_extension`. Absent → those + /// methods fail with `error.MissingExtensionResolver`. + ext_resolver: ?ExtensionResolver = null, + pub fn init() Bridge { return .{}; } diff --git a/src/etch/interp.zig b/src/etch/interp.zig index 4e27d3d..e2663a8 100644 --- a/src/etch/interp.zig +++ b/src/etch/interp.zig @@ -36,6 +36,10 @@ const DynamicQuery = weld_core.ecs.world.DynamicQuery; const CoreEntityId = weld_core.ecs.entity.EntityId; const Tick = weld_core.ecs.tick.Tick; const initial_tick = weld_core.ecs.tick.initial_tick; +// M1.0.9 — the Tier-0 scene loader (extension activate/deactivate runtime +// entries + the `ExtensionResolver` type). `weld_etch` depends on `weld_core`, +// so this is the legal direction (core never imports etch). +const scene_loader = weld_core.scene.loader; const AstArena = ast_mod.AstArena; const NodeId = ast_mod.NodeId; @@ -988,6 +992,15 @@ pub const Interpreter = struct { return report; } + /// M1.0.9 — give the interpreter a runtime extension resolver (name → cooked + /// `.prefab.bin` bytes, the same interface the scene loader receives) so an + /// Etch `entity.activate_extension("X")` / `deactivate_extension("X")` + /// resolves the extension at runtime. Absent → those methods fail with + /// `error.MissingExtensionResolver`. + pub fn setExtensionResolver(self: *Interpreter, resolver: scene_loader.ExtensionResolver) void { + self.bridge.ext_resolver = resolver; + } + /// Register this program's observer rules into `world`'s `ObserverRegistry` /// (M1.0.2 E3). Idempotent: the first call allocates one `ObserverCtx` per /// observer rule and registers a trampoline keyed on the rule's lifecycle @@ -997,6 +1010,14 @@ pub const Interpreter = struct { if (self.observers_bound) return; self.observers_bound = true; + // M1.0.9 — register the extension hook seams so the loader's + // `dispatchOnAttach` / runtime `deactivate_extension` reach `execHookText`. + // Registered unconditionally (before the observer-rule early-return): the + // callbacks only fire when an extension actually activates / deactivates, + // so a program with no observer rules still wires its hook execution. + world.registerOnAttach(self, &extensionAttachTrampoline); + world.registerOnDetach(self, &extensionDetachTrampoline); + var n: usize = 0; for (self.rule_descs) |rd| { if (rd.observer_kind != null) n += 1; @@ -1123,6 +1144,104 @@ pub const Interpreter = struct { } } + /// Execute a cooked extension hook body (M1.0.9 E2). `hook_text` is the + /// canonical Etch statement run a `.prefab.bin` carries for an `on_attach` / + /// `on_detach` hook (`descriptor.renderStmtRunAlloc`): statements joined by + /// `"; "`, no braces. Parse it into a transient `AstArena`, rebind `self.ast` + /// to it for the body's duration (the executor resolves identifiers via + /// `self.ast.strings`, so the body MUST run with `ast` pointing at the hook + /// arena), bind the implicit `entity`, run the body with the same + /// `execStmtRun` that drives every rule, and route any deferred structural + /// change into the world's shared observer-deferred buffer (drained by the + /// loader before `on_spawned`). Mirrors `runObserverBody` — same fresh-scope + /// + store-reset discipline. No re-entrancy: a hook runs at a load/flush + /// boundary, never nested inside another running hook. + fn execHookText(self: *Interpreter, world: *World, entity: CoreEntityId, hook_text: []const u8) !void { + var block = parser_mod.parseStmtBlock(self.gpa, hook_text) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + // A cooked hook that fails to re-parse is a corrupt asset (the cook + // validated it via `renderStmtRunAlloc` → `HookRenderFailed`), so this + // should be unreachable in practice — surface it clearly regardless. + else => return error.MalformedExtensionHook, + }; + defer block.deinit(self.gpa); + if (block.diagnostics.len > 0) return error.MalformedExtensionHook; + + // Rebind the program AST to the hook arena for the body's duration. Safe: + // `ast` is a reassignable `*const AstArena` field; nothing on the executor + // path dereferences a *program*-arena `NodeId` while rebound (component / + // resource field access + enum shorthand resolve by NAME via the registry, + // `emit` enqueues by event-name id, and hook-arena `StringId`s resolve + // through `self.ast.strings`). + const saved_ast = self.ast; + self.ast = &block.ast; + defer self.ast = saved_ast; + + var locals: Locals = .{}; + defer locals.deinit(self.gpa); + defer self.collections.reset(self.gpa); + defer self.closures.reset(self.gpa); + defer self.structs.reset(self.gpa); + defer self.optionals.clearRetainingCapacity(); + defer self.resetRunStrings(); + + // Bind the implicit `entity` — only if the body references it (else the + // name is not interned in the hook arena and no binding is needed). + if (block.ast.strings.find("entity")) |eid| { + try locals.put(self.gpa, eid, .{ .entity_id = @bitCast(entity) }, false); + } + + // Route the body's deferred structural mutations to the world's shared + // observer-deferred buffer (lazily created; freed by the registry). The + // loader drains it before `on_spawned`, so a hook-issued structural change + // is applied at the same flush boundary an observer's would be. + if (world.observer_registry.deferred == null) { + world.observer_registry.deferred = CommandBuffer.init(self.gpa, world); + } + const prev_deferred = self.observer_deferred; + self.observer_deferred = &world.observer_registry.deferred.?; + defer self.observer_deferred = prev_deferred; + + self.control = .none; + self.thrown = false; + self.returning = false; + self.pending_error = null; + + self.execStmtRun(world, &locals, block.body_start, block.body_len) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + error.RuntimeFailure => { + self.pending_error = null; + self.thrown = false; + self.returning = false; + self.control = .none; + return error.ExtensionHookFailed; + }, + }; + // Leave the interpreter clean for the next body (a hook `return` / control + // signal is not meaningful at a hook boundary — reset like an observer). + self.thrown = false; + self.returning = false; + self.control = .none; + self.pending_error = null; + } + + /// Top-level trampoline matching `World.ExtensionAttachFn` (M1.0.9 E2): + /// recover the `*Interpreter` from `ctx` and execute the cooked `on_attach` + /// hook text. `null` text (an extension with no `on_attach`) is a no-op. + fn extensionAttachTrampoline(ctx: ?*anyopaque, world: *World, entity: CoreEntityId, name: []const u8, text: ?[]const u8) anyerror!void { + _ = name; + const self: *Interpreter = @ptrCast(@alignCast(ctx.?)); + if (text) |t| try self.execHookText(world, entity, t); + } + + /// Top-level trampoline matching `World.ExtensionDetachFn` (M1.0.9 E3): same + /// shape as the attach trampoline, for the `on_detach` hook text. + fn extensionDetachTrampoline(ctx: ?*anyopaque, world: *World, entity: CoreEntityId, name: []const u8, text: ?[]const u8) anyerror!void { + _ = name; + const self: *Interpreter = @ptrCast(@alignCast(ctx.?)); + if (text) |t| try self.execHookText(world, entity, t); + } + /// Materialise an observer value binding (`value`/`old`/`new`) as a struct /// value over the component's raw bytes (M1.0.2 E3). Mirrors the `@on_event` /// payload binding: one `StructField` per declared field that is referenced @@ -2275,6 +2394,17 @@ pub const Interpreter = struct { return Value{ .struct_ref = handle }; } + /// M1.0.9 — extract the single string argument of an extension method + /// (`activate_extension` / `deactivate_extension` / `has_extension`) as raw + /// bytes. Borrowed from the current AST arena / run-string store — valid for + /// the synchronous resolve / lookup that immediately follows. + fn extensionNameArg(self: *Interpreter, world: *World, locals: *Locals, mc: ast_mod.MethodCall) StmtError![]const u8 { + if (mc.args_len != 1) return error.RuntimeFailure; + const arg: NodeId = @bitCast(self.ast.extra.items[mc.args_start]); + const v = try self.evalExpr(world, locals, arg); + return self.stringBytes(v) orelse error.RuntimeFailure; + } + /// Dispatch an instance method call on an already-evaluated receiver /// value — §5.5 order: inherent / trait on user types, then the builtin /// string / collection subsets. Split from the `.method_call` arm so the @@ -2289,7 +2419,40 @@ pub const Interpreter = struct { const method = self.methods.get(key) orelse self.trait_methods.get(key) orelse return error.RuntimeFailure; return try self.callMethod(world, locals, method, mc, recv); }, - .entity_id => { + .entity_id => |eid| { + const mname = self.ast.strings.slice(mc.method_name); + // M1.0.9 — runtime extension API on an entity receiver. Checked + // before the `impl Trait for Entity` lookup (these are builtin + // methods, not user traits). activate/deactivate route through the + // shared loader entries; a missing resolver / unknown extension / + // component conflict surfaces as the interp's `RuntimeFailure` + // (the loader path keeps the named `MissingExtensionResolver` etc.). + if (std.mem.eql(u8, mname, "activate_extension")) { + const name = try self.extensionNameArg(world, locals, mc); + const resolver = self.bridge.ext_resolver orelse return error.RuntimeFailure; + scene_loader.runtimeActivate(world, self.gpa, @bitCast(eid), name, resolver) catch return error.RuntimeFailure; + return Value{ .unit = {} }; + } + if (std.mem.eql(u8, mname, "deactivate_extension")) { + const name = try self.extensionNameArg(world, locals, mc); + const resolver = self.bridge.ext_resolver orelse return error.RuntimeFailure; + scene_loader.runtimeDeactivate(world, self.gpa, @bitCast(eid), name, resolver) catch return error.RuntimeFailure; + return Value{ .unit = {} }; + } + if (std.mem.eql(u8, mname, "has_extension")) { + const name = try self.extensionNameArg(world, locals, mc); + return Value{ .bool_ = world.hasEntityExtension(@bitCast(eid), name) }; + } + if (std.mem.eql(u8, mname, "active_extensions")) { + if (mc.args_len != 0) return error.RuntimeFailure; + const handle = try self.collections.newArray(self.gpa); + for (world.entityExtensions(@bitCast(eid))) |n| { + // Wrap each owned name as a borrowed persistent-string view + // (the names outlive the call — owned by the side-table). + try self.collections.arrays.items[handle].append(self.gpa, Value{ .string_persistent = .{ .ptr = @intFromPtr(n.ptr), .len = @intCast(n.len) } }); + } + return Value{ .array_ref = handle }; + } // Trait method on an Entity (`impl Trait for Entity`). The // type key is the interned `Entity`; self is the handle. const entity_name = self.ast.strings.find("Entity") orelse return error.RuntimeFailure; @@ -7442,3 +7605,82 @@ test "cooked Etch scene loads, on_spawned rules emit, resource string round-trip const title: [*]const u8 = @ptrFromInt(ss.ptr); try std.testing.expectEqualStrings("level_42", title[0..ss.len]); } + +test "execHookText mutates a component on the live world (M1.0.9 E2)" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + const source = + \\component Health { current: i32 = 100, max: i32 = 100 } + \\rule keep(entity: Entity) when entity has Health {} + ; + 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(); + const cid = world.registry.idOf("Health").?; + var hv = [_]i32{ 100, 100 }; // current, max + const eid = try world.spawnDynamicWithValues(gpa, &[_]ComponentId{cid}, &[_][]const u8{std.mem.asBytes(&hv)}); + + // The exact text a cooked `on_attach` carries (see CombatModule in the spec). + try interp.execHookText(&world, eid, "entity.get_mut(Health).max += 50"); + + const hb = world.componentBytes(eid, cid).?; + try std.testing.expectEqual(@as(i32, 150), std.mem.readInt(i32, hb[4..8], .little)); // max @4 +} + +test "execHookText restores self.ast and the program still steps (M1.0.9 E2)" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + const source = + \\component Health { current: i32 = 100, max: i32 = 100 } + \\rule tick(entity: Entity) when entity has Health { entity.get_mut(Health).current += 1 } + ; + 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(); + const cid = world.registry.idOf("Health").?; + var hv = [_]i32{ 100, 100 }; + const eid = try world.spawnDynamicWithValues(gpa, &[_]ComponentId{cid}, &[_][]const u8{std.mem.asBytes(&hv)}); + + const program_ast = interp.ast; // == &pr.ast + try interp.execHookText(&world, eid, "entity.get_mut(Health).max += 50"); + // The hook ran against a transient arena; the program AST pointer is restored. + try std.testing.expectEqual(program_ast, interp.ast); + + // The program still steps on the restored AST: the rule bumps current 100→101. + _ = try interp.runFor(&world, 1); + const hb = world.componentBytes(eid, cid).?; + try std.testing.expectEqual(@as(i32, 101), std.mem.readInt(i32, hb[0..4], .little)); // current @0 + try std.testing.expectEqual(@as(i32, 150), std.mem.readInt(i32, hb[4..8], .little)); // max @4 (hook effect persisted) +} + +test "execHookText emit enqueues into the dynamic event store (M1.0.9 E2)" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + const source = + \\component Health { current: i32 = 100, max: i32 = 100 } + \\rule keep(entity: Entity) when entity has Health {} + ; + 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(); + const cid = world.registry.idOf("Health").?; + var hv = [_]i32{ 100, 100 }; + const eid = try world.spawnDynamicWithValues(gpa, &[_]ComponentId{cid}, &[_][]const u8{std.mem.asBytes(&hv)}); + + try std.testing.expectEqual(@as(usize, 0), interp.events.list.items.len); + try interp.execHookText(&world, eid, "emit ExtensionAttached {}"); + // The hook's `emit` landed in the per-tick dynamic event store. + try std.testing.expectEqual(@as(usize, 1), interp.events.list.items.len); +} From a65029458d83d0bc7ed49532c9dccc7ab82e0c3c Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Tue, 30 Jun 2026 01:15:16 +0200 Subject: [PATCH 08/16] test(scene): extension hook execution + lifecycle round-trip (M1.0.9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Headline: a cooked scene activating CombatModule runs on_attach at load (Health.max 100->150). Plus activate/deactivate/has_extension/active_extensions via Etch methods (single-entity rules keep the immediate structural mutation safe), and a deferred-command drain-before-on_spawned test via a Tier-0 stand-in attach callback (the interpreter has no entity.add/spawn in bodies, so a cooked hook cannot itself issue a deferred structural change). These live here, not in interp.zig, because they need the cook pipeline (circular import otherwise) — the M1.0.8 tier-dependency precedent. Co-Authored-By: Claude Opus 4.8 --- tests/scene/extensions_test.zig | 293 ++++++++++++++++++++++++++++++++ 1 file changed, 293 insertions(+) diff --git a/tests/scene/extensions_test.zig b/tests/scene/extensions_test.zig index cac6ad0..1203fb5 100644 --- a/tests/scene/extensions_test.zig +++ b/tests/scene/extensions_test.zig @@ -19,6 +19,12 @@ const scene = weld_core.scene; const Accessor = scene.accessor.Accessor; const World = weld_core.ecs.World; const EntityId = weld_core.ecs.EntityId; +// M1.0.9 — hook execution (the interpreter binds the real on_attach/on_detach +// seam) + the deferred-drain stand-in (command buffer / observer registry). +const Interpreter = weld_etch.Interpreter; +const ComponentId = weld_core.ecs.registry.ComponentId; +const command_buffer = weld_core.ecs.command_buffer; +const CommandBuffer = command_buffer.CommandBuffer; /// One-entry in-process base-prefab resolver (for `extends`/`of`/`instance`). const OneResolver = struct { @@ -362,3 +368,290 @@ fn uuidBytes(last: u8) [16]u8 { u[15] = last; return u; } + +// ── M1.0.9 — hook EXECUTION (the E6 seam now re-parses + runs the cooked text) ── +// +// These tests live here rather than inline in `interp.zig` (where the brief lists +// the activate/deactivate/has/active tests) because they need the cook pipeline +// (`scene_cook.cookPrefab`) + the loader, which would form a circular import from +// `interp.zig` (`scene_cook` already imports `interp`). Same tier-dependency +// reason as the M1.0.8 cross-file tests. See the brief's Recorded deviations. + +/// Cook `CombatModule extends BaseCharacter` to `.prefab.bin` bytes (adds +/// `Weapon`; `on_attach` does `Health.max += 50`, `on_detach` `-= 50`). The +/// `Health` declared here matches `base_character`'s layout. Caller frees. +fn cookCombatModule(gpa: std.mem.Allocator) ![]const u8 { + var base = try scene_cook.cookPrefab(gpa, base_character, null, null); + defer base.deinit(gpa); + const base_bytes = try scene.writer.write(gpa, base.model, &base.registry); + defer gpa.free(base_bytes); + var base_res = OneResolver{ .name = "BaseCharacter", .bytes = base_bytes }; + const combat = + \\component Health { current: i32 = 100, max: i32 = 100 } + \\component Weapon { damage: i32 = 10 } + \\prefab "CombatModule" extends "BaseCharacter" requires Health { + \\ entity "mod" { uuid: "9c4f3a2b-1e7d-4a5c-b8e9-f4d2c3a1b5e6" Weapon { damage: 25 } } + \\ on_attach { entity.get_mut(Health).max += 50 } + \\ on_detach { entity.get_mut(Health).max -= 50 } + \\} + ; + var cooked = try scene_cook.cookPrefab(gpa, combat, base_res.base(), null); + defer cooked.deinit(gpa); + return scene.writer.write(gpa, cooked.model, &cooked.registry); +} + +/// Compile + bind an interpreter declaring `Health` + `Weapon` (WITH fields, so a +/// hook's `Health.max` resolves) into `world`, registering the real on_attach / +/// on_detach execution seam. The caller owns `pr` (parse result) and `interp`. +const HookEnv = struct { + pr: parser.ParseResult, + interp: Interpreter, + fn deinit(self: *HookEnv, gpa: std.mem.Allocator) void { + self.interp.deinit(); + self.pr.deinit(gpa); + } +}; + +fn spawnHealth(world: *World, gpa: std.mem.Allocator, current: i32, max: i32) !EntityId { + const cid = world.componentId("Health").?; + var hv = [_]i32{ current, max }; + return world.spawnDynamicWithValues(gpa, &[_]ComponentId{cid}, &[_][]const u8{std.mem.asBytes(&hv)}); +} + +fn healthMax(world: *World, entity: EntityId) i32 { + const hb = world.componentBytes(entity, world.componentId("Health").?).?; + return std.mem.readInt(i32, hb[4..8], .little); +} + +test "scene with active extension executes on_attach at load — Health.max adjusted (M1.0.9 headline)" { + const gpa = std.testing.allocator; + + const combat_bytes = try cookCombatModule(gpa); + defer gpa.free(combat_bytes); + + const scene_src = + \\component Health { current: i32 = 100, max: i32 = 100 } + \\scene "S" { + \\ entity "npc" { + \\ uuid: "00000000-0000-0000-0000-0000000000f1" + \\ extensions: ["CombatModule"] + \\ Health { current: 100, max: 100 } + \\ } + \\} + ; + var scene_cooked = try scene_cook.cook(gpa, scene_src, null); + defer scene_cooked.deinit(gpa); + const scene_bytes = try scene.writer.write(gpa, scene_cooked.model, &scene_cooked.registry); + defer gpa.free(scene_bytes); + + var world = World.init(); + defer world.deinit(gpa); + + // Compile + bind an interpreter declaring Health+Weapon WITH fields and + // registering the real on_attach execution seam. + const prog = + \\component Health { current: i32 = 100, max: i32 = 100 } + \\component Weapon { damage: i32 = 0 } + \\rule keep(entity: Entity) when entity has Health {} + ; + var prog_pr = try parser.parse(gpa, prog); + defer prog_pr.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), prog_pr.diagnostics.len); + var interp = try Interpreter.compile(gpa, &prog_pr.ast, &world); + defer interp.deinit(); + try interp.bindToWorld(&world); + + var ext_res = OneResolver{ .name = "CombatModule", .bytes = combat_bytes }; + var result = try scene.loader.loadFromBytes(&world, gpa, scene_bytes, ext_res.ext()); + defer result.deinit(gpa); + + const npc = result.uuid_to_entity.get(uuidBytes(0xf1)).?; + // The headline: on_attach RAN at load — Health.max went 100 → 150. + try std.testing.expectEqual(@as(i32, 150), healthMax(&world, npc)); + // The extension's component is present and the extension is tracked active. + try std.testing.expect(world.componentBytes(npc, world.componentId("Weapon").?) != null); + try std.testing.expect(world.hasEntityExtension(npc, "CombatModule")); +} + +test "entity.activate_extension executes on_attach (M1.0.9)" { + const gpa = std.testing.allocator; + const combat_bytes = try cookCombatModule(gpa); + defer gpa.free(combat_bytes); + + var world = World.init(); + defer world.deinit(gpa); + + // Single-entity rule: `activate_extension` does an IMMEDIATE structural add + // (Weapon). One matching entity makes the mid-body archetype migration safe + // (the live chunk scan has no swapped-in entity to skip); the `not has Weapon` + // guard keeps it idempotent. + const prog = + \\component Health { current: i32 = 100, max: i32 = 100 } + \\component Weapon { damage: i32 = 0 } + \\rule go(entity: Entity) when entity has Health and not entity has Weapon { + \\ entity.activate_extension("CombatModule") + \\} + ; + var pr = try parser.parse(gpa, prog); + 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); + var res = OneResolver{ .name = "CombatModule", .bytes = combat_bytes }; + interp.setExtensionResolver(res.ext()); + + const eid = try spawnHealth(&world, gpa, 100, 100); + _ = try interp.runFor(&world, 1); + + try std.testing.expectEqual(@as(i32, 150), healthMax(&world, eid)); // on_attach + try std.testing.expect(world.componentBytes(eid, world.componentId("Weapon").?) != null); + try std.testing.expect(world.hasEntityExtension(eid, "CombatModule")); +} + +test "has_extension / active_extensions (Etch methods) reflect activation (M1.0.9)" { + const gpa = std.testing.allocator; + const combat_bytes = try cookCombatModule(gpa); + defer gpa.free(combat_bytes); + + var world = World.init(); + defer world.deinit(gpa); + + const prog = + \\component Health { current: i32 = 100, max: i32 = 100 } + \\component Weapon { damage: i32 = 0 } + \\component Probe { has_combat: bool = false, count: i32 = 0 } + \\rule probe(entity: Entity) when entity has Probe { + \\ entity.get_mut(Probe).has_combat = entity.has_extension("CombatModule") + \\ entity.get_mut(Probe).count = entity.active_extensions().len() as i32 + \\} + ; + var pr = try parser.parse(gpa, prog); + 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); + var res = OneResolver{ .name = "CombatModule", .bytes = combat_bytes }; + interp.setExtensionResolver(res.ext()); + + // Spawn Health+Probe, then activate directly via the loader entry (no rule + // iteration → the immediate component add is unambiguously safe). + const health_id = world.componentId("Health").?; + const probe_id = world.componentId("Probe").?; + const eid = try world.spawnDynamic(gpa, &[_]ComponentId{ health_id, probe_id }); + try scene.loader.runtimeActivate(&world, gpa, eid, "CombatModule", res.ext()); + + _ = try interp.runFor(&world, 1); // probe reads has_extension / active_extensions + const pb = world.componentBytes(eid, probe_id).?; + try std.testing.expect(pb[0] != 0); // has_combat == true (bool @0) + try std.testing.expectEqual(@as(i32, 1), std.mem.readInt(i32, pb[4..8], .little)); // active_extensions().len() == 1 +} + +test "entity.deactivate_extension executes on_detach and removes components (M1.0.9)" { + const gpa = std.testing.allocator; + const combat_bytes = try cookCombatModule(gpa); + defer gpa.free(combat_bytes); + + var world = World.init(); + defer world.deinit(gpa); + + const prog = + \\component Health { current: i32 = 100, max: i32 = 100 } + \\component Weapon { damage: i32 = 0 } + \\rule off(entity: Entity) when entity has Weapon { + \\ entity.deactivate_extension("CombatModule") + \\} + ; + var pr = try parser.parse(gpa, prog); + 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); + var res = OneResolver{ .name = "CombatModule", .bytes = combat_bytes }; + interp.setExtensionResolver(res.ext()); + + // Activate first (direct loader entry: max 100→150, Weapon added). + const eid = try spawnHealth(&world, gpa, 100, 100); + try scene.loader.runtimeActivate(&world, gpa, eid, "CombatModule", res.ext()); + const weapon_id = world.componentId("Weapon").?; + try std.testing.expectEqual(@as(i32, 150), healthMax(&world, eid)); + try std.testing.expect(world.componentBytes(eid, weapon_id) != null); + + // The `off` rule (single entity carrying Weapon) deactivates. + _ = try interp.runFor(&world, 1); + + // on_detach ran (max 150 → 100) and the extension's component is gone. + try std.testing.expectEqual(@as(i32, 100), healthMax(&world, eid)); + try std.testing.expect(world.componentBytes(eid, weapon_id) == null); + try std.testing.expect(!world.hasEntityExtension(eid, "CombatModule")); +} + +test "on_attach-issued structural command is drained before on_spawned (M1.0.9)" { + const gpa = std.testing.allocator; + const combat_bytes = try cookCombatModule(gpa); + defer gpa.free(combat_bytes); + + const scene_src = + \\component Health { current: i32 = 100, max: i32 = 100 } + \\scene "S" { + \\ entity "npc" { + \\ uuid: "00000000-0000-0000-0000-0000000000f1" + \\ extensions: ["CombatModule"] + \\ Health { current: 100, max: 100 } + \\ } + \\} + ; + var scene_cooked = try scene_cook.cook(gpa, scene_src, null); + defer scene_cooked.deinit(gpa); + const scene_bytes = try scene.writer.write(gpa, scene_cooked.model, &scene_cooked.registry); + defer gpa.free(scene_bytes); + + var world = World.init(); + defer world.deinit(gpa); + _ = try world.registry.registerComponentRaw(gpa, .{ .name = "Health", .size = 8, .alignment = 4, .default_bytes = &[_]u8{0} ** 8, .fields = &.{} }); + _ = try world.registry.registerComponentRaw(gpa, .{ .name = "Weapon", .size = 4, .alignment = 4, .default_bytes = &[_]u8{0} ** 4, .fields = &.{} }); + DrainSpy.marker_id = try world.registry.registerComponentRaw(gpa, .{ .name = "Marker", .size = 4, .alignment = 4, .default_bytes = &[_]u8{0} ** 4, .fields = &.{} }); + DrainSpy.saw_marker_at_spawn = false; + + // Stand-in for a hook that issues a DEFERRED structural change: the attach + // callback enqueues `add_component(Marker)` into the world's shared observer- + // deferred buffer — the exact channel `execHookText` routes a hook's deferred + // structural change into. (The interpreter has no `entity.add(T)`/`spawn` in + // bodies — S4 boundary — and tag mutation is not in the cookable hook subset, + // so a cooked Etch hook cannot itself issue a deferred structural change; this + // Tier-0 stand-in exercises the same drain channel + ordering. See the brief's + // Recorded deviations.) + world.registerOnAttach(null, &DrainSpy.attachCb); + try world.observer_registry.registerOnSpawned(gpa, &world, null, &DrainSpy.onSpawnedCb); + + var ext_res = OneResolver{ .name = "CombatModule", .bytes = combat_bytes }; + var result = try scene.loader.loadFromBytes(&world, gpa, scene_bytes, ext_res.ext()); + defer result.deinit(gpa); + + const npc = result.uuid_to_entity.get(uuidBytes(0xf1)).?; + // The deferred command was drained: the entity carries Marker after load. + try std.testing.expect(world.componentBytes(npc, DrainSpy.marker_id) != null); + // And it was drained BEFORE on_spawned (the spawn observer saw Marker). + try std.testing.expect(DrainSpy.saw_marker_at_spawn); +} + +/// Tier-0 stand-in for a hook that issues a deferred structural change (see the +/// drain test). The attach callback enqueues an `add_component(Marker)` into the +/// registry's deferred buffer; the on_spawned observer records whether the +/// command was already applied when the spawn lifecycle fired. +const DrainSpy = struct { + var marker_id: ComponentId = undefined; + var saw_marker_at_spawn: bool = false; + var marker_bytes = [_]u8{ 1, 0, 0, 0 }; + fn attachCb(_: ?*anyopaque, world: *World, entity: EntityId, _: []const u8, _: ?[]const u8) anyerror!void { + if (world.observer_registry.deferred == null) { + world.observer_registry.deferred = CommandBuffer.init(std.testing.allocator, world); + } + try world.observer_registry.deferred.?.commands.append(std.testing.allocator, .{ .add_component = .{ .entity = entity, .component_id = marker_id, .bytes = marker_bytes[0..] } }); + } + fn onSpawnedCb(_: ?*anyopaque, world: *World, entity: EntityId, _: ?ComponentId, _: ?*const anyopaque, _: ?*const anyopaque, _: *CommandBuffer) anyerror!void { + if (world.componentBytes(entity, marker_id) != null) saw_marker_at_spawn = true; + } +}; From c28b879d4bca771844306fe4176ea645b2a07e8d Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Tue, 30 Jun 2026 01:18:28 +0200 Subject: [PATCH 09/16] docs(etch): refresh extension-hook seam comments (M1.0.9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Now that the on_attach/on_detach seam executes the cooked Etch text via the bridge's registered callback, the doc comments on ExtensionAttachFn, dispatchOnAttach, applyExtensions, and the M1.0.6 seam test no longer frame execution as a future M1.0.9 deferral. Part of the §3.6.1 closing audit. Co-Authored-By: Claude Opus 4.8 --- src/core/ecs/world.zig | 8 +++++--- src/core/scene/loader.zig | 6 +++--- tests/scene/extensions_test.zig | 5 +++-- 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/src/core/ecs/world.zig b/src/core/ecs/world.zig index 6cc55c2..63b07a5 100644 --- a/src/core/ecs/world.zig +++ b/src/core/ecs/world.zig @@ -95,8 +95,9 @@ const EntityIdentityStore = entity_mod.EntityIdentityStore; /// M1.0.6 E6 — the `on_attach` extension dispatch seam (D-E). A Tier-0 function /// pointer the Etch bridge registers; the scene loader fires it after adding an /// extension's components, passing the entity, the extension name, and the cooked -/// `on_attach` Etch source text (`null` if absent). M1.0.6 wires + fires the seam; -/// running the text is M1.0.9. +/// `on_attach` Etch source text (`null` if absent). M1.0.6 wired + fired the +/// seam; M1.0.9 registers the Etch bridge's callback, which re-parses + runs the +/// text — the seam itself still only fires whatever callback is registered. pub const ExtensionAttachFn = *const fn ( ctx: ?*anyopaque, world: *World, @@ -339,7 +340,8 @@ pub const World = struct { /// extension `extension_name`, passing the cooked `on_attach_text` (the Etch /// hook source; `null` if the extension has no `on_attach`). No-op if no hook /// is registered. The loader calls this after adding the extension's - /// components. Executing the text is M1.0.9 — here the seam just fires. + /// components. The registered callback (the Etch bridge, M1.0.9) re-parses + + /// executes the text; here the seam just fires it. pub fn dispatchOnAttach(self: *World, entity: EntityId, extension_name: []const u8, on_attach_text: ?[]const u8) anyerror!void { if (self.attach_hook) |h| try h.func(h.ctx, self, entity, extension_name, on_attach_text); } diff --git a/src/core/scene/loader.zig b/src/core/scene/loader.zig index 8b4e48d..2efff0a 100644 --- a/src/core/scene/loader.zig +++ b/src/core/scene/loader.zig @@ -320,9 +320,9 @@ fn resolveCrossRefs(world: *World, acc: Accessor, remap: []const ComponentId, uu /// Table, in table order, activate each of its extensions: resolve the extension /// `.prefab.bin` by name (Prefab ID Table → `ExtensionResolver`), add its /// components, and fire the `on_attach` Tier-0 seam. **No-op when the scene has no -/// active extensions** (so an extension-free scene needs no resolver). The actual -/// `on_attach` hook EXECUTION is M1.0.9 — here `dispatchOnAttach` only fires the -/// registered seam with the cooked hook text. +/// active extensions** (so an extension-free scene needs no resolver). The +/// `on_attach` hook EXECUTION (M1.0.9) runs inside the registered seam's callback +/// (the Etch bridge); here `dispatchOnAttach` fires it with the cooked hook text. fn applyExtensions(world: *World, gpa: std.mem.Allocator, acc: Accessor, uuid_to_entity: UuidMap, ext_resolver: ?ExtensionResolver) !void { const count = acc.extensionsCount(); if (count == 0) return; diff --git a/tests/scene/extensions_test.zig b/tests/scene/extensions_test.zig index 1203fb5..affb571 100644 --- a/tests/scene/extensions_test.zig +++ b/tests/scene/extensions_test.zig @@ -356,8 +356,9 @@ test "load applies extension components and the on_attach seam fires" { try std.testing.expect(AttachSpy.saw_name); try std.testing.expect(AttachSpy.saw_text); - // M1.0.9 boundary: the hook is NOT executed at load — Health.max stays 100 - // (the `+= 50` effect is M1.0.9, not E6). + // The bare Tier-0 seam (an AttachSpy callback, no Etch bridge bound) does NOT + // execute the hook — Health.max stays 100. The M1.0.9 headline test below + // binds the real interpreter callback and asserts the `+= 50` effect (150). const health_id = world.componentId("Health").?; const hb = world.componentBytes(npc, health_id).?; try std.testing.expectEqual(@as(i32, 100), std.mem.readInt(i32, hb[4..8], .little)); // max @4 From aad6a46543a9f476f57e0e746abe3870ed1fcc30 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Tue, 30 Jun 2026 01:21:20 +0200 Subject: [PATCH 10/16] docs: update CLAUDE.md state and tags for M1.0.9 Current-state table (last released tag v0.10.9, next milestone M1.1.0), a new Tags row for v0.10.9-extension-hooks, the M1.0.6 text-vs-bytecode decision closed (text re-parse), a new M1.0.9 scope-boundary open-decision entry (surface findings + recorded deviations), and the Last updated date. Co-Authored-By: Claude Opus 4.8 --- CLAUDE.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 45bed34..8899daf 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.8-const-private-test` | +| Last released tag | `v0.10.9-extension-hooks` | | Active branch | `main` | -| Next planned milestone | M1.0.9 — extension hook `on_attach`/`on_detach` execution (the text-vs-bytecode serialization decision; the interp is compile-once from the AST with no runtime text-execution surface yet). `override` stays the last reserved `non_s3_keywords` member (waits for a Tier-1 overridable module). | +| Next planned milestone | M1.1.0 — Forge 3D foundations (zolt): types + physics math + `BodyManager` (SoA aligned ECS). M1.0 (Etch ↔ ECS interpreter, M1.0.0–M1.0.9) is complete. `override` stays the last reserved `non_s3_keywords` member (waits for a Tier-1 overridable module). | ## Tags @@ -46,6 +46,7 @@ knowledge base — see § Quick links spec. | `v0.10.6-prefabs-crossrefs-extensions` | 2026-06-28 | M1.0.6 — prefabs + entity→entity cross-references + extension activation | `.prefab.bin` cook (standalone + `of` variants) + `instance of` flattening at scene cook (byte-identical to hand-authored). Entity→entity cross-refs via new `FieldKind.entity_` (8 B `EntityId`, component-only, default `dead`=`0xFF`) — by **name** (like `parent:`), resolved at load (`resolveCrossRefs`, bounds-checked → `MalformedScene`). Extension activation: `extensions:` grammar clause (entity + instance) + Entity Extensions Table + dedup Prefab ID Table + hooks sub-section in the `extensions_offset` region (shape A); `extends` cook (components + `requires` + `on_attach`/`on_detach` rendered as **text**). Load `applyExtensions`: resolve by name → `addComponentDynamic` (conflict → `ExtensionComponentConflict`) → fire Tier-0 `on_attach` seam (`registerOnAttach`/`dispatchOnAttach`; loader never touches the VM). **`format_version` 1→2** (region restructured; v1 → `BadVersion`, re-cook). Hook **execution** re-scoped → M1.0.9. | | `v0.10.7-cross-file-import` | 2026-06-29 | M1.0.7 — Cross-file `import` (resolver pass-1) | `import` graduated parser-up (lexer `kw_import` out of `non_s3_keywords`; `ImportDecl` AST + arena slabs; `parseImportDecl` — the 4 forms, items accept IDENT **and** TYPE_IDENT, D-D). `root.validateProject` builds the module dependency graph from `ProjectFile.name` (module path under `src/`), topo-sorts it (deps-first `checkProject` order), and detects cycles → **`E0108 ImportCycle`** (D-B: NOT E0101; E0101 stays DuplicateSymbol). **Per-module** byte-keyed exports index (`ExportEntry {kind, visibility, arena_index, item_id}` — NOT a flat global index; two modules exporting the same name never collide) extends the M0.9 `ProjectContext`. `bindImports` resolves each file's imports: selective items enter scope under their local name; module aliases record an `imported_alias` binding (qualified `m.Type` resolution deferred — D-F); diagnostics `E0103 NotAModule` / `E0104 UnknownExport` / `E0107 ImportPrivateItem` (wired-but-dormant until `private`, D-G). `checkComponentInstance` resolves an imported component **cross-arena** (decl fetched from its defining arena, field names compared by **bytes**) → **unblocks the E1793 false positive**: a `.prefab.etch` importing its components validates clean; E1793 fires only for a genuinely-undeclared component. Cross-arena field-TYPE check (E1795) is builtin-typed-only; named foreign field types are a documented residual. | | `v0.10.8-const-private-test` | 2026-06-29 | M1.0.8 — `const` top-level + `private` + `test` graduation | The last three `non_s3_keywords` graduate parser-up (`override` stays reserved). Lexer: `kw_const`/`kw_private`/`kw_test` added to `s3_keywords`, removed from the reserve list (identifier→keyword logic unchanged). AST: `ConstDecl`/`TestDecl` side-slabs + `Visibility {public, private}` field on the `Item` node (`itemVisibility`/`setItemVisibility`). Parser: `parseConstDecl` (`const ( IDENT \| TYPE_IDENT ) : type = const_expr`, top-level only — `parseStmt` untouched, so `const` in a block is a parse error per part1 §4.5); `parseTestDecl` (`test STRING block`, reuses `parseBlockExpr`, no execution); `private` prefix in `parseOneTopLevel` (after annotations, before dispatch; rejects `private import/const/type`; sets the item `.private`). Lockstep set extended {dispatch, `recoverToTopLevel` stop-set, the single error-message enumeration} — `private` adds no stop-set member. Resolver: `SymbolKind` += `const_`/`test_`; `pass1Collect` registers both, `checkConstValue` reuses the field-default surface (`E1101 NotConstEvaluable` + `E0200 TypeMismatch`); tests registered but not exported. `buildExports` exports `const_decl` and reads `Item.visibility` per decl → **activates the dormant `E0107 ImportPrivateItem`** check. **Cleared M1.0.7 debts**: cross-file `const` resolves; selectively importing a `private` item emits `E0107`. | +| `v0.10.9-extension-hooks` | 2026-06-30 | M1.0.9 — Execute extension hooks (`on_attach`/`on_detach`) | Founds the runtime text-execution surface M1.0.6 deferred. Decision: **text re-parse, not bytecode** (the VM is Phase 2, `etch-bytecode.md §17`). `parser.parseStmtBlock` parses a cooked hook statement-run (`"; "`-separated, no braces — the shape `descriptor.renderStmtRunAlloc` emits) into a transient `AstArena` via a new `parseStmtFragment` loop (reuses `parseStmt`, skips one optional `.semicolon` between statements). `interp.execHookText` rebinds `self.ast` to the hook arena (the field is a reassignable `*const AstArena`; the executor resolves identifiers via `self.ast.strings`, so the rebind is required), binds the implicit `entity`, runs via the existing `execStmtRun`, and routes deferred structural changes through the world's shared observer-deferred buffer (mirror of `runObserverBody`). `bindToWorld` registers the real `on_attach`/`on_detach` trampolines (`ctx = *Interpreter`); the loader's `dispatchOnAttach` now reaches execution. `world.zig` gains the `on_detach` seam (`registerOnDetach`/`dispatchOnDetach` + `ExtensionDetachFn`/`detach_hook`, mirror of `on_attach`) + a per-entity active-extension side-table (`entity_extensions`: `addEntityExtension`/`removeEntityExtension`/`hasEntityExtension`/`entityExtensions`, freed in `deinit`). `dispatchMethodOnValue` entity arm gains `activate_extension`/`deactivate_extension`/`has_extension`/`active_extensions`; the `Bridge` holds an optional `ExtensionResolver` (`setExtensionResolver`); `loader.runtimeActivate`/`runtimeDeactivate` are the pub runtime entries (deactivate fires `on_detach` first, then `removeComponentDynamic`). Headline: a cooked scene activating `CombatModule` runs `on_attach` at load (`Health.max` 100→150). Conflict policy stays **reject** (`ExtensionComponentConflict`), not last-wins. | ## Hypotheses validated by spikes @@ -72,11 +73,12 @@ knowledge base — see § Quick links spec. - **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`. -- **M1.0.6 scope boundary**: prefab `instance of` flattened at scene cook + `.prefab.bin` standalone/`of` cook; entity→entity cross-refs by name via `FieldKind.entity_` (resolved at load); extension **activation** = Entity Extensions Table + Prefab ID Table + add-component at load + Tier-0 `on_attach` dispatch seam. Hook **execution** (compile/run the Etch hook text) is **M1.0.9** — the interpreter is compile-once from the AST with no runtime text-execution surface, and the text-vs-bytecode serialization choice is unsettled. The §30.5 additive-conflict **compile-time** warning is also M1.0.9 (the cook's `diag_out` is mono-message set-on-error; a non-fatal warning channel is needed); the dangerous **runtime** case is already caught (`ExtensionComponentConflict` at load). +- **M1.0.6 scope boundary**: prefab `instance of` flattened at scene cook + `.prefab.bin` standalone/`of` cook; entity→entity cross-refs by name via `FieldKind.entity_` (resolved at load); extension **activation** = Entity Extensions Table + Prefab ID Table + add-component at load + Tier-0 `on_attach` dispatch seam. Hook **execution** (compile/run the Etch hook text) is **M1.0.9** — the interpreter is compile-once from the AST with no runtime text-execution surface, and the text-vs-bytecode serialization choice is unsettled. **[Resolved in M1.0.9: text re-parse, not bytecode — see the M1.0.9 entry below.]** The §30.5 additive-conflict **compile-time** warning was predicted M1.0.9 but **fell outside the M1.0.9 brief scope** (E1–E5 = hook execution + runtime API only) and remains deferred to a later cook milestone; the dangerous **runtime** case is already caught (`ExtensionComponentConflict` at load). - **`format_version` 1→2 (correction to the L68 day-1 prediction)**: the cross-references section was genuinely additive (no bump at E4, count-0 back-compatible), but the Entity Extensions Table's full structure (vs M1.0.4's bare `[0]` count-placeholder) is **not** count-0 back-compatible → required `format_version` 1→2. The `FieldKind` dispatch prediction held (`entity_` added as a new variant). `.scene.bin`/`.prefab.bin` are re-cookable Phase-1 artifacts (deterministic cook) → no v1 back-compat. - **`FieldKind.entity_` realizes `Entity` (M1.0.6)**: 8 B/8-align `EntityId`, default `dead` (`@memset 0xFF`, not `{0,0}` = a live handle to entity 0), component-only (gated to `reg_kind == .component`, mirror of resource-only `string_`/`enum_`). - **M1.0.7 scope boundary (cross-file import)**: `import` graduated parser-up (the only `non_s3_keywords` member to leave; `const`/`private`/`test`/`override` stay reserved for M1.0.8). **Validated approach**: a **per-module** byte-keyed exports index (`{name bytes → {kind, arena_index, item_id}}`) extends the M0.9 byte-keyed `ProjectContext` pattern (StringIds are per-arena) — NOT a flat global index, so two modules exporting the same name never collide; the imported-component cross-arena resolution (decl fetched from its defining arena, field names compared by bytes) **unblocks the E1793 false positive** for `.prefab.etch`. **Deferred-but-pre-wired**: module-alias qualified `m.Type` resolution (D-F — the `imported_alias` binding is recorded at E5, so the descending `Path` walk is purely additive later); `E0107 ImportPrivateItem` (D-G — wired through the exports `visibility` flag, dormant until `private` graduates M1.0.8). **Not debt — moot**: the cross-arena field-TYPE check (E1795) resolves builtin types; this is COMPLETE for components because `validateFieldsInDecl(.component_like)` admits only builtin-POD field types (named struct/enum/string rejected on components) — the named-type branch is unreachable for a valid component (forward-compat headroom only). The cross-file `const`-import acceptance test is deferred to M1.0.8 (`const` is not parseable until it graduates) — cross-file resolution is covered by the imported-component type test + the prefab unblock. - **M1.0.8 scope boundary (`const`/`private`/`test` graduation)**: the last three `non_s3_keywords` graduate parser-up; `override` stays reserved (waits for a Tier-1 overridable module). **Top-level `const` only** — `parseStmt` is deliberately NOT extended, so a block-level `const` is a parse error; the tri-document drift (`const_stmt` under `etch-grammar.md §4.1` statements vs §4.5 "top-level only" vs `local_const` in `etch-resolver-types.md §2.1`) is a PREEXISTING cross-doc inconsistency deferred to a KB-audit conversation (NOT resolved here). **`private` is direct export-visibility + `E0107` only** — visibility inheritance (`etch-resolver-types.md §10.2`) and `W0902 PrivateTypeInPublicImpl` stay additive/deferred; `private` is parsed as a prefix on a `declaration_body` (rejects `private import/const/type`) and adds no `recoverToTopLevel` stop-set member. **`test` is parse + validate + symbol registration only** — no execution surface exists (same blocker family as M1.0.9); tests register a `test_` symbol but are never exported. **Residual**: a string-named `test "X"` registers under the byte sequence `X` via `registerSymbol`, so it shares the name namespace with identifier-named symbols (a `test "Foo"` collides with `component Foo` → `E0101`); acceptable for M1.0.8, revisit when the M1.0.9 test-runner formalizes test identity. **Cross-file tests** live in `tests/etch/import_resolve_test.zig` (the `validateProject` harness), not inline in `types.zig` (which cannot reach `validateProject` — a tier-up dependency). +- **M1.0.9 scope boundary (execute extension hooks)** — **decided: TEXT re-parse, not bytecode** (the VM is Phase 2, `etch-bytecode.md §17`; `etch-visual-scripting.md §4`). The Tier-0 seam (`register`/`dispatch` `On{Attach,Detach}`) is backend-agnostic — a Phase-2 migration swaps the bridge callback + the cook side + a format bump. `execHookText` reuses the SAME `execStmtRun` that runs all Phase-1 gameplay (not hook-specific scaffolding). **Surface findings vs the brief's premise** (the brief's NOTE pre-authorizes adapting to the actual surface): (1) the interpreter has **no `entity.add(T)`/`spawn`/`despawn`** structural mutation in bodies (S4 boundary, `interp.zig` header) and the only deferred-structural producer (tag mutation) is **not in the cookable hook subset** `{let,emit,expr,assign,return}` — so a cooked Etch hook **cannot** itself issue a deferred structural change; the "drained before `on_spawned`" test is realized with a Tier-0 stand-in attach callback that enqueues into the same observer-deferred channel (Recorded deviation). (2) Runtime `activate_extension`/`deactivate_extension` do **immediate** `addComponentDynamic`/`removeComponentDynamic` (the shared `activateExtension` path per brief), which is unsafe mid-`iterateArchetype`; the round-trip tests use single-entity rules (the live-chunk scan has no swapped-in entity to skip) or the direct `runtimeActivate` entry. (3) The activate/deactivate/has/active tests live in `tests/scene/extensions_test.zig`, not inline in `interp.zig`, because they need the cook pipeline (`scene_cook` → circular import otherwise) — the M1.0.8 tier-dependency precedent. (4) The four new entity methods are **interpreter-level only** (no type-checker recognition) — tests skip the type-checker, consistent with the brief's interp-only E3/E4; type-checker support for them is not in M1.0.9. **Deferred (not M1.0.9)**: the §30.5 compile-time additive-conflict warning (out of brief scope); a `test`-runner (the `ast.zig` `TestDecl` comment "execution surface is M1.0.9" is a pre-existing M1.0.8 imprecision — M1.0.9 delivers HOOK execution, not `test` execution). ## Non-negotiable rules @@ -210,4 +212,4 @@ The `briefs/` directory is the source of truth for milestone state. The brief's --- -Last updated: 2026-06-29 +Last updated: 2026-06-30 From 1b1028504d09bd68f338e32af8ddee4b6296ce5d Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Tue, 30 Jun 2026 01:23:33 +0200 Subject: [PATCH 11/16] docs(brief): close M1.0.9 Co-Authored-By: Claude Opus 4.8 --- briefs/M1.0.9-extension-hooks.md | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/briefs/M1.0.9-extension-hooks.md b/briefs/M1.0.9-extension-hooks.md index 32cb4d7..d1a92ce 100644 --- a/briefs/M1.0.9-extension-hooks.md +++ b/briefs/M1.0.9-extension-hooks.md @@ -1,12 +1,12 @@ # M1.0.9 — Execute extension hooks (`on_attach` / `on_detach`) -> **Status:** ACTIVE +> **Status:** CLOSED > **Phase:** 1.0 > **Branch:** `phase-1/etch/extension-hooks` > **Planned tag:** `v0.10.9-extension-hooks` > **Dependencies:** M1.0.6 (`v0.10.6-prefabs-crossrefs-extensions`) for the dispatch seam + cooked hook text; base tag M1.0.8 (`v0.10.8-const-private-test`) > **Opened:** 2026-06-29 -> **Closed:** — +> **Closed:** 2026-06-30 --- @@ -135,12 +135,22 @@ M1.0.9 founds the runtime text-execution surface that M1.0.6 deferred. At M1.0.6 - 2026-06-30 00:35 — Surface reconnaissance (read-only) complete; all brief-named symbols confirmed first-hand. Line numbers from the brief are accurate (no drift): `Interpreter.ast` interp.zig:537, `execBody` :1663, `execStmtRun` :1755, `execAssign` :2019, `dispatchMethodOnValue` :2284; `world.registerOnAttach` :293 / `dispatchOnAttach` :302 / `ExtensionAttachFn` :100 / `AttachHook` :109 / `attach_hook` field :185; `removeComponentDynamic` :792 / `addComponentDynamic` :726 / `World.deinit` :202; `loader.activateExtension` :345 / `dispatchSpawnLifecycle` :436 / `applyExtensions` :316 / `ExtensionResolver` loader.zig:69; `accessor.Hook` accessor.zig:213 + `hook(i)` :215; `renderStmtRunAlloc` descriptor.zig:980. - 2026-06-30 00:35 — Five surface findings that refine (not contradict) the frozen design: (1) the brief is RIGHT that `self.ast` is rebindable — the field is `ast: *const AstArena` (pointer-to-const, reassignable when `self` is `*Interpreter`); rebind is also NECESSARY because `execStmt`/`evalExpr` resolve identifiers via `self.ast.strings`, so the hook body must execute with `self.ast` pointing at the hook arena. (2) `renderStmtRunAlloc` emits statements separated by `"; "` with NO braces; the lexer has a `.semicolon` token but the parser only consumes it inside a fill-array `[v; n]` (parser.zig:6157), NEVER between statements → `parseStmtBlock` (E1) needs its OWN loop: `parseStmt` then skip an optional `.semicolon`, until `.eof` (reuses `parseStmt`, the statement parser — new entry point, not new grammar). (3) Two `EntityId` types: core `packed struct(u64){index,generation}` (entity.zig:30) vs interp `u64` (value.zig:21); the bridge bitcasts (mirror `runObserverBody` interp.zig:1077). (4) Deferred structural changes from a body route via `self.observer_deferred` → `world.observer_registry.deferred` (`?CommandBuffer`, lazily `ensureDeferred`); `flushWithObservers` drains it first → `dispatchSpawnLifecycle`'s opening flush already applies hook-issued deferred cmds before `on_spawned`. `execHookText` mirrors `runObserverBody` (fresh `Locals`, reset stores, set `observer_deferred`) + the `self.ast` rebind. (5) Interp→loader is a legal dependency (`weld_etch` depends on `weld_core`; `weld_core.scene.loader` + `ExtensionResolver` are exported), so the runtime activate/deactivate entries live in `loader.zig` and the interp calls them. - 2026-06-30 00:35 — RISK noted for E2 deferred-drain test: the interp has NO `entity.add(T)`/`spawn`/`despawn` structural mutation in bodies (S4 boundary, interp.zig:11), and the only deferred structural producer (tag mutation) is NOT in the cookable subset `{let,emit,expr,assign,return}` (descriptor.zig renderStmt). Headline test (`Health.max += 50`) is an IMMEDIATE field write (no deferral). The brief NOTE "the surface is the source of truth" pre-authorizes adapting the "structural command drained before on_spawned" test to the actual deferred mechanism; will resolve its concrete realization during E2 and record any deviation, escalating only if no in-scope realization exists. +- 2026-06-30 00:55 — **E1**: `parseStmtBlock` + `StmtBlockResult` (`parser.zig`) — a new fragment entry that reuses `parseStmt` via `parseStmtFragment` (skips one optional `.semicolon` between statements, matching `renderStmtRunAlloc`'s `"; "` join; the parser otherwise only consumes `;` inside a fill-array literal). 2 inline tests green. Commit `f0da191`. +- 2026-06-30 01:00 — **E3/E4 (world-side)**: `world.zig` `on_detach` seam (`ExtensionDetachFn`/`detach_hook`/`registerOnDetach`/`dispatchOnDetach`, mirror) + per-entity `entity_extensions` side-table (`add`/`remove`/`has`/`entityExtensions`, freed in `deinit`). 2 inline tests green (seam + side-table incl. leak-freedom). Verified the `_ = ecs.world;` pin runs them. Commit `b7bec52`. +- 2026-06-30 01:10 — **E2 + E3/E4 (rest)**: `interp.execHookText` (transient arena + `self.ast` rebind + implicit `entity` + `execStmtRun` + observer-deferred routing) + `bindToWorld` registers the real attach/detach trampolines + `dispatchMethodOnValue` 4 entity methods + `setExtensionResolver` + bridge `ext_resolver`; `loader` `addEntityExtension` in the shared `activateExtension` + `runtimeActivate`/`runtimeDeactivate` + explicit deferred drain after the activation pass. 3 `execHookText` inline tests + 5 scene tests (headline + activate/has/active/deactivate + drain) green. Commits `3adc8c4`, `a650294`. +- 2026-06-30 01:18 — Gates green: `zig build` (zero warnings), `zig build test` (full suite exit 0, no leaks), `zig build test-extensions` (13/13), `zig fmt --check`, `zig build lint`. §3.6.1 audits: language clean (no FR in diff/brief); drift — refreshed the now-stale "execution is M1.0.9" doc comments in `world.zig`/`loader.zig`/`extensions_test.zig` (commit `c28b879`); two out-of-scope residual refs noted in Closing notes. `CLAUDE.md` §3.4 updated (commit `aad6a46`). ## Recorded deviations *Changes to the FROZEN SECTION made mid-milestone after a Claude.ai round-trip. Each deviation references the commit that records it. If empty at milestone end: nominal case.* -- +> **None of the below were round-tripped through Claude.ai.** They are surface-adaptations made under the FROZEN NOTE *"Surface verified @ v0.10.8 during scoping; reconfirm at clone — the surface is the source of truth"*, which pre-authorizes adapting to the actual code surface where the brief's premise diverged. **Flagged for Guy's review** — item (1) is the one that touches the spirit of a frozen acceptance test and may warrant a round-trip if the literal form is wanted. + +- `a650294` — **(1) The "on_attach structural command drained before on_spawned" acceptance test (#3 of the loader/scene tests) is realized with a Tier-0 stand-in attach callback that enqueues a deferred `add_component` into the world's observer-deferred buffer — NOT a cooked Etch hook issuing `entity.add(...)`/`spawn`.** Reason (surface vs brief premise): the tree-walking interpreter has **no `entity.add(T)`/`spawn`/`despawn`** structural mutation in rule/hook bodies (S4 boundary, `interp.zig` header), and the only deferred-structural producer (tag mutation) is **not in the cookable hook subset** `{let,emit,expr,assign,return}` (`descriptor.renderStmt`). So a *cooked Etch hook cannot itself issue a deferred structural change* with the current surface. The drain MECHANISM (E2: `execHookText` routes through `observer_deferred`; loader drains after the activation pass, before `on_spawned`) is built and correct; the stand-in exercises the exact same drain channel + ordering. The headline test (`Health.max += 50`) and the round-trip use IMMEDIATE field writes / component add-remove, which the surface fully supports. +- `a650294` — **(2) The `activate_extension`/`deactivate_extension`/`has_extension`/`active_extensions` tests (brief lists them under `src/etch/interp.zig`) live in `tests/scene/extensions_test.zig`.** Reason: they need the cook pipeline (`scene_cook.cookPrefab`), and `interp.zig` importing `scene_cook` would be circular (`scene_cook` imports `interp`). Same tier-dependency reason as the M1.0.8 cross-file tests. The 3 `execHookText` tests that need no cook stay inline in `interp.zig` as specified. +- `3adc8c4` — **(3) The real `ExtensionAttachFn`/`ExtensionDetachFn` callbacks (trampolines) live in `interp.zig`** (next to `observerTrampoline`), not in `ecs_bridge.zig` as the brief's "Files to create or modify" lists. Reason: the trampoline calls `Interpreter.execHookText` and needs the `*Interpreter` type; `ecs_bridge.zig` hosting it would be a circular import (`interp.zig` already imports `ecs_bridge`). Only the `ExtensionResolver` field lives on `Bridge` (`ecs_bridge.zig`) as the brief specifies. +- `f0da191` — **(4) The E1 parse test's emit body is `emit ExtensionAttached { source: entity }`, not the brief's `emit ExtensionAttached { entity }`.** Reason: Etch struct-literal field-init requires `field: expression` (`etch-grammar.md §4.3`); there is no bare-name shorthand, so `{ entity }` is a parse error. The 2-statement assign+emit shape the test verifies is unchanged. +- `aad6a46` — **(5) The §30.5 additive-conflict compile-time warning is NOT delivered.** The M1.0.6 `CLAUDE.md` entry predicted it would land in M1.0.9, but it is outside the M1.0.9 brief's Scope (E1–E5 = hook execution + runtime API; the warning is a cook-time `diag_out` change). Recorded as still-deferred in `CLAUDE.md` Open decisions. The dangerous runtime conflict case is already caught (`ExtensionComponentConflict`). ## Blockers encountered @@ -152,8 +162,8 @@ M1.0.9 founds the runtime text-execution surface that M1.0.6 deferred. At M1.0.6 *Fill in at Status → CLOSED, just before opening the PR.* -- **What worked:** -- **What deviated from the original spec:** -- **What to flag explicitly in review:** -- **Final measurements** (perf, binary size, compile time, whatever is relevant to the milestone): -- **Residual risks / tech debt left intentionally:** +- **What worked:** Recon-first (a read-only parallel surface map + first-hand re-reads) caught three things before any code: the `;`-separator (the cooked hook text is `"; "`-joined but the parser only consumes `;` inside fill-arrays → `parseStmtBlock` needs its own separator-skipping loop), the brief's CORRECT call that `self.ast` is rebindable (`*const AstArena` is a reassignable field — a recon agent misread it as immutable; the rebind is in fact *required* because the executor resolves identifiers via `self.ast.strings`), and the two-`EntityId` duality (core `packed struct(u64)` vs interp `u64`). `execHookText` mirrors `runObserverBody` almost exactly, so it inherits the proven fresh-scope/store-reset/deferred-routing discipline. The M1.0.6 `extensions_test.zig` scaffold (`OneResolver`, `cookPrefab` → `writer.write`) made the scene/round-trip tests cheap. +- **What deviated from the original spec:** Five surface-adaptations (see Recorded deviations), none round-tripped — all under the FROZEN "surface is the source of truth" note. The headline deviation is **#1**: the "deferred structural change drained before on_spawned" test is realized with a Tier-0 stand-in (the interpreter has no `entity.add`/`spawn` in bodies and tag mutation is not cookable, so a cooked hook literally cannot issue a deferred structural change today). The drain mechanism itself is built and correct. +- **What to flag explicitly in review:** (a) **Deviation #1** — confirm the stand-in realization of the drain test is acceptable, or round-trip if the literal `entity.add`/`spawn`-from-a-cooked-hook form is wanted (it needs interp structural-mutation support, a separate milestone). (b) Runtime `activate_extension`/`deactivate_extension` do **immediate** `addComponentDynamic`/`removeComponentDynamic` (the brief's shared-path design) — unsafe if called while a multi-entity query iterates that archetype; the tests use single-entity rules / the direct entry. Worth deciding later whether the Etch method should defer (a new command-buffer variant) for general safety. (c) The four new entity methods are **interpreter-level only** — the type-checker does not recognise them (tests skip the checker), consistent with the brief's interp-only E3/E4. +- **Final measurements** (perf, binary size, compile time, whatever is relevant to the milestone): N/A — correctness milestone (hooks run once per activation, not per tick). +12 tests (parser 2, interp 3, world 2, scene 5); full `zig build test` green debug + ReleaseSafe (no leaks under the testing allocator), `test-extensions` 13/13, `zig build` zero warnings, `fmt`/`lint` clean. +- **Residual risks / tech debt left intentionally:** (1) A cooked Etch hook cannot issue a *deferred* structural change until the interpreter gains `entity.add(T)`/`spawn`/`despawn` (S4 boundary) — the drain wiring future-proofs it. (2) Runtime activate/deactivate immediate-mutation safety (see flag b). (3) The §30.5 compile-time additive-conflict warning is deferred (out of M1.0.9 scope). (4) Two out-of-scope "M1.0.9" doc-comment references left untouched: `src/etch/ast.zig:647` (`TestDecl` — says `test` execution "is M1.0.9"; pre-existing M1.0.8 imprecision, M1.0.9 delivers *hook* execution not *test* execution) and `tests/scene/prefab_integration_test.zig:6` (correctly points to `extensions_test.zig` for hook execution). Both are outside the milestone's file set and substantively defensible; a KB/test-comment cleanup can fold them in. From fd148facbcd6e1aa12347f68b831c971ebced0df Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Tue, 30 Jun 2026 10:40:23 +0200 Subject: [PATCH 12/16] docs(brief): record B1+B2 round-trip (M1.0.9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reopened ACTIVE for the merge-blocker round-trip. FROZEN delta: E3 gains the B1 deferred-command requirement (Etch activate/deactivate enqueue, applied at the flush boundary after iteration — runtimeActivate/runtimeDeactivate stay for load + direct paths); E4 gains the B2 type-checker recognition of the four entity methods; two acceptance tests added; Recorded deviations B1 + B2. Co-Authored-By: Claude Opus 4.8 --- briefs/M1.0.9-extension-hooks.md | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/briefs/M1.0.9-extension-hooks.md b/briefs/M1.0.9-extension-hooks.md index d1a92ce..c63166e 100644 --- a/briefs/M1.0.9-extension-hooks.md +++ b/briefs/M1.0.9-extension-hooks.md @@ -1,12 +1,12 @@ # M1.0.9 — Execute extension hooks (`on_attach` / `on_detach`) -> **Status:** CLOSED +> **Status:** ACTIVE (reopened 2026-06-30 for the B1+B2 merge-blocker round-trip) > **Phase:** 1.0 > **Branch:** `phase-1/etch/extension-hooks` > **Planned tag:** `v0.10.9-extension-hooks` > **Dependencies:** M1.0.6 (`v0.10.6-prefabs-crossrefs-extensions`) for the dispatch seam + cooked hook text; base tag M1.0.8 (`v0.10.8-const-private-test`) > **Opened:** 2026-06-29 -> **Closed:** 2026-06-30 +> **Closed:** — --- @@ -23,7 +23,9 @@ M1.0.9 founds the runtime text-execution surface that M1.0.6 deferred. At M1.0.6 - **E1 — Parser fragment entry.** `parseStmtBlock` in `src/etch/parser.zig`: parse a bare statement run (the exact shape `scene_cook.zig::renderStmtRunAlloc` emits for a hook body) into a fresh `AstArena`, exposing the body statement range (`body_start` / `body_len`, the same encoding rule bodies use in `extra`). Reuses the existing statement parser used for rule/`fn` bodies — this is a new ENTRY POINT, not a new statement grammar. An empty body parses to a zero-statement block with no diagnostics. - **E2 — Hook-execution primitive + `on_attach` at load.** `Interpreter.execHookText(world, entity, text)` in `src/etch/interp.zig`: parse `text` via `parseStmtBlock` into a transient `AstArena`; rebind `self.ast` to that arena for the duration (`const saved = self.ast; self.ast = &hook_ast; defer self.ast = saved;`); bind the implicit `entity` into a fresh `Locals` scope as a `Value.entity_id`; run the body with the existing `execStmtRun`; then drain hook-issued deferred structural commands via the existing command-buffer flush path. The real `ExtensionAttachFn` lives in `src/etch/ecs_bridge.zig` with `ctx = *Interpreter`, calling `execHookText`; the loader's `dispatchOnAttach` now reaches execution. The extension-activation pass drains deferred commands AFTER the pass, BEFORE `on_spawned` (mirror `dispatchSpawnLifecycle`'s drain). - **E3 — `on_detach` seam + runtime activate/deactivate.** `src/core/ecs/world.zig` gains `registerOnDetach` / `dispatchOnDetach` (mirror of the `on_attach` pair: an `ExtensionDetachFn` type + a `detach_hook` field, last-registration-wins). A runtime activation entry reuses the shared `activateExtension` (add components + execute `on_attach`); a runtime deactivation entry fires `on_detach` via the new seam, then removes the extension's declared components via `removeComponentDynamic`. `src/etch/interp.zig::dispatchMethodOnValue` gains entity-receiver method branches: `activate_extension(name)` / `deactivate_extension(name)` routed to the runtime entries. The bridge holds the `ExtensionResolver` (name → cooked `.prefab.bin` bytes, the same interface the loader receives) so a name-only Etch call resolves at runtime; absent resolver → a clear error. + - **B1 (round-trip 2026-06-30) — deferred activate/deactivate.** `iterateArchetype` walks `arch.chunks` LIVE (no snapshot), so an Etch `entity.activate_extension`/`deactivate_extension` that does an IMMEDIATE `addComponentDynamic`/`removeComponentDynamic` migrates the entity's archetype mid-iteration and corrupts the walk. Fix: the Etch method dispatch (`dispatchMethodOnValue`, entity arm) **ENQUEUES a deferred command** (resolving the extension bytes at the call) instead of calling `runtimeActivate`/`runtimeDeactivate`; the structural change + hook are applied at the **flush boundary** (after iteration) — add (activate) / remove (deactivate) components → Tier-0 `dispatchOnAttach`/`dispatchOnDetach`. No recursive drain. `runtimeActivate`/`runtimeDeactivate` (immediate) **remain** for the load + direct-programmatic paths (outside iteration, safe). - **E4 — Introspection + active-set.** `src/core/ecs/world.zig` tracks per-entity active extensions in a Tier-0 side-table (`EntityId` → owned list of extension-name byte-slices), populated INSIDE the shared `activateExtension` (so load AND runtime activation both track for free) and pruned on deactivate; freed at `World.deinit`. `src/etch/interp.zig::dispatchMethodOnValue` exposes `has_extension(name) -> bool` and `active_extensions() -> [string]` reading the side-table (wrapping owned names as Etch string values on the read path). + - **B2 (round-trip 2026-06-30) — type-checker recognition.** `synthMethodCall` (entity arm, `src/etch/types.zig`) emits `.type_mismatch` "no method '{s}' on an Entity" for any non-inherent/trait method, so the four new methods — recognized only at runtime (`dispatchMethodOnValue`) — fail `weld check`, making the API unusable from real Etch. Fix: in the entity arm of `synthMethodCall`, BEFORE the "no method … on an Entity" error, recognize the four builtin methods on an `Entity` receiver with arg-count/type validation: `activate_extension(string) -> unit`, `deactivate_extension(string) -> unit`, `has_extension(string) -> bool`, `active_extensions() -> [string]`. - **E5 — CLAUDE.md update (§3.4).** On the milestone branch, within the closing PR: update the current-state table; add one Tags row (`v0.10.9-extension-hooks`); close the **"text-vs-bytecode for hook execution"** entry in *Open / deferred decisions* (decided: text); update the "Last updated" date. No narrative prose. ## Out of scope @@ -72,6 +74,8 @@ M1.0.9 founds the runtime text-execution surface that M1.0.6 deferred. At M1.0.6 - `src/core/ecs/world.zig` — `test "registerOnDetach/dispatchOnDetach fires the seam"` — a Tier-0 stand-in callback receives the cooked `on_detach` text (mirror of the M1.0.6 `on_attach` seam test) - `src/core/scene/loader.zig` (or canonical scene test) — `test "scene with active extension executes on_attach at load"` — a cooked scene with `extensions: ["CombatModule"]` adding to `Health`; load → `Health.max` adjusted (**the headline criterion**) - `src/core/scene/loader.zig` (or canonical scene test) — `test "on_attach structural command is drained before on_spawned"` — a hook that issues a deferred structural change (`entity.add(...)` / spawn) is visible after load +- **B1** — canonical scene test — `test "multi-entity rule activate_extension defers without corrupting iteration"` — a rule matching N (>1) entities, each calling `entity.activate_extension`, runs over the full live archetype walk with NO corruption; after the tick's flush every matched entity carries the extension's components + the `on_attach` effect +- **B2** — `src/etch/types.zig` (or a `tests/etch/` type-check harness) — `test "extension methods type-check on an Entity receiver"` — a type-checked Etch program (NO checker skip) whose rule body calls all four methods (`activate_extension`/`deactivate_extension`/`has_extension`/`active_extensions`) passes `TypeChecker.check` with zero diagnostics (no `E0200`/`type_mismatch`) ### Benchmarks @@ -144,7 +148,14 @@ M1.0.9 founds the runtime text-execution surface that M1.0.6 deferred. At M1.0.6 *Changes to the FROZEN SECTION made mid-milestone after a Claude.ai round-trip. Each deviation references the commit that records it. If empty at milestone end: nominal case.* -> **None of the below were round-tripped through Claude.ai.** They are surface-adaptations made under the FROZEN NOTE *"Surface verified @ v0.10.8 during scoping; reconfirm at clone — the surface is the source of truth"*, which pre-authorizes adapting to the actual code surface where the brief's premise diverged. **Flagged for Guy's review** — item (1) is the one that touches the spirit of a frozen acceptance test and may warrant a round-trip if the literal form is wanted. +**Round-tripped through Claude.ai (2026-06-30) — the B1+B2 merge-blocker delta.** Recorded with `docs(brief): record B1+B2 round-trip (M1.0.9)`; implemented in the commits that follow it. + +- **B1 — deferred activate/deactivate (fixes immediate structural mutation mid-iteration).** `iterateArchetype` walks `arch.chunks` live; the Etch `activate_extension`/`deactivate_extension` methods previously called `runtimeActivate`/`runtimeDeactivate` (immediate `add`/`removeComponentDynamic`) → archetype migration mid-iteration → corrupted walk. Now `dispatchMethodOnValue` ENQUEUES a deferred command (extension bytes resolved at the call); it is applied at the flush boundary (after iteration) — components added/removed + the Tier-0 `dispatchOnAttach`/`dispatchOnDetach` seam fired, no recursive drain. `runtimeActivate`/`runtimeDeactivate` (immediate) stay for the load + direct-programmatic paths. **Realization detail:** the deferred queue is **interp-side** (`pending_extensions`, a mirror of `pending_tags`, flushed at the tick boundary), NOT the Tier-0 `CommandBuffer` — the ECS command-buffer's apply path (Tier 0) cannot reach `src/core/scene/loader.zig` to apply an extension op (the layering forbids `ecs → scene`); the interpreter (`weld_etch`) is the correct tier to own + flush it, and it calls the same `activateExtension` / a new bytes-taking `deactivateExtension` that fire the seam. +1 test (B1, multi-entity, no corruption). +- **B2 — type-checker recognition of the four extension methods.** `synthMethodCall` (entity arm, `src/etch/types.zig`) rejected any non-inherent/trait method on an `Entity` with `.type_mismatch`, so a real rule body calling the methods failed `weld check`. The entity arm now recognizes `activate_extension(string) -> unit`, `deactivate_extension(string) -> unit`, `has_extension(string) -> bool`, `active_extensions() -> [string]` (arg-count/type validated) before the "no method" error. This SUPERSEDES the M1.0.9 *Out of scope* "interpreter-level only / type-checker not touched" position for these four methods (the tests no longer skip the checker). +1 test (B2, type-check clean). + +--- + +> **The entries below were NOT round-tripped through Claude.ai.** They are surface-adaptations made under the FROZEN NOTE *"Surface verified @ v0.10.8 during scoping; reconfirm at clone — the surface is the source of truth"*, which pre-authorizes adapting to the actual code surface where the brief's premise diverged. **Flagged for Guy's review** — item (1) is the one that touches the spirit of a frozen acceptance test and may warrant a round-trip if the literal form is wanted. - `a650294` — **(1) The "on_attach structural command drained before on_spawned" acceptance test (#3 of the loader/scene tests) is realized with a Tier-0 stand-in attach callback that enqueues a deferred `add_component` into the world's observer-deferred buffer — NOT a cooked Etch hook issuing `entity.add(...)`/`spawn`.** Reason (surface vs brief premise): the tree-walking interpreter has **no `entity.add(T)`/`spawn`/`despawn`** structural mutation in rule/hook bodies (S4 boundary, `interp.zig` header), and the only deferred-structural producer (tag mutation) is **not in the cookable hook subset** `{let,emit,expr,assign,return}` (`descriptor.renderStmt`). So a *cooked Etch hook cannot itself issue a deferred structural change* with the current surface. The drain MECHANISM (E2: `execHookText` routes through `observer_deferred`; loader drains after the activation pass, before `on_spawned`) is built and correct; the stand-in exercises the exact same drain channel + ordering. The headline test (`Health.max += 50`) and the round-trip use IMMEDIATE field writes / component add-remove, which the surface fully supports. - `a650294` — **(2) The `activate_extension`/`deactivate_extension`/`has_extension`/`active_extensions` tests (brief lists them under `src/etch/interp.zig`) live in `tests/scene/extensions_test.zig`.** Reason: they need the cook pipeline (`scene_cook.cookPrefab`), and `interp.zig` importing `scene_cook` would be circular (`scene_cook` imports `interp`). Same tier-dependency reason as the M1.0.8 cross-file tests. The 3 `execHookText` tests that need no cook stay inline in `interp.zig` as specified. From 2c844726a6cf1e44d5583e7579b93a86e1075041 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Tue, 30 Jun 2026 10:47:56 +0200 Subject: [PATCH 13/16] fix(etch): defer extension activate/deactivate (M1.0.9 B1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit iterateArchetype walks arch.chunks LIVE, so an Etch activate_extension/ deactivate_extension doing an immediate add/removeComponentDynamic migrated the entity's archetype mid-iteration and corrupted the walk. dispatchMethodOnValue now ENQUEUES a deferred command (PendingExtension, mirror of pending_tags; extension bytes resolved at the call), drained at the tick boundary by flushPendingExtensions (after iteration, snapshot to avoid recursive drain), which calls the loader's bytes-taking activateExtension / new deactivateExtension (both pub now) — components added/removed + the Tier-0 seam fired. The immediate runtimeActivate/runtimeDeactivate stay for the load + direct-programmatic paths. +1 test: a multi-entity rule activates each matched entity with no corruption. Co-Authored-By: Claude Opus 4.8 --- src/core/scene/loader.zig | 33 +++++++++++---- src/etch/interp.zig | 73 ++++++++++++++++++++++++++++++--- tests/scene/extensions_test.zig | 51 +++++++++++++++++++++-- 3 files changed, 138 insertions(+), 19 deletions(-) diff --git a/src/core/scene/loader.zig b/src/core/scene/loader.zig index 2efff0a..b336918 100644 --- a/src/core/scene/loader.zig +++ b/src/core/scene/loader.zig @@ -346,13 +346,15 @@ fn applyExtensions(world: *World, gpa: std.mem.Allocator, acc: Accessor, uuid_to } } -/// Activate one extension on one entity (M1.0.6 E6) — the shared path reused by -/// the runtime `activate_extension` entry: open the extension's `.prefab.bin`, add -/// its single entity's components to `entity`, then fire the `on_attach` seam with -/// the cooked hook text. The extension prefab is mono-entity (cooked as such); a -/// component the entity already carries is a conflict (§30.5) — surfaced as +/// Activate one extension on one entity (M1.0.6 E6) — the shared bytes-taking +/// path reused by load (`applyExtensions`), the runtime `activate_extension` +/// entry, AND the interpreter's deferred B1 flush: open the extension's +/// `.prefab.bin`, add its single entity's components to `entity`, record the +/// active extension, then fire the `on_attach` seam with the cooked hook text. +/// The extension prefab is mono-entity (cooked as such); a component the entity +/// already carries is a conflict (§30.5) — surfaced as /// `error.ExtensionComponentConflict` rather than the dynamic-add assert. -fn activateExtension(world: *World, gpa: std.mem.Allocator, entity: EntityId, name: []const u8, ext_bytes: []const u8) !void { +pub fn activateExtension(world: *World, gpa: std.mem.Allocator, entity: EntityId, name: []const u8, ext_bytes: []const u8) !void { const ext = try openVerified(ext_bytes); // Mono-entity: the extension's components live on its single entity. @@ -405,10 +407,15 @@ pub fn runtimeActivate(world: *World, gpa: std.mem.Allocator, entity: EntityId, /// extension must be active (`error.ExtensionNotActive` otherwise). The §30.5 /// reject conflict policy makes the component set unambiguous — no two active /// extensions share a component — so removal needs no provenance tracking. -pub fn runtimeDeactivate(world: *World, gpa: std.mem.Allocator, entity: EntityId, name: []const u8, resolver: ExtensionResolver) !void { +/// M1.0.9 — deactivate one extension on one entity given its cooked bytes: the +/// shared bytes-taking core reused by the runtime deactivate entry AND the +/// interpreter's deferred B1 flush. Fires `on_detach` FIRST (the hook still reads +/// the extension's components), then removes them, then drops the active record. +/// The extension must be active (`error.ExtensionNotActive`). The §30.5 reject +/// conflict policy makes the component set unambiguous — no provenance tracking. +pub fn deactivateExtension(world: *World, gpa: std.mem.Allocator, entity: EntityId, name: []const u8, ext_bytes: []const u8) !void { if (!world.hasEntityExtension(entity, name)) return error.ExtensionNotActive; - const bytes = resolver.resolve(name) orelse return error.UnknownExtension; - const ext = try openVerified(bytes); + const ext = try openVerified(ext_bytes); // `on_detach` before the components go away (the hook can still read them). const on_detach_text: ?[]const u8 = if (ext.hookCount() > 0) ext.hook(0).on_detach else null; @@ -432,6 +439,14 @@ pub fn runtimeDeactivate(world: *World, gpa: std.mem.Allocator, entity: EntityId world.removeEntityExtension(gpa, entity, name); } +/// M1.0.9 — runtime deactivation entry (direct-programmatic path): resolve the +/// extension by name, then `deactivateExtension`. The Etch method goes through +/// the interpreter's deferred queue instead (B1); this stays for direct callers. +pub fn runtimeDeactivate(world: *World, gpa: std.mem.Allocator, entity: EntityId, name: []const u8, resolver: ExtensionResolver) !void { + const bytes = resolver.resolve(name) orelse return error.UnknownExtension; + try deactivateExtension(world, gpa, entity, name, bytes); +} + /// 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 diff --git a/src/etch/interp.zig b/src/etch/interp.zig index e2663a8..75d956c 100644 --- a/src/etch/interp.zig +++ b/src/etch/interp.zig @@ -141,6 +141,26 @@ const PendingTag = struct { set: bool, }; +/// A deferred extension activate/deactivate (M1.0.9 B1 round-trip). Enqueued by +/// the Etch `entity.activate_extension`/`deactivate_extension` methods during a +/// tick — the extension bytes are resolved AT THE CALL — and applied at the tick +/// boundary (after every rule has run, so never mid-`iterateArchetype`-walk; +/// applying adds/removes components, an archetype transition). The immediate +/// `runtimeActivate`/`runtimeDeactivate` loader entries stay for the load + +/// direct-programmatic paths, which run outside any query iteration. +const ExtOp = enum { activate, deactivate }; + +const PendingExtension = struct { + entity: CoreEntityId, + /// Owned copy of the extension name (the AST / run-string source may not + /// outlive the flush); freed when the batch is drained. + name: []u8, + /// Borrowed cooked `.prefab.bin` bytes, resolved at the call site; the + /// resolver's backing outlives the tick. + bytes: []const u8, + op: ExtOp, +}; + /// Resolved view of a `when` clause node. The interpreter walks /// `predicate_pool` at iteration time to filter archetypes. const PredicateNodeKind = enum { @@ -624,6 +644,9 @@ pub const Interpreter = struct { /// Deferred tag mutations queued during a tick, flushed at the tick boundary /// (M0.8 E3) — never applied mid-archetype-walk. pending_tags: std.ArrayListUnmanaged(PendingTag) = .empty, + /// M1.0.9 B1 — deferred extension activate/deactivate, drained at the tick + /// boundary (after iteration). Mirror of `pending_tags`. + pending_extensions: std.ArrayListUnmanaged(PendingExtension) = .empty, /// True iff any rule carries a `changed` filter (M0.8 E3). Gates the whole /// tick-based change-detection path: only then does `runFor` advance /// `current_tick` (`beginFrame`) and a component write `markChanged`s — so @@ -712,6 +735,8 @@ pub const Interpreter = struct { self.trait_methods.deinit(self.gpa); self.tag_table.deinit(self.gpa); self.pending_tags.deinit(self.gpa); + for (self.pending_extensions.items) |pe| self.gpa.free(pe.name); + self.pending_extensions.deinit(self.gpa); for (self.async_slots) |*slot| slot.deinit(self.gpa); self.gpa.free(self.async_slots); self.descriptors.deinit(self.gpa); @@ -1320,6 +1345,9 @@ pub const Interpreter = struct { // Apply deferred tag mutations at the tick boundary — after every rule // has run, never mid-archetype-walk (M0.8 E3, `etch-grammar.md` §4.4). try self.flushPendingTags(world); + // Apply deferred extension activate/deactivate at the same boundary + // (M1.0.9 B1) — same never-mid-walk discipline. + try self.flushPendingExtensions(world); } fn runRule(self: *Interpreter, world: *World, rd: *RuleDesc, report: *RuntimeReport) !void { @@ -1757,6 +1785,27 @@ pub const Interpreter = struct { self.pending_tags.clearRetainingCapacity(); } + /// M1.0.9 B1 — drain the deferred extension activate/deactivate queue at the + /// tick boundary (after every rule has run, so no live `iterateArchetype` + /// walk is in flight — the immediate `add`/`removeComponentDynamic` an op + /// performs is then safe). Each op applies its structural change + fires the + /// Tier-0 `on_attach`/`on_detach` seam via the loader's bytes-taking + /// `activateExtension` / `deactivateExtension`. The batch is snapshotted + /// (`toOwnedSlice`) so a hook fired during apply that enqueues more ops does + /// NOT drain recursively — new ops wait for the next flush. + fn flushPendingExtensions(self: *Interpreter, world: *World) !void { + if (self.pending_extensions.items.len == 0) return; + const batch = try self.pending_extensions.toOwnedSlice(self.gpa); + defer { + for (batch) |pe| self.gpa.free(pe.name); + self.gpa.free(batch); + } + for (batch) |pe| switch (pe.op) { + .activate => try scene_loader.activateExtension(world, self.gpa, pe.entity, pe.name, pe.bytes), + .deactivate => try scene_loader.deactivateExtension(world, self.gpa, pe.entity, pe.name, pe.bytes), + }; + } + /// Resolve a `tag_path` operand node to its leaf bit via the global table, /// or `null` if unknown / a namespace (the resolver rejects those — a /// `null` here means an inconsistent program and the caller fails loud). @@ -2405,6 +2454,20 @@ pub const Interpreter = struct { return self.stringBytes(v) orelse error.RuntimeFailure; } + /// M1.0.9 B1 — resolve the extension bytes NOW and enqueue a deferred + /// activate/deactivate, applied at the tick boundary (`flushPendingExtensions`) + /// — NOT the immediate `runtimeActivate`/`runtimeDeactivate`, which would + /// mutate an archetype mid-`iterateArchetype`. Missing resolver / unknown name + /// surface as `RuntimeFailure` (the interp's failure channel). The name is + /// dup'd (the AST / run-string source may not outlive the flush). + fn enqueueExtension(self: *Interpreter, entity: CoreEntityId, name: []const u8, op: ExtOp) StmtError!void { + const resolver = self.bridge.ext_resolver orelse return error.RuntimeFailure; + const bytes = resolver.resolve(name) orelse return error.RuntimeFailure; + const name_dup = try self.gpa.dupe(u8, name); + errdefer self.gpa.free(name_dup); + try self.pending_extensions.append(self.gpa, .{ .entity = entity, .name = name_dup, .bytes = bytes, .op = op }); + } + /// Dispatch an instance method call on an already-evaluated receiver /// value — §5.5 order: inherent / trait on user types, then the builtin /// string / collection subsets. Split from the `.method_call` arm so the @@ -2428,15 +2491,13 @@ pub const Interpreter = struct { // component conflict surfaces as the interp's `RuntimeFailure` // (the loader path keeps the named `MissingExtensionResolver` etc.). if (std.mem.eql(u8, mname, "activate_extension")) { - const name = try self.extensionNameArg(world, locals, mc); - const resolver = self.bridge.ext_resolver orelse return error.RuntimeFailure; - scene_loader.runtimeActivate(world, self.gpa, @bitCast(eid), name, resolver) catch return error.RuntimeFailure; + // B1: ENQUEUE (deferred to the tick boundary) — never an + // immediate structural mutation here (we may be mid-iteration). + try self.enqueueExtension(@bitCast(eid), try self.extensionNameArg(world, locals, mc), .activate); return Value{ .unit = {} }; } if (std.mem.eql(u8, mname, "deactivate_extension")) { - const name = try self.extensionNameArg(world, locals, mc); - const resolver = self.bridge.ext_resolver orelse return error.RuntimeFailure; - scene_loader.runtimeDeactivate(world, self.gpa, @bitCast(eid), name, resolver) catch return error.RuntimeFailure; + try self.enqueueExtension(@bitCast(eid), try self.extensionNameArg(world, locals, mc), .deactivate); return Value{ .unit = {} }; } if (std.mem.eql(u8, mname, "has_extension")) { diff --git a/tests/scene/extensions_test.zig b/tests/scene/extensions_test.zig index affb571..fd848c9 100644 --- a/tests/scene/extensions_test.zig +++ b/tests/scene/extensions_test.zig @@ -482,10 +482,10 @@ test "entity.activate_extension executes on_attach (M1.0.9)" { var world = World.init(); defer world.deinit(gpa); - // Single-entity rule: `activate_extension` does an IMMEDIATE structural add - // (Weapon). One matching entity makes the mid-body archetype migration safe - // (the live chunk scan has no swapped-in entity to skip); the `not has Weapon` - // guard keeps it idempotent. + // `activate_extension` ENQUEUES a deferred command (B1); the structural add + // (Weapon) + on_attach are applied at the tick-boundary flush, after the live + // archetype walk. Single entity here; the multi-entity no-corruption case is + // the dedicated B1 test below. const prog = \\component Health { current: i32 = 100, max: i32 = 100 } \\component Weapon { damage: i32 = 0 } @@ -589,6 +589,49 @@ test "entity.deactivate_extension executes on_detach and removes components (M1. try std.testing.expect(!world.hasEntityExtension(eid, "CombatModule")); } +test "multi-entity rule activate_extension defers without corrupting iteration (M1.0.9 B1)" { + const gpa = std.testing.allocator; + const combat_bytes = try cookCombatModule(gpa); + defer gpa.free(combat_bytes); + + var world = World.init(); + defer world.deinit(gpa); + + // No `not has Weapon` guard: B1 defers the structural add to the tick boundary, + // so the rule's live archetype walk never sees a mid-walk migration. Every one + // of the N matched entities enqueues; the flush activates them all afterwards. + const prog = + \\component Health { current: i32 = 100, max: i32 = 100 } + \\component Weapon { damage: i32 = 0 } + \\rule go(entity: Entity) when entity has Health { + \\ entity.activate_extension("CombatModule") + \\} + ; + var pr = try parser.parse(gpa, prog); + 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); + var res = OneResolver{ .name = "CombatModule", .bytes = combat_bytes }; + interp.setExtensionResolver(res.ext()); + + // Several entities in the SAME archetype — the live-walk corruption case. + const n = 5; + var ents: [n]EntityId = undefined; + for (&ents) |*e| e.* = try spawnHealth(&world, gpa, 100, 100); + + _ = try interp.runFor(&world, 1); + + // Every matched entity was activated after the flush — none skipped, no crash. + const weapon_id = world.componentId("Weapon").?; + for (ents) |e| { + try std.testing.expectEqual(@as(i32, 150), healthMax(&world, e)); // on_attach + try std.testing.expect(world.componentBytes(e, weapon_id) != null); // component added + try std.testing.expect(world.hasEntityExtension(e, "CombatModule")); + } +} + test "on_attach-issued structural command is drained before on_spawned (M1.0.9)" { const gpa = std.testing.allocator; const combat_bytes = try cookCombatModule(gpa); From d05b333d681e5a44dfb1ea0e3a9e3e5dc0fdd821 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Tue, 30 Jun 2026 10:53:22 +0200 Subject: [PATCH 14/16] fix(etch): type-check the four entity extension methods (M1.0.9 B2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit synthMethodCall's entity arm (dispatchMethodOnType, types.zig) emitted type_mismatch "no method on an Entity" for any non-inherent/trait method, so a real rule body calling activate_extension/deactivate_extension/has_extension/ active_extensions failed weld check — the API was unusable from type-checked Etch. The entity arm now recognizes the four builtin methods before the trait lookup (falling through for anything else): activate/deactivate_extension(string) -> unit (unknown, statement-use), has_extension(string) -> bool, active_extensions() -> [string], with arg-count/type validation (checkExtensionNameArg). +1 test: a checked program calling all four passes clean; a wrong-typed arg is still rejected. Co-Authored-By: Claude Opus 4.8 --- src/etch/types.zig | 65 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/src/etch/types.zig b/src/etch/types.zig index b016056..e9ef0ef 100644 --- a/src/etch/types.zig +++ b/src/etch/types.zig @@ -5285,6 +5285,22 @@ pub const TypeChecker = struct { return ResolvedType.unknown; } + /// M1.0.9 B2 — validate the single string argument of an `Entity` extension + /// method (`activate_extension` / `deactivate_extension` / `has_extension`). + /// A non-builtin / `unknown` arg type is left alone (no cascading error), the + /// same shape as the collection-method arg checks above. + fn checkExtensionNameArg(self: *TypeChecker, id: NodeId, mc: ast_mod.MethodCall, method_slice: []const u8, ctx_opt: ?*RuleCtx) TypeError!void { + if (mc.args_len != 1) { + try self.emit(.type_mismatch, .error_, self.arena.exprSpan(id), "Entity method '{s}' takes exactly one (string) argument", .{method_slice}); + return; + } + const arg: NodeId = @bitCast(self.arena.extra.items[mc.args_start]); + const arg_t = try self.synthExprE(arg, ctx_opt); + if (arg_t == .builtin and arg_t.builtin != .string_) { + try self.emit(.type_mismatch, .error_, self.arena.exprSpan(arg), "Entity method '{s}' expects a string extension name", .{method_slice}); + } + } + /// Dispatch an instance method call against an already-typed receiver /// (`etch-resolver-types.md §5.5` strict order: inherent → trait → /// builtin → service). Split from `synthMethodCall` so the optional @@ -5422,6 +5438,27 @@ pub const TypeChecker = struct { return ResolvedType.unknown; } + // M1.0.9 B2 — builtin extension methods on an `Entity` receiver, checked + // BEFORE the trait-method resolution below (these are interpreter builtins, + // not user traits; any other method on an Entity falls through to the + // trait lookup). `activate_extension`/`deactivate_extension` are + // statement-use (`unknown` return, like `array.push`); `has_extension` + // → bool; `active_extensions` → `[string]`. + if (recv_t == .builtin and recv_t.builtin == .entity) { + if (std.mem.eql(u8, method_slice, "activate_extension") or std.mem.eql(u8, method_slice, "deactivate_extension")) { + try self.checkExtensionNameArg(id, mc, method_slice, ctx_opt); + return ResolvedType.unknown; + } + if (std.mem.eql(u8, method_slice, "has_extension")) { + try self.checkExtensionNameArg(id, mc, method_slice, ctx_opt); + return ResolvedType{ .builtin = .bool_ }; + } + if (std.mem.eql(u8, method_slice, "active_extensions")) { + if (mc.args_len != 0) try self.emit(.type_mismatch, .error_, self.arena.exprSpan(id), "Entity method 'active_extensions' takes no arguments", .{}); + return ResolvedType{ .array_dyn = .string_ }; + } + } + const type_name: StringId = typeNameOfResolved(recv_t) orelse blk: { if (recv_t == .builtin and recv_t.builtin == .entity) { break :blk self.arena.strings.find("Entity") orelse { @@ -9059,3 +9096,31 @@ test "ability: canonical ability checks clean, structural codes fire (M0.8 E4)" defer gated.deinit(gpa); try std.testing.expect(gated.diagnostics.items.len > 0); } + +test "extension methods type-check on an Entity receiver (M1.0.9 B2)" { + const gpa = std.testing.allocator; + // A real (NOT checker-skipped) rule body calling all four extension methods + // must type-check clean — no `type_mismatch` "no method on an Entity". + var ok = try parseAndCheck(gpa, + \\component Health { current: i32 = 100, max: i32 = 100 } + \\rule probe(entity: Entity) when entity has Health { + \\ entity.activate_extension("Combat") + \\ entity.deactivate_extension("Combat") + \\ let h = entity.has_extension("Combat") + \\ let xs = entity.active_extensions() + \\} + ); + defer ok.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), ok.parse_diags.len); + try std.testing.expectEqual(@as(usize, 0), ok.diagnostics.items.len); + + // Wrong arg count / type is still rejected. + var bad = try parseAndCheck(gpa, + \\component Health { current: i32 = 100 } + \\rule probe(entity: Entity) when entity has Health { + \\ entity.activate_extension(42) + \\} + ); + defer bad.deinit(gpa); + try std.testing.expect(bad.diagnostics.items.len > 0); +} From 727aab434c08a9d48adea6ec43362f525404ae4e Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Tue, 30 Jun 2026 10:58:26 +0200 Subject: [PATCH 15/16] docs(brief): close M1.0.9 (B1+B2 done) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Journal B1+B2 implementation; reconcile Closing notes (the two first-close flags are now RESOLVED — B1 defers the Etch activate/deactivate, B2 type-checks the four methods); +14 tests; Status CLOSED. Co-Authored-By: Claude Opus 4.8 --- briefs/M1.0.9-extension-hooks.md | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/briefs/M1.0.9-extension-hooks.md b/briefs/M1.0.9-extension-hooks.md index c63166e..da87c61 100644 --- a/briefs/M1.0.9-extension-hooks.md +++ b/briefs/M1.0.9-extension-hooks.md @@ -1,12 +1,12 @@ # M1.0.9 — Execute extension hooks (`on_attach` / `on_detach`) -> **Status:** ACTIVE (reopened 2026-06-30 for the B1+B2 merge-blocker round-trip) +> **Status:** CLOSED (reopened 2026-06-30 for the B1+B2 merge-blocker round-trip, re-closed same day) > **Phase:** 1.0 > **Branch:** `phase-1/etch/extension-hooks` > **Planned tag:** `v0.10.9-extension-hooks` > **Dependencies:** M1.0.6 (`v0.10.6-prefabs-crossrefs-extensions`) for the dispatch seam + cooked hook text; base tag M1.0.8 (`v0.10.8-const-private-test`) > **Opened:** 2026-06-29 -> **Closed:** — +> **Closed:** 2026-06-30 --- @@ -142,7 +142,8 @@ M1.0.9 founds the runtime text-execution surface that M1.0.6 deferred. At M1.0.6 - 2026-06-30 00:55 — **E1**: `parseStmtBlock` + `StmtBlockResult` (`parser.zig`) — a new fragment entry that reuses `parseStmt` via `parseStmtFragment` (skips one optional `.semicolon` between statements, matching `renderStmtRunAlloc`'s `"; "` join; the parser otherwise only consumes `;` inside a fill-array literal). 2 inline tests green. Commit `f0da191`. - 2026-06-30 01:00 — **E3/E4 (world-side)**: `world.zig` `on_detach` seam (`ExtensionDetachFn`/`detach_hook`/`registerOnDetach`/`dispatchOnDetach`, mirror) + per-entity `entity_extensions` side-table (`add`/`remove`/`has`/`entityExtensions`, freed in `deinit`). 2 inline tests green (seam + side-table incl. leak-freedom). Verified the `_ = ecs.world;` pin runs them. Commit `b7bec52`. - 2026-06-30 01:10 — **E2 + E3/E4 (rest)**: `interp.execHookText` (transient arena + `self.ast` rebind + implicit `entity` + `execStmtRun` + observer-deferred routing) + `bindToWorld` registers the real attach/detach trampolines + `dispatchMethodOnValue` 4 entity methods + `setExtensionResolver` + bridge `ext_resolver`; `loader` `addEntityExtension` in the shared `activateExtension` + `runtimeActivate`/`runtimeDeactivate` + explicit deferred drain after the activation pass. 3 `execHookText` inline tests + 5 scene tests (headline + activate/has/active/deactivate + drain) green. Commits `3adc8c4`, `a650294`. -- 2026-06-30 01:18 — Gates green: `zig build` (zero warnings), `zig build test` (full suite exit 0, no leaks), `zig build test-extensions` (13/13), `zig fmt --check`, `zig build lint`. §3.6.1 audits: language clean (no FR in diff/brief); drift — refreshed the now-stale "execution is M1.0.9" doc comments in `world.zig`/`loader.zig`/`extensions_test.zig` (commit `c28b879`); two out-of-scope residual refs noted in Closing notes. `CLAUDE.md` §3.4 updated (commit `aad6a46`). +- 2026-06-30 01:18 — Gates green: `zig build` (zero warnings), `zig build test` (full suite exit 0, no leaks), `zig build test-extensions` (13/13), `zig fmt --check`, `zig build lint`. §3.6.1 audits: language clean (no FR in diff/brief); drift — refreshed the now-stale "execution is M1.0.9" doc comments in `world.zig`/`loader.zig`/`extensions_test.zig` (commit `c28b879`); two out-of-scope residual refs noted in Closing notes. `CLAUDE.md` §3.4 updated (commit `aad6a46`). PR #37 opened. +- 2026-06-30 10:50 — **B1** (commit `2c84472`): the Etch `activate_extension`/`deactivate_extension` methods now ENQUEUE a deferred `PendingExtension` (mirror of `pending_tags`, bytes resolved at the call), drained at the tick boundary by `flushPendingExtensions` (after iteration, snapshot → no recursive drain) calling the loader's `pub` `activateExtension` / new `deactivateExtension` (bytes-taking). `runtimeActivate`/`runtimeDeactivate` (immediate) kept for load + direct paths. +1 test (multi-entity, no corruption). **B2** (commit `d05b333`): `dispatchMethodOnType` (entity arm, `types.zig`) recognizes the four methods (`checkExtensionNameArg` arg validation) before the "no method on an Entity" error — `activate`/`deactivate → unknown` (statement-use), `has_extension → bool`, `active_extensions → [string]`. +1 test (type-check clean + bad-arg rejected). Full suite green debug + ReleaseSafe; build/lint/fmt green. ## Recorded deviations @@ -174,7 +175,7 @@ M1.0.9 founds the runtime text-execution surface that M1.0.6 deferred. At M1.0.6 *Fill in at Status → CLOSED, just before opening the PR.* - **What worked:** Recon-first (a read-only parallel surface map + first-hand re-reads) caught three things before any code: the `;`-separator (the cooked hook text is `"; "`-joined but the parser only consumes `;` inside fill-arrays → `parseStmtBlock` needs its own separator-skipping loop), the brief's CORRECT call that `self.ast` is rebindable (`*const AstArena` is a reassignable field — a recon agent misread it as immutable; the rebind is in fact *required* because the executor resolves identifiers via `self.ast.strings`), and the two-`EntityId` duality (core `packed struct(u64)` vs interp `u64`). `execHookText` mirrors `runObserverBody` almost exactly, so it inherits the proven fresh-scope/store-reset/deferred-routing discipline. The M1.0.6 `extensions_test.zig` scaffold (`OneResolver`, `cookPrefab` → `writer.write`) made the scene/round-trip tests cheap. -- **What deviated from the original spec:** Five surface-adaptations (see Recorded deviations), none round-tripped — all under the FROZEN "surface is the source of truth" note. The headline deviation is **#1**: the "deferred structural change drained before on_spawned" test is realized with a Tier-0 stand-in (the interpreter has no `entity.add`/`spawn` in bodies and tag mutation is not cookable, so a cooked hook literally cannot issue a deferred structural change today). The drain mechanism itself is built and correct. -- **What to flag explicitly in review:** (a) **Deviation #1** — confirm the stand-in realization of the drain test is acceptable, or round-trip if the literal `entity.add`/`spawn`-from-a-cooked-hook form is wanted (it needs interp structural-mutation support, a separate milestone). (b) Runtime `activate_extension`/`deactivate_extension` do **immediate** `addComponentDynamic`/`removeComponentDynamic` (the brief's shared-path design) — unsafe if called while a multi-entity query iterates that archetype; the tests use single-entity rules / the direct entry. Worth deciding later whether the Etch method should defer (a new command-buffer variant) for general safety. (c) The four new entity methods are **interpreter-level only** — the type-checker does not recognise them (tests skip the checker), consistent with the brief's interp-only E3/E4. -- **Final measurements** (perf, binary size, compile time, whatever is relevant to the milestone): N/A — correctness milestone (hooks run once per activation, not per tick). +12 tests (parser 2, interp 3, world 2, scene 5); full `zig build test` green debug + ReleaseSafe (no leaks under the testing allocator), `test-extensions` 13/13, `zig build` zero warnings, `fmt`/`lint` clean. -- **Residual risks / tech debt left intentionally:** (1) A cooked Etch hook cannot issue a *deferred* structural change until the interpreter gains `entity.add(T)`/`spawn`/`despawn` (S4 boundary) — the drain wiring future-proofs it. (2) Runtime activate/deactivate immediate-mutation safety (see flag b). (3) The §30.5 compile-time additive-conflict warning is deferred (out of M1.0.9 scope). (4) Two out-of-scope "M1.0.9" doc-comment references left untouched: `src/etch/ast.zig:647` (`TestDecl` — says `test` execution "is M1.0.9"; pre-existing M1.0.8 imprecision, M1.0.9 delivers *hook* execution not *test* execution) and `tests/scene/prefab_integration_test.zig:6` (correctly points to `extensions_test.zig` for hook execution). Both are outside the milestone's file set and substantively defensible; a KB/test-comment cleanup can fold them in. +- **What deviated from the original spec:** The B1+B2 round-trip (2026-06-30) closed the two flags raised at the first close: B1 makes the Etch `activate_extension`/`deactivate_extension` **deferred** (no more immediate mutation mid-iteration), and B2 gives the four methods **type-checker recognition** (they no longer require skipping the checker). Both are recorded as round-tripped FROZEN deltas (Recorded deviations, top). The remaining items are five non-round-tripped surface-adaptations (Recorded deviations, below); the only one touching a frozen acceptance test is **#1** (the drain test uses a Tier-0 stand-in because a cooked Etch hook cannot itself issue a deferred structural change — the interpreter has no `entity.add`/`spawn` in bodies, tag mutation is not cookable; the drain mechanism is built + correct). +- **What to flag explicitly in review:** (a) **Surface-adaptation #1** — confirm the stand-in realization of the drain test is acceptable, or round-trip if the literal `entity.add`/`spawn`-from-a-cooked-hook form is wanted (it needs interp structural-mutation support, a separate milestone). (b) ~~Runtime activate/deactivate immediate-mutation safety~~ — **RESOLVED by B1** (the Etch method now defers to the tick boundary; the immediate `runtimeActivate`/`runtimeDeactivate` remain only for the load + direct-programmatic paths, which run outside iteration). (c) ~~The four methods are interpreter-level only~~ — **RESOLVED by B2** (the type-checker now recognizes them; tests no longer skip the checker). +- **Final measurements** (perf, binary size, compile time, whatever is relevant to the milestone): N/A — correctness milestone (hooks run once per activation, not per tick). +14 tests (parser 2, interp 3, world 2, scene 6 incl. B1, types 1 B2); full `zig build test` green debug + ReleaseSafe (no leaks under the testing allocator), `test-extensions` 14/14, `zig build` zero warnings, `fmt`/`lint` clean. +- **Residual risks / tech debt left intentionally:** (1) A cooked Etch hook cannot issue a *deferred* structural change until the interpreter gains `entity.add(T)`/`spawn`/`despawn` (S4 boundary) — the drain wiring future-proofs it (surface-adaptation #1). (2) ~~Runtime activate/deactivate immediate-mutation safety~~ — RESOLVED by B1 (the Etch method defers; immediate path is load/direct only). (3) The §30.5 compile-time additive-conflict warning is deferred (out of M1.0.9 scope). (4) Two out-of-scope "M1.0.9" doc-comment references left untouched: `src/etch/ast.zig:647` (`TestDecl` — says `test` execution "is M1.0.9"; pre-existing M1.0.8 imprecision, M1.0.9 delivers *hook* execution not *test* execution) and `tests/scene/prefab_integration_test.zig:6` (correctly points to `extensions_test.zig` for hook execution). Both are outside the milestone's file set and substantively defensible; a KB/test-comment cleanup can fold them in. From ae09ff8e6055314f7dea838db0dbae6e75595682 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Tue, 30 Jun 2026 10:59:23 +0200 Subject: [PATCH 16/16] docs: reconcile CLAUDE.md for B1+B2 (M1.0.9) The M1.0.9 scope-boundary items (2) immediate-mutation and (4) interpreter-level- only are now superseded by the round-trip: B1 defers the Etch activate/deactivate, B2 type-checks the four methods. Tags row notes B1+B2. Co-Authored-By: Claude Opus 4.8 --- CLAUDE.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 8899daf..ca1d36f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -46,7 +46,7 @@ knowledge base — see § Quick links spec. | `v0.10.6-prefabs-crossrefs-extensions` | 2026-06-28 | M1.0.6 — prefabs + entity→entity cross-references + extension activation | `.prefab.bin` cook (standalone + `of` variants) + `instance of` flattening at scene cook (byte-identical to hand-authored). Entity→entity cross-refs via new `FieldKind.entity_` (8 B `EntityId`, component-only, default `dead`=`0xFF`) — by **name** (like `parent:`), resolved at load (`resolveCrossRefs`, bounds-checked → `MalformedScene`). Extension activation: `extensions:` grammar clause (entity + instance) + Entity Extensions Table + dedup Prefab ID Table + hooks sub-section in the `extensions_offset` region (shape A); `extends` cook (components + `requires` + `on_attach`/`on_detach` rendered as **text**). Load `applyExtensions`: resolve by name → `addComponentDynamic` (conflict → `ExtensionComponentConflict`) → fire Tier-0 `on_attach` seam (`registerOnAttach`/`dispatchOnAttach`; loader never touches the VM). **`format_version` 1→2** (region restructured; v1 → `BadVersion`, re-cook). Hook **execution** re-scoped → M1.0.9. | | `v0.10.7-cross-file-import` | 2026-06-29 | M1.0.7 — Cross-file `import` (resolver pass-1) | `import` graduated parser-up (lexer `kw_import` out of `non_s3_keywords`; `ImportDecl` AST + arena slabs; `parseImportDecl` — the 4 forms, items accept IDENT **and** TYPE_IDENT, D-D). `root.validateProject` builds the module dependency graph from `ProjectFile.name` (module path under `src/`), topo-sorts it (deps-first `checkProject` order), and detects cycles → **`E0108 ImportCycle`** (D-B: NOT E0101; E0101 stays DuplicateSymbol). **Per-module** byte-keyed exports index (`ExportEntry {kind, visibility, arena_index, item_id}` — NOT a flat global index; two modules exporting the same name never collide) extends the M0.9 `ProjectContext`. `bindImports` resolves each file's imports: selective items enter scope under their local name; module aliases record an `imported_alias` binding (qualified `m.Type` resolution deferred — D-F); diagnostics `E0103 NotAModule` / `E0104 UnknownExport` / `E0107 ImportPrivateItem` (wired-but-dormant until `private`, D-G). `checkComponentInstance` resolves an imported component **cross-arena** (decl fetched from its defining arena, field names compared by **bytes**) → **unblocks the E1793 false positive**: a `.prefab.etch` importing its components validates clean; E1793 fires only for a genuinely-undeclared component. Cross-arena field-TYPE check (E1795) is builtin-typed-only; named foreign field types are a documented residual. | | `v0.10.8-const-private-test` | 2026-06-29 | M1.0.8 — `const` top-level + `private` + `test` graduation | The last three `non_s3_keywords` graduate parser-up (`override` stays reserved). Lexer: `kw_const`/`kw_private`/`kw_test` added to `s3_keywords`, removed from the reserve list (identifier→keyword logic unchanged). AST: `ConstDecl`/`TestDecl` side-slabs + `Visibility {public, private}` field on the `Item` node (`itemVisibility`/`setItemVisibility`). Parser: `parseConstDecl` (`const ( IDENT \| TYPE_IDENT ) : type = const_expr`, top-level only — `parseStmt` untouched, so `const` in a block is a parse error per part1 §4.5); `parseTestDecl` (`test STRING block`, reuses `parseBlockExpr`, no execution); `private` prefix in `parseOneTopLevel` (after annotations, before dispatch; rejects `private import/const/type`; sets the item `.private`). Lockstep set extended {dispatch, `recoverToTopLevel` stop-set, the single error-message enumeration} — `private` adds no stop-set member. Resolver: `SymbolKind` += `const_`/`test_`; `pass1Collect` registers both, `checkConstValue` reuses the field-default surface (`E1101 NotConstEvaluable` + `E0200 TypeMismatch`); tests registered but not exported. `buildExports` exports `const_decl` and reads `Item.visibility` per decl → **activates the dormant `E0107 ImportPrivateItem`** check. **Cleared M1.0.7 debts**: cross-file `const` resolves; selectively importing a `private` item emits `E0107`. | -| `v0.10.9-extension-hooks` | 2026-06-30 | M1.0.9 — Execute extension hooks (`on_attach`/`on_detach`) | Founds the runtime text-execution surface M1.0.6 deferred. Decision: **text re-parse, not bytecode** (the VM is Phase 2, `etch-bytecode.md §17`). `parser.parseStmtBlock` parses a cooked hook statement-run (`"; "`-separated, no braces — the shape `descriptor.renderStmtRunAlloc` emits) into a transient `AstArena` via a new `parseStmtFragment` loop (reuses `parseStmt`, skips one optional `.semicolon` between statements). `interp.execHookText` rebinds `self.ast` to the hook arena (the field is a reassignable `*const AstArena`; the executor resolves identifiers via `self.ast.strings`, so the rebind is required), binds the implicit `entity`, runs via the existing `execStmtRun`, and routes deferred structural changes through the world's shared observer-deferred buffer (mirror of `runObserverBody`). `bindToWorld` registers the real `on_attach`/`on_detach` trampolines (`ctx = *Interpreter`); the loader's `dispatchOnAttach` now reaches execution. `world.zig` gains the `on_detach` seam (`registerOnDetach`/`dispatchOnDetach` + `ExtensionDetachFn`/`detach_hook`, mirror of `on_attach`) + a per-entity active-extension side-table (`entity_extensions`: `addEntityExtension`/`removeEntityExtension`/`hasEntityExtension`/`entityExtensions`, freed in `deinit`). `dispatchMethodOnValue` entity arm gains `activate_extension`/`deactivate_extension`/`has_extension`/`active_extensions`; the `Bridge` holds an optional `ExtensionResolver` (`setExtensionResolver`); `loader.runtimeActivate`/`runtimeDeactivate` are the pub runtime entries (deactivate fires `on_detach` first, then `removeComponentDynamic`). Headline: a cooked scene activating `CombatModule` runs `on_attach` at load (`Health.max` 100→150). Conflict policy stays **reject** (`ExtensionComponentConflict`), not last-wins. | +| `v0.10.9-extension-hooks` | 2026-06-30 | M1.0.9 — Execute extension hooks (`on_attach`/`on_detach`) | Founds the runtime text-execution surface M1.0.6 deferred. Decision: **text re-parse, not bytecode** (the VM is Phase 2, `etch-bytecode.md §17`). `parser.parseStmtBlock` parses a cooked hook statement-run (`"; "`-separated, no braces — the shape `descriptor.renderStmtRunAlloc` emits) into a transient `AstArena` via a new `parseStmtFragment` loop (reuses `parseStmt`, skips one optional `.semicolon` between statements). `interp.execHookText` rebinds `self.ast` to the hook arena (the field is a reassignable `*const AstArena`; the executor resolves identifiers via `self.ast.strings`, so the rebind is required), binds the implicit `entity`, runs via the existing `execStmtRun`, and routes deferred structural changes through the world's shared observer-deferred buffer (mirror of `runObserverBody`). `bindToWorld` registers the real `on_attach`/`on_detach` trampolines (`ctx = *Interpreter`); the loader's `dispatchOnAttach` now reaches execution. `world.zig` gains the `on_detach` seam (`registerOnDetach`/`dispatchOnDetach` + `ExtensionDetachFn`/`detach_hook`, mirror of `on_attach`) + a per-entity active-extension side-table (`entity_extensions`: `addEntityExtension`/`removeEntityExtension`/`hasEntityExtension`/`entityExtensions`, freed in `deinit`). `dispatchMethodOnValue` entity arm gains `activate_extension`/`deactivate_extension`/`has_extension`/`active_extensions`; the `Bridge` holds an optional `ExtensionResolver` (`setExtensionResolver`); `loader.runtimeActivate`/`runtimeDeactivate` are the pub runtime entries (deactivate fires `on_detach` first, then `removeComponentDynamic`). Headline: a cooked scene activating `CombatModule` runs `on_attach` at load (`Health.max` 100→150). Conflict policy stays **reject** (`ExtensionComponentConflict`), not last-wins. **B1+B2 merge-blocker round-trip (same day):** the Etch `activate_extension`/`deactivate_extension` now ENQUEUE a deferred op (interp-side `pending_extensions`, drained at the tick boundary) instead of mutating immediately mid-`iterateArchetype` (the immediate `runtimeActivate`/`runtimeDeactivate` stay for load + direct paths); and the type-checker (`dispatchMethodOnType` entity arm) recognizes all four methods so they pass `weld check`. | ## Hypotheses validated by spikes @@ -78,7 +78,7 @@ knowledge base — see § Quick links spec. - **`FieldKind.entity_` realizes `Entity` (M1.0.6)**: 8 B/8-align `EntityId`, default `dead` (`@memset 0xFF`, not `{0,0}` = a live handle to entity 0), component-only (gated to `reg_kind == .component`, mirror of resource-only `string_`/`enum_`). - **M1.0.7 scope boundary (cross-file import)**: `import` graduated parser-up (the only `non_s3_keywords` member to leave; `const`/`private`/`test`/`override` stay reserved for M1.0.8). **Validated approach**: a **per-module** byte-keyed exports index (`{name bytes → {kind, arena_index, item_id}}`) extends the M0.9 byte-keyed `ProjectContext` pattern (StringIds are per-arena) — NOT a flat global index, so two modules exporting the same name never collide; the imported-component cross-arena resolution (decl fetched from its defining arena, field names compared by bytes) **unblocks the E1793 false positive** for `.prefab.etch`. **Deferred-but-pre-wired**: module-alias qualified `m.Type` resolution (D-F — the `imported_alias` binding is recorded at E5, so the descending `Path` walk is purely additive later); `E0107 ImportPrivateItem` (D-G — wired through the exports `visibility` flag, dormant until `private` graduates M1.0.8). **Not debt — moot**: the cross-arena field-TYPE check (E1795) resolves builtin types; this is COMPLETE for components because `validateFieldsInDecl(.component_like)` admits only builtin-POD field types (named struct/enum/string rejected on components) — the named-type branch is unreachable for a valid component (forward-compat headroom only). The cross-file `const`-import acceptance test is deferred to M1.0.8 (`const` is not parseable until it graduates) — cross-file resolution is covered by the imported-component type test + the prefab unblock. - **M1.0.8 scope boundary (`const`/`private`/`test` graduation)**: the last three `non_s3_keywords` graduate parser-up; `override` stays reserved (waits for a Tier-1 overridable module). **Top-level `const` only** — `parseStmt` is deliberately NOT extended, so a block-level `const` is a parse error; the tri-document drift (`const_stmt` under `etch-grammar.md §4.1` statements vs §4.5 "top-level only" vs `local_const` in `etch-resolver-types.md §2.1`) is a PREEXISTING cross-doc inconsistency deferred to a KB-audit conversation (NOT resolved here). **`private` is direct export-visibility + `E0107` only** — visibility inheritance (`etch-resolver-types.md §10.2`) and `W0902 PrivateTypeInPublicImpl` stay additive/deferred; `private` is parsed as a prefix on a `declaration_body` (rejects `private import/const/type`) and adds no `recoverToTopLevel` stop-set member. **`test` is parse + validate + symbol registration only** — no execution surface exists (same blocker family as M1.0.9); tests register a `test_` symbol but are never exported. **Residual**: a string-named `test "X"` registers under the byte sequence `X` via `registerSymbol`, so it shares the name namespace with identifier-named symbols (a `test "Foo"` collides with `component Foo` → `E0101`); acceptable for M1.0.8, revisit when the M1.0.9 test-runner formalizes test identity. **Cross-file tests** live in `tests/etch/import_resolve_test.zig` (the `validateProject` harness), not inline in `types.zig` (which cannot reach `validateProject` — a tier-up dependency). -- **M1.0.9 scope boundary (execute extension hooks)** — **decided: TEXT re-parse, not bytecode** (the VM is Phase 2, `etch-bytecode.md §17`; `etch-visual-scripting.md §4`). The Tier-0 seam (`register`/`dispatch` `On{Attach,Detach}`) is backend-agnostic — a Phase-2 migration swaps the bridge callback + the cook side + a format bump. `execHookText` reuses the SAME `execStmtRun` that runs all Phase-1 gameplay (not hook-specific scaffolding). **Surface findings vs the brief's premise** (the brief's NOTE pre-authorizes adapting to the actual surface): (1) the interpreter has **no `entity.add(T)`/`spawn`/`despawn`** structural mutation in bodies (S4 boundary, `interp.zig` header) and the only deferred-structural producer (tag mutation) is **not in the cookable hook subset** `{let,emit,expr,assign,return}` — so a cooked Etch hook **cannot** itself issue a deferred structural change; the "drained before `on_spawned`" test is realized with a Tier-0 stand-in attach callback that enqueues into the same observer-deferred channel (Recorded deviation). (2) Runtime `activate_extension`/`deactivate_extension` do **immediate** `addComponentDynamic`/`removeComponentDynamic` (the shared `activateExtension` path per brief), which is unsafe mid-`iterateArchetype`; the round-trip tests use single-entity rules (the live-chunk scan has no swapped-in entity to skip) or the direct `runtimeActivate` entry. (3) The activate/deactivate/has/active tests live in `tests/scene/extensions_test.zig`, not inline in `interp.zig`, because they need the cook pipeline (`scene_cook` → circular import otherwise) — the M1.0.8 tier-dependency precedent. (4) The four new entity methods are **interpreter-level only** (no type-checker recognition) — tests skip the type-checker, consistent with the brief's interp-only E3/E4; type-checker support for them is not in M1.0.9. **Deferred (not M1.0.9)**: the §30.5 compile-time additive-conflict warning (out of brief scope); a `test`-runner (the `ast.zig` `TestDecl` comment "execution surface is M1.0.9" is a pre-existing M1.0.8 imprecision — M1.0.9 delivers HOOK execution, not `test` execution). +- **M1.0.9 scope boundary (execute extension hooks)** — **decided: TEXT re-parse, not bytecode** (the VM is Phase 2, `etch-bytecode.md §17`; `etch-visual-scripting.md §4`). The Tier-0 seam (`register`/`dispatch` `On{Attach,Detach}`) is backend-agnostic — a Phase-2 migration swaps the bridge callback + the cook side + a format bump. `execHookText` reuses the SAME `execStmtRun` that runs all Phase-1 gameplay (not hook-specific scaffolding). **Surface findings vs the brief's premise** (the brief's NOTE pre-authorizes adapting to the actual surface): (1) the interpreter has **no `entity.add(T)`/`spawn`/`despawn`** structural mutation in bodies (S4 boundary, `interp.zig` header) and the only deferred-structural producer (tag mutation) is **not in the cookable hook subset** `{let,emit,expr,assign,return}` — so a cooked Etch hook **cannot** itself issue a deferred structural change; the "drained before `on_spawned`" test is realized with a Tier-0 stand-in attach callback that enqueues into the same observer-deferred channel (Recorded deviation). (2) **[B1 round-trip 2026-06-30]** the Etch `activate_extension`/`deactivate_extension` ENQUEUE a deferred command (interp-side `pending_extensions`, mirror of `pending_tags`, drained at the tick boundary by `flushPendingExtensions`) instead of an immediate `add`/`removeComponentDynamic` — fixing the archetype-migration-mid-`iterateArchetype` corruption. The immediate `runtimeActivate`/`runtimeDeactivate` (and the new bytes-taking `pub activateExtension`/`deactivateExtension`) stay for the load + direct-programmatic paths (outside iteration). (3) The activate/deactivate/has/active tests live in `tests/scene/extensions_test.zig`, not inline in `interp.zig`, because they need the cook pipeline (`scene_cook` → circular import otherwise) — the M1.0.8 tier-dependency precedent. (4) **[B2 round-trip 2026-06-30]** the four entity methods are recognized by the **type-checker** (`dispatchMethodOnType` entity arm, `types.zig`: `activate`/`deactivate_extension → unit`/unknown, `has_extension → bool`, `active_extensions → [string]`, arg-validated) — a real rule body calling them passes `weld check`; tests no longer skip the checker. **Deferred (not M1.0.9)**: the §30.5 compile-time additive-conflict warning (out of brief scope); a `test`-runner (the `ast.zig` `TestDecl` comment "execution surface is M1.0.9" is a pre-existing M1.0.8 imprecision — M1.0.9 delivers HOOK execution, not `test` execution). ## Non-negotiable rules