From 03a5d742651c20430b0b30fe341346d2a92cd545 Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Fri, 14 Aug 2026 11:15:24 -0700 Subject: [PATCH] =?UTF-8?q?feat(wasm):=20ui=5Fset=5Ftick=5Frate=20export?= =?UTF-8?q?=20=E2=80=94=20declared-rate=20realms=20reach=20the=20sim?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wasm core takes the same declare-before-first-tick lifecycle as the native surfaces; wasm-ops publishes an applied rate as ops.__tickHz and retracts it on init's core reset. The sim declares Scenario.tickHz before eval (mount-time conversions run at the rate), drives tickHz/hz core ticks per virtual frame, and touchGlide's finest-grid emission takes the rate instead of a literal 60. Co-Authored-By: Claude Fable 5 --- engine/wasm/src/lib.rs | 13 +++- hosts/sim/sim.ts | 80 +++++++++++++++++------- hosts/web/wasm-ops.js | 20 +++++- tests/tick-rate-sim.test.ts | 121 ++++++++++++++++++++++++++++++++++++ tools/test.ts | 9 +++ 5 files changed, 219 insertions(+), 24 deletions(-) create mode 100644 tests/tick-rate-sim.test.ts diff --git a/engine/wasm/src/lib.rs b/engine/wasm/src/lib.rs index 4292d595..e4696fca 100644 --- a/engine/wasm/src/lib.rs +++ b/engine/wasm/src/lib.rs @@ -74,6 +74,16 @@ pub extern "C" fn ui_set_viewport(width: f32, height: f32) { ui().set_viewport(width, height); } +/// Declare the realm's tick rate — the same lifecycle as the native +/// surfaces: 1..=MAX_TICK_HZ, rejected once the first `ui_tick` has run +/// (a mid-run rate change would change the fixed step under live +/// animations). `ui_init` resets the rate to the spec 60 with the rest of +/// the core — redeclare after every reset. Returns 1 applied / 0 rejected. +#[no_mangle] +pub extern "C" fn ui_set_tick_rate(hz: u32) -> i32 { + i32::from(ui().set_tick_rate(hz)) +} + /// Allocate `len` bytes of scratch in linear memory for host -> wasm buffers. #[no_mangle] pub extern "C" fn ui_alloc(len: usize) -> *mut u8 { @@ -233,7 +243,8 @@ pub extern "C" fn ui_measure_text(ptr: *const u8, len: usize, font_slot: u32) -> // ---- frame ------------------------------------------------------------------ -/// Advance one fixed-dt (1/60 s) frame: animations, then layout if dirty. +/// Advance one fixed-dt frame (1/60 s unless `ui_set_tick_rate` declared +/// another rate): animations, then layout if dirty. #[no_mangle] pub extern "C" fn ui_tick() { ui().tick() diff --git a/hosts/sim/sim.ts b/hosts/sim/sim.ts index f05e6e45..29be9d65 100644 --- a/hosts/sim/sim.ts +++ b/hosts/sim/sim.ts @@ -4,9 +4,11 @@ // built app bundle against the wasm core (the SAME HostOps binding the // browser host and tests/golden.ts use), then drives virtual frames as fast // as the CPU allows. The clock policy is explicit: `hz` virtual frames per -// virtual second, each one JS frame() transaction plus 60/hz core ticks — -// so ms-based animations cover the same virtual time at every rate, and a -// low-rate world is a strict subsampling of the 60 Hz world's trajectory. +// virtual second, each one JS frame() transaction plus tickHz/hz core ticks +// (tickHz = the realm's declared rate, spec 60 unless the scenario declares +// the rate its bundle was built with) — so ms-based animations cover the +// same virtual time at every rate, and a low-rate world is a strict +// subsampling of the full-rate world's trajectory. // // Input is a SCRIPT in virtual seconds (`{ at, press }`), not frame counts, // so one user journey drives every simulation rate. The run product is a @@ -27,7 +29,7 @@ import { existsSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { join, resolve } from "node:path"; import { createWasmUi } from "../web/wasm-ops.js"; -import { normalizeHz, TICKS_PER_SECOND } from "../../framework/src/clock.ts"; +import { TICKS_PER_SECOND } from "../../framework/src/clock.ts"; import { createTouchHitFacts, __packTouch } from "../../framework/src/touch.ts"; const ROOT = resolve(fileURLToPath(new URL("../..", import.meta.url))); // PocketJS/ @@ -56,8 +58,13 @@ export interface ScriptEvent { export interface Scenario { /** Built bundle name under dist/ (e.g. "cafe-main"). */ app: string; - /** Virtual frames per second; must divide 60. Default 60. */ + /** Virtual frames per second; must divide the tick rate. Default = tickHz. */ hz?: number; + /** Core ticks per virtual second — the realm's declared rate. MUST equal + * the rate the bundle was built with (`tools/build.ts --hz`); the sim + * cannot read the baked rate, it can only drive the one you declare. + * Default 60, the spec rate every plain build bakes. */ + tickHz?: number; /** Journey length in virtual seconds. */ seconds: number; script?: ScriptEvent[]; @@ -159,24 +166,27 @@ export function touchGlide( t0: number, t1: number, id = 0, + gridHz = 60, ): ScriptEvent[] { if (t1 <= t0) throw new Error("sim: touchGlide needs t1 > t0"); const out: ScriptEvent[] = []; - // Emit one event per frame at EVERY valid hz's grid: use the 60 Hz frame - // grid (the finest), and scriptToMasks' rounding lands each event on the - // active rate's frame. Same-frame duplicates collapse level-triggered. - const f0 = Math.round(t0 * 60); - const f1 = Math.round(t1 * 60); + // Emit one event per frame at EVERY valid hz's grid: use the realm's tick + // rate as the finest grid (60 for every committed tape — pass the + // scenario's tickHz for a declared-rate run), and scriptToMasks' rounding + // lands each event on the active rate's frame. Same-frame duplicates + // collapse level-triggered. + const f0 = Math.round(t0 * gridHz); + const f1 = Math.round(t1 * gridHz); for (let f = f0; f < f1; f++) { const t = (f - f0) / (f1 - f0); out.push({ - at: f / 60, + at: f / gridHz, touch: [ { id, x: Math.round(x0 + (x1 - x0) * t), y: Math.round(y0 + (y1 - y0) * t) }, ], }); } - out.push({ at: f1 / 60, touch: [] }); + out.push({ at: f1 / gridHz, touch: [] }); return out; } @@ -198,6 +208,9 @@ export interface SimWorld { render: () => Uint8Array; ticksPerFrame: number; hz: number; + /** The realm's declared core rate (pair rate-aware companions with it, + * e.g. createSimAudioSink(world.tickHz)). */ + tickHz: number; effects: EffectEvent[]; getTree: () => unknown; } @@ -209,6 +222,11 @@ export interface SimViewportOptions { renderScale?: number; } +export interface SimBootOptions extends SimViewportOptions { + /** Core ticks per virtual second (see Scenario.tickHz). Default 60. */ + tickHz?: number; +} + /** * Boot a fresh world: fresh wasm core, fresh bundle eval, host globals * (ui/__pak/__simHz/effect trace/DevTools transport) installed before eval — @@ -221,13 +239,31 @@ export async function bootWorld( hz: number, extraGlobals?: Record, mutateOps?: (ops: Record) => void, - viewport: SimViewportOptions = {}, + options: SimBootOptions = {}, ): Promise { + const tickHz = options.tickHz ?? TICKS_PER_SECOND; + if (!Number.isInteger(tickHz) || tickHz < 1 || tickHz > 240) { + throw new Error(`sim: tickHz must be an integer from 1 through 240, got ${tickHz}`); + } + if (tickHz % hz !== 0) { + throw new Error(`sim: hz=${hz} does not divide the tick rate ${tickHz}`); + } ensureBuilt(WASM_PATH, [process.execPath, "tools/wasm.ts"]); ensureBuilt(DIST + app + ".js", [process.execPath, "tools/build.ts", app]); if (!wasmBytes) wasmBytes = await Bun.file(WASM_PATH).arrayBuffer(); - const wasm = await createWasmUi(wasmBytes, viewport); - const renderScale = viewport.renderScale ?? 1; + const wasm = await createWasmUi(wasmBytes, options); + // Declare before eval, the same order the native surfaces enforce: eval + // runs mount-time animate()/spring() and those convert ms at the rate. + if (tickHz !== TICKS_PER_SECOND) { + const setTickRate = (wasm.ops as { setTickRate?: (hz: number) => boolean }).setTickRate; + if (!setTickRate) { + throw new Error("sim: this pocketjs.wasm predates ui_set_tick_rate — rebuild it: bun tools/wasm.ts"); + } + if (!setTickRate(tickHz)) { + throw new Error(`sim: the core refused tick rate ${tickHz}`); + } + } + const renderScale = options.renderScale ?? 1; const g = globalThis as Record; const effects: EffectEvent[] = []; const inbox: string[] = []; @@ -272,8 +308,9 @@ export async function bootWorld( frame, tick: wasm.tick, render: () => wasm.renderScaled(renderScale), - ticksPerFrame: TICKS_PER_SECOND / hz, + ticksPerFrame: tickHz / hz, hz, + tickHz, effects, // Tree probe: ask the DevTools shim, flush with one extra frame (the // shim polls its transport at frame start). The probe frame advances the @@ -282,7 +319,7 @@ export async function bootWorld( outbox.length = 0; inbox.push(JSON.stringify({ t: "getTree" })); frame(0); - for (let t = 0; t < TICKS_PER_SECOND / hz; t++) wasm.tick(); + for (let t = 0; t < tickHz / hz; t++) wasm.tick(); for (const line of outbox) { const msg = JSON.parse(line) as { t: string; root?: unknown }; if (msg.t === "tree") return msg.root; @@ -294,13 +331,14 @@ export async function bootWorld( /** Run one scenario to completion and return its trace. */ export async function runScenario(scenario: Scenario, chaos?: ChaosOptions): Promise { - const hz = normalizeHz(scenario.hz ?? TICKS_PER_SECOND); - if (hz !== (scenario.hz ?? TICKS_PER_SECOND)) { - throw new Error(`sim: hz=${scenario.hz} does not divide ${TICKS_PER_SECOND}`); + const tickHz = scenario.tickHz ?? TICKS_PER_SECOND; + const hz = scenario.hz ?? tickHz; + if (!Number.isInteger(hz) || hz < 1 || tickHz % hz !== 0) { + throw new Error(`sim: hz=${scenario.hz} does not divide the tick rate ${tickHz}`); } const frames = Math.round(scenario.seconds * hz); const { masks, analogs, touches } = scriptToMasks(scenario.script ?? [], hz, frames); - const world = await bootWorld(scenario.app, hz); + const world = await bootWorld(scenario.app, hz, undefined, undefined, { tickHz }); const hashes: string[] = []; let garbage: unknown[] = []; for (let f = 0; f < frames; f++) { diff --git a/hosts/web/wasm-ops.js b/hosts/web/wasm-ops.js index 40d3a89a..84b81a20 100644 --- a/hosts/web/wasm-ops.js +++ b/hosts/web/wasm-ops.js @@ -42,6 +42,9 @@ export async function createWasmUi(wasm, options = {}) { const init = (rasterDensity = initialDensity) => { ex.ui_init(integerInRange(rasterDensity, "rasterDensity", 1, 255)); + // ui_init builds a fresh core at the spec 60 — retract the published + // rate so a runner that resets must redeclare before the next eval. + delete ops.__tickHz; // Older wasm binaries predate ui_set_viewport (same convention as // drawHash): tolerate them at the stock size, fail loud otherwise. if (ex.ui_set_viewport) ex.ui_set_viewport(viewportWidth, viewportHeight); @@ -49,7 +52,6 @@ export async function createWasmUi(wasm, options = {}) { throw new Error("this pocketjs.wasm predates ui_set_viewport — rebuild it: bun tools/wasm.ts"); } }; - init(initialDensity); // Copy bytes into wasm scratch, run fn(ptr, len), free. Views are rebuilt // per call: memory.buffer is detached whenever linear memory grows. @@ -125,6 +127,20 @@ export async function createWasmUi(wasm, options = {}) { } if (ex.ui_set_cursor_pos) ops.setCursorPos = (x, y) => ex.ui_set_cursor_pos(x, y); + // Per-realm tick rate — feature-detected so a stale pocketjs.wasm predating + // it still boots at the spec 60. An applied rate is published as + // ops.__tickHz, the same mount fact the native surfaces publish; declare it + // before eval and before the first tick (the core rejects it afterwards). + if (ex.ui_set_tick_rate) { + ops.setTickRate = (hz) => { + const applied = ex.ui_set_tick_rate(hz) !== 0; + if (applied) ops.__tickHz = hz; + return applied; + }; + } + + init(initialDensity); + function framebufferView(ptr, scale) { if (!ptr) throw new Error(`pocketjs.wasm rejected render scale ${scale}`); return new Uint8Array( @@ -139,7 +155,7 @@ export async function createWasmUi(wasm, options = {}) { exports: ex, /** Reset the core and set raster samples per logical pixel (default 1). */ init, - /** Advance exactly one fixed-dt (1/60 s) frame. */ + /** Advance exactly one fixed-dt frame (1/60 s unless a rate was declared). */ tick: () => ex.ui_tick(), /** Hash the current DrawList without rasterizing it (BigInt, wasm i64). */ drawHash: ex.ui_draw_hash ? () => ex.ui_draw_hash() : null, diff --git a/tests/tick-rate-sim.test.ts b/tests/tick-rate-sim.test.ts new file mode 100644 index 00000000..4539b7f4 --- /dev/null +++ b/tests/tick-rate-sim.test.ts @@ -0,0 +1,121 @@ +// Sim-side coverage for per-realm tick rates (docs/DETERMINISM.md): the +// engine/wasm `ui_set_tick_rate` export with its declare-before-first-tick +// lifecycle, and a 120 Hz-baked realm driven end to end through runScenario — +// deterministic, and (for the café app, which is inside the subsampling +// theorem's scope: JS state changes only on events and virtual time) strictly +// subsampled by lower presentation rates, exactly like tests/sim.test.ts +// proves for the 60 Hz realm. +// +// Stage prep (tools/test.ts) rebuilds pocketjs.wasm and bakes the 120 Hz café +// bundle into dist/tick-rate-120/; this file re-ensures both so it also runs +// standalone. + +import { describe, expect, test } from "bun:test"; +import { existsSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { join, resolve } from "node:path"; +import { createWasmUi } from "../hosts/web/wasm-ops.js"; +import { runScenario, treeHasText, type Trace } from "../hosts/sim/sim.ts"; +import { BTN } from "../contracts/spec/spec.ts"; + +const ROOT = resolve(fileURLToPath(new URL("..", import.meta.url))); +const WASM_PATH = join(ROOT, "hosts/web/pocketjs.wasm"); +const APP = "tick-rate-120/cafe-main"; // dist path fragment; baked --hz=120 +const APP_JS = join(ROOT, "dist/tick-rate-120/cafe-main.js"); + +function run(cmd: string[]): void { + const p = Bun.spawnSync(cmd, { cwd: ROOT, stdout: "inherit", stderr: "inherit" }); + if (p.exitCode !== 0) throw new Error(`tick-rate-sim: ${cmd.join(" ")} failed`); +} + +async function freshUi() { + return createWasmUi(await Bun.file(WASM_PATH).arrayBuffer()); +} + +// A stale on-disk pocketjs.wasm predates the export on long-lived dev +// machines — rebuild once, exactly what the bootWorld error would ask for. +if (!existsSync(WASM_PATH) || !(await freshUi()).ops.setTickRate) { + run([process.execPath, "tools/wasm.ts"]); +} +if (!existsSync(APP_JS)) { + run([process.execPath, "tools/build.ts", "cafe-main", "--hz=120", "--outdir=dist/tick-rate-120"]); +} + +describe("wasm ui_set_tick_rate", () => { + test("declares before the first tick and publishes ops.__tickHz", async () => { + const ui = await freshUi(); + expect(ui.ops.setTickRate(120)).toBe(true); + expect(ui.ops.__tickHz).toBe(120); + }); + + test("rejects 0, above-240, and post-tick declarations", async () => { + const ui = await freshUi(); + expect(ui.ops.setTickRate(0)).toBe(false); + expect(ui.ops.setTickRate(241)).toBe(false); + expect(ui.ops.__tickHz).toBeUndefined(); + ui.tick(); + expect(ui.ops.setTickRate(120)).toBe(false); + expect(ui.ops.__tickHz).toBeUndefined(); + }); + + test("init resets the core to the spec 60 and retracts the published rate", async () => { + const ui = await freshUi(); + expect(ui.ops.setTickRate(120)).toBe(true); + ui.init(); + expect(ui.ops.__tickHz).toBeUndefined(); + expect(ui.ops.setTickRate(90)).toBe(true); + expect(ui.ops.__tickHz).toBe(90); + }); +}); + +// The café journey from tests/sim.test.ts — the 0.5 s grid lands on an exact +// frame at 120/60/30 Hz the same way it does at 60/4/2. +const JOURNEY = [ + { at: 1.0, press: BTN.CIRCLE }, + { at: 1.5, press: BTN.DOWN }, + { at: 2.0, press: BTN.CIRCLE }, + { at: 3.0, press: BTN.CIRCLE }, + { at: 3.5, press: BTN.START }, +]; +const SECONDS = 6.5; + +const scenario = (hz?: number) => ({ + app: APP, + tickHz: 120, + hz, + seconds: SECONDS, + script: JOURNEY, +}); + +describe("a 120 Hz realm through the sim", () => { + test("reaches the core, runs deterministically, and the journey lands", async () => { + const a: Trace = await runScenario(scenario()); + const b: Trace = await runScenario(scenario()); + expect(a.hz).toBe(120); + expect(a.frames).toBe(SECONDS * 120); + expect(a.hashes).toEqual(b.hashes); + expect(a.effects).toEqual(b.effects); + expect(treeHasText(a.tree, "ORDERS PLACED 1")).toBe(true); + }, 30000); + + test("lower presentation rates strictly subsample the 120 Hz trajectory", async () => { + const full: Trace = await runScenario(scenario()); + for (const hz of [60, 30]) { + const sub: Trace = await runScenario(scenario(hz)); + const k = 120 / hz; + expect(sub.frames).toBe(SECONDS * hz); + for (let m = 0; m < sub.frames; m++) { + expect(sub.hashes[m]).toBe(full.hashes[k * (m + 1) - 1]); + } + expect(Buffer.from(sub.finalFrame).equals(Buffer.from(full.finalFrame))).toBe(true); + const seconds = (t: Trace) => t.effects.map((e) => ({ kind: e.kind, sec: e.frame / t.hz })); + expect(seconds(sub)).toEqual(seconds(full)); + } + }, 30000); + + test("refuses an hz that does not divide the declared rate", async () => { + await expect(runScenario({ app: APP, tickHz: 120, hz: 50, seconds: 1 })).rejects.toThrow( + /divide/, + ); + }); +}); diff --git a/tools/test.ts b/tools/test.ts index d0999ed5..8f37beac 100644 --- a/tools/test.ts +++ b/tools/test.ts @@ -136,6 +136,15 @@ const SUITE: readonly Stage[] = [ browser: true, tests: ["tests/audio-sim.test.ts"], }, + { + name: "tick-rate sim", + prep: [ + ["bun", "tools/wasm.ts"], + ["bun", "tools/build.ts", "cafe-main", "--hz=120", "--outdir=dist/tick-rate-120"], + ], + browser: true, + tests: ["tests/tick-rate-sim.test.ts"], + }, { name: "launcher sim", prep: [["bun", "tools/launcher.ts", "covers"]],