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
67 changes: 60 additions & 7 deletions framework/src/devtools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export interface DevtoolsTransport {

/** Input tape: the complete session input, RLE-encoded (docs/DEVTOOLS.md §4). */
export interface Tape {
v: 1;
v: 1 | 2;
app?: string;
/** Total frames represented by `masks`. */
frames: number;
Expand All @@ -35,6 +35,13 @@ export interface Tape {
* frame count as `masks`. Omitted when the whole session held center —
* pre-analog tapes stay byte-identical and replay as center. */
analog?: [number, number][];
/** v2: sparse touch track — [frameIndex (relative to the tape start),
* packed contacts] entries for exactly the frames that HAD contacts
* (touch.ts __packTouch words). Contacts vary per frame during a drag, so
* RLE degenerates; sparse-by-frame costs zero bytes on idle frames. A
* touch-free session exports v:1 with no track — byte-identical to
* pre-touch tapes — and replays every frame as no-contacts. */
touch?: [number, number[]][];
/** Absolute frame index of masks[0] (0 unless the ring wrapped). */
startFrame?: number;
}
Expand All @@ -49,15 +56,19 @@ interface DevtoolsState {
transport: DevtoolsTransport | null;
app: string | undefined;
frame: number; // frames actually executed (== core frame counter)
// tape ring (masks + packed analog share indices/start/len)
// tape ring (masks + packed analog + touch share indices/start/len)
tape: Uint16Array;
tapeAnalog: Uint16Array;
/** Touch ring — allocated lazily on the first frame that HAS contacts, so
* touch-free sessions (every PSP session) never pay for it. */
tapeTouch: (number[] | null)[] | null;
tapeStart: number; // ring index of the oldest frame
tapeLen: number;
tapeFirstFrame: number; // absolute frame index of the oldest entry
// replay
replayMasks: Uint16Array | null;
replayAnalog: Uint16Array | null;
replayTouch: (number[] | undefined)[] | null;
replayAt: number;
// pause
paused: boolean;
Expand All @@ -81,11 +92,13 @@ const state: DevtoolsState = {
frame: 0,
tape: new Uint16Array(TAPE_CAP),
tapeAnalog: new Uint16Array(TAPE_CAP),
tapeTouch: null,
tapeStart: 0,
tapeLen: 0,
tapeFirstFrame: 0,
replayMasks: null,
replayAnalog: null,
replayTouch: null,
replayAt: 0,
paused: false,
stepQueued: 0,
Expand Down Expand Up @@ -118,8 +131,10 @@ export function initDevtools(ops: HostOps): void {
state.tapeStart = 0;
state.tapeLen = 0;
state.tapeFirstFrame = 0;
state.tapeTouch = null;
state.replayMasks = null;
state.replayAnalog = null;
state.replayTouch = null;
state.paused = false;
state.stepQueued = 0;
state.inspectReportId = null;
Expand Down Expand Up @@ -174,13 +189,15 @@ export function wrapFrameHandler(
if (state.replayAt < state.replayMasks.length) {
mask = state.replayMasks[state.replayAt];
analog = state.replayAnalog ? state.replayAnalog[state.replayAt] : ANALOG_CENTER;
// A replay that predates touch input must not leak live hardware state
// into the deterministic tape.
touch = undefined;
// Replay owns EVERY input track: live hardware touch must not leak
// into the deterministic tape. A v1 tape (no touch track) replays
// every frame as no-contacts.
touch = state.replayTouch ? state.replayTouch[state.replayAt] : undefined;
state.replayAt++;
} else {
state.replayMasks = null; // tape exhausted: back to live input
state.replayAnalog = null;
state.replayTouch = null;
send({ t: "replayDone", frame: state.frame });
}
}
Expand All @@ -189,7 +206,7 @@ export function wrapFrameHandler(
state.stepQueued--;
state.ops?.debugStep?.(); // arm exactly one core tick
}
recordMask(mask, analog);
recordMask(mask, analog, touch);
state.frame++;
try {
h(mask, analog, touch);
Expand All @@ -210,15 +227,24 @@ export function wrapFrameHandler(
// tape
// ---------------------------------------------------------------------------

function recordMask(mask: number, analog: number): void {
function recordMask(mask: number, analog: number, touch?: readonly number[]): void {
// Defensive copy: hosts may reuse the packed-contact buffer across frames.
const contacts = touch && touch.length > 0 ? touch.slice(0, 8) : null;
if (contacts && !state.tapeTouch) {
// First contact of the session: allocate the ring (touch-free sessions
// never reach here). Frames recorded before this point had no contacts.
state.tapeTouch = new Array<number[] | null>(TAPE_CAP).fill(null);
}
if (state.tapeLen < TAPE_CAP) {
const at = (state.tapeStart + state.tapeLen) % TAPE_CAP;
state.tape[at] = mask;
state.tapeAnalog[at] = analog;
if (state.tapeTouch) state.tapeTouch[at] = contacts;
state.tapeLen++;
} else {
state.tape[state.tapeStart] = mask;
state.tapeAnalog[state.tapeStart] = analog;
if (state.tapeTouch) state.tapeTouch[state.tapeStart] = contacts;
state.tapeStart = (state.tapeStart + 1) % TAPE_CAP;
state.tapeFirstFrame++;
}
Expand Down Expand Up @@ -249,6 +275,19 @@ function exportTape(): Tape {
if (analog.length > 1 || (analog.length === 1 && analog[0][0] !== ANALOG_CENTER)) {
tape.analog = analog;
}
// Touch upgrades the tape to v2 only when a recorded frame actually had
// contacts — touch-free exports stay v:1 byte-identical.
if (state.tapeTouch) {
const touch: [number, number[]][] = [];
for (let i = 0; i < state.tapeLen; i++) {
const contacts = state.tapeTouch[(state.tapeStart + i) % TAPE_CAP];
if (contacts) touch.push([i, contacts.slice()]);
}
if (touch.length > 0) {
tape.v = 2;
tape.touch = touch;
}
}
return tape;
}

Expand Down Expand Up @@ -277,6 +316,18 @@ export function expandTapeAnalog(tape: Tape): Uint16Array {
return expandPairs(tape.analog ?? [], ANALOG_CENTER, total);
}

/** Expand a tape's sparse touch track into one packed-contact array (or
* undefined) per frame. A v1 tape yields all-undefined — no contacts. */
export function expandTapeTouch(tape: Tape): (number[] | undefined)[] {
let total = 0;
for (const [, n] of tape.masks) total += n;
const out = new Array<number[] | undefined>(total).fill(undefined);
for (const [frame, contacts] of tape.touch ?? []) {
if (frame >= 0 && frame < total) out[frame] = contacts;
}
return out;
}

// ---------------------------------------------------------------------------
// protocol
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -397,6 +448,7 @@ function handleMessage(line: string): void {
if (tape && Array.isArray(tape.masks)) {
state.replayMasks = expandTape(tape);
state.replayAnalog = tape.analog ? expandTapeAnalog(tape) : null;
state.replayTouch = tape.touch ? expandTapeTouch(tape) : null;
state.replayAt = 0;
}
break;
Expand Down Expand Up @@ -604,6 +656,7 @@ const api = {
replay: (tape: Tape): void => {
state.replayMasks = expandTape(tape);
state.replayAnalog = tape.analog ? expandTapeAnalog(tape) : null;
state.replayTouch = tape.touch ? expandTapeTouch(tape) : null;
state.replayAt = 0;
},
};
61 changes: 56 additions & 5 deletions hosts/sim/sim.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ 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 { __packTouch } from "../../framework/src/touch.ts";

const ROOT = resolve(fileURLToPath(new URL("../..", import.meta.url))); // PocketJS/
const DIST = join(ROOT, "dist/");
Expand All @@ -46,6 +47,10 @@ export interface ScriptEvent {
* frame until the next event that carries `analog` (level-triggered —
* a one-frame nub pulse cannot pan anything). spec ANALOG_CENTER releases. */
analog?: number;
/** Front-panel contacts held from this frame until the next event that
* carries `touch` (level-triggered like `hold`/`analog`); [] releases.
* Logical px; `id` defaults to 0 (the common single-finger case). */
touch?: { id?: number; x: number; y: number }[];
}

export interface Scenario {
Expand Down Expand Up @@ -95,17 +100,19 @@ export function fnv1a(bytes: Uint8Array): string {
return (h >>> 0).toString(16).padStart(8, "0");
}

/** Expand a virtual-seconds script into per-frame masks + analog values. */
/** Expand a virtual-seconds script into per-frame masks + analog + touch. */
export function scriptToMasks(
script: ScriptEvent[],
hz: number,
frames: number,
): { masks: number[]; analogs: number[] } {
): { masks: number[]; analogs: number[]; touches: (number[] | undefined)[] } {
const ANALOG_CENTER = 0x8080; // spec.ts (kept literal — this module stays host-side)
const masks = new Array<number>(frames).fill(0);
const analogs = new Array<number>(frames).fill(ANALOG_CENTER);
const touches = new Array<number[] | undefined>(frames).fill(undefined);
const holds: { f: number; v: number }[] = [];
const nubs: { f: number; v: number }[] = [];
const contacts: { f: number; v: number[] }[] = [];
for (const ev of script) {
const f = Math.round(ev.at * hz);
if (f < 0 || f >= frames) {
Expand All @@ -114,10 +121,14 @@ export function scriptToMasks(
if (ev.press !== undefined) masks[f] |= ev.press;
if (ev.hold !== undefined) holds.push({ f, v: ev.hold });
if (ev.analog !== undefined) nubs.push({ f, v: ev.analog & 0xffff });
if (ev.touch !== undefined) {
contacts.push({ f, v: ev.touch.map((c) => __packTouch(c.id ?? 0, c.x, c.y)) });
}
}
// Level-triggered tracks: each event holds its value until the next one.
holds.sort((a, b) => a.f - b.f);
nubs.sort((a, b) => a.f - b.f);
contacts.sort((a, b) => a.f - b.f);
for (let i = 0; i < holds.length; i++) {
const end = i + 1 < holds.length ? holds[i + 1].f : frames;
for (let f = holds[i].f; f < end; f++) masks[f] |= holds[i].v;
Expand All @@ -126,7 +137,47 @@ export function scriptToMasks(
const end = i + 1 < nubs.length ? nubs[i + 1].f : frames;
for (let f = nubs[i].f; f < end; f++) analogs[f] = nubs[i].v;
}
return { masks, analogs };
for (let i = 0; i < contacts.length; i++) {
const end = i + 1 < contacts.length ? contacts[i + 1].f : frames;
const v = contacts[i].v.length > 0 ? contacts[i].v : undefined;
for (let f = contacts[i].f; f < end; f++) touches[f] = v;
}
return { masks, analogs, touches };
}

/**
* One finger gliding from (x0, y0) to (x1, y1) between t0 and t1 virtual
* seconds: per-frame linear interpolation on the frame grid (int-rounded
* px), released at t1. Composable — spread into a scenario script:
* script: [...touchGlide(240, 200, 240, 80, 1, 1.5), { at: 3, press: … }]
*/
export function touchGlide(
x0: number,
y0: number,
x1: number,
y1: number,
t0: number,
t1: number,
id = 0,
): 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);
for (let f = f0; f < f1; f++) {
const t = (f - f0) / (f1 - f0);
out.push({
at: f / 60,
touch: [
{ id, x: Math.round(x0 + (x1 - x0) * t), y: Math.round(y0 + (y1 - y0) * t) },
],
});
}
out.push({ at: f1 / 60, touch: [] });
return out;
}

function ensureBuilt(path: string, cmd: string[]): void {
Expand Down Expand Up @@ -237,7 +288,7 @@ export async function runScenario(scenario: Scenario, chaos?: ChaosOptions): Pro
throw new Error(`sim: hz=${scenario.hz} does not divide ${TICKS_PER_SECOND}`);
}
const frames = Math.round(scenario.seconds * hz);
const { masks, analogs } = scriptToMasks(scenario.script ?? [], hz, frames);
const { masks, analogs, touches } = scriptToMasks(scenario.script ?? [], hz, frames);
const world = await bootWorld(scenario.app, hz);
const hashes: string[] = [];
let garbage: unknown[] = [];
Expand All @@ -250,7 +301,7 @@ export async function runScenario(scenario: Scenario, chaos?: ChaosOptions): Pro
if (garbage.length > 64) garbage = [];
if (chaos.gcEvery && f % chaos.gcEvery === chaos.gcEvery - 1) Bun.gc(true);
}
world.frame(masks[f], analogs[f]); // one virtual-frame transaction (JS side)
world.frame(masks[f], analogs[f], touches[f]); // one virtual-frame transaction (JS side)
for (let t = 0; t < world.ticksPerFrame; t++) world.tick(); // core catch-up
hashes.push(fnv1a(world.render()));
}
Expand Down
Loading