Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion engine/wasm/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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()
Expand Down
80 changes: 59 additions & 21 deletions hosts/sim/sim.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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/
Expand Down Expand Up @@ -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[];
Expand Down Expand Up @@ -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;
}

Expand All @@ -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;
}
Expand All @@ -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 —
Expand All @@ -221,13 +239,31 @@ export async function bootWorld(
hz: number,
extraGlobals?: Record<string, unknown>,
mutateOps?: (ops: Record<string, unknown>) => void,
viewport: SimViewportOptions = {},
options: SimBootOptions = {},
): Promise<SimWorld> {
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<string, unknown>;
const effects: EffectEvent[] = [];
const inbox: string[] = [];
Expand Down Expand Up @@ -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
Expand All @@ -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;
Expand All @@ -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<Trace> {
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++) {
Expand Down
20 changes: 18 additions & 2 deletions hosts/web/wasm-ops.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,14 +42,16 @@ 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);
else if (viewportWidth !== FB_W || viewportHeight !== FB_H) {
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.
Expand Down Expand Up @@ -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(
Expand All @@ -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,
Expand Down
121 changes: 121 additions & 0 deletions tests/tick-rate-sim.test.ts
Original file line number Diff line number Diff line change
@@ -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/,
);
});
});
9 changes: 9 additions & 0 deletions tools/test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"]],
Expand Down