diff --git a/src/devkit/macroStore.ts b/src/devkit/macroStore.ts new file mode 100644 index 0000000..e8b3241 --- /dev/null +++ b/src/devkit/macroStore.ts @@ -0,0 +1,147 @@ +/** + * DEVKIT — Macro recorder/player for fake controller inputs. + * + * Records fakeInput poses + grip/trigger at ~30Hz to a JSON-serializable + * timeline. Replays by writing values back into fakeInput. Used to reproduce + * bugs reported by the headset tester. + */ +import * as THREE from 'three'; +import { fakeInput } from './fakeInputStore'; + +export interface MacroFrame { + t: number; // ms since start + l: { m: number[]; g: number; t: number }; + r: { m: number[]; g: number; t: number }; +} + +export interface Macro { + name: string; + duration: number; + frames: MacroFrame[]; +} + +type Phase = 'idle' | 'recording' | 'playing'; + +interface State { + phase: Phase; + recStart: number; + rec: MacroFrame[]; + play: { macro: Macro; start: number; idx: number } | null; + current: Macro | null; + listeners: Set<() => void>; +} + +export const macroState: State = { + phase: 'idle', + recStart: 0, + rec: [], + play: null, + current: null, + listeners: new Set(), +}; + +function notify() { for (const l of macroState.listeners) l(); } + +export function subscribeMacro(cb: () => void) { + macroState.listeners.add(cb); + return () => macroState.listeners.delete(cb); +} + +export function startRecording() { + macroState.phase = 'recording'; + macroState.recStart = performance.now(); + macroState.rec = []; + notify(); +} + +export function stopRecording(): Macro { + const macro: Macro = { + name: `macro-${new Date().toISOString().slice(11, 19)}`, + duration: performance.now() - macroState.recStart, + frames: macroState.rec.slice(), + }; + macroState.phase = 'idle'; + macroState.current = macro; + notify(); + return macro; +} + +export function startPlaying(macro?: Macro) { + const m = macro ?? macroState.current; + if (!m || m.frames.length === 0) return; + macroState.play = { macro: m, start: performance.now(), idx: 0 }; + macroState.phase = 'playing'; + notify(); +} + +export function stopPlaying() { + macroState.play = null; + macroState.phase = 'idle'; + notify(); +} + +export function tickMacro() { + if (macroState.phase === 'recording') { + const t = performance.now() - macroState.recStart; + // throttle to ~30Hz + const last = macroState.rec[macroState.rec.length - 1]; + if (!last || t - last.t > 33) { + macroState.rec.push({ + t, + l: { m: matrixToArray(fakeInput.left.gripWorld), g: fakeInput.left.gripValue, t: fakeInput.left.triggerValue }, + r: { m: matrixToArray(fakeInput.right.gripWorld), g: fakeInput.right.gripValue, t: fakeInput.right.triggerValue }, + }); + } + } else if (macroState.phase === 'playing' && macroState.play) { + const t = performance.now() - macroState.play.start; + const frames = macroState.play.macro.frames; + while ( + macroState.play.idx < frames.length - 1 && + frames[macroState.play.idx + 1].t <= t + ) { + macroState.play.idx++; + } + const f = frames[macroState.play.idx]; + if (f) { + arrayToMatrix(f.l.m, fakeInput.left.gripWorld); + fakeInput.left.gripValue = f.l.g; + fakeInput.left.triggerValue = f.l.t; + arrayToMatrix(f.r.m, fakeInput.right.gripWorld); + fakeInput.right.gripValue = f.r.g; + fakeInput.right.triggerValue = f.r.t; + } + if (t > macroState.play.macro.duration) stopPlaying(); + } +} + +export function isPlaybackActive() { + return macroState.phase === 'playing'; +} + +function matrixToArray(m: THREE.Matrix4): number[] { + return Array.from(m.elements); +} +function arrayToMatrix(arr: number[], target: THREE.Matrix4) { + target.fromArray(arr); +} + +export function downloadMacro(macro: Macro) { + const blob = new Blob([JSON.stringify(macro, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${macro.name}.json`; + a.click(); + URL.revokeObjectURL(url); +} + +export function loadMacroFromFile(file: File): Promise { + return new Promise((resolve, reject) => { + const r = new FileReader(); + r.onload = () => { + try { resolve(JSON.parse(String(r.result)) as Macro); } catch (e) { reject(e); } + }; + r.onerror = reject; + r.readAsText(file); + }); +}