From 5203464f1dd4ac0d8ad0c7a047c69c2023dd9779 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Fri, 3 Jul 2026 23:41:01 +0200 Subject: [PATCH 01/18] docs(brief): add M1.0.13 milestone brief --- briefs/M1.0.13-time-and-timers.md | 154 ++++++++++++++++++++++++++++++ 1 file changed, 154 insertions(+) create mode 100644 briefs/M1.0.13-time-and-timers.md diff --git a/briefs/M1.0.13-time-and-timers.md b/briefs/M1.0.13-time-and-timers.md new file mode 100644 index 0000000..8d87243 --- /dev/null +++ b/briefs/M1.0.13-time-and-timers.md @@ -0,0 +1,154 @@ +# M1.0.13 — Time subsystem and timers + +> **Status:** PLANNED +> **Phase:** 1 +> **Branch:** `phase-1/etch/time-and-timers` +> **Planned tag:** `v0.10.13-time-and-timers` +> **Dependencies:** M1.0.11 (async suspension core — `wait`/`await` substrate this milestone re-plumbs), M1.0.12 (concurrency algebra — the pointer-stable/monotonic/husk pool discipline the timer registry mirrors) +> **Opened:** 2026-07-03 +> **Closed:** — + +--- + +# FROZEN SECTION + +*Produced by Claude.ai. Not modifiable by Claude Code outside a Claude.ai round-trip (cf. § Recorded deviations).* + +## Context + +Phase 1, M1.0 series (M1.0.0–M1.0.20) closing the EBNF v0.6 execution gaps for the tree-walking interpreter (criterion C1.6). This milestone introduces the **first builtin engine resources** (`GameTime`/`UnscaledTime`/`RealTime`), re-plumbs `await wait`/`await wait_unscaled` from the raw 1/60 frame counter onto scaled/unscaled game time (pausable, `time_scale`-aware), and graduates + executes the **timer family** `after`/`every`/`after_unscaled` with a `TimerHandle` runtime. `quantize` stays reserved (its musical clock — Sequencer/Pulse — is absent in Phase 1; assigned to a later Sequencer-adjacent milestone). + +## Scope + +Executed **gate by gate E1→E6**, in order. Each gate is pushed and reviewed on the real diff before the next opens (cf. § Notes › Gate protocol). + +**E1 — Token graduation.** +- Graduate `every` and `after_unscaled` out of `non_s3_keywords` to real keywords (`kw_every`, `kw_after_unscaled`) in `token.zig`. Update the graduation comment block. `quantize` **stays** in `non_s3_keywords` (reserved). + +**E2 — AST timer nodes.** +- Add `TimerKind` enum (`after`, `every`, `after_unscaled`), a `TimerStmt` payload struct (`kind: TimerKind`, `arg: NodeId` Duration expression, `body_start: u32`, `body_len: u32`, `binding: StringId` — `0` when the handle is discarded), and a `timer_stmts` arena list, wired to `StmtKind.timer_stmt`. `quantize_stmt` stays a payloadless placeholder (no struct, no arena list). + +**E3 — Parser timer statement.** +- Parse `timer_stmt = [ "let" , IDENT , "=" ] , timer_kind , "(" , expression , ")" , block` (`etch-grammar.md §4.3`): unbound form dispatched in the statement-head path, bound form (`let t = after(...) { }`) dispatched inside `parseLetStmt`, mirroring `spawn_stmt`. Remove the statement-head timer fail-loud (`parser.zig`, the `kw_after` `parseErr` at the statement dispatch — currently "not in M0.8 scope (Phase 2)"). `quantize` keeps an **explicit fail-loud** at the statement head, message updated to reference the later Sequencer-adjacent milestone (drop the stale "Phase 2" wording). + +**E4 — Type-checker: handle + builtin-resource seeding.** +- `TimerHandle` builtin type: new `timer_handle` variant on the builtin-type enum, mirroring `task_handle`. Exposes **`cancel()` only** (no args, idempotent semantics enforced at runtime) — **not `await`-joinable** (a timer is not a task); any other method or an `await` on a `TimerHandle` is rejected (`E0200`, as for `TaskHandle` misuse). Non-POD: rejected as a `component`/`resource` field type. +- A bound timer types its binding as `TimerHandle`. +- The timer **argument** is type-checked as `Duration` (full expression, not restricted to a literal). +- The timer **body is a synchronous context**: an `await` or an `async` call inside a timer body is `E0901 AsyncCallInNonAsyncContext` (the timer carries no `{async}` effect — it is absent from the `§9.4` builtin-effect table). Requires a timer-body context frame in the resolver. +- Add a single **builtin-resource descriptor table** as a `pub const` in `types.zig` (`GameTime`/`UnscaledTime`/`RealTime`: name + fields with types + defaults, per `engine-gameplay-systems.md` "Les trois temps"). The type-checker consults it so `get(GameTime).dt` / `get_mut(GameTime).time_scale` / `get_mut(GameTime).paused` resolve by name. + +**E5 — Runtime time subsystem.** +- Register the three builtin resources in `interp.zig` Pass A from `types.zig`'s descriptor table, at the **same injection point as the builtin `TagSet`** (after the user-decl registration loop), idempotent on hot-reload. +- Populate them at the **start of `stepOnce`** (the tree-walker equivalent of the `pre_update` refresh), fixed timestep: read `time_scale`/`paused` from `GameTime`; advance two internal `f64` accumulators (game, unscaled — the sources of truth); write `GameTime.dt = fixed_dt · time_scale` (`0` when `paused`), `GameTime.total += GameTime.dt`, increment `GameTime.frame` and `GameTime.fixed_frame`; `UnscaledTime.dt = fixed_dt`, `.total += fixed_dt`; `RealTime.dt = fixed_dt`, `.total`/`.since_startup += fixed_dt`. `UnscaledTime` and `RealTime` are **unaffected by `time_scale` and `paused`**. `fixed_dt` is the `1/60` constant (`= GameTime.fixed_dt` default, `= async_fixed_dt_hz`). +- Re-plumb `await wait` / `await wait_unscaled`: the `WakeCond` deadline becomes an **`f64`-seconds target** — `wait` against the game accumulator, `wait_unscaled` against the unscaled accumulator. `evalAwaitTarget` handles `wait_unscaled` (no longer fail-loud); `asyncWakeFired` compares each `wait_until` against the correct accumulator. `wait` freezes under `paused` and scales with `time_scale`; `wait_unscaled` always advances. **Byte-identical** wake tick at `time_scale = 1`, `fixed_dt = 1/60`. +- Demote `async_tick` to the frame counter that backs `GameTime.frame`; it no longer drives `wait`. + +**E6 — Runtime timer registry.** +- A **timer registry** in persistent heap (as async tasks, `etch-memory-model.md §7.1`), same **monotone pointer-stable + husk** discipline as the M1.0.12 task pool: entries allocated individually (pointer-stable), slots **never reused** (a fired one-shot or a canceled timer parks as a husk) — so a `TimerHandle` is a safe **bare index** (no generations). Entry: `{ clock (game|unscaled), deadline: f64, period: f64 (every), callback body range, scope snapshot, canceled }`. +- **Scope snapshot** taken at scheduling: a value-level copy of the current scope (captures `entity` and other locals), same heap-backed-value caveats as the M1.0.12 branch snapshot. +- **Firing** at the start of `stepOnce`, **after** the clock advance and **before** rule dispatch, in **registration order** (deterministic): every entry whose `deadline ≤` its clock's accumulator fires its callback body, executed **synchronously run-to-completion** against its snapshot. `after`/`after_unscaled`: removed after fire. `every`: re-armed (`deadline += period`, fixed period, no drift correction Phase 1). Canceled: skipped/removed. A callback that schedules a timer appends an entry (monotone pool — the current scan is not invalidated). +- Bound timers bind `Value.timer_handle` (registry index) in the parent scope after the snapshot. `TimerHandle.cancel()` marks the entry canceled — **idempotent** (no-op on an already-fired or already-canceled timer). + +## Out of scope + +- **`quantize`** (`quantize_stmt`, `etch-grammar.md §4.3`; `etch-reference-part1.md §9.11`) — depends on a beat/bar musical clock (Sequencer/Pulse) absent from the Phase-1 runtime. `quantize` stays reserved in `non_s3_keywords`; its statement-head fail-loud stays (message re-pointed); `quantize_stmt` stays a payloadless placeholder. Assigned to a later Sequencer-adjacent milestone — a scope boundary, NOT parked debt. +- **`time.*` stdlib sugar** (`etch-stdlib.md §20`: `time.dt`/`time.total`/`time.scale`/`time.unscaled_dt`/`time.fixed_dt`, lowering `time.X → get(GameTime|UnscaledTime).Y`) — an additive resolution add-on over the resources this milestone delivers; introducing it later touches no call site here. Later stdlib milestone. (`§20`'s `time.scale`/`time.frame_count` vs the real `time_scale`/`frame` field names is reconciled with the sugar, not now.) +- **`dt` as an injected rule parameter** (`rule tick(entity: Entity, dt: float)`, `engine-spec.md §24.10`) — additive injection over `GameTime.dt`, later milestone. +- **Per-entity `TimeDilation`, selective pause (`@pause_group` / `PauseState`), `WorldClock`/day-night** (`engine-gameplay-systems.md`) — depend on the phase scheduler (per-rule dispatch metadata, per-entity `dt` in iteration) which does not exist in the tree-walker. +- **Non-literal `Duration` for `await wait` / `await wait_unscaled`** — both keep the M1.0.11 literal-only restriction (non-literal Duration → fail-loud). Only the timer family evaluates a full `Duration` expression (its one-shot scheduling has no suspension reentry). Lifting `wait`'s restriction is a separate additive change, out of scope. +- **New diagnostic codes** — none are minted (see § Notes). If a genuinely new validation surfaces mid-gate, STOP for a Claude.ai round-trip before minting `E0908`. +- **Phase-2 bytecode lowering** of timers / the time subsystem (`etch-bytecode.md §9`, §14.4). +- **Codegen (Zig) of timers / time** — M1.0.x is interpreter-only for these constructs; no `programs/` differential file (the differential corpus rejects async and is not the vehicle here). +- **CLAUDE.md content** beyond the §3.4 update wired into E6 close. + +## Specs to read first + +Mandatory before any production code; checked off in the LIVING SECTION. + +1. `etch-reference-part1.md` — §9.10 (timers surface + the normative "Handle, annulation, corps" paragraph: Duration-expr arg, `TimerHandle` cancel-only/no-await/non-POD, synchronous body), §9.4 (builtin-effect table — timers carry no `{async}`), §9.12 (Phase-1 tree-walker realization: the async substrate AND the "Subsystème de temps et timers (M1.0.13)" block — the authoritative mechanism for resources, `stepOnce` population, `wait` re-plumb, timer registry, firing), §9.11 (quantize — context only, out of scope). +2. `etch-grammar.md` — §4.3 (`timer_stmt` with the `[ "let" IDENT "=" ]` prefix, `quantize_stmt`), §2.2 (`TimerHandle` builtin), §4.2 (`spawn_stmt` — the binding-form precedent to mirror). +3. `engine-gameplay-systems.md` — "Les trois temps" (the `GameTime`/`UnscaledTime`/`RealTime` field layout and `time_scale`/`paused` semantics — the source for the descriptor table), "Timers et actions différées". +4. `etch-resolver-types.md` — §9.2 (effect propagation, `E0901`/`E0905` — for the timer-body synchronous-context enforcement), §9.4 (builtin effects). +5. `etch-memory-model.md` — §7.1 (async state in persistent heap — the allocation model for the timer registry), §7.2 (escaping closures). +6. `etch-diagnostics.md` — §12 (E09xx block — confirm no new code is needed; `E0908` is the next free code if one is unavoidable). + +## Files to create or modify + +- `src/etch/token.zig` — modify — E1: graduate `every`/`after_unscaled`; keep `quantize` reserved. +- `src/etch/ast.zig` — modify — E2: `TimerKind`, `TimerStmt`, `timer_stmts` arena; `StmtKind.timer_stmt` wired. +- `src/etch/parser.zig` — modify — E3: `timer_stmt` production (+ `let`-prefix dispatch), remove the timer fail-loud, re-point the `quantize` fail-loud message. +- `src/etch/types.zig` — modify — E4: `timer_handle` builtin (cancel-only, field-rejected), timer-arg `Duration` check, timer-body synchronous-context frame, binding type; **builtin-resource descriptor table** (`pub const`) consumed by the type-checker and (via the existing `@import("types.zig")`) by `interp.zig`. +- `src/etch/interp.zig` — modify — E5: builtin-resource registration in Pass A + `stepOnce` population + `wait`/`wait_unscaled` re-plumb + `async_tick` demotion. E6: timer registry + firing + `Value.timer_handle` + `cancel()` dispatch. + +No new files. No changes outside `src/etch/`. `token.zig` is the only E1 file; `interp.zig` carries both E5 and E6. + +## Acceptance criteria + +### Tests + +Tests are **inline** in the respective `src/etch/*.zig` files (repo convention), green in `debug` and `ReleaseSafe`. + +- `src/etch/token.zig` — `test` — `every`/`after_unscaled` classify as keywords (not `reserved`); `quantize` still `reserved`. +- `src/etch/parser.zig` — `test` — `after(d) { }`, `every(d) { }`, `after_unscaled(d) { }` parse (unbound); `let t = after(d) { }` parses as a `TimerStmt` with a binding (not a `let_stmt`); the timer arg parses as an expression (variable Duration accepted); `quantize(...)` still fail-louds at the statement head. +- `src/etch/types.zig` — `test` — bound timer types the binding `TimerHandle`; `h.cancel()` accepted; `await h` and any other method on a `TimerHandle` rejected; `TimerHandle` rejected as a `component`/`resource` field; non-`Duration` timer arg rejected; an `await`/async call inside a timer body → `E0901`; `get(GameTime).dt` / `get_mut(GameTime).time_scale` type-check. +- `src/etch/interp.zig` — `test` (time subsystem, E5) — at `time_scale = 1`, `GameTime.dt == fixed_dt` and `GameTime.total` advances `1/60`/tick; `time_scale = 0.5` halves `GameTime.dt`/`.total` rate; `paused` zeroes `GameTime.dt` but `UnscaledTime.dt`/`RealTime.dt` keep advancing; `await wait(d)` wakes at the same tick as the pre-milestone conversion (byte-identical at `scale = 1`); `wait` frozen under `paused`, scaled under `time_scale`; `await wait_unscaled(d)` fires under `paused`. +- `src/etch/interp.zig` — `test` (timer registry, E6) — `after(d)` fires its body once at the deadline then is removed; `every(d)` fires each period; `after_unscaled(d)` fires under `paused`; a canceled timer never fires and `cancel()` is idempotent; the callback observes the scheduling-time scope snapshot (captured `entity`); a timer scheduled from a non-async rule fires; firing order is registration order (deterministic across two timers due the same tick). + +### Benchmarks + +None (no perf target for this milestone). + +### Observable behavior + +- A test-harness scenario (inline, driven by repeated `stepOnce`): a rule schedules `after(1.0s) { emit … }`; drive 60 ticks → the callback fires at tick 60 at `time_scale = 1`. Set `get_mut(GameTime).time_scale = 0.5` → fires at tick 120. Set `get_mut(GameTime).paused = true` → `wait`/`after` freeze while an `after_unscaled` still fires. Observable via the emitted event / mutation and the read-back `get(GameTime).total`. + +### 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/time-and-timers` +- **Final tag:** `v0.10.13-time-and-timers` (posed by Guy after merge) +- **PR title:** `Phase 1 / Etch / Time subsystem and timers` +- **Commit convention:** Conventional Commits (cf. `engine-development-workflow.md §4.3`) — TYPE whitelist strict (8 types; `ci` is NOT a type → use `chore(ci)`). +- **Merge strategy:** squash-and-merge (cf. `engine-development-workflow.md §4.6`) + +## Notes + +- **Gate protocol (overrides Étape 3's "implement all at once").** `git push -u` at branch creation. Implement E1→E6 in order. After each gate's commits, **push**, then emit exactly `étape E terminée, prête pour review` and **stop** — do not open the next gate until Claude.ai returns a GO on the pushed diff. A STOP verdict returns a problem + fix; re-push and re-emit the same signal. The PR (Étape 5) opens only after the E6 GO. Reviews are on the real pushed diff, never on a CC summary. +- **Spec reconciliations already applied** to the KB at milestone open (Claude.ai re-upload): `etch-grammar.md` §2.2 (`TimerHandle`) + §4.3 (`let`-prefix on `timer_stmt`); `engine-gameplay-systems.md` "Les trois temps" (`UnscaledTime` pause `Oui`→`Non`) + timer examples (`s` Duration suffixes) + the phase note on realization; `etch-reference-part1.md` §9.10 (normative timer paragraph) + §9.12 (the M1.0.13 realization block). **CC performs no spec authoring** — it reads the updated specs. `etch-stdlib.md §20` is intentionally NOT reconciled here (deferred with the `time.*` sugar). +- **No new diagnostic codes.** The timer arg is validated through existing type-mismatch machinery; `TimerHandle`/`cancel()` through existing builtin-method resolution (mirroring `TaskHandle`); an `await` in a timer body through the existing `E0901`. If E4/E6 surfaces a genuinely new, unavoidable validation, STOP for a Claude.ai round-trip before minting `E0908` (next free in the E09xx block; do not invent a code from the code-map). +- **Builtin resources are auto-registered** (the `TagSet` precedent), from a single descriptor table in `types.zig`, consumed by the type-checker and by `interp.zig` via the existing import — **no `.etch` prelude**, no new module. +- **`TimerHandle` vs `TaskHandle`.** Distinct builtin types: `TaskHandle` (a task) supports both `cancel()` and `await`; `TimerHandle` (not a task) supports `cancel()` only. Reusing `TaskHandle` for timers is rejected — it would conflate two lifecycle models. +- **Fix-as-you-go (non-negotiable).** Any gap, inconsistency, or finding at any point in a gate is fixed within this milestone or explicitly refused by Guy — no parking, no deferred comment. A construct assigned to another milestone (`quantize`) is a scope boundary, not debt. +- **CLAUDE.md §3.4 update (E6 close, on the branch, `docs(claude-md): update for M1.0.13`).** Current-state table: `Last released tag` → `v0.10.13-time-and-timers`; `Next planned milestone` → M1.0.14 (entity-scoped events); update the timer-family note (`every`/`after_unscaled` graduate, `quantize` stays reserved). Tags table: +1 row for `v0.10.13-time-and-timers`. Open/deferred decisions: +1 M1.0.13 scope-boundary entry (resources builtin, `wait` re-plumb byte-identical at `scale=1`, timer registry husk-pool, `quantize`/`time.*`/`dt`-param/scheduler-features out). `Last updated` date. +- **Rejected alternatives.** A `dt` parameter on `stepOnce` (rejected: the tree-walker is harness-driven and superseded by the Phase-2 VM; the real variable-`dt` Game Loop targets the scheduler/VM, not this backend — a fixed timestep is the correct and sufficient model here). An `.etch` prelude for the resources (rejected: no prelude infrastructure; the `TagSet` builtin-injection point already exists). A separate `builtins.zig` module (rejected: `interp.zig` already imports `types.zig`, so the table lives there with no cycle and no new-file wiring). + +--- + +# 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 + +- [ ] `etch-reference-part1.md` (§9.10, §9.4, §9.12, §9.11) — read +- [ ] `etch-grammar.md` (§4.3, §2.2, §4.2) — read +- [ ] `engine-gameplay-systems.md` (Les trois temps, Timers) — read +- [ ] `etch-resolver-types.md` (§9.2, §9.4) — read +- [ ] `etch-memory-model.md` (§7.1, §7.2) — read +- [ ] `etch-diagnostics.md` (§12) — read + +## Execution log + +## Recorded deviations + +## Blockers encountered + +## Closing notes From 2b9b176b7c85605b099a162faaf3ec96137c9809 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sat, 4 Jul 2026 00:08:55 +0200 Subject: [PATCH 02/18] docs(brief): confirm specs read for M1.0.13 --- briefs/M1.0.13-time-and-timers.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/briefs/M1.0.13-time-and-timers.md b/briefs/M1.0.13-time-and-timers.md index 8d87243..5ced4d6 100644 --- a/briefs/M1.0.13-time-and-timers.md +++ b/briefs/M1.0.13-time-and-timers.md @@ -138,12 +138,12 @@ None (no perf target for this milestone). ## Specs read -- [ ] `etch-reference-part1.md` (§9.10, §9.4, §9.12, §9.11) — read -- [ ] `etch-grammar.md` (§4.3, §2.2, §4.2) — read -- [ ] `engine-gameplay-systems.md` (Les trois temps, Timers) — read -- [ ] `etch-resolver-types.md` (§9.2, §9.4) — read -- [ ] `etch-memory-model.md` (§7.1, §7.2) — read -- [ ] `etch-diagnostics.md` (§12) — read +- [x] `etch-reference-part1.md` (§9.10, §9.4, §9.12, §9.11) — read 2026-07-03 23:35 +- [x] `etch-grammar.md` (§4.3, §2.2, §4.2) — read 2026-07-03 23:38 +- [x] `engine-gameplay-systems.md` (Les trois temps, Timers) — read 2026-07-03 23:41 +- [x] `etch-resolver-types.md` (§9.2, §9.4) — read 2026-07-03 23:44 +- [x] `etch-memory-model.md` (§7.1, §7.2) — read 2026-07-03 23:30 +- [x] `etch-diagnostics.md` (§12) — read 2026-07-03 23:30 ## Execution log From 8607ec4e292d9a0a39bea3fa10a47f340d99c05b Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sat, 4 Jul 2026 00:09:10 +0200 Subject: [PATCH 03/18] docs(brief): activate M1.0.13 --- briefs/M1.0.13-time-and-timers.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/briefs/M1.0.13-time-and-timers.md b/briefs/M1.0.13-time-and-timers.md index 5ced4d6..537560d 100644 --- a/briefs/M1.0.13-time-and-timers.md +++ b/briefs/M1.0.13-time-and-timers.md @@ -1,6 +1,6 @@ # M1.0.13 — Time subsystem and timers -> **Status:** PLANNED +> **Status:** ACTIVE > **Phase:** 1 > **Branch:** `phase-1/etch/time-and-timers` > **Planned tag:** `v0.10.13-time-and-timers` From f2fd9fd06d971dbdf668c5fdb916ce67d0d9e91a Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sat, 4 Jul 2026 00:29:46 +0200 Subject: [PATCH 04/18] feat(etch): graduate every/after_unscaled keywords (M1.0.13 E1) --- src/etch/token.zig | 56 +++++++++++++++++++++++++++++++++++++--------- 1 file changed, 46 insertions(+), 10 deletions(-) diff --git a/src/etch/token.zig b/src/etch/token.zig index b281b8c..daae57c 100644 --- a/src/etch/token.zig +++ b/src/etch/token.zig @@ -113,6 +113,8 @@ pub const TokenKind = enum { kw_spawn, // structural spawn expr `spawn(C{…})` (M1.0.10, §3.2 structural_spawn) + the async task statement `[let IDENT =] spawn { }` (M1.0.12, §4.2 spawn_stmt) — disambiguated by the next token kw_race, // race statement `race { race_branch* }` (M1.0.12 — graduated from non_s3_keywords; §4.2 race_stmt) kw_sync, // sync statement `sync { sync_branch* }` (M1.0.12 — graduated from non_s3_keywords; §4.2 sync_stmt) + kw_every, // repeating timer statement `[let IDENT =] every(d) { }` (M1.0.13 — graduated from non_s3_keywords; §4.3 timer_stmt) + kw_after_unscaled, // unscaled one-shot timer statement `[let IDENT =] after_unscaled(d) { }` (M1.0.13 — graduated from non_s3_keywords; §4.3 timer_stmt) // ── Primitive type keywords (lexed as kw_type_*) ── kw_int, @@ -283,6 +285,8 @@ pub const s3_keywords = [_]KeywordEntry{ .{ .lexeme = "spawn", .kind = .kw_spawn }, .{ .lexeme = "race", .kind = .kw_race }, .{ .lexeme = "sync", .kind = .kw_sync }, + .{ .lexeme = "every", .kind = .kw_every }, + .{ .lexeme = "after_unscaled", .kind = .kw_after_unscaled }, .{ .lexeme = "true", .kind = .bool_literal }, .{ .lexeme = "false", .kind = .bool_literal }, .{ .lexeme = "int", .kind = .kw_int }, @@ -326,10 +330,11 @@ pub const non_s3_keywords = [_][]const u8{ // `race` / `sync` with M1.0.12 (concurrency algebra, §4.2) ── // ── Timers / lifecycle (out of S3; `emit` graduated with E3 ECS layer; - // `after` graduated with E4 routine triggers — the §4.3 timer - // statement keeps an explicit fail-loud parse error) ── - "every", - "after_unscaled", + // `after` graduated with E4 routine triggers; `every` / `after_unscaled` + // graduated with M1.0.13 — the §4.3 timer statement parses and executes. + // `quantize` stays reserved: its musical beat/bar clock (Sequencer / + // Pulse) is absent from the Phase-1 runtime, so its realization is + // assigned to a later Sequencer-adjacent milestone) ── "quantize", // Note: `where`, `self`, `none`, `some` are intentionally NOT listed — @@ -414,8 +419,7 @@ test "race/sync graduate to s3 keywords (M1.0.12 E2)" { // M1.0.12: `race` / `sync` move from the reserve list into `s3_keywords`, // mapped to `kw_race` / `kw_sync` — the concurrency-algebra statements // (§4.2) become parseable. `override` remains the last reserved top-level - // construct keyword (waits for a Tier-1 overridable module); the timer - // family (`every` / `after_unscaled` / `quantize`) waits for M1.0.13. + // construct keyword (waits for a Tier-1 overridable module). const T = struct { fn s3Kind(lexeme: []const u8) ?TokenKind { for (s3_keywords) |kw| { @@ -434,13 +438,45 @@ test "race/sync graduate to s3 keywords (M1.0.12 E2)" { try std.testing.expectEqual(TokenKind.kw_sync, T.s3Kind("sync").?); try std.testing.expect(!T.reserved("race")); try std.testing.expect(!T.reserved("sync")); - // `override` stays reserved; the timers stay reserved until M1.0.13. + // `override` stays reserved. try std.testing.expect(T.reserved("override")); - try std.testing.expect(T.reserved("every")); - try std.testing.expect(T.reserved("after_unscaled")); - try std.testing.expect(T.reserved("quantize")); // Graduated keywords sit inside the contiguous keyword range (tag-path // contextual acceptance via `isKeywordToken`). try std.testing.expect(isKeywordToken(.kw_race)); try std.testing.expect(isKeywordToken(.kw_sync)); } + +test "every/after_unscaled graduate to s3 keywords (M1.0.13 E1)" { + // M1.0.13: `every` / `after_unscaled` move from the reserve list into + // `s3_keywords`, mapped to `kw_every` / `kw_after_unscaled` — the §4.3 + // timer statements become parseable (`after` has been a real keyword + // since M0.8 E4 routine triggers). `quantize` stays reserved: its + // musical clock is absent from the Phase-1 runtime. + const T = struct { + fn s3Kind(lexeme: []const u8) ?TokenKind { + for (s3_keywords) |kw| { + if (std.mem.eql(u8, kw.lexeme, lexeme)) return kw.kind; + } + return null; + } + fn reserved(lexeme: []const u8) bool { + for (non_s3_keywords) |kw| { + if (std.mem.eql(u8, kw, lexeme)) return true; + } + return false; + } + }; + try std.testing.expectEqual(TokenKind.kw_every, T.s3Kind("every").?); + try std.testing.expectEqual(TokenKind.kw_after_unscaled, T.s3Kind("after_unscaled").?); + try std.testing.expect(!T.reserved("every")); + try std.testing.expect(!T.reserved("after_unscaled")); + // `quantize` stays reserved (still lexes to error_unknown_keyword); + // `override` stays the last reserved top-level construct keyword. + try std.testing.expect(T.s3Kind("quantize") == null); + try std.testing.expect(T.reserved("quantize")); + try std.testing.expect(T.reserved("override")); + // Graduated keywords sit inside the contiguous keyword range (tag-path + // contextual acceptance via `isKeywordToken`). + try std.testing.expect(isKeywordToken(.kw_every)); + try std.testing.expect(isKeywordToken(.kw_after_unscaled)); +} From e86fcfe748c473339e509e855cea257d0207fad3 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sat, 4 Jul 2026 00:29:47 +0200 Subject: [PATCH 05/18] docs(brief): journal update --- briefs/M1.0.13-time-and-timers.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/briefs/M1.0.13-time-and-timers.md b/briefs/M1.0.13-time-and-timers.md index 537560d..e0e7f9a 100644 --- a/briefs/M1.0.13-time-and-timers.md +++ b/briefs/M1.0.13-time-and-timers.md @@ -147,6 +147,10 @@ None (no perf target for this milestone). ## Execution log +- 2026-07-03 — Branch created from up-to-date main, brief committed verbatim, pushed -u (push-early). First pre-push attempt hit the known local watchdog SIGKILL flake (947/964 passed, brief-only diff); retry green. +- 2026-07-03 — All six specs read in full, in brief order; Specs read checked off with timestamps; brief activated. +- 2026-07-04 — E1: `every`/`after_unscaled` graduated to `kw_every`/`kw_after_unscaled` (enum + `s3_keywords`); reserve list down to `override` + `quantize` (comment block updated); stale M1.0.12 test assertions on the timer family removed; new M1.0.13 E1 test added. token.zig standalone tests 5/5 green; full suite green in debug. + ## Recorded deviations ## Blockers encountered From 4c451ac2893c2d09b921ca7062996bafdf824a8b Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sat, 4 Jul 2026 01:16:53 +0200 Subject: [PATCH 06/18] feat(etch): add TimerKind and TimerStmt ast nodes (M1.0.13 E2) --- src/etch/ast.zig | 77 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/src/etch/ast.zig b/src/etch/ast.zig index 89a16c6..40c7d89 100644 --- a/src/etch/ast.zig +++ b/src/etch/ast.zig @@ -1052,6 +1052,37 @@ pub const SpawnStmt = struct { body_len: u32, }; +/// Timer statement kind (M1.0.13 E2, `etch-grammar.md` §4.3 `timer_kind`). +pub const TimerKind = enum { + /// `after(d) { }` — one-shot on the game clock (pausable, scaled). + after, + /// `every(d) { }` — repeating on the game clock (fixed period, no drift + /// correction Phase 1). + every, + /// `after_unscaled(d) { }` — one-shot on the unscaled clock (fires under + /// pause, ignores `time_scale`). + after_unscaled, +}; + +/// `[ "let" IDENT "=" ] timer_kind "(" expression ")" block` statement +/// (M1.0.13 E2, `etch-grammar.md` §4.3 `timer_stmt`) — schedules a callback +/// on the runtime timer registry (§9.10: a timer is NOT a task; its body is a +/// synchronous context). `arg` is the Duration expression, evaluated once at +/// scheduling time (a full expression — not restricted to a literal, unlike +/// `await wait`). `binding` is the `let` name, typed `TimerHandle` (`0` when +/// absent — the handle is discarded); as with `SpawnStmt`, the binding is +/// PART of the statement, not a `let_stmt` whose initializer is a timer. The +/// body is a statement run in `arena.extra`. `quantize_stmt` stays a +/// payloadless placeholder — no struct, no arena list (its musical beat/bar +/// clock is assigned to a later Sequencer-adjacent milestone). +pub const TimerStmt = struct { + kind: TimerKind, + arg: NodeId, + body_start: u32, + body_len: u32, + binding: StringId, +}; + /// `|a, b| expr` closure (M0.8 closures, `etch-grammar.md` §524). Params are a /// flat `(start, len)` range of `arena.closure_params`; the body is an /// expression node. E1 closures take an expression body — a `{ block }` body @@ -2563,6 +2594,9 @@ pub const AstArena = struct { sync_stmts: std.ArrayListUnmanaged(SyncStmt) = .empty, branch_stmts: std.ArrayListUnmanaged(BranchStmt) = .empty, spawn_stmts: std.ArrayListUnmanaged(SpawnStmt) = .empty, + /// Timer statement payloads (M1.0.13 E2, §4.3 `timer_stmt`), indexed by + /// the stmt node's `data`. `quantize_stmt` has NO slab (placeholder). + timer_stmts: std.ArrayListUnmanaged(TimerStmt) = .empty, named_types: std.ArrayListUnmanaged(NamedTypeNode) = .empty, array_types: std.ArrayListUnmanaged(ArrayTypeNode) = .empty, map_types: std.ArrayListUnmanaged(MapTypeNode) = .empty, @@ -2781,6 +2815,7 @@ pub const AstArena = struct { self.sync_stmts.deinit(gpa); self.branch_stmts.deinit(gpa); self.spawn_stmts.deinit(gpa); + self.timer_stmts.deinit(gpa); self.named_types.deinit(gpa); self.array_types.deinit(gpa); self.map_types.deinit(gpa); @@ -3524,6 +3559,12 @@ pub const AstArena = struct { return try self.addStmt(gpa, .spawn_stmt, idx, span); } + pub fn addTimerStmt(self: *AstArena, gpa: std.mem.Allocator, ts: TimerStmt, span: SourceSpan) !NodeId { + const idx: u32 = @intCast(self.timer_stmts.items.len); + try self.timer_stmts.append(gpa, ts); + return try self.addStmt(gpa, .timer_stmt, idx, span); + } + pub fn addLetStmt(self: *AstArena, gpa: std.mem.Allocator, let: LetStmt, span: SourceSpan) !NodeId { const idx: u32 = @intCast(self.let_stmts.items.len); try self.let_stmts.append(gpa, let); @@ -3750,6 +3791,42 @@ test "AstArena spans align with passed-in byte offsets" { try std.testing.expectEqual(@as(u32, 13), arena.exprSpan(id_b).byte_start); } +test "AstArena timer statement round-trips through the timer_stmts slab (M1.0.13 E2)" { + const gpa = std.testing.allocator; + var arena = try AstArena.init(gpa); + defer arena.deinit(gpa); + + const arg = try arena.addExpr(gpa, .duration_lit, 0, .{ .byte_start = 6, .byte_end = 10 }); + const binding = try arena.strings.intern(gpa, "t"); + const bound = try arena.addTimerStmt(gpa, .{ + .kind = .every, + .arg = arg, + .body_start = 7, + .body_len = 2, + .binding = binding, + }, .{ .byte_start = 0, .byte_end = 20 }); + const unbound = try arena.addTimerStmt(gpa, .{ + .kind = .after_unscaled, + .arg = arg, + .body_start = 9, + .body_len = 0, + .binding = 0, + }, .{ .byte_start = 21, .byte_end = 40 }); + + try std.testing.expectEqual(StmtKind.timer_stmt, arena.stmtKind(bound)); + try std.testing.expectEqual(StmtKind.timer_stmt, arena.stmtKind(unbound)); + const first = arena.timer_stmts.items[arena.stmtData(bound)]; + try std.testing.expectEqual(TimerKind.every, first.kind); + try std.testing.expectEqual(arg, first.arg); + try std.testing.expectEqual(@as(u32, 7), first.body_start); + try std.testing.expectEqual(@as(u32, 2), first.body_len); + try std.testing.expectEqual(binding, first.binding); + const second = arena.timer_stmts.items[arena.stmtData(unbound)]; + try std.testing.expectEqual(TimerKind.after_unscaled, second.kind); + // `binding == 0` is the discarded-handle sentinel (the SpawnStmt precedent). + try std.testing.expectEqual(@as(StringId, 0), second.binding); +} + test "AnnotationKind.fromName recognises builtin names" { try std.testing.expectEqual(AnnotationKind.phase, AnnotationKind.fromName("phase")); try std.testing.expectEqual(AnnotationKind.range, AnnotationKind.fromName("range")); From f3df2ec5cb3cb569351b2c5848b20db0c7e7051d Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sat, 4 Jul 2026 01:16:55 +0200 Subject: [PATCH 07/18] docs(brief): journal update --- briefs/M1.0.13-time-and-timers.md | 1 + 1 file changed, 1 insertion(+) diff --git a/briefs/M1.0.13-time-and-timers.md b/briefs/M1.0.13-time-and-timers.md index e0e7f9a..b00bb88 100644 --- a/briefs/M1.0.13-time-and-timers.md +++ b/briefs/M1.0.13-time-and-timers.md @@ -150,6 +150,7 @@ None (no perf target for this milestone). - 2026-07-03 — Branch created from up-to-date main, brief committed verbatim, pushed -u (push-early). First pre-push attempt hit the known local watchdog SIGKILL flake (947/964 passed, brief-only diff); retry green. - 2026-07-03 — All six specs read in full, in brief order; Specs read checked off with timestamps; brief activated. - 2026-07-04 — E1: `every`/`after_unscaled` graduated to `kw_every`/`kw_after_unscaled` (enum + `s3_keywords`); reserve list down to `override` + `quantize` (comment block updated); stale M1.0.12 test assertions on the timer family removed; new M1.0.13 E1 test added. token.zig standalone tests 5/5 green; full suite green in debug. +- 2026-07-04 — E1 GO (review on pushed diff). E2: `TimerKind` enum + `TimerStmt` payload struct (kind, arg NodeId, body run in `arena.extra`, binding StringId with `0` = discarded — the SpawnStmt precedent) + `timer_stmts` slab wired to `StmtKind.timer_stmt` via `addTimerStmt`; deinit added; `quantize_stmt` untouched (payloadless placeholder). Round-trip inline test added; ast.zig standalone 11/11 green; full suite green in debug. ## Recorded deviations From c40841ddd3fa1c8ab128157abc5a15f10629f30e Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sat, 4 Jul 2026 01:56:14 +0200 Subject: [PATCH 08/18] feat(etch): parse timer statements, unbound and let-bound (M1.0.13 E3) --- src/etch/parser.zig | 195 ++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 186 insertions(+), 9 deletions(-) diff --git a/src/etch/parser.zig b/src/etch/parser.zig index 9bf1640..a7d073e 100644 --- a/src/etch/parser.zig +++ b/src/etch/parser.zig @@ -727,7 +727,17 @@ pub const Parser = struct { switch (self.peek()) { .error_byte => return self.parseErrFmt(self.peekSpan(), "unexpected byte '{s}'", .{self.sliceOf(self.peekSpan())}), .error_utf8 => return self.parseErr(self.peekSpan(), "invalid UTF-8 sequence"), - .error_unknown_keyword => return self.parseErrFmt(self.peekSpan(), "Etch keyword '{s}' is not supported in S3 (UnsupportedConstructInS3)", .{self.sliceOf(self.peekSpan())}), + .error_unknown_keyword => { + const lexeme = self.sliceOf(self.peekSpan()); + // §4.3 `quantize_stmt` stays reserved (M1.0.13): its beat/bar + // musical clock (Sequencer / Pulse) is absent from the Phase-1 + // runtime — explicit fail-loud pointing at the milestone that + // owns it, instead of the generic reserved-keyword message. + if (std.mem.eql(u8, lexeme, "quantize")) { + return self.parseErr(self.peekSpan(), "quantize statements need the Sequencer beat/bar musical clock; 'quantize' stays reserved until a later Sequencer-adjacent milestone"); + } + return self.parseErrFmt(self.peekSpan(), "Etch keyword '{s}' is not supported in S3 (UnsupportedConstructInS3)", .{lexeme}); + }, else => {}, } } @@ -5274,6 +5284,9 @@ pub const Parser = struct { // structural `spawn (` stays on the expression path. .kw_race, .kw_sync, .kw_branch => true, .kw_spawn => self.peekNext() == .lbrace, + // The §4.3 timer statements (M1.0.13 E3) — never a block's + // trailing value. + .kw_after, .kw_every, .kw_after_unscaled => true, .ident => self.peekNext() == .colon, // labeled loop `outer:` else => false, }; @@ -5702,6 +5715,46 @@ pub const Parser = struct { }, .{ .byte_start = start_span.byte_start, .byte_end = closing.span.byte_end }); } + /// True when `kind` heads a §4.3 timer statement (M1.0.13 E3). + fn isTimerKindToken(kind: TokenKind) bool { + return switch (kind) { + .kw_after, .kw_every, .kw_after_unscaled => true, + else => false, + }; + } + + /// Parse `timer_kind "(" expression ")" block` (M1.0.13 E3, + /// `etch-grammar.md` §4.3 `timer_stmt`). `binding` is the `let` name for + /// the bound form `let t = after(d) { }` (dispatched from `parseLetStmt`; + /// `0` = discarded handle); `start_span` is the statement's first token + /// (`let` or the timer keyword). The duration argument is a FULL + /// expression, evaluated once at scheduling time (E5) — not restricted to + /// a literal, unlike `await wait` (M1.0.11). The body is a statement run + /// (the branch/spawn layout); its synchronous-context enforcement (`await` + /// inside → `E0901`) is the type-checker's job (E4). + fn parseTimerStmt(self: *Parser, binding: StringId, start_span: SourceSpan) ParseError!NodeId { + const kw = try self.advance(); // 'after' / 'every' / 'after_unscaled' + const kind: ast_mod.TimerKind = switch (kw.kind) { + .kw_after => .after, + .kw_every => .every, + .kw_after_unscaled => .after_unscaled, + else => unreachable, // dispatch is gated on isTimerKindToken + }; + _ = try self.expect(.lparen, "expected '(' after the timer keyword"); + const arg = try self.parseExpr(0); + _ = try self.expect(.rparen, "expected ')' to close the timer duration argument"); + _ = try self.expect(.lbrace, "expected '{' to open the timer body"); + const body = try self.parseStmtRun(); + const closing = try self.expect(.rbrace, "expected '}' to close the timer body"); + return try self.arena.addTimerStmt(self.gpa, .{ + .kind = kind, + .arg = arg, + .body_start = body.start, + .body_len = body.len, + .binding = binding, + }, .{ .byte_start = start_span.byte_start, .byte_end = closing.span.byte_end }); + } + fn parseStmt(self: *Parser) ParseError!NodeId { if (self.peek() == .kw_branch) { // The async `branch { }` statement (M1.0.12 E2, §4.2 branch_stmt). @@ -5722,12 +5775,12 @@ pub const Parser = struct { if (self.peek() == .kw_spawn and self.peekNext() == .lbrace) { return try self.parseSpawnStmt(0, self.peekSpan()); } - if (self.peek() == .kw_after) { - // `after` graduated to a keyword for routine triggers (M0.8 E4); - // the §4.3 timer statement it also heads stays out of M0.8 — - // keep the fail-loud explicit (it lexed `error_unknown_keyword` - // before the graduation). - return self.parseErr(self.peekSpan(), "timer statements ('after ...') are not in M0.8 scope (Phase 2)"); + if (isTimerKindToken(self.peek())) { + // §4.3 timer statement, unbound form (M1.0.13 E3) — the bound form + // `let t = after(d) { }` is dispatched inside `parseLetStmt`. The + // routine-trigger `after Segment` context is parsed inside the + // routine construct parser and never reaches statement position. + return try self.parseTimerStmt(0, self.peekSpan()); } if (self.peek() == .kw_let) { return try self.parseLetStmt(); @@ -5817,6 +5870,21 @@ pub const Parser = struct { } return try self.parseSpawnStmt(name_id, let_span); } + // `let t = after(d) { }` — the bound timer statement (M1.0.13 E3, §4.3 + // `timer_stmt = [ "let" IDENT "=" ] timer_kind "(" expression ")" + // block`): as with `spawn_stmt`, the binding is PART of the timer + // statement, not a let-stmt whose initializer is a timer. The grammar + // admits neither `mut` nor a type annotation on this form (the handle + // types as the builtin `TimerHandle`, E4). + if (isTimerKindToken(self.peek())) { + if (is_mut) { + return self.parseErr(self.peekSpan(), "a timer binding takes the form 'let IDENT = after(d) { ... }' — 'mut' is not part of the timer_stmt grammar (§4.3)"); + } + if (!type_annotation.isNone()) { + return self.parseErr(self.peekSpan(), "a timer binding takes the form 'let IDENT = after(d) { ... }' — a type annotation is not part of the timer_stmt grammar (§4.3)"); + } + return try self.parseTimerStmt(name_id, let_span); + } const value = try self.parseExpr(0); const span: SourceSpan = .{ .byte_start = let_span.byte_start, @@ -8667,15 +8735,124 @@ test "parser rejects routine clause-order and shape violations (M0.8 E4)" { try std.testing.expect(bad_time.diagnostics.len > 0); } -test "parser rejects the out-of-scope timer statement after kw_after graduation (M0.8 E4)" { +test "timer statements parse: unbound forms, expression arg (M1.0.13 E3)" { const gpa = std.testing.allocator; var result = try parse(gpa, \\rule r(entity: Entity) when entity has Health { - \\ after 2.0 { } + \\ after(2.0s) { + \\ entity.get_mut(Health).current += 1.0 + \\ } + \\ every(30.0s) { } + \\ after_unscaled(0.5s) { } + \\ let d = 1.5s + \\ after(d) { } + \\} + ); + defer result.deinit(gpa); + if (result.diagnostics.len > 0) { + std.debug.print("unexpected parse diagnostic: {s}\n", .{result.diagnostics[0].primary_message}); + try std.testing.expect(false); + } + var buf: [8]ast_mod.StmtKind = undefined; + const kinds = firstRuleBodyKinds(&result, &buf); + try std.testing.expectEqualSlices(ast_mod.StmtKind, &.{ .timer_stmt, .timer_stmt, .timer_stmt, .let_stmt, .timer_stmt }, kinds); + const timers = result.ast.timer_stmts.items; + try std.testing.expectEqual(@as(usize, 4), timers.len); + try std.testing.expectEqual(ast_mod.TimerKind.after, timers[0].kind); + try std.testing.expectEqual(@as(u32, 1), timers[0].body_len); + try std.testing.expectEqual(@as(u32, 0), timers[0].binding); + try std.testing.expectEqual(ast_mod.TimerKind.every, timers[1].kind); + try std.testing.expectEqual(@as(u32, 0), timers[1].body_len); + try std.testing.expectEqual(ast_mod.TimerKind.after_unscaled, timers[2].kind); + // The duration argument is a full expression (§9.10): a variable is + // accepted at parse — `after(d)` carries an ident arg, not a literal. + try std.testing.expectEqual(ast_mod.ExprKind.duration_lit, result.ast.exprKind(timers[0].arg)); + try std.testing.expectEqual(ast_mod.ExprKind.ident, result.ast.exprKind(timers[3].arg)); +} + +test "bound timer parses as a TimerStmt, not a let_stmt (M1.0.13 E3)" { + const gpa = std.testing.allocator; + var result = try parse(gpa, + \\event Boom { } + \\rule r() { + \\ let t = after(5.0s) { + \\ emit Boom { } + \\ } + \\} + ); + defer result.deinit(gpa); + if (result.diagnostics.len > 0) { + std.debug.print("unexpected parse diagnostic: {s}\n", .{result.diagnostics[0].primary_message}); + try std.testing.expect(false); + } + try std.testing.expectEqual(@as(usize, 1), result.ast.timer_stmts.items.len); + const timer = result.ast.timer_stmts.items[0]; + try std.testing.expectEqual(ast_mod.TimerKind.after, timer.kind); + try std.testing.expectEqualStrings("t", result.ast.strings.slice(timer.binding)); + try std.testing.expectEqual(@as(u32, 1), timer.body_len); + // No let-stmt was produced for the bound form (the binding is part of + // the timer statement — the SpawnStmt precedent). + try std.testing.expectEqual(@as(usize, 0), result.ast.let_stmts.items.len); +} + +test "timer statement routes inside a value block (M1.0.13 E3)" { + const gpa = std.testing.allocator; + // `startsKeywordStmt` must route the timer keywords to `parseStmt` in a + // value-block body so the trailing value stays detectable. + var result = try parse(gpa, + \\rule r() { + \\ let x = { + \\ every(1.0s) { } + \\ 3 + \\ } + \\} + ); + defer result.deinit(gpa); + if (result.diagnostics.len > 0) { + std.debug.print("unexpected parse diagnostic: {s}\n", .{result.diagnostics[0].primary_message}); + try std.testing.expect(false); + } + try std.testing.expectEqual(@as(usize, 1), result.ast.timer_stmts.items.len); + try std.testing.expectEqual(ast_mod.TimerKind.every, result.ast.timer_stmts.items[0].kind); +} + +test "timer binding form rejects mut and type annotation (M1.0.13 E3)" { + const gpa = std.testing.allocator; + // §4.3: `timer_stmt = [ "let" IDENT "=" ] timer_kind "(" expression ")" + // block` — no `mut`, no type annotation on the binding. + var r1 = try parse(gpa, + \\rule r() { + \\ let mut t = after(1.0s) { } + \\} + ); + defer r1.deinit(gpa); + try std.testing.expect(r1.diagnostics.len > 0); + try std.testing.expectEqual(diag_mod.DiagnosticCode.parse_error, r1.diagnostics[0].code); + var r2 = try parse(gpa, + \\rule r() { + \\ let t: int = after(1.0s) { } + \\} + ); + defer r2.deinit(gpa); + try std.testing.expect(r2.diagnostics.len > 0); + try std.testing.expectEqual(diag_mod.DiagnosticCode.parse_error, r2.diagnostics[0].code); +} + +test "quantize still fail-louds at the statement head (M1.0.13 E3)" { + const gpa = std.testing.allocator; + var result = try parse(gpa, + \\rule r() { + \\ quantize(.next_beat) { } \\} ); defer result.deinit(gpa); try std.testing.expect(result.diagnostics.len > 0); + try std.testing.expectEqual(diag_mod.DiagnosticCode.parse_error, result.diagnostics[0].code); + // The message is re-pointed at the Sequencer-adjacent milestone — the + // stale "Phase 2" wording is gone. + const msg = result.diagnostics[0].primary_message; + try std.testing.expect(std.mem.indexOf(u8, msg, "Sequencer") != null); + try std.testing.expect(std.mem.indexOf(u8, msg, "Phase 2") == null); } test "parser recovers and a valid routine after a broken construct survives (M0.8 E4 lockstep)" { From a40d1ceede002ef3e2556756036f2a6ac4b926c6 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sat, 4 Jul 2026 01:56:15 +0200 Subject: [PATCH 09/18] docs(brief): journal update --- briefs/M1.0.13-time-and-timers.md | 1 + 1 file changed, 1 insertion(+) diff --git a/briefs/M1.0.13-time-and-timers.md b/briefs/M1.0.13-time-and-timers.md index b00bb88..1fc454b 100644 --- a/briefs/M1.0.13-time-and-timers.md +++ b/briefs/M1.0.13-time-and-timers.md @@ -151,6 +151,7 @@ None (no perf target for this milestone). - 2026-07-03 — All six specs read in full, in brief order; Specs read checked off with timestamps; brief activated. - 2026-07-04 — E1: `every`/`after_unscaled` graduated to `kw_every`/`kw_after_unscaled` (enum + `s3_keywords`); reserve list down to `override` + `quantize` (comment block updated); stale M1.0.12 test assertions on the timer family removed; new M1.0.13 E1 test added. token.zig standalone tests 5/5 green; full suite green in debug. - 2026-07-04 — E1 GO (review on pushed diff). E2: `TimerKind` enum + `TimerStmt` payload struct (kind, arg NodeId, body run in `arena.extra`, binding StringId with `0` = discarded — the SpawnStmt precedent) + `timer_stmts` slab wired to `StmtKind.timer_stmt` via `addTimerStmt`; deinit added; `quantize_stmt` untouched (payloadless placeholder). Round-trip inline test added; ast.zig standalone 11/11 green; full suite green in debug. +- 2026-07-04 — E2 GO. E3: `parseTimerStmt` (arg = full expression, body via `parseStmtRun` — the branch/spawn layout); unbound form dispatched at statement head via `isTimerKindToken` (replaces the M0.8 `kw_after` fail-loud, stale "Phase 2" wording dropped); bound form dispatched in `parseLetStmt` after the spawn branch (rejects `mut` + type annotation, the spawn precedent); timer keywords added to `startsKeywordStmt` (value-block routing); `quantize` fail-loud made explicit in `surfaceTokenErrors` with the message re-pointed at a later Sequencer-adjacent milestone. Five inline tests (unbound ×3 + expr arg, bound-not-let, value-block routing, mut/annotation rejection, quantize fail-loud); parser.zig standalone 145/145 green; full suite green in debug. EBNF example corpus untouched (M1.0.10–12 precedent; outside Files to modify). ## Recorded deviations From 16b48833c9f00d333c9a0ec6eb8beb04ffec8ea3 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sat, 4 Jul 2026 02:17:47 +0200 Subject: [PATCH 10/18] feat(etch): timer type-check + builtin time resources (M1.0.13 E4) --- src/etch/types.zig | 352 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 352 insertions(+) diff --git a/src/etch/types.zig b/src/etch/types.zig index 4043c6a..c78c50e 100644 --- a/src/etch/types.zig +++ b/src/etch/types.zig @@ -54,6 +54,14 @@ pub const BuiltinType = enum { /// rejected as a component/resource field type. `h.cancel()` and `await h` /// are its only operations (§9.8). task_handle, + /// `TimerHandle` (§2.2 builtin, M1.0.13 E4) — the value of a bound timer + /// statement (`let t = after(d) { }`). Non-POD (`etch-grammar.md` §2.2): + /// like `task_handle` it is deliberately NOT in `fromName`, so it stays + /// rejected as a component/resource field type. `t.cancel()` is its ONLY + /// operation (§9.10) — a timer is NOT a task: it is not awaitable and + /// carries no join semantics. Distinct from `task_handle` by design (two + /// different lifecycle models; reusing one for the other is rejected). + timer_handle, pub fn isNumeric(self: BuiltinType) bool { return switch (self) { @@ -92,6 +100,67 @@ pub const BuiltinType = enum { } }; +/// Default-value carrier for a builtin-resource field (M1.0.13 E4). The three +/// time resources only need the POD scalars below. +pub const BuiltinResourceDefault = union(enum) { + float_: f64, + int_: i64, + bool_: bool, +}; + +/// One field of a builtin engine resource (M1.0.13 E4). +pub const BuiltinResourceField = struct { + name: []const u8, + type_: BuiltinType, + default: BuiltinResourceDefault, +}; + +/// A builtin engine resource descriptor (M1.0.13 E4). +pub const BuiltinResource = struct { + name: []const u8, + fields: []const BuiltinResourceField, +}; + +/// The three builtin engine time resources (M1.0.13, +/// `engine-gameplay-systems.md` "Les trois temps") — the SINGLE descriptor +/// table: the type-checker resolves `get(GameTime).dt` / +/// `get_mut(GameTime).time_scale` against it, and `interp.zig` Pass A +/// auto-registers the resources from it at the builtin `TagSet` injection +/// point (E5). No `.etch` prelude, no separate module. `GameTime.fixed_dt`'s +/// default IS the interpreter's fixed timestep (`1.0 / 60.0`, the M1.0.11 +/// async 60 Hz convention). `UnscaledTime` and `RealTime` are affected by +/// neither `time_scale` nor `paused`. +pub const builtin_resources = [_]BuiltinResource{ + .{ .name = "GameTime", .fields = &.{ + .{ .name = "dt", .type_ = .float_, .default = .{ .float_ = 0.0 } }, + .{ .name = "fixed_dt", .type_ = .float_, .default = .{ .float_ = 1.0 / 60.0 } }, + .{ .name = "total", .type_ = .float_, .default = .{ .float_ = 0.0 } }, + .{ .name = "time_scale", .type_ = .float_, .default = .{ .float_ = 1.0 } }, + .{ .name = "frame", .type_ = .int_, .default = .{ .int_ = 0 } }, + .{ .name = "fixed_frame", .type_ = .int_, .default = .{ .int_ = 0 } }, + .{ .name = "paused", .type_ = .bool_, .default = .{ .bool_ = false } }, + } }, + .{ .name = "UnscaledTime", .fields = &.{ + .{ .name = "dt", .type_ = .float_, .default = .{ .float_ = 0.0 } }, + .{ .name = "total", .type_ = .float_, .default = .{ .float_ = 0.0 } }, + } }, + .{ .name = "RealTime", .fields = &.{ + .{ .name = "dt", .type_ = .float_, .default = .{ .float_ = 0.0 } }, + .{ .name = "total", .type_ = .float_, .default = .{ .float_ = 0.0 } }, + .{ .name = "since_startup", .type_ = .float_, .default = .{ .float_ = 0.0 } }, + } }, +}; + +/// Descriptor lookup by resource name bytes (M1.0.13 E4). Consulted by the +/// receiver-less `get(T)` resolution and by the builtin-resource field +/// lookup; `interp.zig` iterates `builtin_resources` directly (E5). +pub fn builtinResourceByName(name: []const u8) ?*const BuiltinResource { + for (&builtin_resources) |*r| { + if (std.mem.eql(u8, r.name, name)) return r; + } + return null; +} + /// Fixed-array carrier for `ResolvedType.array_fixed`: builtin element type + /// compile-time length. E1 collections hold **builtin primitive** elements /// only (collections of components / structs are E2); a non-builtin element @@ -3323,6 +3392,12 @@ pub const TypeChecker = struct { // it is rejected as a field type everywhere (a task // handle is a live runtime identity, never stored state). try self.emit(.undefined_symbol, .error_, tspan, "type 'TaskHandle' is non-POD (etch-grammar.md par. 2.2) and cannot be a field type", .{}); + } else if (std.mem.eql(u8, tname, "TimerHandle")) { + // M1.0.13 E4 — `TimerHandle` is a non-POD builtin + // (`etch-grammar.md` §2.2): rejected as a field type + // everywhere, the `TaskHandle` precedent (a timer handle + // is a live runtime identity, never stored state). + try self.emit(.undefined_symbol, .error_, tspan, "type 'TimerHandle' is non-POD (etch-grammar.md par. 2.2) and cannot be a field type", .{}); } else { try self.emit(.undefined_symbol, .error_, tspan, "unknown type '{s}'", .{tname}); } @@ -4516,10 +4591,60 @@ pub const TypeChecker = struct { try ctx.locals.put(self.gpa, ss.binding, .{ .type_ = .{ .builtin = .task_handle }, .is_mut = false }); } }, + .timer_stmt => { + // `[let t =] after/every/after_unscaled(d) { }` (M1.0.13 E4, + // §9.10). A timer is usable in ANY body, async or not — it + // carries no `{async}` effect (absent from the §9.4 + // builtin-effect table), so there is no E0901 gate on the + // statement itself. + const ts = self.arena.timer_stmts.items[data]; + // The duration argument is a full expression typed `Duration` + // (§9.10), evaluated once at scheduling time (E6). + const arg_t = self.synthExpr(ts.arg, ctx); + if (arg_t != .unknown and !(arg_t == .builtin and arg_t.builtin == .duration)) { + try self.emit(.type_mismatch, .error_, self.arena.exprSpan(ts.arg), "timer argument must be a Duration expression", .{}); + } + try self.checkTimerBodyRun(ctx, ts.body_start, ts.body_len); + // The binding types as the builtin `TimerHandle` (§2.2, + // non-POD), bound AFTER the body: the callback runs on a + // scope snapshot taken at scheduling, before the parent binds + // the handle (E6) — the handle does not exist inside the body. + if (ts.binding != 0) { + try ctx.locals.put(self.gpa, ts.binding, .{ .type_ = .{ .builtin = .timer_handle }, .is_mut = false }); + } + }, else => {}, } } + /// Check a timer callback body (M1.0.13 E4, §9.10) — a SYNCHRONOUS + /// context: `current_is_async` is forced false so an `await` / async call + /// inside it emits `E0901` even when the scheduling rule is async (the + /// timer carries no `{async}` effect). The concurrency-branch state + /// resets across the boundary too: the callback is a detached synchronous + /// context executed run-to-completion at firing (E6), not part of an + /// enclosing task branch. + fn checkTimerBodyRun(self: *TypeChecker, ctx: *RuleCtx, start: u32, len: u32) TypeError!void { + const saved_async = self.current_is_async; + const saved_branch = self.conc_branch; + const saved_depth = self.conc_loop_depth; + const saved_base = self.conc_labels_base; + self.current_is_async = false; + self.conc_branch = null; + self.conc_loop_depth = 0; + self.conc_labels_base = self.conc_labels.items.len; + defer { + self.current_is_async = saved_async; + self.conc_branch = saved_branch; + self.conc_loop_depth = saved_depth; + self.conc_labels_base = saved_base; + } + var i: u32 = 0; + while (i < len) : (i += 1) { + try self.checkStmt(ctx, @bitCast(self.arena.extra.items[start + i])); + } + } + /// Enter a concurrency-branch context around one `race`/`sync` branch /// statement (M1.0.12 E3): E0906/E0907 anchor to the INNERMOST branch and /// the in-branch loop depth and label window restart at the boundary. The @@ -4748,6 +4873,15 @@ pub const TypeChecker = struct { // (D-S3-resource-receiver). `T` must name a resource; a // component here is the symmetric E0301 error. if (mg.receiver.isNone()) { + // Builtin engine resource (M1.0.13 E4): resolved against + // the `builtin_resources` descriptor table — no AST + // declaration, auto-registered by the runtime (E5). The + // when-clause gate does not apply: the time resources are + // ambient, readable from any rule (the spec examples read + // `get(GameTime).dt` without a `when resource` clause). + if (builtinResourceByName(tname) != null) { + return .{ .resource = mg.type_name }; + } if (self.symbols.get(mg.type_name)) |sym| { if (sym.kind == .component) { try self.emit(.resource_expected_component_given, .error_, self.arena.exprSpan(id), "'{s}' is a component — receiver-less get(...) accesses a resource; use entity.get({s})", .{ tname, tname }); @@ -4884,6 +5018,13 @@ pub const TypeChecker = struct { // unit in Phase 1 (spawn bodies have no value channel — // brief Notes); `unknown` ≈ unit, the house convention. if (t == .builtin and t.builtin == .task_handle) return ResolvedType.unknown; + // A `TimerHandle` is NOT awaitable (M1.0.13 E4, §9.10): + // a timer is not a task — no join semantics. Precise + // message ahead of the generic rejection below. + if (t == .builtin and t.builtin == .timer_handle) { + try self.emit(.type_mismatch, .error_, self.arena.exprSpan(aw.arg_expr), "a TimerHandle is not awaitable — a timer is not a task (its only operation is 'cancel()', par. 9.10)", .{}); + return ResolvedType.unknown; + } if (t != .unknown) { try self.emit(.type_mismatch, .error_, self.arena.exprSpan(aw.arg_expr), "await target must be a direct async call or a TaskHandle", .{}); } @@ -5900,6 +6041,21 @@ pub const TypeChecker = struct { return ResolvedType.unknown; } + // M1.0.13 E4 — builtin TimerHandle method (§9.10): `cancel()` — no + // args, statement-effect (`unknown` ≈ unit), idempotent at runtime + // (E6). Its ONLY operation: a timer is not a task — no `await t`, + // no join, nothing else. + if (recv_t == .builtin and recv_t.builtin == .timer_handle) { + if (std.mem.eql(u8, method_slice, "cancel")) { + if (mc.args_len != 0) { + try self.emit(.type_mismatch, .error_, self.arena.exprSpan(id), "TimerHandle method 'cancel' takes no arguments", .{}); + } + return ResolvedType.unknown; + } + try self.emit(.type_mismatch, .error_, self.arena.exprSpan(id), "no method '{s}' on a TimerHandle (only 'cancel()'; a timer is not awaitable)", .{method_slice}); + 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 @@ -6395,6 +6551,17 @@ pub const TypeChecker = struct { return ResolvedType.unknown; }, .resource => |name_id| { + // Builtin engine resource (M1.0.13 E4): fields resolve + // against the `builtin_resources` descriptor table (there is + // no AST declaration to consult). + if (builtinResourceByName(self.arena.strings.slice(name_id))) |br| { + const fname = self.arena.strings.slice(field_name); + for (br.fields) |f| { + if (std.mem.eql(u8, fname, f.name)) return .{ .builtin = f.type_ }; + } + try self.emit(.invalid_field_filter, .error_, span, "field '{s}' does not exist on builtin resource '{s}'", .{ fname, br.name }); + return ResolvedType.unknown; + } const sym = self.symbols.get(name_id) orelse return ResolvedType.unknown; const decl = self.arena.resource_decls.items[self.arena.itemData(sym.item_id)]; var i: u32 = 0; @@ -10199,6 +10366,191 @@ test "TaskHandle is rejected as a component/resource field type (M1.0.12 E3)" { try std.testing.expectEqual(@as(usize, 2), count); } +test "timer: binding typed TimerHandle, cancel accepted, expression arg, non-async rule (M1.0.13 E4)" { + const gpa = std.testing.allocator; + // A bound timer types `t` as the builtin TimerHandle; `cancel()` is its + // only operation (§9.10). Timers carry no `{async}` effect: they are + // legal in a plain (non-async) rule. The duration argument is a full + // expression — a `Duration` variable is accepted. + var ok = try parseAndCheck(gpa, + \\resource Out { n: int = 0 } + \\event Boom { } + \\rule r() + \\ when resource Out + \\{ + \\ let d = 1.5s + \\ let t = after(d) { + \\ emit Boom { } + \\ } + \\ t.cancel() + \\ every(30.0s) { } + \\ after_unscaled(0.5s) { } + \\} + ); + 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); + + // `cancel` with arguments, and any other method on a TimerHandle → E0200. + var badm = try parseAndCheck(gpa, + \\resource Out { n: int = 0 } + \\rule r() + \\ when resource Out + \\{ + \\ let t = after(1.0s) { } + \\ t.cancel(1) + \\ t.join() + \\} + ); + defer badm.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), badm.parse_diags.len); + var count: usize = 0; + for (badm.diagnostics.items) |d| { + if (d.code == .type_mismatch) count += 1; + } + try std.testing.expectEqual(@as(usize, 2), count); +} + +test "timer: await on a TimerHandle is rejected — a timer is not a task (M1.0.13 E4)" { + const gpa = std.testing.allocator; + var bad = try parseAndCheck(gpa, + \\resource Out { n: int = 0 } + \\async rule r() + \\ when resource Out + \\{ + \\ let t = after(1.0s) { } + \\ await t + \\} + ); + defer bad.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), bad.parse_diags.len); + try expectAnyCode(bad.diagnostics.items, .type_mismatch); +} + +test "TimerHandle is rejected as a component/resource field type (M1.0.13 E4)" { + const gpa = std.testing.allocator; + // Non-POD builtin (§2.2) — the TaskHandle precedent: a `TimerHandle` + // field is rejected on both a component and a resource. + var bad = try parseAndCheck(gpa, + \\component C { t: TimerHandle } + \\resource R { t: TimerHandle } + ); + defer bad.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), bad.parse_diags.len); + var count: usize = 0; + for (bad.diagnostics.items) |d| { + if (d.code == .undefined_symbol) count += 1; + } + try std.testing.expectEqual(@as(usize, 2), count); +} + +test "timer: non-Duration argument is rejected (M1.0.13 E4)" { + const gpa = std.testing.allocator; + var bad = try parseAndCheck(gpa, + \\resource Out { n: int = 0 } + \\rule r() + \\ when resource Out + \\{ + \\ after(5) { } + \\ every(true) { } + \\} + ); + defer bad.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), bad.parse_diags.len); + var count: usize = 0; + for (bad.diagnostics.items) |d| { + if (d.code == .type_mismatch) count += 1; + } + try std.testing.expectEqual(@as(usize, 2), count); +} + +test "timer body is a synchronous context: await/async call inside is E0901 (M1.0.13 E4)" { + const gpa = std.testing.allocator; + // §9.10: the timer body carries no `{async}` effect — an `await` or an + // async call inside it is E0901 even when the SCHEDULING rule is async. + var bad = try parseAndCheck(gpa, + \\resource Out { n: int = 0 } + \\async fn f() { + \\ await wait(0.02s) + \\} + \\async rule r() + \\ when resource Out + \\{ + \\ after(1.0s) { + \\ await wait(0.5s) + \\ } + \\ every(1.0s) { + \\ f() + \\ } + \\} + ); + defer bad.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), bad.parse_diags.len); + var count: usize = 0; + for (bad.diagnostics.items) |d| { + if (d.code == .async_call_in_non_async_context) count += 1; + } + try std.testing.expectEqual(@as(usize, 2), count); + + // A deferred structural mutation and an emit stay LEGAL in the body — + // the synchronous context only refuses the async effect. + var ok = try parseAndCheck(gpa, + \\resource Out { n: int = 0 } + \\component Health { current: f32 = 100.0 } + \\event Boom { } + \\rule r(entity: Entity) + \\ when entity has Health and resource Out + \\{ + \\ after(2.0s) { + \\ emit Boom { } + \\ entity.remove(Health) + \\ } + \\} + ); + 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); +} + +test "builtin time resources resolve by name from the descriptor table (M1.0.13 E4)" { + const gpa = std.testing.allocator; + // `get(GameTime).dt` / `get_mut(GameTime).time_scale` resolve against + // `builtin_resources` — no declaration, no `when resource` clause (the + // time resources are ambient; `Out` alone is in the when). + var ok = try parseAndCheck(gpa, + \\resource Out { total: float = 0.0 } + \\rule r() + \\ when resource Out + \\{ + \\ let scaled: float = get(GameTime).dt + \\ let fixed: float = get(GameTime).fixed_dt + \\ let frames: int = get(GameTime).frame + \\ let halted: bool = get(GameTime).paused + \\ get_mut(GameTime).time_scale = 0.5 + \\ get_mut(GameTime).paused = true + \\ let u: float = get(UnscaledTime).dt + \\ let startup: float = get(RealTime).since_startup + \\ get_mut(Out).total = get(UnscaledTime).total + \\} + ); + 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); + + // An unknown field on a builtin resource is caught against the table. + var bad = try parseAndCheck(gpa, + \\resource Out { total: float = 0.0 } + \\rule r() + \\ when resource Out + \\{ + \\ let x = get(GameTime).nope + \\} + ); + defer bad.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), bad.parse_diags.len); + try expectAnyCode(bad.diagnostics.items, .invalid_field_filter); +} + test "conditional branch guards type-check as bool in the parent scope (M1.0.12 E3)" { const gpa = std.testing.allocator; // A bool guard referencing a parent local is clean; a non-bool guard is From 382740810bb46c572a5db15a2fe15ca805645599 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sat, 4 Jul 2026 02:17:49 +0200 Subject: [PATCH 11/18] docs(brief): journal update --- briefs/M1.0.13-time-and-timers.md | 1 + 1 file changed, 1 insertion(+) diff --git a/briefs/M1.0.13-time-and-timers.md b/briefs/M1.0.13-time-and-timers.md index 1fc454b..b6a88cc 100644 --- a/briefs/M1.0.13-time-and-timers.md +++ b/briefs/M1.0.13-time-and-timers.md @@ -152,6 +152,7 @@ None (no perf target for this milestone). - 2026-07-04 — E1: `every`/`after_unscaled` graduated to `kw_every`/`kw_after_unscaled` (enum + `s3_keywords`); reserve list down to `override` + `quantize` (comment block updated); stale M1.0.12 test assertions on the timer family removed; new M1.0.13 E1 test added. token.zig standalone tests 5/5 green; full suite green in debug. - 2026-07-04 — E1 GO (review on pushed diff). E2: `TimerKind` enum + `TimerStmt` payload struct (kind, arg NodeId, body run in `arena.extra`, binding StringId with `0` = discarded — the SpawnStmt precedent) + `timer_stmts` slab wired to `StmtKind.timer_stmt` via `addTimerStmt`; deinit added; `quantize_stmt` untouched (payloadless placeholder). Round-trip inline test added; ast.zig standalone 11/11 green; full suite green in debug. - 2026-07-04 — E2 GO. E3: `parseTimerStmt` (arg = full expression, body via `parseStmtRun` — the branch/spawn layout); unbound form dispatched at statement head via `isTimerKindToken` (replaces the M0.8 `kw_after` fail-loud, stale "Phase 2" wording dropped); bound form dispatched in `parseLetStmt` after the spawn branch (rejects `mut` + type annotation, the spawn precedent); timer keywords added to `startsKeywordStmt` (value-block routing); `quantize` fail-loud made explicit in `surfaceTokenErrors` with the message re-pointed at a later Sequencer-adjacent milestone. Five inline tests (unbound ×3 + expr arg, bound-not-let, value-block routing, mut/annotation rejection, quantize fail-loud); parser.zig standalone 145/145 green; full suite green in debug. EBNF example corpus untouched (M1.0.10–12 precedent; outside Files to modify). +- 2026-07-04 — E3 GO. E4: `timer_handle` builtin variant (task_handle precedent — not in `fromName`); `TimerHandle` field-type rejection (component + resource); `cancel()`-only method dispatch (no-arg checked; anything else E0200 with a not-awaitable pointer); `await t` on a TimerHandle explicitly rejected in the await arm (E0200, precise message, ahead of the generic rejection); `checkStmt` `.timer_stmt` arm — no E0901 gate on the statement (no `{async}` effect, §9.4), arg typed `Duration` (E0200 otherwise, `unknown` permissive), binding put in scope as `timer_handle` AFTER the body (snapshot semantics, E6); `checkTimerBodyRun` forces `current_is_async = false` + resets the concurrency-branch state (detached synchronous callback context). Builtin-resource descriptor table `builtin_resources` + `builtinResourceByName` (`pub const`, GameTime 7 fields with `fixed_dt = 1/60`, UnscaledTime 2, RealTime 3); `get`/`get_mut(T)` resolves builtin names ahead of the symbol table and BYPASSES the when-clause gate (ambient time resources — the spec examples read them without a `when resource` clause); `lookupFieldType` `.resource` arm consults the table first (unknown field → E1211 against the table). Six inline tests; two initial test-source fixes (int-literal defaults on f32, f32-vs-float assignment — checker correct, tests adjusted); types.zig standalone 289/289 green; full suite green in debug. ## Recorded deviations From e41e5ece34e923b2854dd3977904760f49abec41 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sat, 4 Jul 2026 10:26:36 +0200 Subject: [PATCH 12/18] feat(etch): time subsystem and wait re-plumb (M1.0.13 E5) --- src/etch/interp.zig | 545 +++++++++++++++++++++++++++++++++++++++----- 1 file changed, 491 insertions(+), 54 deletions(-) diff --git a/src/etch/interp.zig b/src/etch/interp.zig index 609f0f2..385992c 100644 --- a/src/etch/interp.zig +++ b/src/etch/interp.zig @@ -514,13 +514,58 @@ const Control = enum { none, break_, continue_ }; /// What an enclosing loop should do once a control signal has surfaced. const LoopAction = enum { again, stop, propagate }; -/// Phase-1 fixed-timestep tick rate (`etch-reference-part1.md §9.12`): `await -/// wait(d)` converts a `Duration` to `async_tick` counts as `round(seconds * -/// 60)` — the 1/60 frame convention. M1.0.13 replaces this with scaled game time -/// WITHOUT changing the `wait` signature: at `time_scale = 1` and fixed `dt = -/// 1/60`, the behavior is identical. +/// Phase-1 fixed-timestep tick rate (`etch-reference-part1.md §9.12`): one +/// `stepOnce` = one fixed 1/60 tick. `await wait(d)` converts a `Duration` to +/// whole ticks as `round(seconds * 60)` and schedules them on the GAME clock +/// (scaled by `time_scale`, frozen under `paused` — M1.0.13 E5); +/// `wait_unscaled(d)` schedules the same conversion on the UNSCALED clock. At +/// `time_scale = 1` the wake ticks are byte-identical to the pre-M1.0.13 +/// integer conversion. Also the default of the builtin `GameTime.fixed_dt` +/// resource field (`types.builtin_resources`). const async_fixed_dt_hz: f64 = 60.0; +/// Seconds per fixed tick — the `dt` the time subsystem publishes each +/// `stepOnce` (M1.0.13 E5, `engine-gameplay-systems.md` "Les trois temps"). +const fixed_dt_secs: f64 = 1.0 / async_fixed_dt_hz; + +/// Resolved runtime handles of the three builtin time resources (M1.0.13 E5): +/// registry ids plus the byte offsets of every field `advanceTime` writes. +/// Resolved once in `compile`, right after the Pass-A registration from +/// `types.builtin_resources` (the ids and offsets are layout-stable for the +/// interpreter's lifetime — hot reload reuses the existing registration). +const TimeResources = struct { + game_id: ComponentId, + unscaled_id: ComponentId, + real_id: ComponentId, + game_dt: u16, + game_total: u16, + game_time_scale: u16, + game_frame: u16, + game_fixed_frame: u16, + game_paused: u16, + unscaled_dt: u16, + unscaled_total: u16, + real_dt: u16, + real_total: u16, + real_since_startup: u16, +}; + +/// Read an `f64` resource field at `off` (M1.0.13 E5 population helpers — +/// unaligned-safe byte copies, the resource store guarantees nothing better). +fn readF64At(bytes: []const u8, off: u16) f64 { + var x: f64 = undefined; + @memcpy(std.mem.asBytes(&x), bytes[off..][0..8]); + return x; +} + +fn writeF64At(bytes: []u8, off: u16, v: f64) void { + @memcpy(bytes[off..][0..8], std.mem.asBytes(&v)); +} + +fn writeI64At(bytes: []u8, off: u16, v: i64) void { + @memcpy(bytes[off..][0..8], std.mem.asBytes(&v)); +} + /// Parse the seconds of a `Duration` literal lexeme (`"1.5s"` → 1.5) — the /// minimal Duration→seconds path `await wait` needs (M1.0.11 E3). `null` if the /// lexeme is malformed. General `Duration` arithmetic stays out of scope. @@ -541,10 +586,11 @@ fn durationLiteralSeconds(text: []const u8) ?f64 { // - A statement-head `await` suspends the whole task at ANY depth: `driveLoop` // returns, the frame-stack persists, and resume re-enters the innermost frame // at its cursor — a prefix statement is NEVER re-run, so `emit` and structural -// mutations don't double-fire. `wait(Duration)` resolves against `async_tick` -// via the fixed 1/60 timestep (`async_fixed_dt_hz`); `global_event` against the -// per-tick event store; the direct-call `future` (`await f()`) is frame -// inlining (below). +// mutations don't double-fire. `wait(Duration)` resolves against the GAME +// clock accumulator and `wait_unscaled(Duration)` against the UNSCALED one +// (M1.0.13 E5 — both via the fixed 1/60 tick conversion); `global_event` +// against the per-tick event store; the direct-call `future` (`await f()`) +// is frame inlining (below). // - Frame kinds cover every EBNF v0.6 statement block (C1.6): `run` (rule/`fn` // body, `if` branch, `match` arm, plain block), `loop_`, `while_`, `for_`, // `try_` (a `throw` after a resume routes to the enclosing `try_` — the @@ -556,18 +602,26 @@ fn durationLiteralSeconds(text: []const u8) ?f64 { // full RHS on the frame-driven spine; a sub-expression `await`, or one in a // synchronously-evaluated VALUE block, is rejected. Coloring (§9.3, `E0901`): // an `await` / async call in a non-async `fn`/`rule` is rejected. -// - A sync-only program allocates no task, keeps `async_tick` at 0, and is -// byte-identical to the pre-async runtime (by construction). +// - A sync-only program allocates no task and never drives the pool. (Since +// M1.0.13 the time subsystem advances unconditionally — the builtin time +// resources are ambient state every program can read.) /// The condition that resumes a suspended `async rule` (M0.8 E3 sub-slice B). /// The tree-walker is its own runtime (`etch-reference-part1.md §9`): an /// `await` suspends the rule as a task-record, polled each tick in `stepOnce`. const WakeCond = union(enum) { - /// Resume once `async_tick` reaches this value. `await wait(d)` reached at - /// tick T sets it to T + `round(seconds(d) * 60)` — the Phase-1 fixed-timestep - /// conversion (M1.0.11 E3, `async_fixed_dt_hz`). `wait_unscaled` (M1.0.13) - /// stays fail-loud. - wait_until: u64, + /// Resume once the GAME clock accumulator reaches this deadline, in + /// tick units (M1.0.13 E5): `await wait(d)` reached with the game clock + /// at G sets it to `G + round(seconds(d) * 60)`. The game clock advances + /// by `time_scale` per tick and freezes under `paused`, so the wait + /// scales and pauses with game time. At `time_scale = 1` the clock sums + /// stay exact integer-valued f64 and the wake ticks are byte-identical + /// to the pre-M1.0.13 `async_tick` conversion (M1.0.11 E3). + wait_until: f64, + /// `await wait_unscaled(d)` (M1.0.13 E5): the same conversion against + /// the UNSCALED clock, which advances by 1 per tick unconditionally — + /// the wait keeps running under `paused` and ignores `time_scale`. + wait_until_unscaled: f64, /// Resume once an event of this type is present in the per-tick EventStore /// (M0.8 E3 sub-slice B — `await global_event(T)`). The producer must run /// before the awaiter in the rule order, same as the observer drain. @@ -920,14 +974,32 @@ pub const Interpreter = struct { /// a `changed`-free program is byte-identical to the pre-E3 runtime (no /// tick churn, no marking overhead). has_changed: bool = false, - /// True iff any rule is `async` (M0.8 E3 sub-slice B). Gates the async tick - /// counter + the task-record dispatch in `stepOnce`; a sync-only program is - /// byte-identical to the pre-B runtime (no `async_tick` churn, no slots). + /// True iff any rule is `async` (M0.8 E3 sub-slice B). Gates the + /// task-record dispatch in `stepOnce`; a sync-only program allocates no + /// task and never drives the pool. has_async: bool = false, - /// Logical async clock — incremented once per `stepOnce` when `has_async`. - /// `await wait(d)` resolves against it: a `Duration` is converted to a tick - /// count via the fixed 1/60 timestep (`async_fixed_dt_hz`, M1.0.11 E3). + /// Frame counter — incremented once per `stepOnce`; backs the builtin + /// `GameTime.frame` / `GameTime.fixed_frame` fields (M1.0.13 E5). + /// DEMOTED from its M1.0.11 role: it no longer drives `await wait` — + /// the two clock accumulators below do. async_tick: u64 = 0, + /// The time subsystem's two internal clock accumulators (M1.0.13 E5) — + /// the sources of truth for `await wait` / `await wait_unscaled` + /// deadlines and for the published `total` fields. They advance in TICK + /// units (game: `+= time_scale`, 0 under `paused`; unscaled: `+= 1`) + /// rather than seconds: at `time_scale = 1` the sums stay exact + /// integer-valued f64, which keeps the wake ticks byte-identical to the + /// pre-M1.0.13 integer conversion (a seconds accumulator accrues + /// floating-point rounding that can shift a wake by one tick). Seconds + /// are derived (`ticks * fixed_dt_secs`) only when publishing the + /// resource fields. Seeded from the surviving resource values on a + /// hot-reload re-compile (game time continues across an AST swap). + game_clock_ticks: f64 = 0, + unscaled_clock_ticks: f64 = 0, + /// Resolved ids + field offsets of the three builtin time resources + /// (M1.0.13 E5), cached at `compile` so the per-tick population in + /// `advanceTime` is plain offset writes. + time_res: TimeResources, /// Dynamic pool of suspendable tasks (M1.0.11 E1) — the growable replacement /// for the M0.8 per-rule `AsyncSlot` slice. POINTER-STABLE since M1.0.12 E1: /// each element is a heap record (`gpa.create`), because `race`/`sync`/ @@ -1134,6 +1206,94 @@ pub const Interpreter = struct { } } + // Register the three builtin engine time resources (M1.0.13 E5) from + // the `types.zig` descriptor table — the same injection point as the + // builtin `TagSet` (after the user-decl registration loop). Idempotent + // on a hot-reload re-compile: the existing registration and the live + // resource values survive the AST swap (the `compileResource` seeding + // discipline). + for (&types_mod.builtin_resources) |*br| { + if (world.registry.idOf(br.name)) |existing| { + try bridge.mapResource(gpa, br.name, existing); + continue; + } + var fields_buf: [8]FieldDesc = undefined; + var default_buf: [64]u8 = @splat(0); + var size: usize = 0; + var max_align: usize = 1; + for (br.fields, 0..) |bf, fi| { + const kind: FieldKind = switch (bf.type_) { + .float_ => .float_, + .int_ => .int_, + .bool_ => .bool_, + else => unreachable, // the table holds POD scalars only + }; + const align_b = kind.alignBytes(); + if (align_b > max_align) max_align = align_b; + const off = std.mem.alignForward(usize, size, align_b); + size = off + kind.sizeBytes(); + fields_buf[fi] = .{ .name = bf.name, .offset = @intCast(off), .kind = kind }; + const v: Value = switch (bf.default) { + .float_ => |x| .{ .float_ = x }, + .int_ => |x| .{ .int_ = x }, + .bool_ => |x| .{ .bool_ = x }, + }; + try bridge_mod.writeValueAsBytes(kind, default_buf[off..], v); + } + size = std.mem.alignForward(usize, size, max_align); + const id = try world.registry.registerComponentRaw(gpa, .{ + .name = br.name, + .size = @intCast(size), + .alignment = @intCast(max_align), + .default_bytes = default_buf[0..size], + .fields = fields_buf[0..br.fields.len], + }); + try bridge.mapResource(gpa, br.name, id); + try world.addResource(gpa, id, default_buf[0..size]); + } + + // Resolve the population handles (ids + field offsets) once. The + // lookups cannot miss: the resources were registered from the same + // descriptor table just above (or by the previous compile of this + // world — same table). + const time_res = blk: { + const gid = world.registry.idOf("GameTime").?; + const uid = world.registry.idOf("UnscaledTime").?; + const rid = world.registry.idOf("RealTime").?; + break :blk TimeResources{ + .game_id = gid, + .unscaled_id = uid, + .real_id = rid, + .game_dt = world.registry.findField(gid, "dt").?.offset, + .game_total = world.registry.findField(gid, "total").?.offset, + .game_time_scale = world.registry.findField(gid, "time_scale").?.offset, + .game_frame = world.registry.findField(gid, "frame").?.offset, + .game_fixed_frame = world.registry.findField(gid, "fixed_frame").?.offset, + .game_paused = world.registry.findField(gid, "paused").?.offset, + .unscaled_dt = world.registry.findField(uid, "dt").?.offset, + .unscaled_total = world.registry.findField(uid, "total").?.offset, + .real_dt = world.registry.findField(rid, "dt").?.offset, + .real_total = world.registry.findField(rid, "total").?.offset, + .real_since_startup = world.registry.findField(rid, "since_startup").?.offset, + }; + }; + + // Seed the clock accumulators from the published `total` fields so + // game time CONTINUES across a hot-reload re-compile (the resource + // values survive; a fresh world reads the 0.0 defaults). Ticks = + // seconds / fixed_dt. + var game_clock_ticks: f64 = 0; + var unscaled_clock_ticks: f64 = 0; + var frame_seed: u64 = 0; + if (world.resources.getResource(time_res.game_id)) |game_bytes| { + game_clock_ticks = readF64At(game_bytes, time_res.game_total) * async_fixed_dt_hz; + const frame: i64 = @bitCast(game_bytes[time_res.game_frame..][0..8].*); + frame_seed = @intCast(@max(frame, 0)); + } + if (world.resources.getResource(time_res.unscaled_id)) |unscaled_bytes| { + unscaled_clock_ticks = readF64At(unscaled_bytes, time_res.unscaled_total) * async_fixed_dt_hz; + } + // Pass B — compile rules. Need the registry to resolve field // filter offsets/kinds. var rule_descs: std.ArrayListUnmanaged(RuleDesc) = .empty; @@ -1249,8 +1409,7 @@ pub const Interpreter = struct { } // Allocate the per-rule task-handle map iff any rule is `async` (M1.0.11 // E1). The task pool itself starts empty and grows on first spawn; a - // sync-only program keeps an empty map + pool and never advances - // `async_tick` — byte-identical to the pre-async runtime. + // sync-only program keeps an empty map + pool. var any_async = false; for (slice) |rd| { if (rd.is_async) { @@ -1282,6 +1441,10 @@ pub const Interpreter = struct { .trait_methods = trait_methods, .tag_table = tag_table, .tagset_id = tagset_id, + .time_res = time_res, + .game_clock_ticks = game_clock_ticks, + .unscaled_clock_ticks = unscaled_clock_ticks, + .async_tick = frame_seed, .has_changed = any_changed, .has_async = any_async, .rule_tasks = rule_tasks, @@ -1605,9 +1768,15 @@ pub const Interpreter = struct { // calls `beginFrame` at the same point. A `changed`-free program never // advances the tick (byte-identical to the pre-E3 runtime). if (self.has_changed) world.beginFrame(); - // Advance the logical async clock once per tick when async rules are - // present (M0.8 E3 sub-slice B). `await wait(d)` resolves against it. - if (self.has_async) self.async_tick += 1; + // Advance the time subsystem (M1.0.13 E5) — the tree-walker + // equivalent of the `pre_update` refresh, fixed timestep 1/60 + // (`engine-gameplay-systems.md` "Les trois temps"): read the controls + // from `GameTime`, advance the two clock accumulators (game freezes + // under `paused` and scales with `time_scale`; unscaled always + // advances), publish the resource fields. `async_tick` is the frame + // counter backing `GameTime.frame` — it no longer drives `wait`. + self.async_tick += 1; + self.advanceTime(world); // Events have a per-tick lifetime (`Lifetime.tick`): clear the previous // tick's queue before running this tick's rules (M0.8 E3). self.events.clear(self.gpa); @@ -1644,6 +1813,35 @@ pub const Interpreter = struct { try self.flushStructural(world); } + /// Advance the two clock accumulators and publish the three builtin time + /// resources (M1.0.13 E5) — called at the start of every `stepOnce`, + /// before rule dispatch. `GameTime.dt = fixed_dt * time_scale` (0 under + /// `paused`); `UnscaledTime`/`RealTime` are affected by neither control. + /// The published `total` fields derive from the tick accumulators + /// (`ticks * fixed_dt_secs` — single rounding, no seconds-side drift); + /// `frame`/`fixed_frame` publish the per-`stepOnce` counter (one fixed + /// tick per step — the two counters coincide in the Phase-1 tree-walker). + fn advanceTime(self: *Interpreter, world: *World) void { + const tr = &self.time_res; + const game = world.resources.getMutResource(tr.game_id) orelse return; + const scale = readF64At(game, tr.game_time_scale); + const paused = game[tr.game_paused] != 0; + const game_dt: f64 = if (paused) 0 else fixed_dt_secs * scale; + self.game_clock_ticks += if (paused) 0 else scale; + self.unscaled_clock_ticks += 1; + writeF64At(game, tr.game_dt, game_dt); + writeF64At(game, tr.game_total, self.game_clock_ticks * fixed_dt_secs); + writeI64At(game, tr.game_frame, @intCast(self.async_tick)); + writeI64At(game, tr.game_fixed_frame, @intCast(self.async_tick)); + const unscaled = world.resources.getMutResource(tr.unscaled_id) orelse return; + writeF64At(unscaled, tr.unscaled_dt, fixed_dt_secs); + writeF64At(unscaled, tr.unscaled_total, self.unscaled_clock_ticks * fixed_dt_secs); + const real = world.resources.getMutResource(tr.real_id) orelse return; + writeF64At(real, tr.real_dt, fixed_dt_secs); + writeF64At(real, tr.real_total, self.unscaled_clock_ticks * fixed_dt_secs); + writeF64At(real, tr.real_since_startup, self.unscaled_clock_ticks * fixed_dt_secs); + } + fn runRule(self: *Interpreter, world: *World, rd: *RuleDesc, report: *RuntimeReport) !void { // `resource T { expression }` gates (M0.8 E4 — §6, item-4 ruling): // checked once per rule evaluation, alongside the resource deps the @@ -2448,13 +2646,13 @@ pub const Interpreter = struct { }, } }, - .wait, .global_event => { + .wait, .wait_unscaled, .global_event => { task.wake = try self.evalAwaitTarget(site.await_id); cursor.* += 1; task.pending_bind = site.ret; return .suspended; }, - .wait_unscaled, .entity_event => return error.RuntimeFailure, + .entity_event => return error.RuntimeFailure, } } const sk = self.ast.stmtKind(stmt); @@ -3025,26 +3223,30 @@ pub const Interpreter = struct { } /// Resolve a wake-condition `await` target to a `WakeCond` (M1.0.11 E3). Only - /// `wait` / `global_event` reach here (`future` is handled by `beginAsyncCall` - /// upstream). `wait` takes a `Duration` (final API, §9.4): a Duration LITERAL - /// → seconds → `async_tick` counts via the fixed 1/60 timestep - /// (`async_fixed_dt_hz`). M1.0.13 swaps this for scaled game time WITHOUT - /// changing the signature (at `time_scale = 1`, fixed `dt = 1/60`, identical). - /// A non-literal Duration (const / arithmetic) is out of scope → fail loud. - /// `global_event(T)` waits for an event of type `T`. `wait_unscaled` (M1.0.13) - /// / `entity_event` (M1.0.14) fail loud (defensive; filtered upstream). + /// `wait` / `wait_unscaled` / `global_event` reach here (`future` is handled + /// by `beginAsyncCall` upstream). `wait` and `wait_unscaled` take a `Duration` + /// (final API, §9.4): a Duration LITERAL → whole ticks via `round(seconds * + /// 60)`, scheduled on the game clock (`wait` — scaled, pausable) or the + /// unscaled clock (`wait_unscaled` — M1.0.13 E5, fires under pause). Both + /// keep the M1.0.11 literal-only restriction: a non-literal Duration (const / + /// arithmetic) fails loud — only the TIMER family evaluates a full Duration + /// expression (E6). `global_event(T)` waits for an event of type `T`; + /// `entity_event` (M1.0.14) fails loud (defensive; filtered upstream). fn evalAwaitTarget(self: *Interpreter, await_id: NodeId) StmtError!WakeCond { const aw = self.ast.awaitExpr(await_id); switch (aw.target_kind) { - .wait => { + .wait, .wait_unscaled => { if (self.ast.exprKind(aw.arg_expr) != .duration_lit) return error.RuntimeFailure; const secs = durationLiteralSeconds(self.ast.strings.slice(self.ast.exprData(aw.arg_expr))) orelse return error.RuntimeFailure; if (secs < 0) return error.RuntimeFailure; - const ticks: u64 = @intFromFloat(@round(secs * async_fixed_dt_hz)); - return .{ .wait_until = self.async_tick + ticks }; + const ticks = @round(secs * async_fixed_dt_hz); + return switch (aw.target_kind) { + .wait => .{ .wait_until = self.game_clock_ticks + ticks }, + else => .{ .wait_until_unscaled = self.unscaled_clock_ticks + ticks }, + }; }, .global_event => return .{ .global_event = aw.event_type }, - .wait_unscaled, .entity_event, .future => return error.RuntimeFailure, + .entity_event, .future => return error.RuntimeFailure, } } @@ -3053,7 +3255,8 @@ pub const Interpreter = struct { /// machinery; the pool is small and the poll runs at the rule's position. fn asyncWakeFired(self: *const Interpreter, wake: WakeCond) bool { return switch (wake) { - .wait_until => |t| self.async_tick >= t, + .wait_until => |t| self.game_clock_ticks >= t, + .wait_until_unscaled => |t| self.unscaled_clock_ticks >= t, .global_event => |type_name| self.events.count(type_name) > 0, // Race parent: a winner exists (some child `.done`) — or no child // remains `.suspended` (every branch failed/canceled → no winner; @@ -8652,6 +8855,247 @@ test "async rule suspends at await wait(s) and resumes at the equivalent tick try std.testing.expectEqual(@as(i64, 2), readResourceInt(&world, out_id)); } +test "time subsystem populates the three builtin resources (M1.0.13 E5)" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + + // A plain sync program: the time resources are ambient — populated every + // tick regardless of async rules. 60 ticks at time_scale = 1 → one + // second of game/unscaled/real time, frame 60. + const source = + \\resource Out { n: int = 0 } + \\rule r() + \\ when resource Out + \\{ + \\ get_mut(Out).n = get(GameTime).frame + \\} + ; + var pr = try parser_mod.parse(gpa, source); + defer pr.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), pr.diagnostics.len); + var diags: std.ArrayListUnmanaged(Diagnostic) = .empty; + defer { + for (diags.items) |*d| d.deinit(gpa); + diags.deinit(gpa); + } + try types_mod.TypeChecker.check(gpa, &pr.ast, &diags); + try std.testing.expectEqual(@as(usize, 0), diags.items.len); + + var interp = try Interpreter.compile(gpa, &pr.ast, &world); + defer interp.deinit(); + _ = try interp.runFor(&world, 60); + + const tr = interp.time_res; + const game = world.resources.getResource(tr.game_id).?; + try std.testing.expectEqual(fixed_dt_secs, readF64At(game, tr.game_dt)); + try std.testing.expectApproxEqAbs(@as(f64, 1.0), readF64At(game, tr.game_total), 1e-12); + try std.testing.expectEqual(@as(i64, 60), std.mem.bytesToValue(i64, game[tr.game_frame..][0..8])); + try std.testing.expectEqual(@as(i64, 60), std.mem.bytesToValue(i64, game[tr.game_fixed_frame..][0..8])); + try std.testing.expectEqual(@as(f64, 1.0), readF64At(game, tr.game_time_scale)); + try std.testing.expectEqual(@as(u8, 0), game[tr.game_paused]); + const unscaled = world.resources.getResource(tr.unscaled_id).?; + try std.testing.expectEqual(fixed_dt_secs, readF64At(unscaled, tr.unscaled_dt)); + try std.testing.expectApproxEqAbs(@as(f64, 1.0), readF64At(unscaled, tr.unscaled_total), 1e-12); + const real = world.resources.getResource(tr.real_id).?; + try std.testing.expectApproxEqAbs(@as(f64, 1.0), readF64At(real, tr.real_since_startup), 1e-12); + // A rule read the ambient `GameTime.frame` without a `when resource` + // clause on it — the value it saw at tick 60 is the published counter. + const out_id = world.registry.idOf("Out").?; + try std.testing.expectEqual(@as(i64, 60), readResourceInt(&world, out_id)); +} + +test "time_scale halves GameTime.dt/total; paused zeroes them but not unscaled/real (M1.0.13 E5)" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + + const source = + \\resource Out { n: int = 0 } + \\rule r() + \\ when resource Out + \\{ } + ; + var pr = try parser_mod.parse(gpa, source); + defer pr.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), pr.diagnostics.len); + var diags: std.ArrayListUnmanaged(Diagnostic) = .empty; + defer { + for (diags.items) |*d| d.deinit(gpa); + diags.deinit(gpa); + } + try types_mod.TypeChecker.check(gpa, &pr.ast, &diags); + try std.testing.expectEqual(@as(usize, 0), diags.items.len); + + var interp = try Interpreter.compile(gpa, &pr.ast, &world); + defer interp.deinit(); + const tr = interp.time_res; + + // time_scale = 0.5 → dt halves, total advances at half rate. + { + const game = world.resources.getMutResource(tr.game_id).?; + writeF64At(game, tr.game_time_scale, 0.5); + } + _ = try interp.runFor(&world, 60); + { + const game = world.resources.getResource(tr.game_id).?; + try std.testing.expectEqual(fixed_dt_secs * 0.5, readF64At(game, tr.game_dt)); + try std.testing.expectApproxEqAbs(@as(f64, 0.5), readF64At(game, tr.game_total), 1e-12); + } + + // paused → GameTime.dt = 0 and total freezes; UnscaledTime/RealTime keep + // advancing (affected by neither control). + { + const game = world.resources.getMutResource(tr.game_id).?; + game[tr.game_paused] = 1; + } + _ = try interp.runFor(&world, 60); + const game = world.resources.getResource(tr.game_id).?; + try std.testing.expectEqual(@as(f64, 0), readF64At(game, tr.game_dt)); + try std.testing.expectApproxEqAbs(@as(f64, 0.5), readF64At(game, tr.game_total), 1e-12); + const unscaled = world.resources.getResource(tr.unscaled_id).?; + try std.testing.expectEqual(fixed_dt_secs, readF64At(unscaled, tr.unscaled_dt)); + try std.testing.expectApproxEqAbs(@as(f64, 2.0), readF64At(unscaled, tr.unscaled_total), 1e-12); + const real = world.resources.getResource(tr.real_id).?; + try std.testing.expectEqual(fixed_dt_secs, readF64At(real, tr.real_dt)); + try std.testing.expectApproxEqAbs(@as(f64, 2.0), readF64At(real, tr.real_total), 1e-12); +} + +test "await wait scales with time_scale and freezes under paused (M1.0.13 E5)" { + const gpa = std.testing.allocator; + // (a) At time_scale = 0.5, a 0.1s wait (6 fixed ticks) needs 12 real + // ticks of game clock: suspended at tick 1 (game clock 0.5), deadline + // 6.5, fires when the game clock reaches 6.5 — tick 13. + { + var world = World.init(); + defer world.deinit(gpa); + const source = + \\resource Out { n: int = 0 } + \\async rule seq() + \\ when resource Out + \\{ + \\ get_mut(Out).n = 1 + \\ await wait(0.1s) + \\ get_mut(Out).n = 2 + \\} + ; + var pr = try parser_mod.parse(gpa, source); + defer pr.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), pr.diagnostics.len); + var diags: std.ArrayListUnmanaged(Diagnostic) = .empty; + defer { + for (diags.items) |*d| d.deinit(gpa); + diags.deinit(gpa); + } + try types_mod.TypeChecker.check(gpa, &pr.ast, &diags); + try std.testing.expectEqual(@as(usize, 0), diags.items.len); + var interp = try Interpreter.compile(gpa, &pr.ast, &world); + defer interp.deinit(); + const out_id = world.registry.idOf("Out").?; + { + const game = world.resources.getMutResource(interp.time_res.game_id).?; + writeF64At(game, interp.time_res.game_time_scale, 0.5); + } + _ = try interp.runFor(&world, 12); + try std.testing.expectEqual(@as(i64, 1), readResourceInt(&world, out_id)); + _ = try interp.runFor(&world, 1); + try std.testing.expectEqual(@as(i64, 2), readResourceInt(&world, out_id)); + } + // (b) Under paused, the same wait never fires (the game clock freezes); + // unpausing lets it complete. + { + var world = World.init(); + defer world.deinit(gpa); + const source = + \\resource Out { n: int = 0 } + \\async rule seq() + \\ when resource Out + \\{ + \\ get_mut(Out).n = 1 + \\ await wait(0.04s) + \\ get_mut(Out).n = 2 + \\} + ; + var pr = try parser_mod.parse(gpa, source); + defer pr.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), pr.diagnostics.len); + var diags: std.ArrayListUnmanaged(Diagnostic) = .empty; + defer { + for (diags.items) |*d| d.deinit(gpa); + diags.deinit(gpa); + } + try types_mod.TypeChecker.check(gpa, &pr.ast, &diags); + try std.testing.expectEqual(@as(usize, 0), diags.items.len); + var interp = try Interpreter.compile(gpa, &pr.ast, &world); + defer interp.deinit(); + const out_id = world.registry.idOf("Out").?; + // tick 1: n=1, suspend (deadline = game clock 1 + 2 = 3). + _ = try interp.runFor(&world, 1); + try std.testing.expectEqual(@as(i64, 1), readResourceInt(&world, out_id)); + { + const game = world.resources.getMutResource(interp.time_res.game_id).?; + game[interp.time_res.game_paused] = 1; + } + // 10 paused ticks: the game clock is frozen at 1 — the wait never fires. + _ = try interp.runFor(&world, 10); + try std.testing.expectEqual(@as(i64, 1), readResourceInt(&world, out_id)); + { + const game = world.resources.getMutResource(interp.time_res.game_id).?; + game[interp.time_res.game_paused] = 0; + } + // Unpaused: clock 2 (< 3) after one tick, fires on the second. + _ = try interp.runFor(&world, 1); + try std.testing.expectEqual(@as(i64, 1), readResourceInt(&world, out_id)); + _ = try interp.runFor(&world, 1); + try std.testing.expectEqual(@as(i64, 2), readResourceInt(&world, out_id)); + } +} + +test "await wait_unscaled fires under paused (M1.0.13 E5)" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + + const source = + \\resource Out { n: int = 0 } + \\async rule seq() + \\ when resource Out + \\{ + \\ get_mut(Out).n = 1 + \\ await wait_unscaled(0.04s) + \\ get_mut(Out).n = 2 + \\} + ; + var pr = try parser_mod.parse(gpa, source); + defer pr.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), pr.diagnostics.len); + var diags: std.ArrayListUnmanaged(Diagnostic) = .empty; + defer { + for (diags.items) |*d| d.deinit(gpa); + diags.deinit(gpa); + } + try types_mod.TypeChecker.check(gpa, &pr.ast, &diags); + try std.testing.expectEqual(@as(usize, 0), diags.items.len); + var interp = try Interpreter.compile(gpa, &pr.ast, &world); + defer interp.deinit(); + const out_id = world.registry.idOf("Out").?; + + // tick 1: n=1, suspend (unscaled deadline = 1 + 2 = 3); pause immediately. + _ = try interp.runFor(&world, 1); + try std.testing.expectEqual(@as(i64, 1), readResourceInt(&world, out_id)); + { + const game = world.resources.getMutResource(interp.time_res.game_id).?; + game[interp.time_res.game_paused] = 1; + } + // tick 2: unscaled clock 2 < 3 — still suspended. + _ = try interp.runFor(&world, 1); + try std.testing.expectEqual(@as(i64, 1), readResourceInt(&world, out_id)); + // tick 3: unscaled clock 3 >= 3 — fires DESPITE the pause. + const r = try interp.runFor(&world, 1); + try std.testing.expectEqual(@as(u64, 0), r.runtime_errors); + try std.testing.expectEqual(@as(i64, 2), readResourceInt(&world, out_id)); +} + test "async rule resumes on await global_event(T) (M0.8 E3 sub-slice B)" { const gpa = std.testing.allocator; var world = World.init(); @@ -9125,17 +9569,10 @@ fn asyncFailLoudCount(gpa: std.mem.Allocator, source: []const u8) !u64 { return report.runtime_errors; } -test "await wait_unscaled / entity_event still fail loud (partition boundary intact, M1.0.11 E3)" { +test "await entity_event still fails loud (partition boundary intact, M1.0.13 E5)" { const gpa = std.testing.allocator; - // `wait_unscaled` — needs the scaled/unscaled time subsystem (M1.0.13). - try std.testing.expect((try asyncFailLoudCount(gpa, - \\resource Out { n: int = 0 } - \\async rule r() - \\ when resource Out - \\{ - \\ await wait_unscaled(1.0s) - \\} - )) >= 1); + // `wait_unscaled` graduated to a working await target with the M1.0.13 + // time subsystem (E5); the remaining partition boundary is // `entity_event` — needs entity-scoped events (M1.0.14). try std.testing.expect((try asyncFailLoudCount(gpa, \\event Ev { } @@ -9146,10 +9583,10 @@ test "await wait_unscaled / entity_event still fail loud (partition boundary int \\ await entity_event(get(Out), Ev) \\} )) >= 1); - // The third M1.0.11 case — `await` on a stored non-TaskHandle value — is + // The M1.0.11 case — `await` on a stored non-TaskHandle value — is // rejected at TYPE-CHECK since M1.0.12 E3 (E0200, "await target must be a // direct async call or a TaskHandle"), so it never reaches the runtime: - // covered by the types.zig E3 tests. Real handle-await execution is E5. + // covered by the types.zig E3 tests. } test "task pool is pointer-stable and cancelTask parks a suspended task for good (M1.0.12 E1)" { From bd6fad81cfe428b09052fde631156f645478a391 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sat, 4 Jul 2026 10:26:37 +0200 Subject: [PATCH 13/18] docs(brief): journal update --- briefs/M1.0.13-time-and-timers.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/briefs/M1.0.13-time-and-timers.md b/briefs/M1.0.13-time-and-timers.md index b6a88cc..a0f3482 100644 --- a/briefs/M1.0.13-time-and-timers.md +++ b/briefs/M1.0.13-time-and-timers.md @@ -154,6 +154,9 @@ None (no perf target for this milestone). - 2026-07-04 — E2 GO. E3: `parseTimerStmt` (arg = full expression, body via `parseStmtRun` — the branch/spawn layout); unbound form dispatched at statement head via `isTimerKindToken` (replaces the M0.8 `kw_after` fail-loud, stale "Phase 2" wording dropped); bound form dispatched in `parseLetStmt` after the spawn branch (rejects `mut` + type annotation, the spawn precedent); timer keywords added to `startsKeywordStmt` (value-block routing); `quantize` fail-loud made explicit in `surfaceTokenErrors` with the message re-pointed at a later Sequencer-adjacent milestone. Five inline tests (unbound ×3 + expr arg, bound-not-let, value-block routing, mut/annotation rejection, quantize fail-loud); parser.zig standalone 145/145 green; full suite green in debug. EBNF example corpus untouched (M1.0.10–12 precedent; outside Files to modify). - 2026-07-04 — E3 GO. E4: `timer_handle` builtin variant (task_handle precedent — not in `fromName`); `TimerHandle` field-type rejection (component + resource); `cancel()`-only method dispatch (no-arg checked; anything else E0200 with a not-awaitable pointer); `await t` on a TimerHandle explicitly rejected in the await arm (E0200, precise message, ahead of the generic rejection); `checkStmt` `.timer_stmt` arm — no E0901 gate on the statement (no `{async}` effect, §9.4), arg typed `Duration` (E0200 otherwise, `unknown` permissive), binding put in scope as `timer_handle` AFTER the body (snapshot semantics, E6); `checkTimerBodyRun` forces `current_is_async = false` + resets the concurrency-branch state (detached synchronous callback context). Builtin-resource descriptor table `builtin_resources` + `builtinResourceByName` (`pub const`, GameTime 7 fields with `fixed_dt = 1/60`, UnscaledTime 2, RealTime 3); `get`/`get_mut(T)` resolves builtin names ahead of the symbol table and BYPASSES the when-clause gate (ambient time resources — the spec examples read them without a `when resource` clause); `lookupFieldType` `.resource` arm consults the table first (unknown field → E1211 against the table). Six inline tests; two initial test-source fixes (int-literal defaults on f32, f32-vs-float assignment — checker correct, tests adjusted); types.zig standalone 289/289 green; full suite green in debug. +- 2026-07-04 — Design note (E5): the two internal accumulators advance in TICK units (game `+= time_scale`, 0 under pause; unscaled `+= 1`), not seconds; seconds are derived (`ticks * fixed_dt`) only when publishing the resource fields, and wait deadlines are tick-valued (`clock + round(secs * 60)`). Rationale: at `time_scale = 1` the tick sums stay exact integer-valued f64, making the brief's byte-identical wake-tick guarantee robust — a seconds accumulator accrues floating-point rounding that can shift a wake by one tick (and the pre-M1.0.13 conversion ROUNDS the duration to whole ticks, so the fractional-duration behavior only matches under the same rounding). Same clock modulo the fixed_dt unit; the E5 mechanism is otherwise as specified. +- 2026-07-04 — E4 GO. E5: builtin-resource registration in Pass A at the TagSet injection point (idempotent hot-reload: existing registration + live values reused); `TimeResources` handle cache (ids + field offsets via `registry.findField`) resolved once at compile; `advanceTime` at the head of `stepOnce` (pre_update equivalent) — reads `time_scale`/`paused` from `GameTime`, advances the two clock accumulators, publishes dt/total/frame/fixed_frame + UnscaledTime/RealTime (unaffected by both controls, `getMutResource` so the dirty bit reflects the writes); `async_tick` demoted to the frame counter (unconditional increment; no longer read by any wake path); `WakeCond.wait_until` re-plumbed to an f64 game-clock deadline + new `wait_until_unscaled` against the unscaled clock; `evalAwaitTarget` handles `wait_unscaled` (literal-only restriction kept for BOTH, per Out of scope); resume-path partition narrowed to `entity_event`. Clock accumulators seeded from the surviving `total` fields on re-compile (game time continues across a hot-reload AST swap). Stale M1.0.11 partition test updated (wait_unscaled works now; boundary = entity_event). Four E5 inline tests (population/frame, scale+pause on resources, wait scaled + frozen-under-pause, wait_unscaled fires under pause); byte-identity at scale=1 proven by the UNTOUCHED M1.0.11/12 tick-precise test expectations all staying green. Full suite green in debug. + ## Recorded deviations ## Blockers encountered From d698e930f941465d8841b654b8d11b16d9e344dc Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sat, 4 Jul 2026 11:13:20 +0200 Subject: [PATCH 14/18] feat(etch): runtime timer registry with cancelable handles (M1.0.13 E6) --- src/etch/interp.zig | 514 ++++++++++++++++++++++++++++++++++++++++++++ src/etch/value.zig | 17 +- 2 files changed, 530 insertions(+), 1 deletion(-) diff --git a/src/etch/interp.zig b/src/etch/interp.zig index 385992c..789efb5 100644 --- a/src/etch/interp.zig +++ b/src/etch/interp.zig @@ -550,6 +550,41 @@ const TimeResources = struct { real_since_startup: u16, }; +/// One scheduled timer (M1.0.13 E6, `etch-reference-part1.md` §9.10/§9.12) — +/// a heap record in the `Interpreter.timers` registry, DISTINCT from the task +/// pool (a timer is not a task: no join, no frames, no wake condition). The +/// registry has the same monotone pointer-stable + husk discipline as the +/// M1.0.12 task pool: records are allocated individually (a callback that +/// schedules a timer appends mid-scan without invalidating live pointers), +/// slots are never reused (a fired one-shot or a canceled timer parks as a +/// husk with its snapshot freed) — so a `TimerHandle` is a safe bare index, +/// no generations. +const TimerEntry = struct { + /// Which clock accumulator the deadline compares against: `after`/`every` + /// run on the game clock (scaled, frozen under pause); `after_unscaled` + /// on the unscaled clock (fires under pause). + clock: enum { game, unscaled }, + /// Tick-unit deadline against the owning clock (the E5 encoding: + /// `clock + round(seconds * 60)` at scheduling). + deadline: f64, + /// Re-arm period in ticks for `every` (`deadline += period` after each + /// fire — fixed period, no drift correction in Phase 1); 0 for the + /// one-shots. + period: f64, + body_start: u32, + body_len: u32, + /// Value-level copy of the scheduling scope (captures `entity` & co.) — + /// the M1.0.12 branch-snapshot semantics and heap-backed-value caveats. + /// An `every` timer's fires share this scope (mutations persist across + /// periods, like a task scope). + snapshot: Locals, + state: enum { armed, fired, canceled }, + /// True while the fire scan is executing this entry's body: a + /// self-cancel from inside the callback defers its snapshot free to the + /// scan (never freed mid-body). + firing: bool = false, +}; + /// Read an `f64` resource field at `off` (M1.0.13 E5 population helpers — /// unaligned-safe byte copies, the resource store guarantees nothing better). fn readF64At(bytes: []const u8, off: u16) f64 { @@ -1000,6 +1035,11 @@ pub const Interpreter = struct { /// (M1.0.13 E5), cached at `compile` so the per-tick population in /// `advanceTime` is plain offset writes. time_res: TimeResources, + /// Runtime timer registry (M1.0.13 E6, §9.10) — heap records, monotone + /// pointer-stable + husk (see `TimerEntry`). Scanned by `fireTimers` at + /// the head of `stepOnce`, in registration order (deterministic); a + /// `Value.timer_handle` is an index into it. + timers: std.ArrayListUnmanaged(*TimerEntry) = .empty, /// Dynamic pool of suspendable tasks (M1.0.11 E1) — the growable replacement /// for the M0.8 per-rule `AsyncSlot` slice. POINTER-STABLE since M1.0.12 E1: /// each element is a heap record (`gpa.create`), because `race`/`sync`/ @@ -1098,6 +1138,13 @@ pub const Interpreter = struct { self.gpa.destroy(task); } self.async_tasks.deinit(self.gpa); + // Timer registry teardown (M1.0.13 E6): husks (fired/canceled) had + // their snapshot freed at husk time — only armed entries still own one. + for (self.timers.items) |t| { + if (t.state == .armed) t.snapshot.deinit(self.gpa); + self.gpa.destroy(t); + } + self.timers.deinit(self.gpa); self.task_children.deinit(self.gpa); self.gpa.free(self.rule_tasks); self.descriptors.deinit(self.gpa); @@ -1780,6 +1827,10 @@ pub const Interpreter = struct { // Events have a per-tick lifetime (`Lifetime.tick`): clear the previous // tick's queue before running this tick's rules (M0.8 E3). self.events.clear(self.gpa); + // Fire due timers (M1.0.13 E6) — after the clock advance and the + // event-queue clear, before rule dispatch: a callback's `emit` lands + // in THIS tick's store, visible to every rule of the tick. + try self.fireTimers(world, report); for (self.rule_descs, 0..) |*rd, i| { // Observer rules (M1.0.2 E3) never run in the per-tick dispatch: // they fire at the Tier-0 command-buffer flush via the @@ -1842,6 +1893,131 @@ pub const Interpreter = struct { writeF64At(real, tr.real_since_startup, self.unscaled_clock_ticks * fixed_dt_secs); } + /// Schedule one timer statement (M1.0.13 E6, §9.10): evaluate the + /// Duration argument ONCE (a full expression — unlike `await wait`'s + /// literal-only path), snapshot the scheduling scope, and append a + /// registry entry. Returns the entry's index — the `TimerHandle`. + fn scheduleTimer(self: *Interpreter, world: *World, locals: *Locals, ts: ast_mod.TimerStmt) StmtError!u32 { + const argv = try self.evalExpr(world, locals, ts.arg); + if (argv != .duration) return error.RuntimeFailure; + if (argv.duration < 0) return error.RuntimeFailure; + const ticks = @round(argv.duration * async_fixed_dt_hz); + const entry = try self.gpa.create(TimerEntry); + entry.* = .{ + .clock = if (ts.kind == .after_unscaled) .unscaled else .game, + .deadline = if (ts.kind == .after_unscaled) + self.unscaled_clock_ticks + ticks + else + self.game_clock_ticks + ticks, + .period = if (ts.kind == .every) ticks else 0, + .body_start = ts.body_start, + .body_len = ts.body_len, + .snapshot = .{}, + .state = .armed, + }; + errdefer { + entry.snapshot.deinit(self.gpa); + self.gpa.destroy(entry); + } + try cloneLocalsInto(self.gpa, locals, &entry.snapshot); + const idx: u32 = @intCast(self.timers.items.len); + try self.timers.append(self.gpa, entry); + return idx; + } + + /// Fire every due timer (M1.0.13 E6) — called at the head of `stepOnce`, + /// AFTER the clock advance and BEFORE rule dispatch, in registration + /// order (deterministic). An entry is due when its deadline has been + /// reached on ITS clock. One-shots (`after`/`after_unscaled`) park as + /// husks after firing (snapshot freed); `every` re-arms at `deadline += + /// period`. The scan reads the LIVE registry length, so a callback that + /// schedules a timer appends an entry the same scan can reach (monotone + /// pool — no invalidation); each entry fires at most once per scan. + fn fireTimers(self: *Interpreter, world: *World, report: *RuntimeReport) error{OutOfMemory}!void { + var i: usize = 0; + while (i < self.timers.items.len) : (i += 1) { + const t = self.timers.items[i]; + if (t.state != .armed) continue; + const now = switch (t.clock) { + .game => self.game_clock_ticks, + .unscaled => self.unscaled_clock_ticks, + }; + if (t.deadline > now) continue; + if (t.period > 0) { + t.deadline += t.period; + } else { + t.state = .fired; + } + t.firing = true; + try self.execTimerBody(world, t, report); + t.firing = false; + // Husk a one-shot (just fired) or an entry the callback canceled + // mid-fire (the deferred half of `cancelTimer`); a re-armed + // `every` keeps its snapshot for the next period. + if (t.state != .armed) { + t.snapshot.deinit(self.gpa); + t.snapshot = .{}; + } + } + } + + /// Execute a timer callback body run-to-completion against its snapshot + /// (M1.0.13 E6) — the timer body is a synchronous context (§9.10, E4); + /// runtime failures and uncaught throws are harvested into the report + /// exactly like a rule body (`execBody`); a top-level `return` / + /// `break` / `continue` ends the callback. The per-body arena stores are + /// NOT reset here: the snapshot may hold handles into them (the same + /// heap-backed-value caveat as the M1.0.12 task scopes). + fn execTimerBody(self: *Interpreter, world: *World, t: *TimerEntry, report: *RuntimeReport) error{OutOfMemory}!void { + self.control = .none; + self.thrown = false; + self.returning = false; + self.pending_error = null; + var s: u32 = 0; + while (s < t.body_len) : (s += 1) { + const stmt_id: NodeId = @bitCast(self.ast.extra.items[t.body_start + s]); + self.execStmt(world, &t.snapshot, stmt_id) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + error.RuntimeFailure => { + self.harvestError(report); + return; + }, + }; + if (self.thrown) { + self.thrown = false; + self.pending_error = .{ .kind = .UncaughtThrow, .span = self.thrown_span }; + self.harvestError(report); + return; + } + if (self.control != .none) { + self.control = .none; + self.control_label = 0; + return; + } + if (self.returning) { + self.returning = false; + self.return_value = .{ .unit = {} }; + return; + } + } + } + + /// `TimerHandle.cancel()` (M1.0.13 E6, §9.10) — IDEMPOTENT: cancels an + /// armed timer (husk: snapshot freed), a no-op on one already fired or + /// already canceled. A self-cancel from inside the entry's own firing + /// callback marks the state only; the fire scan frees the snapshot after + /// the body returns (never mid-body). + fn cancelTimer(self: *Interpreter, ti: u32) void { + if (ti >= self.timers.items.len) return; + const t = self.timers.items[ti]; + if (t.state != .armed) return; + t.state = .canceled; + if (!t.firing) { + t.snapshot.deinit(self.gpa); + t.snapshot = .{}; + } + } + fn runRule(self: *Interpreter, world: *World, rd: *RuleDesc, report: *RuntimeReport) !void { // `resource T { expression }` gates (M0.8 E4 — §6, item-4 ruling): // checked once per rule evaluation, alongside the resource deps the @@ -3537,6 +3713,21 @@ pub const Interpreter = struct { const assign = self.ast.assign_stmts.items[data]; try self.execAssign(world, locals, assign); }, + .timer_stmt => { + // `[let t =] after/every/after_unscaled(d) { }` (M1.0.13 E6, + // §9.10) — schedule a registry entry; the statement itself + // never fires the body (firing is `fireTimers`, next tick at + // the earliest). Reached from sync rule bodies directly and + // from async bodies via `stepBodyStmt`'s shared-executor + // fall-through. The binding enters the SCHEDULING scope + // AFTER the snapshot — the handle does not exist inside the + // callback (the spawn_stmt precedent). + const ts = self.ast.timer_stmts.items[data]; + const handle = try self.scheduleTimer(world, locals, ts); + if (ts.binding != 0) { + try locals.put(self.gpa, ts.binding, .{ .timer_handle = handle }, false); + } + }, .expr_stmt => { const eid: NodeId = @bitCast(data); _ = try self.evalExpr(world, locals, eid); @@ -4108,6 +4299,20 @@ pub const Interpreter = struct { } return error.RuntimeFailure; }, + .timer_handle => |ti| { + // M1.0.13 E6 — the TimerHandle's single method (§9.10): + // `t.cancel()` is IDEMPOTENT — cancels an armed timer, a + // no-op on one already fired or already canceled. A timer is + // not a task: no `await t`, no other method (E4 rejects them + // at check; the fail-loud below is the runtime belt). + const mname = self.ast.strings.slice(mc.method_name); + if (std.mem.eql(u8, mname, "cancel")) { + if (mc.args_len != 0) return error.RuntimeFailure; + self.cancelTimer(ti); + return Value{ .unit = {} }; + } + return error.RuntimeFailure; + }, .struct_ref => |handle| { const type_name = self.structs.list.items[handle].type_name; const key = methodKey(type_name, mc.method_name); @@ -4439,6 +4644,14 @@ pub const Interpreter = struct { const text = self.ast.strings.slice(data); return Value{ .bool_ = std.mem.eql(u8, text, "true") }; }, + .duration_lit => { + // `1.5s` → seconds (M1.0.13 E6) — the runtime Duration value, + // carried so a timer argument can be a full expression + // (`after(d)`). `await wait` keeps its own literal-only path + // in `evalAwaitTarget` (M1.0.11 restriction). + const secs = durationLiteralSeconds(self.ast.strings.slice(data)) orelse return error.RuntimeFailure; + return Value{ .duration = secs }; + }, .string_lit => return Value{ .string_id = data }, .string_interp => { // Interpolation (M0.8 E3-C tranche 1c, stdlib §12.5: @@ -9096,6 +9309,307 @@ test "await wait_unscaled fires under paused (M1.0.13 E5)" { try std.testing.expectEqual(@as(i64, 2), readResourceInt(&world, out_id)); } +test "after(d) fires once at the deadline from a non-async rule, then parks as a husk (M1.0.13 E6)" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + // A NON-async rule schedules a one-shot at tick 1 (game clock 1, 0.05s = + // 3 ticks → deadline 4). The `armed` latch keeps the rule from + // re-scheduling every tick. + var pr = try checkCleanProgram(gpa, + \\resource Out { n: int = 0, armed: bool = true } + \\rule sched() + \\ when resource Out + \\{ + \\ if get(Out).armed { + \\ get_mut(Out).armed = false + \\ after(0.05s) { + \\ get_mut(Out).n = get(Out).n + 1 + \\ } + \\ } + \\} + ); + defer pr.deinit(gpa); + var interp = try Interpreter.compile(gpa, &pr.ast, &world); + defer interp.deinit(); + const out_id = world.registry.idOf("Out").?; + + // Ticks 1-3: scheduled, not yet due. + _ = try interp.runFor(&world, 3); + try std.testing.expectEqual(@as(i64, 0), readResourceInt(&world, out_id)); + try std.testing.expectEqual(@as(usize, 1), interp.timers.items.len); + // Tick 4: the deadline is reached at the head of the tick — fires once. + const r = try interp.runFor(&world, 1); + try std.testing.expectEqual(@as(u64, 0), r.runtime_errors); + try std.testing.expectEqual(@as(i64, 1), readResourceInt(&world, out_id)); + try std.testing.expect(interp.timers.items[0].state == .fired); + // The husk never re-fires and the slot is never reused. + _ = try interp.runFor(&world, 6); + try std.testing.expectEqual(@as(i64, 1), readResourceInt(&world, out_id)); + try std.testing.expectEqual(@as(usize, 1), interp.timers.items.len); + // cancel() on an already-fired timer is a no-op (idempotent). + interp.cancelTimer(0); + try std.testing.expect(interp.timers.items[0].state == .fired); +} + +test "every(d) fires each period, re-armed at a fixed period (M1.0.13 E6)" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + var pr = try checkCleanProgram(gpa, + \\resource Out { n: int = 0, armed: bool = true } + \\rule sched() + \\ when resource Out + \\{ + \\ if get(Out).armed { + \\ get_mut(Out).armed = false + \\ every(0.05s) { + \\ get_mut(Out).n = get(Out).n + 1 + \\ } + \\ } + \\} + ); + defer pr.deinit(gpa); + var interp = try Interpreter.compile(gpa, &pr.ast, &world); + defer interp.deinit(); + const out_id = world.registry.idOf("Out").?; + + // Scheduled at tick 1 (deadline 4, period 3): fires at ticks 4, 7, 10. + _ = try interp.runFor(&world, 3); + try std.testing.expectEqual(@as(i64, 0), readResourceInt(&world, out_id)); + _ = try interp.runFor(&world, 1); // tick 4 + try std.testing.expectEqual(@as(i64, 1), readResourceInt(&world, out_id)); + _ = try interp.runFor(&world, 2); // ticks 5-6 + try std.testing.expectEqual(@as(i64, 1), readResourceInt(&world, out_id)); + _ = try interp.runFor(&world, 1); // tick 7 + try std.testing.expectEqual(@as(i64, 2), readResourceInt(&world, out_id)); + _ = try interp.runFor(&world, 3); // ticks 8-10 + try std.testing.expectEqual(@as(i64, 3), readResourceInt(&world, out_id)); + // Still armed — an `every` never husks on its own. + try std.testing.expect(interp.timers.items[0].state == .armed); +} + +test "under paused, after freezes while after_unscaled still fires (M1.0.13 E6)" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + var pr = try checkCleanProgram(gpa, + \\resource Out { a: int = 0, b: int = 0, armed: bool = true } + \\rule sched() + \\ when resource Out + \\{ + \\ if get(Out).armed { + \\ get_mut(Out).armed = false + \\ after(0.05s) { get_mut(Out).a = 1 } + \\ after_unscaled(0.05s) { get_mut(Out).b = 1 } + \\ } + \\} + ); + defer pr.deinit(gpa); + var interp = try Interpreter.compile(gpa, &pr.ast, &world); + defer interp.deinit(); + const out_id = world.registry.idOf("Out").?; + const a_off = world.registry.findField(out_id, "a").?.offset; + const b_off = world.registry.findField(out_id, "b").?.offset; + + // Tick 1: both scheduled (game deadline 4, unscaled deadline 4); pause. + _ = try interp.runFor(&world, 1); + { + const game = world.resources.getMutResource(interp.time_res.game_id).?; + game[interp.time_res.game_paused] = 1; + } + // Ticks 2-6 paused: the unscaled clock reaches 4 → `after_unscaled` + // fires; the game clock is frozen at 1 → `after` does not. + _ = try interp.runFor(&world, 5); + { + const out = world.resources.getResource(out_id).?; + try std.testing.expectEqual(@as(i64, 0), std.mem.bytesToValue(i64, out[a_off..][0..8])); + try std.testing.expectEqual(@as(i64, 1), std.mem.bytesToValue(i64, out[b_off..][0..8])); + } + // Unpause: the game clock resumes (2, 3, then 4) — `after` fires. + { + const game = world.resources.getMutResource(interp.time_res.game_id).?; + game[interp.time_res.game_paused] = 0; + } + _ = try interp.runFor(&world, 2); + { + const out = world.resources.getResource(out_id).?; + try std.testing.expectEqual(@as(i64, 0), std.mem.bytesToValue(i64, out[a_off..][0..8])); + } + _ = try interp.runFor(&world, 1); + { + const out = world.resources.getResource(out_id).?; + try std.testing.expectEqual(@as(i64, 1), std.mem.bytesToValue(i64, out[a_off..][0..8])); + } +} + +test "time_scale stretches an after deadline (M1.0.13 E6)" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + var pr = try checkCleanProgram(gpa, + \\resource Out { n: int = 0, armed: bool = true } + \\rule sched() + \\ when resource Out + \\{ + \\ if get(Out).armed { + \\ get_mut(Out).armed = false + \\ after(0.1s) { get_mut(Out).n = 1 } + \\ } + \\} + ); + defer pr.deinit(gpa); + var interp = try Interpreter.compile(gpa, &pr.ast, &world); + defer interp.deinit(); + const out_id = world.registry.idOf("Out").?; + // time_scale = 0.5 from the very first tick: scheduled at tick 1 (game + // clock 0.5, 0.1s = 6 ticks → deadline 6.5) — fires when the game clock + // reaches 6.5, i.e. tick 13. + { + const game = world.resources.getMutResource(interp.time_res.game_id).?; + writeF64At(game, interp.time_res.game_time_scale, 0.5); + } + _ = try interp.runFor(&world, 12); + try std.testing.expectEqual(@as(i64, 0), readResourceInt(&world, out_id)); + _ = try interp.runFor(&world, 1); + try std.testing.expectEqual(@as(i64, 1), readResourceInt(&world, out_id)); +} + +test "a canceled timer never fires and cancel is idempotent (M1.0.13 E6)" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + var pr = try checkCleanProgram(gpa, + \\resource Out { n: int = 0, armed: bool = true } + \\rule sched() + \\ when resource Out + \\{ + \\ if get(Out).armed { + \\ get_mut(Out).armed = false + \\ let t = after(0.05s) { get_mut(Out).n = 99 } + \\ t.cancel() + \\ t.cancel() + \\ } + \\} + ); + defer pr.deinit(gpa); + var interp = try Interpreter.compile(gpa, &pr.ast, &world); + defer interp.deinit(); + const out_id = world.registry.idOf("Out").?; + + const r = try interp.runFor(&world, 10); + try std.testing.expectEqual(@as(u64, 0), r.runtime_errors); + try std.testing.expectEqual(@as(i64, 0), readResourceInt(&world, out_id)); + try std.testing.expect(interp.timers.items[0].state == .canceled); + // Unit-level: a third cancel on the husk stays a no-op. + interp.cancelTimer(0); + try std.testing.expect(interp.timers.items[0].state == .canceled); +} + +test "the callback observes the scheduling-time scope snapshot (captured entity) (M1.0.13 E6)" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + var pr = try checkCleanProgram(gpa, + \\component Health { current: int = 0 } + \\resource Out { seen: int = 0, armed: bool = true } + \\rule sched(entity: Entity) + \\ when entity has Health and resource Out + \\{ + \\ if get(Out).armed { + \\ get_mut(Out).armed = false + \\ after(0.05s) { + \\ get_mut(Out).seen = entity.get(Health).current + \\ } + \\ } + \\} + ); + defer pr.deinit(gpa); + var interp = try Interpreter.compile(gpa, &pr.ast, &world); + defer interp.deinit(); + try interp.bindToWorld(&world); + + const health = world.registry.idOf("Health").?; + var hv: i64 = 42; + _ = try world.spawnDynamicWithValues(gpa, &[_]ComponentId{health}, &[_][]const u8{std.mem.asBytes(&hv)}); + + const out_id = world.registry.idOf("Out").?; + // Tick 1 schedules with `entity` captured in the snapshot; the callback + // dereferences it at fire time (tick 4). + _ = try interp.runFor(&world, 3); + try std.testing.expectEqual(@as(i64, 0), readResourceInt(&world, out_id)); + const r = try interp.runFor(&world, 1); + try std.testing.expectEqual(@as(u64, 0), r.runtime_errors); + try std.testing.expectEqual(@as(i64, 42), readResourceInt(&world, out_id)); +} + +test "two timers due the same tick fire in registration order (M1.0.13 E6)" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + var pr = try checkCleanProgram(gpa, + \\resource Out { n: int = 0, armed: bool = true } + \\rule sched() + \\ when resource Out + \\{ + \\ if get(Out).armed { + \\ get_mut(Out).armed = false + \\ after(0.05s) { get_mut(Out).n = get(Out).n * 10 + 1 } + \\ after(0.05s) { get_mut(Out).n = get(Out).n * 10 + 2 } + \\ } + \\} + ); + defer pr.deinit(gpa); + var interp = try Interpreter.compile(gpa, &pr.ast, &world); + defer interp.deinit(); + const out_id = world.registry.idOf("Out").?; + + // Both due at tick 4: registration order → 0*10+1 = 1, then 1*10+2 = 12. + _ = try interp.runFor(&world, 4); + try std.testing.expectEqual(@as(i64, 12), readResourceInt(&world, out_id)); +} + +test "a timer scheduled from an async rule fires and its emit reaches same-tick rules (M1.0.13 E6)" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + // The scheduling statement reaches `execStmt` through the async drive's + // shared-executor fall-through; the callback's `emit` lands in the tick's + // fresh event store (fireTimers runs after the clear, before rules), so + // the `@on_event` consumer sees it the SAME tick. + var pr = try checkCleanProgram(gpa, + \\event Boom { } + \\resource Out { n: int = 0 } + \\async rule sched() + \\ when resource Out + \\{ + \\ after(0.05s) { + \\ emit Boom { } + \\ } + \\} + \\@on_event(Boom) + \\rule consume() + \\ when resource Out + \\{ + \\ get_mut(Out).n = get(Out).n + 1 + \\} + ); + defer pr.deinit(gpa); + var interp = try Interpreter.compile(gpa, &pr.ast, &world); + defer interp.deinit(); + const out_id = world.registry.idOf("Out").?; + + // The async rule spawns once at tick 1 and completes; the timer fires at + // tick 4, exactly once. + _ = try interp.runFor(&world, 3); + try std.testing.expectEqual(@as(i64, 0), readResourceInt(&world, out_id)); + const r = try interp.runFor(&world, 1); + try std.testing.expectEqual(@as(u64, 0), r.runtime_errors); + try std.testing.expectEqual(@as(i64, 1), readResourceInt(&world, out_id)); + _ = try interp.runFor(&world, 3); + try std.testing.expectEqual(@as(i64, 1), readResourceInt(&world, out_id)); +} + test "async rule resumes on await global_event(T) (M0.8 E3 sub-slice B)" { const gpa = std.testing.allocator; var world = World.init(); diff --git a/src/etch/value.zig b/src/etch/value.zig index 29557b6..ba8add9 100644 --- a/src/etch/value.zig +++ b/src/etch/value.zig @@ -114,6 +114,18 @@ pub const Value = union(enum) { /// so no generation is needed in Phase 1. Copyable/storable as a value; /// its operations are `h.cancel()` (idempotent) and `await h` (§9.8). task_handle: u32, + /// A `TimerHandle` (M1.0.13 E6, `etch-grammar.md` §2.2): the registry + /// index of a scheduled timer in `Interpreter.timers`. Safe as a bare + /// index — the registry is MONOTONIC (no slot reuse; a fired one-shot or + /// a canceled timer parks as a husk). Copyable/storable as a value; its + /// ONLY operation is `t.cancel()` (idempotent, §9.10) — a timer is not a + /// task and is not awaitable. + timer_handle: u32, + /// A `Duration` in seconds (M1.0.13 E6): the runtime shape of a + /// `DURATION_LIT` (`1.5s`), carried so a timer argument can be a full + /// expression (`after(d)` with `d` a Duration local). Duration + /// arithmetic stays out of the M1.0.13 surface. + duration: f64, unit, pub fn fromInt(x: i64) Value { @@ -165,8 +177,11 @@ pub const Value = union(enum) { break :blk std.mem.eql(u8, ab[0..a.len], bb[0..b.len]); }, // Handle equality is not an Etch v0.6 operation (no `==` on - // TaskHandle); identity comparison is reserved for a later spec. + // TaskHandle/TimerHandle); identity comparison is reserved for a + // later spec. .task_handle => false, + .timer_handle => false, + .duration => |a| a == other.duration, .unit => true, }; } From 473561d045cfaed9eff44427047779f60520fffe Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sat, 4 Jul 2026 11:13:22 +0200 Subject: [PATCH 15/18] docs(brief): journal update and E5 deviation backfill --- briefs/M1.0.13-time-and-timers.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/briefs/M1.0.13-time-and-timers.md b/briefs/M1.0.13-time-and-timers.md index a0f3482..2cbada2 100644 --- a/briefs/M1.0.13-time-and-timers.md +++ b/briefs/M1.0.13-time-and-timers.md @@ -157,8 +157,12 @@ None (no perf target for this milestone). - 2026-07-04 — Design note (E5): the two internal accumulators advance in TICK units (game `+= time_scale`, 0 under pause; unscaled `+= 1`), not seconds; seconds are derived (`ticks * fixed_dt`) only when publishing the resource fields, and wait deadlines are tick-valued (`clock + round(secs * 60)`). Rationale: at `time_scale = 1` the tick sums stay exact integer-valued f64, making the brief's byte-identical wake-tick guarantee robust — a seconds accumulator accrues floating-point rounding that can shift a wake by one tick (and the pre-M1.0.13 conversion ROUNDS the duration to whole ticks, so the fractional-duration behavior only matches under the same rounding). Same clock modulo the fixed_dt unit; the E5 mechanism is otherwise as specified. - 2026-07-04 — E4 GO. E5: builtin-resource registration in Pass A at the TagSet injection point (idempotent hot-reload: existing registration + live values reused); `TimeResources` handle cache (ids + field offsets via `registry.findField`) resolved once at compile; `advanceTime` at the head of `stepOnce` (pre_update equivalent) — reads `time_scale`/`paused` from `GameTime`, advances the two clock accumulators, publishes dt/total/frame/fixed_frame + UnscaledTime/RealTime (unaffected by both controls, `getMutResource` so the dirty bit reflects the writes); `async_tick` demoted to the frame counter (unconditional increment; no longer read by any wake path); `WakeCond.wait_until` re-plumbed to an f64 game-clock deadline + new `wait_until_unscaled` against the unscaled clock; `evalAwaitTarget` handles `wait_unscaled` (literal-only restriction kept for BOTH, per Out of scope); resume-path partition narrowed to `entity_event`. Clock accumulators seeded from the surviving `total` fields on re-compile (game time continues across a hot-reload AST swap). Stale M1.0.11 partition test updated (wait_unscaled works now; boundary = entity_event). Four E5 inline tests (population/frame, scale+pause on resources, wait scaled + frozen-under-pause, wait_unscaled fires under pause); byte-identity at scale=1 proven by the UNTOUCHED M1.0.11/12 tick-precise test expectations all staying green. Full suite green in debug. +- 2026-07-04 — E5 GO (tick-unit accumulator representation accepted — recorded below; Guy reconciles §9.12 KB-side). E6: `TimerEntry` heap records in an `Interpreter.timers` registry (monotone pointer-stable + husk — the M1.0.12 pool discipline; `TimerHandle` = bare index); `scheduleTimer` evaluates the Duration arg ONCE as a full expression, snapshots the scheduling scope via `cloneLocalsInto` (the M1.0.12 branch-snapshot semantics + heap caveats), binds `Value.timer_handle` in the parent scope AFTER the snapshot; `fireTimers` at the head of `stepOnce` (after `advanceTime` + the event-store clear, before rule dispatch — a callback's emit is visible to same-tick rules), registration-order scan against the owning clock's tick accumulator, one-shots husk (snapshot freed), `every` re-arms `deadline += period` (fixed, no drift correction), live-length scan so a callback-scheduled timer appends safely; `execTimerBody` run-to-completion with rule-body error harvesting (no arena reset — snapshots hold handles, the task-scope discipline); `cancelTimer` idempotent with a `firing` latch (a self-cancel defers its snapshot free to the scan — no mid-body free); `.timer_handle` arm in `dispatchMethodOnValue` (`cancel()` only); deinit teardown (armed snapshots + all records). File justification (outside the per-file list, within `src/etch/`): `value.zig` gains `timer_handle` (the E6 deliverable `Value.timer_handle` — the M1.0.12 `task_handle` precedent lives there) and `duration` (runtime shape of a Duration so `after(d)` takes a variable; arithmetic stays out of scope) + `eql` arms; `evalExpr` gains the `.duration_lit` arm. Eight E6 inline tests (one-shot fire + husk + post-fire cancel no-op; every re-arm; pause freezes after / after_unscaled fires; time_scale stretch; cancel never-fires + idempotent ×3; entity snapshot capture; same-tick registration order; async-rule scheduling + same-tick emit visibility). + ## Recorded deviations +- 2026-07-04 (E5 review GO, Guy) — **Internal clock representation: tick units, not seconds.** The FROZEN E5 frames the two internal accumulators and the `WakeCond` deadline as f64 SECONDS; the implementation advances them in TICK units (game `+= time_scale`, 0 under pause; unscaled `+= 1`) and computes deadlines as `clock + round(secs * 60)`, deriving seconds only when publishing the resource fields. Accepted at the E5 review: the tick-unit sums stay exact integer-valued f64 at `time_scale = 1`, which is what makes the brief's OWN byte-identical wake-tick guarantee exact (a seconds accumulator accrues rounding that can shift a wake by one tick, and the pre-M1.0.13 conversion rounds durations to whole ticks). Same clock modulo the fixed_dt unit; observable semantics unchanged. Guy reconciles `etch-reference-part1.md` §9.12 KB-side. + ## Blockers encountered ## Closing notes From 15ddd774555b5ebd5fa5815aa1707e8780881083 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sat, 4 Jul 2026 11:51:10 +0200 Subject: [PATCH 16/18] docs(claude-md): update for M1.0.13 --- CLAUDE.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 3749698..ef99ed8 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.12-concurrency-algebra` | +| Last released tag | `v0.10.13-time-and-timers` | | Active branch | `main` | -| Next planned milestone | M1.0.13 — time subsystem (scaled/unscaled game time, `await wait_unscaled`, timers `after`/`every`/`after_unscaled`/`quantize`; replaces the fixed-1/60 `wait` conversion without changing its signature). M1.0 (Etch ↔ ECS interpreter) is **21 sub-milestones** (M1.0.0–M1.0.20), **NOT complete**: M1.0.0–M1.0.12 closed; M1.0.13–M1.0.20 close the remaining EBNF v0.6 execution gaps (criterion C1.6), Etch-closure tag at M1.0.20. Await-family partition: `wait_unscaled` + timers → M1.0.13 (time subsystem); `entity_event` → M1.0.14 (entity-scoped events). `override` stays the last reserved top-level construct keyword; the timer family (`every`/`after_unscaled`/`quantize`) also waits in `non_s3_keywords` for M1.0.13. | +| Next planned milestone | M1.0.14 — entity-scoped events (`await entity_event`, the last reserved await target). M1.0 (Etch ↔ ECS interpreter) is **21 sub-milestones** (M1.0.0–M1.0.20), **NOT complete**: M1.0.0–M1.0.13 closed; M1.0.14–M1.0.20 close the remaining EBNF v0.6 execution gaps (criterion C1.6), Etch-closure tag at M1.0.20. Await-family partition: `wait_unscaled` + the timer family delivered in M1.0.13 (time subsystem); `entity_event` → M1.0.14. `every`/`after_unscaled` graduated with M1.0.13; `override` stays the last reserved top-level construct keyword, and `quantize` stays reserved in `non_s3_keywords` (its beat/bar musical clock is a later Sequencer-adjacent milestone). | ## Tags @@ -50,6 +50,7 @@ knowledge base — see § Quick links spec. | `v0.10.10-structural-mutation` | 2026-06-30 | M1.0.10 — Structural mutation in bodies (`spawn`/`despawn`/`add(T)`/`remove(T)`) | The four structural ops are **executable from `rule`/observer/hook bodies** as DEFERRED changes, and the S4 "no structural mutation" interpreter boundary is **lifted**. Parser: `spawn` graduates `non_s3_keywords`→`kw_spawn` + new `ExprKind.spawn_struct` (`spawn (` structural vs `spawn {` = fail-loud M1.0.11 async seam); `despawn`/`add`/`remove` need no parser change (postfix methods on an `Entity` receiver). Type-checker: the four ops recognized on an `Entity` receiver; component-literal fields validated via `checkComponentInstance` reuse (`E0306` unknown field / `E0307` field-type) — no body handle (`E0304`, statement-position only, v0.6) / prefab-name spawn refused (`E0305`, gated on the prefab runtime). Interp: each op ENQUEUES a Tier-0 `CommandBuffer` command onto `world.observer_registry.deferred` (eager payload resolution), drained at the tick boundary by `flushStructural` via `applyWithObservers` (observers fire per op — `on_spawned`+`on_add` / `on_remove`+`on_despawned` / `on_replaced`), never mid-`iterateArchetype`. No sentinel/planned-pool (reserved post-v0.6); `command_buffer.zig`/`entity.zig` untouched (FROZEN). | | `v0.10.11-async-core` | 2026-07-01 | M1.0.11 — Async suspension core (`await` family + `async fn`/`async method` execution) | The per-rule `AsyncSlot` becomes a dynamic `AsyncTask` pool with a **resume frame-stack** (`run`/`loop_`/`while_`/`for_`/`try_`/`call` frames, innermost last); a statement-head `await` suspends at any depth (incl. `for`/`try` bodies) and resumes without re-running prefixes (no double `emit`); a `throw` post-resume routes to the enclosing `try_` frame. `async fn`/`async method` **execute via frame inlining** (`await f()` pushes the callee body + heap-boxed scope + `RetTarget` on the caller task; `return` resolves at the await site; a SYNC call to an `async` fn stays fail-loud — `callFn`/`callMethod` `is_async` gate). Owned `await` targets: `wait` as a **`Duration`** (fixed 60 Hz → ticks; non-literal fail-loud), `global_event`, direct-call `future`. Placement (Phase-1): `await` must be a statement's full RHS on the frame-driven spine — a sub-expression `await` or one in a synchronously-evaluated VALUE block → **`E0904 AwaitNotStatementHead`**. Coloring (§9.3): an `async` call / `await` in a non-async `fn`/`rule` → **`E0901 AsyncCallInNonAsyncContext`**. `parser.zig`/`token.zig`/`ast.zig` untouched. | | `v0.10.12-concurrency-algebra` | 2026-07-03 | M1.0.12 — Concurrency algebra (`race`/`sync`/`branch`/`spawn { }` + cancellation + `TaskHandle` await) | Closes the concurrency chapter of EBNF v0.6 (C1.6). `race`/`sync` graduate (`kw_race`/`kw_sync`); the four statements parse (`ConcurrencyBranch` shared slab + `RaceStmt`/`SyncStmt`/`BranchStmt`/`SpawnStmt`; `spawn (`/`spawn {` token disambiguation kept local; `let h = spawn { }` IS the SpawnStmt binding form, not a let-stmt). Type-check: E0901 on the four forms outside async; **`E0905 UnconsumedAsyncEffect`** — `await` is the SOLE call-grain consumer (§9.2 **revision 2**, STOP round-trip: the constructs relocate the suspension, their bodies are ordinary async contexts); **`E0906`** `return` race-branch-only (winner-return propagation); **`E0907`** break/continue must not cross the task boundary (per-branch loop depth + label window); `TaskHandle` builtin (non-POD, field-rejected), `h.cancel()`, `await h` typed (unit Phase 1). Execution: heap-record pointer-stable monotonic task pool (husk parking — no reuse, index = Phase-1 handle identity, no generations); drive-by-origin (children interleave at the origin rule's position, creation order, mid-pass pickup); `race`/`sync` = child task per admitted branch (guards evaluated first in the live parent scope; per-branch scope SNAPSHOT copy) with parent suspended on `children_any`/`children_all` (zero admitted → same-tick passthrough; one-tick construct latency otherwise — parent precedes children in pool order, so losers are canceled before their wakes can fire); race = first-`.done`-in-declaration-order winner, losers `cancelTask`'d (NON-transitive), winner-`return` re-raised at the race site; failed tasks park `.canceled` (never a winner, never block a join, `await` on them fails loud). `branch`/`spawn` = detached tasks (`Value.task_handle` = pool index); `await h` on done = immediate resume delivering the parked result, on canceled(-while-awaited) = fail-loud. Fix-as-you-go: `return await ` no longer drops its return at resume (M1.0.11 gap). | +| `v0.10.13-time-and-timers` | 2026-07-04 | M1.0.13 — Time subsystem and timers | First builtin engine resources (`GameTime`/`UnscaledTime`/`RealTime`), auto-registered from a single `pub const` descriptor table in `types.zig` (the `TagSet` injection-point precedent; no `.etch` prelude) and known to the type-checker by name (`get(GameTime).dt`, ambient — no `when resource` gate). `stepOnce` populates them at the tick head (fixed 1/60 timestep): two internal f64 clock accumulators in TICK units (game `+= time_scale`, 0 under `paused`; unscaled `+= 1` — E5 recorded deviation vs the brief's seconds framing, accepted: exact integer f64 sums at `scale = 1` make the byte-identical wake guarantee robust), seconds derived at publication. `await wait` re-plumbed onto the game clock (freezes under `paused`, scales with `time_scale`), `await wait_unscaled` executes against the unscaled clock (fires under pause) — both keep the M1.0.11 literal-only restriction; byte-identical wake ticks at `scale = 1` (all M1.0.11/12 tick-precise tests untouched); `async_tick` demoted to the frame counter backing `GameTime.frame`. Timer family graduated + executes: `kw_every`/`kw_after_unscaled`, `TimerStmt` AST slab, `timer_stmt` parses (unbound at statement head + `let t = after(d) { }` in `parseLetStmt`, the spawn precedent), `TimerHandle` builtin type-checks (`cancel()` only, not awaitable, non-POD field-rejected), timer arg = full `Duration` expression, timer body = synchronous context (`await`/async call inside → E0901). Runtime timer registry: heap records, monotone pointer-stable + husk (a `TimerHandle` is a bare index), scope SNAPSHOT at scheduling (`cloneLocalsInto`, handle bound in the parent scope after), fired at the head of `stepOnce` in registration order (deterministic), one-shots husk / `every` re-arms at a fixed period (no drift correction), `cancel()` idempotent with a mid-fire latch. `Value` gains `timer_handle` + `duration` (runtime Duration seconds). `quantize` stays reserved (fail-loud re-pointed at a later Sequencer-adjacent milestone). | ## Hypotheses validated by spikes @@ -85,6 +86,7 @@ knowledge base — see § Quick links spec. - **M1.0.10 scope boundary (structural mutation in bodies)** — the four ops (`spawn`/`despawn`/`add(T)`/`remove(T)`) are DEFERRED structural changes routed through the Tier-0 `CommandBuffer` (`world.observer_registry.deferred`), drained at the tick boundary via `applyWithObservers` (NOT a parallel `pending_*` queue). **In-body `spawn` is statement-only, NO body handle** (v0.6, `etch-grammar.md §4.5`): binding/using a spawn result is refused (`E0304`); the sentinel/planned-pool that would back a body handle is reserved post-v0.6 (`etch-bytecode.md §11.2`) — NOT built. **Prefab-name `spawn("X")`** parses + is recognized but refused (`E0305`), gating on the prefab runtime (post-Etch). **Recorded deviation (Claude.ai round-trip, VERDICT E2 — STOP):** E2 was completed to statically validate component-literal fields (`E0306`/`E0307`) by reusing the scene/prefab `checkComponentInstance` path — "type the expression" implies field validation for `weld check` / C1.6. **Design note (E3):** the 4 ops route via a `structuralDeferred` helper (prefers `observer_deferred` when bound, else the world's shared buffer) rather than binding `observer_deferred` in `execBody` — equivalent routing, leaves the tag/extension `pending_*` paths untouched. **Out (later milestones):** async `spawn { }` task (M1.0.12 — M1.0.11 built the async suspension core but the `spawn { }` task form ships with the concurrency algebra); the §30.5 additive-conflict cook warning (later cook milestone). - **M1.0.11 scope boundary (async suspension core)** — the Phase-1 tree-walker async model (NOT the Phase-2 bytecode state machine, `etch-bytecode.md §9`) is a dynamic `AsyncTask` pool + resume frame-stack reproducing the §9 observable semantics; documented in `etch-reference-part1.md §9.12` (Claude.ai KB re-upload). **Recorded deviations (Claude.ai round-trips):** §9.12 placement broadened to `for`/`try`/`catch` bodies (E1) and to reject `await` in VALUE-position blocks (E3, `E0904`) — both real EBNF v0.6 statements, C1.6; the `programs/` integration file dropped (that corpus is the interp↔codegen differential, rejects async — Observable behavior covered by inline cross-tick tests). **Owned here:** `await` family (`wait` Duration/60 Hz, `global_event`, direct-call `future`) + `async fn`/`method` execution + coloring `E0901` + placement `E0904`. **Out (owned by later milestones, NOT debt):** `race`/`sync`/`branch`/`spawn { }` + cancellation + `await` on a `TaskHandle` → M1.0.12; `await wait_unscaled` + timers (need the scaled/unscaled time subsystem) → M1.0.13; `await entity_event` (needs entity-scoped events) → M1.0.14; entity-bound `async rule` → later; the Phase-2 bytecode async lowering. **Open decision — async effect-consumption completeness:** a BARE `async` call in an `async` context (no `await`, no wrapper) is illegal per `etch-resolver-types.md §9.2` (the `{async}` effect is consumed by `await` OR `spawn`/`branch`/`race`/`sync`) but is **not** `E0901` (the non-async-context case) and currently **fails loud at runtime** (`is_async` gate) — the compile-time check completes with the consumption constructs in **M1.0.12**. - **M1.0.12 scope boundary (concurrency algebra)** — the four constructs (`race`/`sync`/`branch`/`spawn { }`) parse, type-check, and execute as CHILD TASKS in the interpreter's pointer-stable monotonic pool (heap records, husk parking, no slot reuse — a pool index IS the Phase-1 `TaskHandle`, no generations), driven by-origin at the creating rule's position in creation order. **Recorded deviation (STOP round-trip 2026-07-02, `etch-resolver-types.md` §9.2 revision 2):** the brief's "calls inside the four construct bodies count as consumed launch sites" is SUPERSEDED — `await` is the SOLE call-grain consumer of the `{async}` effect (`E0905` applies recursively inside the construct bodies; the constructs relocate the suspension into a child task, they do not replace the `await`). Return asymmetry (Guy's ruling 2026-07-02): `return` legal only in a `race` branch (winner-return re-raised at the race site); `sync`/`branch`/`spawn` bodies reject it (`E0906`). Documented one-tick construct latency: a race/sync parent precedes its children in pool-creation order, so it resumes the tick AFTER its wake fires — which guarantees losers are canceled before their own wakes can fire (zero-admitted constructs have no latency). Cancellation is NON-transitive (Phase 1, `etch-bytecode.md` §9.5). Failed tasks (uncaught `throw` / runtime failure) park `.canceled` — never a race winner, never block a `sync` join, `await`ing them fails loud (§9.8 amended). Fix-as-you-go: `return await ` dropped its return at resume (M1.0.11 gap) — fixed. **Out (later):** `wait_unscaled` + timers (M1.0.13), `entity_event` (M1.0.14), entity-bound `async rule`, Phase-2 bytecode lowering (`etch-bytecode.md` §9.4), transitive cancellation (Phase 2+), task-pool slot reuse / generations (Phase-2 refcounted model), value-producing spawn bodies (no EBNF v0.6 value channel). +- **M1.0.13 scope boundary (time subsystem and timers)** — the three builtin time resources (`GameTime`/`UnscaledTime`/`RealTime`) are auto-registered from the single `pub const builtin_resources` descriptor table in `types.zig` (the `TagSet` injection-point precedent — no `.etch` prelude, no separate module) and resolve AMBIENTLY (`get(GameTime).dt` needs no `when resource` clause — the spec examples read them without one). `await wait` re-plumbed onto scaled game time and `await wait_unscaled` onto unscaled time with **byte-identical wake ticks at `time_scale = 1`** — realized by the E5 recorded deviation: the two internal clock accumulators advance in TICK units (game `+= time_scale`, 0 under `paused`; unscaled `+= 1`; deadlines `clock + round(secs * 60)`), seconds derived only at resource publication (exact integer f64 sums at `scale = 1`; a seconds accumulator would accrue rounding that can shift a wake by one tick). Both waits KEEP the M1.0.11 literal-only Duration restriction; only the timer family evaluates a full `Duration` expression. The timer registry mirrors the M1.0.12 task-pool discipline (heap records, monotone pointer-stable, husk parking, `TimerHandle` = bare index, no generations) but is a DISTINCT mechanism — a timer is not a task: `cancel()` only, not awaitable, body is a synchronous context (E0901 on `await`/async calls inside). Timer callbacks fire at the head of `stepOnce` (after the clock advance and event-store clear, before rule dispatch) in registration order; `every` re-arms at a fixed period (no drift correction Phase 1). `Value` gained `timer_handle` + `duration` (`value.zig` — outside the brief's per-file list, justified: `Value.timer_handle` is the E6 deliverable and the `task_handle` precedent lives there). **Out (later, not debt):** `quantize` (stays reserved; needs the Sequencer/Pulse beat/bar clock — later Sequencer-adjacent milestone), `time.*` stdlib sugar (later stdlib milestone; `etch-stdlib.md §20` reconciliation deferred with it), `dt` as an injected rule parameter, per-entity `TimeDilation` / selective pause / `WorldClock` (need the phase scheduler), lifting `wait`'s literal-only restriction, Phase-2 bytecode lowering. ## Non-negotiable rules @@ -218,4 +220,4 @@ The `briefs/` directory is the source of truth for milestone state. The brief's --- -Last updated: 2026-07-03 +Last updated: 2026-07-04 From 90269f77f3db201fcb9542419df14b364fdc6e39 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sat, 4 Jul 2026 11:54:09 +0200 Subject: [PATCH 17/18] docs(etch): retire stale wake-comment wording after the clock re-plumb --- src/etch/interp.zig | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/etch/interp.zig b/src/etch/interp.zig index 789efb5..aa58465 100644 --- a/src/etch/interp.zig +++ b/src/etch/interp.zig @@ -9053,10 +9053,10 @@ test "async rule suspends at await wait(s) and resumes at the equivalent tick defer interp.deinit(); const out_id = world.registry.idOf("Out").?; - // tick 1: spawn → n=1, suspend at `await wait(0.04s)` (wake at async_tick 3). + // tick 1: spawn → n=1, suspend at `await wait(0.04s)` (wake at game clock 3). _ = try interp.runFor(&world, 1); try std.testing.expectEqual(@as(i64, 1), readResourceInt(&world, out_id)); - // tick 2: still suspended (async_tick 2 < 3) — n unchanged. + // tick 2: still suspended (game clock 2 < 3) — n unchanged. _ = try interp.runFor(&world, 1); try std.testing.expectEqual(@as(i64, 1), readResourceInt(&world, out_id)); // tick 3: wake fires (3 >= 3) → resume, n=2, complete. @@ -9720,7 +9720,7 @@ test "async rule suspends at a statement-head await inside an if body and resume const log_id = world.registry.idOf("Log").?; // tick 1: n=1, enter the if, emit Beat (counted → Log.n=1), suspend at the - // await (wake at async_tick 3). + // await (wake at game clock 3). _ = try interp.runFor(&world, 1); try std.testing.expectEqual(@as(i64, 1), readResourceInt(&world, out_id)); try std.testing.expectEqual(@as(i64, 1), readResourceInt(&world, log_id)); @@ -9776,10 +9776,10 @@ test "async rule suspends at a statement-head await inside a loop body and resum defer interp.deinit(); const out_id = world.registry.idOf("Out").?; - // tick 1: iteration 1 (n=1), suspend at await (wake at async_tick 2). + // tick 1: iteration 1 (n=1), suspend at await (wake at game clock 2). _ = try interp.runFor(&world, 1); try std.testing.expectEqual(@as(i64, 1), readResourceInt(&world, out_id)); - // tick 2: resume → iteration 2 (n=2), suspend again (wake at async_tick 3). + // tick 2: resume → iteration 2 (n=2), suspend again (wake at game clock 3). _ = try interp.runFor(&world, 1); try std.testing.expectEqual(@as(i64, 2), readResourceInt(&world, out_id)); // tick 3: resume → iteration 3 (n=3), the if fires `break`, the loop exits, @@ -9829,7 +9829,7 @@ test "async rule suspends at a statement-head await inside a for body and resume defer interp.deinit(); const out_id = world.registry.idOf("Out").?; - // tick 1: i=0 → n += 1 = 1, suspend (wake at async_tick 2). + // tick 1: i=0 → n += 1 = 1, suspend (wake at game clock 2). _ = try interp.runFor(&world, 1); try std.testing.expectEqual(@as(i64, 1), readResourceInt(&world, out_id)); // tick 2: resume → i=1 → n += 2 = 3, suspend. @@ -10017,10 +10017,10 @@ test "await wait(1.0s) resumes at the fixed-timestep-equivalent tick count (60) var world = World.init(); defer world.deinit(gpa); - // 1.0 s at the Phase-1 fixed 1/60 timestep = 60 ticks. Spawned at async_tick + // 1.0 s at the Phase-1 fixed 1/60 timestep = 60 ticks. Spawned at game clock // 1, the task wakes at tick 61 — not before. This pins the Duration→tick - // conversion (M1.0.13 will swap the clock without changing the result at - // time_scale = 1). + // conversion (M1.0.13 swapped the wake onto the game clock without changing + // the result at time_scale = 1 — this test IS the byte-identity witness). const source = \\resource Out { n: int = 0 } \\async rule sec() @@ -10049,7 +10049,7 @@ test "await wait(1.0s) resumes at the fixed-timestep-equivalent tick count (60) defer interp.deinit(); const out_id = world.registry.idOf("Out").?; - // tick 1: n=1, suspend (wake at async_tick 61). + // tick 1: n=1, suspend (wake at game clock 61). _ = try interp.runFor(&world, 1); try std.testing.expectEqual(@as(i64, 1), readResourceInt(&world, out_id)); // through tick 60: still suspended (60 < 61). From e0c66f6f079607907330bf4c7b3db9a88d98d12e Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sat, 4 Jul 2026 11:54:10 +0200 Subject: [PATCH 18/18] docs(brief): close M1.0.13 --- briefs/M1.0.13-time-and-timers.md | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/briefs/M1.0.13-time-and-timers.md b/briefs/M1.0.13-time-and-timers.md index 2cbada2..5c16409 100644 --- a/briefs/M1.0.13-time-and-timers.md +++ b/briefs/M1.0.13-time-and-timers.md @@ -1,12 +1,12 @@ # M1.0.13 — Time subsystem and timers -> **Status:** ACTIVE +> **Status:** CLOSED > **Phase:** 1 > **Branch:** `phase-1/etch/time-and-timers` > **Planned tag:** `v0.10.13-time-and-timers` > **Dependencies:** M1.0.11 (async suspension core — `wait`/`await` substrate this milestone re-plumbs), M1.0.12 (concurrency algebra — the pointer-stable/monotonic/husk pool discipline the timer registry mirrors) > **Opened:** 2026-07-03 -> **Closed:** — +> **Closed:** 2026-07-04 --- @@ -166,3 +166,11 @@ None (no perf target for this milestone). ## Blockers encountered ## Closing notes + +- **Delivered (E1→E6, each gate GO'd on the pushed diff).** `every`/`after_unscaled` graduated (`quantize` stays reserved, fail-loud re-pointed at a later Sequencer-adjacent milestone); `TimerKind`/`TimerStmt` AST slab; `timer_stmt` parses (unbound at statement head + `let`-bound in `parseLetStmt`, the spawn precedent; `mut`/annotation rejected); `TimerHandle` builtin type (`cancel()` only, not awaitable, non-POD field-rejected), timer arg typed as a full `Duration` expression, timer body enforced as a synchronous context (E0901); `pub const builtin_resources` descriptor table in `types.zig` (GameTime/UnscaledTime/RealTime, `fixed_dt = 1/60`) consumed by the checker (ambient `get(GameTime).dt`, no `when` gate) and by `interp.zig` Pass A (TagSet injection point, idempotent hot-reload, accumulators re-seeded from surviving totals); `stepOnce` time population (dt/total/frame/fixed_frame; unscaled/real unaffected by `time_scale`/`paused`); `await wait`/`await wait_unscaled` re-plumbed onto the game/unscaled clocks (literal-only restriction kept for both); `async_tick` demoted to the frame counter; runtime timer registry (monotone pointer-stable + husk, scope snapshot at scheduling, firing at the tick head in registration order, `every` re-armed at a fixed period, `cancel()` idempotent with a mid-fire latch, `Value.timer_handle` bound in the parent scope after the snapshot). +- **Byte-identity at `time_scale = 1`**: proven by the untouched M1.0.11/12 tick-precise tests (the 60-tick wait test is the explicit witness); realized by the tick-unit accumulator representation — see Recorded deviations (accepted at the E5 review; §9.12 reconciled KB-side by Guy). +- **Files touched beyond the per-file list** (both justified in the Execution log, within `src/etch/`): `value.zig` (`timer_handle` — the E6 deliverable `Value.timer_handle`; `duration` — runtime Duration seconds so `after(d)` takes a variable; `eql` arms) and the `.duration_lit` arm in `evalExpr`. +- **Fix-as-you-go**: the M1.0.11 partition test's `wait_unscaled` half retired (it graduates here; the remaining boundary is `entity_event`, M1.0.14); two stale M1.0.12 token-test assertions on the reserved timer family removed at E1; test-narration comments "wake at async_tick N" renamed to "wake at game clock N" (same values at `scale = 1`); one stale future-tense M1.0.13 comment rewritten as the byte-identity witness note. +- **§3.6.1 audits**: language audit clean — the only French hits on the branch diff are verbatim quotes of the spec section title "Les trois temps" (`engine-gameplay-systems.md`), the FROZEN brief's own citation convention. Drift audit clean after the comment patches above; no orphaned references to the timer fail-loud, the reserved timer family, or the `wait_unscaled` partition remain in `src/`/`tools/`/`tests/`. +- **Residuals (scope boundaries, not debt)**: `quantize` (Sequencer-adjacent milestone), `time.*` stdlib sugar (+ the deferred `etch-stdlib.md §20` reconciliation), `dt` rule-parameter injection, per-entity `TimeDilation`/selective pause/`WorldClock`, lifting `wait`'s literal-only restriction, Phase-2 bytecode lowering. +- **Validation**: full suite green in `debug` and `ReleaseSafe` at every gate and at close; `zig build`, `zig fmt --check`, `zig build lint` green; no benchmarks required. CLAUDE.md §3.4 updated on the branch (`docs(claude-md): update for M1.0.13`).