From bd3b2a6a468887d598524f4c9b25f3ad0ea5df41 Mon Sep 17 00:00:00 2001 From: "Yifeng \"Evan\" Wang" <7312949+doodlewind@users.noreply.github.com> Date: Thu, 23 Jul 2026 18:02:07 +0800 Subject: [PATCH] feat(devtools): tape v2 sparse touch track MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tapes recorded only buttons + analog, and replay force-dropped touch (`touch = undefined`) — touch interactions were unrecordable and unreplayable, which blocks deterministic testing of any touch UX. Tape v2 adds a sparse `touch: [frameIndex, packed[]][]` track (contacts vary per frame mid-drag, so RLE degenerates; idle frames cost zero bytes). The recorder ring allocates lazily on the first frame that has contacts — every touch-free session (all of PSP) records exactly as before and still exports v:1 byte-identical. Replay now owns all three tracks: a v2 tape drives touches() frame-exact, a v1 tape replays every frame as no-contacts, and live hardware contacts never leak into either. hosts/sim: ScriptEvent gains a level-triggered `touch` track (same convention as hold/analog; [] releases), scriptToMasks expands it to packed words, and touchGlide() emits a per-frame linear glide on the 60 Hz grid so a fling is one line in a scenario. runScenario feeds the third frame arg. tools/tape: record grows --touch "f:id,x,y;f:-" (level-triggered); replay and tree now feed all three expanded tracks — this also fixes the analog track being silently dropped in CLI replays. Tests: sparse export shape, v1 byte-stability, v2 replay determinism + live-leak suppression, expandTapeTouch round-trip, script touch levels, touchGlide grid math. Sim suite stays green. Co-Authored-By: Claude Fable 5 --- framework/src/devtools.ts | 67 +++++++++++++++++++++++++--- hosts/sim/sim.ts | 61 +++++++++++++++++++++++--- tests/devtools.test.ts | 92 ++++++++++++++++++++++++++++++++++++++- tests/tiles.test.ts | 30 ++++++++++++- tools/tape.ts | 50 ++++++++++++++++++--- 5 files changed, 280 insertions(+), 20 deletions(-) diff --git a/framework/src/devtools.ts b/framework/src/devtools.ts index 84f483ca..de2346bf 100644 --- a/framework/src/devtools.ts +++ b/framework/src/devtools.ts @@ -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; @@ -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; } @@ -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; @@ -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, @@ -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; @@ -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 }); } } @@ -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); @@ -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(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++; } @@ -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; } @@ -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(total).fill(undefined); + for (const [frame, contacts] of tape.touch ?? []) { + if (frame >= 0 && frame < total) out[frame] = contacts; + } + return out; +} + // --------------------------------------------------------------------------- // protocol // --------------------------------------------------------------------------- @@ -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; @@ -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; }, }; diff --git a/hosts/sim/sim.ts b/hosts/sim/sim.ts index 5a1a716c..f0551d7f 100644 --- a/hosts/sim/sim.ts +++ b/hosts/sim/sim.ts @@ -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/"); @@ -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 { @@ -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(frames).fill(0); const analogs = new Array(frames).fill(ANALOG_CENTER); + const touches = new Array(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) { @@ -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; @@ -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 { @@ -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[] = []; @@ -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())); } diff --git a/tests/devtools.test.ts b/tests/devtools.test.ts index ae831017..cafa15fe 100644 --- a/tests/devtools.test.ts +++ b/tests/devtools.test.ts @@ -15,7 +15,8 @@ if (Bun.resolveSync("solid-js", import.meta.dir).endsWith("server.js")) { import { installHost, type Host, type HostOps } from "../framework/src/host.ts"; import { render as publicRender } from "../framework/src/index.ts"; -import { expandTape, fmt, type Tape } from "../framework/src/devtools.ts"; +import { expandTape, expandTapeTouch, fmt, type Tape } from "../framework/src/devtools.ts"; +import { touches, __packTouch } from "../framework/src/touch.ts"; import { onFrame } from "../framework/src/lifecycle.ts"; import { createComponent, @@ -333,6 +334,95 @@ describe("tape", () => { }); }); +describe("tape v2 touch track", () => { + function frameTouch(buttons: number, touches?: readonly number[]): void { + (globalThis as { frame?: (b: number, a?: number, t?: readonly number[]) => void }).frame!( + buttons, + undefined, + touches, + ); + } + + test("a touch-free session still exports v:1 with no touch key", () => { + mountApp(() => View({})); + frame(BTN.UP); + frame(0); + push({ t: "dumpTape" }); + frame(0); + const tape = sent("tape")[0].tape as Tape; + expect(tape.v).toBe(1); + expect("touch" in tape).toBe(false); + }); + + test("contact frames export a sparse v:2 track", () => { + mountApp(() => View({})); + frameTouch(0); // frame 0: no contacts + frameTouch(0, [__packTouch(1, 100, 50)]); // frame 1 + frameTouch(0, [__packTouch(1, 110, 60), __packTouch(2, 30, 30)]); // frame 2 + frameTouch(0); // frame 3: released + push({ t: "dumpTape" }); + frame(0); + const tape = sent("tape")[0].tape as Tape; + expect(tape.v).toBe(2); + expect(tape.frames).toBe(4); + expect(tape.touch).toEqual([ + [1, [__packTouch(1, 100, 50)]], + [2, [__packTouch(1, 110, 60), __packTouch(2, 30, 30)]], + ]); + }); + + test("v2 replay drives touches(); live hardware contacts never leak", () => { + const seen: number[] = []; + mountApp(() => { + onFrame(() => seen.push(touches().length)); + return View({}); + }); + const packed = __packTouch(1, 200, 100); + push({ + t: "replay", + tape: { v: 2, frames: 3, masks: [[0, 3]], touch: [[1, [packed]]] } satisfies Tape, + }); + // Live contacts supplied on every frame — replay must own the track: + frameTouch(0, [__packTouch(7, 1, 1)]); // tape frame 0: no contacts + frameTouch(0, [__packTouch(7, 1, 1)]); // tape frame 1: the tape's contact + frameTouch(0, [__packTouch(7, 1, 1)]); // tape frame 2: no contacts + frameTouch(0, [__packTouch(7, 1, 1)]); // exhausted: live input again + expect(seen).toEqual([0, 1, 0, 1]); + }); + + test("a v1 tape replays every frame as no-contacts", () => { + const seen: number[] = []; + mountApp(() => { + onFrame(() => seen.push(touches().length)); + return View({}); + }); + push({ t: "replay", tape: { v: 1, frames: 2, masks: [[0, 2]] } satisfies Tape }); + frameTouch(0, [__packTouch(7, 1, 1)]); + frameTouch(0, [__packTouch(7, 1, 1)]); + expect(seen).toEqual([0, 0]); + }); + + test("recorded touch round-trips through export + replay byte-exactly", () => { + mountApp(() => View({})); + frameTouch(0, [__packTouch(3, 10, 20)]); + frameTouch(0, [__packTouch(3, 12, 24)]); + frameTouch(0); + push({ t: "dumpTape" }); + frame(0); + const exported = sent("tape")[0].tape as Tape; + const expanded = expandTapeTouch(exported); + expect(expanded[0]).toEqual([__packTouch(3, 10, 20)]); + expect(expanded[1]).toEqual([__packTouch(3, 12, 24)]); + expect(expanded[2]).toBeUndefined(); + expect(expanded[3]).toBeUndefined(); // the dump-poll frame + }); + + test("expandTapeTouch on a v1 tape is all undefined", () => { + const tape: Tape = { v: 1, frames: 3, masks: [[0, 3]] }; + expect(expandTapeTouch(tape)).toEqual([undefined, undefined, undefined]); + }); +}); + describe("errors + formatting", () => { test("a throwing frame reports to the channel and still rethrows", () => { let boom = false; diff --git a/tests/tiles.test.ts b/tests/tiles.test.ts index 8fcfe978..803a6d6e 100644 --- a/tests/tiles.test.ts +++ b/tests/tiles.test.ts @@ -20,7 +20,8 @@ import { pack } from "../framework/compiler/pak.ts"; import { installHost, type Host, type HostOps } from "../framework/src/host.ts"; import { loadPack, resetPack } from "../framework/src/pak.ts"; import { freeTileTexture, loadTileTexture, resetTilesetCache } from "../framework/src/tiles.ts"; -import { scriptToMasks } from "../hosts/sim/sim.ts"; +import { scriptToMasks, touchGlide } from "../hosts/sim/sim.ts"; +import { __packTouch } from "../framework/src/touch.ts"; // --------------------------------------------------------------------------- // helpers: hand-build a 2x1 TILESET (tile 0 textured+RLE, tile 1 solid) @@ -221,3 +222,30 @@ test("scriptToMasks: press pulses, hold levels, analog levels", () => { ANALOG_CENTER, ]); }); + +test("scriptToMasks: touch levels hold until the next event; [] releases", () => { + const { touches } = scriptToMasks( + [ + { at: 1, touch: [{ x: 100, y: 50 }] }, + { at: 3, touch: [] }, + ], + 1, + 5, + ); + const packed = __packTouch(0, 100, 50); + expect(touches).toEqual([undefined, [packed], [packed], undefined, undefined]); +}); + +test("touchGlide interpolates per frame on the 60 Hz grid and releases", () => { + const events = touchGlide(240, 200, 240, 140, 1, 1.1); // 6 frames of travel + expect(events).toHaveLength(7); + expect(events[0]).toEqual({ at: 1, touch: [{ id: 0, x: 240, y: 200 }] }); + expect(events[3].touch).toEqual([{ id: 0, x: 240, y: 170 }]); + expect(events[6]).toEqual({ at: 1.1, touch: [] }); + + const { touches } = scriptToMasks(events, 60, 120); + expect(touches[59]).toBeUndefined(); + expect(touches[60]).toEqual([__packTouch(0, 240, 200)]); + expect(touches[65]).toEqual([__packTouch(0, 240, 150)]); + expect(touches[66]).toBeUndefined(); // released at t1 +}); diff --git a/tools/tape.ts b/tools/tape.ts index 6d8798d1..d4964dc1 100644 --- a/tools/tape.ts +++ b/tools/tape.ts @@ -19,7 +19,13 @@ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node import { fileURLToPath } from "node:url"; import { join, resolve } from "node:path"; import { createWasmUi } from "../hosts/web/wasm-ops.js"; -import { expandTape, type Tape } from "../framework/src/devtools.ts"; +import { + expandTape, + expandTapeAnalog, + expandTapeTouch, + type Tape, +} from "../framework/src/devtools.ts"; +import { __packTouch } from "../framework/src/touch.ts"; import { encodePNG } from "../tests/png.ts"; import { SCREEN_H, SCREEN_W } from "../contracts/spec/spec.ts"; @@ -70,7 +76,7 @@ function fnv1a(bytes: Uint8Array): string { } interface BootResult { - frame: (buttons: number) => void; + frame: (buttons: number, analog?: number, touches?: readonly number[]) => void; tick: () => void; render: () => Uint8Array; outbox: string[]; @@ -98,7 +104,7 @@ async function boot(app: string): Promise { }; const src = await Bun.file(RUNTIME_DIST + app + ".js").text(); (0, eval)(src); - const frame = g.frame as ((buttons: number) => void) | undefined; + const frame = g.frame as BootResult["frame"] | undefined; if (typeof frame !== "function") { throw new Error("bundle did not install globalThis.frame (does the entry call render()?)"); } @@ -139,6 +145,8 @@ const [, , cmd, app, tapePathArg] = process.argv; async function cmdReplay(): Promise { const tape = loadTape(tapePathArg); const masks = expandTape(tape); + const analogs = expandTapeAnalog(tape); + const touches = expandTapeTouch(tape); const hashesOut = argValue("--hashes"); const assertPath = argValue("--assert"); const pngFrames = new Set( @@ -153,7 +161,7 @@ async function cmdReplay(): Promise { if (pngFrames.size) mkdirSync(outdir, { recursive: true }); const hashes: string[] = []; for (let f = 0; f < masks.length; f++) { - b.frame(masks[f]); + b.frame(masks[f], analogs[f], touches[f]); b.tick(); const fb = b.render(); const h = fnv1a(fb); @@ -189,11 +197,13 @@ async function cmdReplay(): Promise { async function cmdTree(): Promise { const tape = loadTape(tapePathArg); const masks = expandTape(tape); + const analogs = expandTapeAnalog(tape); + const touches = expandTapeTouch(tape); const at = Number(argValue("--at") ?? masks.length); const upTo = Math.min(at, masks.length); const b = await boot(app); for (let f = 0; f < upTo; f++) { - b.frame(masks[f]); + b.frame(masks[f], analogs[f], touches[f]); b.tick(); } b.outbox.length = 0; @@ -229,6 +239,34 @@ async function cmdRecord(): Promise { else masks.push([m, 1]); } const tape: Tape = { v: 1, app, frames, masks, startFrame: 0 }; + // Touch script (level-triggered): "frame:id,x,y[+id,x,y…]" entries joined + // by ';', "frame:-" releases. e.g. --touch "12:0,240,136;20:-" + const touchArg = argValue("--touch"); + if (touchArg) { + const events: { f: number; contacts: number[] }[] = []; + for (const entry of touchArg.split(";").filter(Boolean)) { + const [fStr, spec] = entry.split(":"); + const contacts = + spec === "-" + ? [] + : spec.split("+").map((triple) => { + const [id, x, y] = triple.split(",").map(Number); + return __packTouch(id, x, y); + }); + events.push({ f: Number(fStr), contacts }); + } + events.sort((a, b) => a.f - b.f); + const touch: [number, number[]][] = []; + for (let i = 0; i < events.length; i++) { + if (events[i].contacts.length === 0) continue; + const end = Math.min(i + 1 < events.length ? events[i + 1].f : frames, frames); + for (let f = events[i].f; f < end; f++) touch.push([f, events[i].contacts]); + } + if (touch.length > 0) { + tape.v = 2; + tape.touch = touch; + } + } writeFileSync(out, JSON.stringify(tape) + "\n"); console.log(`tape: wrote ${out} (${frames} frames)`); } @@ -239,7 +277,7 @@ else if (cmd === "record" && app) await cmdRecord(); else { console.log( "usage:\n" + - ' bun tools/tape.ts record --frames N [--input "f:mask,..."] --out t.json\n' + + ' bun tools/tape.ts record --frames N [--input "f:mask,..."] [--touch "f:id,x,y;f:-"] --out t.json\n' + " bun tools/tape.ts replay [--hashes out.json | --assert hashes.json | --png f1,f2 [--outdir d]]\n" + " bun tools/tape.ts tree --at N", );