diff --git a/docs/replay-logs.md b/docs/replay-logs.md index 6694e47..b7d0059 100644 --- a/docs/replay-logs.md +++ b/docs/replay-logs.md @@ -87,3 +87,33 @@ using standard CSV quoting. For spreadsheet safety, CSV cells whose text starts with `=`, `+`, `-`, or `@`, including after whitespace or control characters, are prefixed with an apostrophe. This changes only the presentation cell; `event_json` retains the original event values for lossless import and analysis. + +## Verify recorded command transitions + +Use offline verification to check whether each logged command's immediate +controller input state matches its recorded `stateAfter`: + +```bash +opencontroller replay verify ./replays/session-001/events.jsonl +``` + +For each command event, the verifier applies the command to that event's +`stateBefore` and compares `buttons`, `analogButtons`, `triggers`, `sticks`, +`dpad`, `touchpad`, and `motion` with `stateAfter`. It checks events +independently, so the log does not need to contain a complete state chain. +Timed commands are checked at their logged boundary: a timed press is checked +while pressed, and its later logged release is checked as a separate event. +Verification does not wait for `durationMs`, synthesize a release, open an +adapter, or send input to a host. + +The output reports `timestamp`, controller and session identity, connection +state, `updatedAt`, `status`, and `feedback` as context that it cannot verify. +Legacy events missing a profile, state snapshot, or command data and commands +that cannot be simulated under a supported profile are reported as +unverifiable with their one-based event and line numbers. A mismatch reports +the first differing deterministic field path. Malformed JSON or event records +are errors with their line and event location. + +This verifies OpenController's recorded state semantics only. It cannot +confirm adapter handoff, whether a host received a command, or any GUI or game +behavior or outcome. diff --git a/packages/cli/src/__tests__/fixtures/replay-verify-legacy.jsonl b/packages/cli/src/__tests__/fixtures/replay-verify-legacy.jsonl new file mode 100644 index 0000000..91c20ac --- /dev/null +++ b/packages/cli/src/__tests__/fixtures/replay-verify-legacy.jsonl @@ -0,0 +1 @@ +{"type":"command","timestamp":1,"controllerId":"pad-1","profile":"xbox","command":{"type":"press","button":"A"}} diff --git a/packages/cli/src/__tests__/fixtures/replay-verify-malformed-state-event.jsonl b/packages/cli/src/__tests__/fixtures/replay-verify-malformed-state-event.jsonl new file mode 100644 index 0000000..252f284 --- /dev/null +++ b/packages/cli/src/__tests__/fixtures/replay-verify-malformed-state-event.jsonl @@ -0,0 +1,2 @@ +{"type":"annotation","timestamp":10,"label":"valid preceding event"} +{"type":"state","timestamp":11} diff --git a/packages/cli/src/__tests__/fixtures/replay-verify-malformed.jsonl b/packages/cli/src/__tests__/fixtures/replay-verify-malformed.jsonl new file mode 100644 index 0000000..0bac59e --- /dev/null +++ b/packages/cli/src/__tests__/fixtures/replay-verify-malformed.jsonl @@ -0,0 +1 @@ +not-json diff --git a/packages/cli/src/__tests__/fixtures/replay-verify-mismatch.jsonl b/packages/cli/src/__tests__/fixtures/replay-verify-mismatch.jsonl new file mode 100644 index 0000000..df4d107 --- /dev/null +++ b/packages/cli/src/__tests__/fixtures/replay-verify-mismatch.jsonl @@ -0,0 +1 @@ +{"type":"command","timestamp":1,"controllerId":"pad-1","profile":"xbox","command":{"type":"press","button":"A","durationMs":60000},"stateBefore":{"id":"pad-1","profile":"xbox","connected":false,"buttons":{"A":false,"B":false,"X":false,"Y":false,"LB":false,"RB":false,"LT":false,"RT":false,"BACK":false,"START":false,"GUIDE":false,"LS":false,"RS":false,"DPAD_UP":false,"DPAD_DOWN":false,"DPAD_LEFT":false,"DPAD_RIGHT":false},"analogButtons":{"LT":0,"RT":0},"sticks":{"left":{"x":0,"y":0},"right":{"x":0,"y":0}},"dpad":{"up":false,"down":false,"left":false,"right":false},"touchpad":{"pressed":false,"contacts":[]},"motion":{"acceleration":{"x":0,"y":0,"z":0},"gyroscope":{"x":0,"y":0,"z":0},"orientation":{"x":0,"y":0,"z":0}},"status":{"battery":{"level":1,"charging":false,"wired":true,"low":false},"connection":{"quality":1,"latencyMs":0,"packetLoss":0}},"feedback":{"rumble":{"weakMotor":0,"strongMotor":0,"leftTriggerMotor":0,"rightTriggerMotor":0},"lights":{"red":0,"green":0,"blue":0,"brightness":0,"playerIndex":0,"playerLightMask":0}},"updatedAt":1791160985387},"stateAfter":{"id":"pad-1","profile":"xbox","connected":false,"buttons":{"A":false,"B":false,"X":false,"Y":false,"LB":false,"RB":false,"LT":false,"RT":false,"BACK":false,"START":false,"GUIDE":false,"LS":false,"RS":false,"DPAD_UP":false,"DPAD_DOWN":false,"DPAD_LEFT":false,"DPAD_RIGHT":false},"analogButtons":{"LT":0,"RT":0},"sticks":{"left":{"x":0,"y":0},"right":{"x":0,"y":0}},"dpad":{"up":false,"down":false,"left":false,"right":false},"touchpad":{"pressed":false,"contacts":[]},"motion":{"acceleration":{"x":0,"y":0,"z":0},"gyroscope":{"x":0,"y":0,"z":0},"orientation":{"x":0,"y":0,"z":0}},"status":{"battery":{"level":1,"charging":false,"wired":true,"low":false},"connection":{"quality":1,"latencyMs":0,"packetLoss":0}},"feedback":{"rumble":{"weakMotor":0,"strongMotor":0,"leftTriggerMotor":0,"rightTriggerMotor":0},"lights":{"red":0,"green":0,"blue":0,"brightness":0,"playerIndex":0,"playerLightMask":0}},"updatedAt":999}} diff --git a/packages/cli/src/__tests__/fixtures/replay-verify-timed.jsonl b/packages/cli/src/__tests__/fixtures/replay-verify-timed.jsonl new file mode 100644 index 0000000..e410ac4 --- /dev/null +++ b/packages/cli/src/__tests__/fixtures/replay-verify-timed.jsonl @@ -0,0 +1,2 @@ +{"type":"command","timestamp":1,"controllerId":"pad-1","profile":"xbox","command":{"type":"press","button":"A","durationMs":60000},"stateBefore":{"id":"pad-1","profile":"xbox","connected":false,"buttons":{"A":false,"B":false,"X":false,"Y":false,"LB":false,"RB":false,"LT":false,"RT":false,"BACK":false,"START":false,"GUIDE":false,"LS":false,"RS":false,"DPAD_UP":false,"DPAD_DOWN":false,"DPAD_LEFT":false,"DPAD_RIGHT":false},"analogButtons":{"LT":0,"RT":0},"sticks":{"left":{"x":0,"y":0},"right":{"x":0,"y":0}},"dpad":{"up":false,"down":false,"left":false,"right":false},"touchpad":{"pressed":false,"contacts":[]},"motion":{"acceleration":{"x":0,"y":0,"z":0},"gyroscope":{"x":0,"y":0,"z":0},"orientation":{"x":0,"y":0,"z":0}},"status":{"battery":{"level":1,"charging":false,"wired":true,"low":false},"connection":{"quality":1,"latencyMs":0,"packetLoss":0}},"feedback":{"rumble":{"weakMotor":0,"strongMotor":0,"leftTriggerMotor":0,"rightTriggerMotor":0},"lights":{"red":0,"green":0,"blue":0,"brightness":0,"playerIndex":0,"playerLightMask":0}},"updatedAt":1791160985387},"stateAfter":{"id":"context-id-0","profile":"xbox","connected":true,"buttons":{"A":true,"B":false,"X":false,"Y":false,"LB":false,"RB":false,"LT":false,"RT":false,"BACK":false,"START":false,"GUIDE":false,"LS":false,"RS":false,"DPAD_UP":false,"DPAD_DOWN":false,"DPAD_LEFT":false,"DPAD_RIGHT":false},"analogButtons":{"LT":0,"RT":0},"sticks":{"left":{"x":0,"y":0},"right":{"x":0,"y":0}},"dpad":{"up":false,"down":false,"left":false,"right":false},"touchpad":{"pressed":false,"contacts":[]},"motion":{"acceleration":{"x":0,"y":0,"z":0},"gyroscope":{"x":0,"y":0,"z":0},"orientation":{"x":0,"y":0,"z":0}},"status":{"hostStatus":"context-0"},"feedback":{"rumble":"context-0"},"updatedAt":999}} +{"type":"command","timestamp":60002,"controllerId":"pad-1","profile":"xbox","command":{"type":"release","button":"A"},"stateBefore":{"id":"pad-1","profile":"xbox","connected":false,"buttons":{"A":true,"B":false,"X":false,"Y":false,"LB":false,"RB":false,"LT":false,"RT":false,"BACK":false,"START":false,"GUIDE":false,"LS":false,"RS":false,"DPAD_UP":false,"DPAD_DOWN":false,"DPAD_LEFT":false,"DPAD_RIGHT":false},"analogButtons":{"LT":0,"RT":0},"sticks":{"left":{"x":0,"y":0},"right":{"x":0,"y":0}},"dpad":{"up":false,"down":false,"left":false,"right":false},"touchpad":{"pressed":false,"contacts":[]},"motion":{"acceleration":{"x":0,"y":0,"z":0},"gyroscope":{"x":0,"y":0,"z":0},"orientation":{"x":0,"y":0,"z":0}},"status":{"battery":{"level":1,"charging":false,"wired":true,"low":false},"connection":{"quality":1,"latencyMs":0,"packetLoss":0}},"feedback":{"rumble":{"weakMotor":0,"strongMotor":0,"leftTriggerMotor":0,"rightTriggerMotor":0},"lights":{"red":0,"green":0,"blue":0,"brightness":0,"playerIndex":0,"playerLightMask":0}},"updatedAt":999},"stateAfter":{"id":"context-id-1","profile":"xbox","connected":true,"buttons":{"A":false,"B":false,"X":false,"Y":false,"LB":false,"RB":false,"LT":false,"RT":false,"BACK":false,"START":false,"GUIDE":false,"LS":false,"RS":false,"DPAD_UP":false,"DPAD_DOWN":false,"DPAD_LEFT":false,"DPAD_RIGHT":false},"analogButtons":{"LT":0,"RT":0},"sticks":{"left":{"x":0,"y":0},"right":{"x":0,"y":0}},"dpad":{"up":false,"down":false,"left":false,"right":false},"touchpad":{"pressed":false,"contacts":[]},"motion":{"acceleration":{"x":0,"y":0,"z":0},"gyroscope":{"x":0,"y":0,"z":0},"orientation":{"x":0,"y":0,"z":0}},"status":{"hostStatus":"context-1"},"feedback":{"rumble":"context-1"},"updatedAt":1000}} diff --git a/packages/cli/src/__tests__/fixtures/replay-verify-unsupported.jsonl b/packages/cli/src/__tests__/fixtures/replay-verify-unsupported.jsonl new file mode 100644 index 0000000..7cdc335 --- /dev/null +++ b/packages/cli/src/__tests__/fixtures/replay-verify-unsupported.jsonl @@ -0,0 +1,3 @@ +{"type":"command","timestamp":1,"controllerId":"pad-1","profile":"xbox","command":{"type":"future-command"}} +{"type":"command","timestamp":2,"controllerId":"pad-1","profile":"arcade","command":{"type":"press","button":"A"}} +{"type":"command","timestamp":3,"controllerId":"pad-1","profile":"xbox","command":{"type":"setButton","button":"A"}} diff --git a/packages/cli/src/__tests__/replay-verify.test.ts b/packages/cli/src/__tests__/replay-verify.test.ts new file mode 100644 index 0000000..1c3e930 --- /dev/null +++ b/packages/cli/src/__tests__/replay-verify.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, test } from "bun:test"; +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { verifyReplay } from "../commands/replay-verify"; + +const fixtures = join(import.meta.dir, "fixtures"); + +describe("replay verify", () => { + test("verifies timed press and separate release at their logged boundaries", async () => { + const result = await verifyReplay( + join(fixtures, "replay-verify-timed.jsonl"), + ); + + expect(result).toEqual({ events: 2, verified: 2, unverifiable: [] }); + }); + + test("reports the first differing control field with event and line", async () => { + const result = await verifyReplay( + join(fixtures, "replay-verify-mismatch.jsonl"), + ); + + expect(result.mismatch).toEqual({ + event: 1, + line: 1, + field: "buttons.A", + }); + expect(result.verified).toBe(0); + }); + + test("reports malformed JSON with one-based line and event", async () => { + const input = join(fixtures, "replay-verify-malformed.jsonl"); + const content = await readFile(input, "utf8"); + expect(content).toContain("not-json"); + await expect(verifyReplay(input)).rejects.toThrow(`at line 1 (event 1)`); + }); + + test("rejects malformed recognized state events with line and event location", async () => { + const input = join(fixtures, "replay-verify-malformed-state-event.jsonl"); + + await expect(verifyReplay(input)).rejects.toThrow( + `Invalid state event in ${input} at line 2 (event 2): expected controllerId and state`, + ); + }); + + test("marks legacy commands unverifiable rather than inferring missing state", async () => { + const result = await verifyReplay( + join(fixtures, "replay-verify-legacy.jsonl"), + ); + + expect(result.verified).toBe(0); + expect(result.unverifiable).toEqual([ + { + event: 1, + line: 1, + reason: "legacy command is missing stateBefore", + }, + ]); + }); + + test("reports unsupported profiles, command types, and missing command fields", async () => { + const result = await verifyReplay( + join(fixtures, "replay-verify-unsupported.jsonl"), + ); + + expect(result.unverifiable.map(({ reason }) => reason)).toEqual([ + 'unsupported command type "future-command"', + 'unsupported or missing profile "arcade"', + "command is missing required boolean pressed", + ]); + }); +}); diff --git a/packages/cli/src/commands/replay-verify.ts b/packages/cli/src/commands/replay-verify.ts new file mode 100644 index 0000000..05c3056 --- /dev/null +++ b/packages/cli/src/commands/replay-verify.ts @@ -0,0 +1,697 @@ +import { createReadStream } from "node:fs"; +import { createInterface } from "node:readline"; +import type { + ControllerCommand, + ControllerProfileName, + ControllerState, + ControllerStatePatch, +} from "@opencontroller/core"; +import { + ControllerStateStore, + normalizeCommand, + resolveProfile, +} from "@opencontroller/core"; + +const profiles = [ + "xbox", + "playstation", + "switch", + "generic-hid", + "keyboard-mouse", +] as const; + +const contextFields = [ + "timestamp", + "sessionId, controllerId / state.id", + "state.connected", + "state.updatedAt", + "state.status", + "state.feedback", +] as const; + +const commandTypes = new Set([ + "press", + "release", + "setButton", + "stick", + "setStick", + "trigger", + "setTrigger", + "dpad", + "setDpad", + "setState", + "setStatus", + "touchpad", + "motion", + "wait", + "neutral", +]); + +export type ReplayVerifyResult = { + events: number; + verified: number; + unverifiable: Array<{ event: number; line: number; reason: string }>; + mismatch?: { event: number; line: number; field: string }; +}; + +/** Verify each logged command at its recorded immediate state boundary. */ +export async function verifyReplay( + inputPath: string, +): Promise { + const lines = createInterface({ + input: createReadStream(inputPath), + crlfDelay: Number.POSITIVE_INFINITY, + }); + const result: ReplayVerifyResult = { + events: 0, + verified: 0, + unverifiable: [], + }; + let lineNumber = 0; + + for await (const line of lines) { + lineNumber += 1; + if (!line.trim()) continue; + result.events += 1; + const eventNumber = result.events; + const event = parseEvent(line, inputPath, lineNumber, eventNumber); + if (event.type !== "command") continue; + + const unverifiable = (reason: string): void => { + result.unverifiable.push({ + event: eventNumber, + line: lineNumber, + reason, + }); + }; + + if (!isRecord(event.command) || typeof event.command.type !== "string") { + unverifiable("legacy command is missing command.type"); + continue; + } + if (!commandTypes.has(event.command.type)) { + unverifiable( + `unsupported command type ${JSON.stringify(event.command.type)}`, + ); + continue; + } + const commandError = validateCommand(event.command); + if (commandError) { + unverifiable(`command ${commandError}`); + continue; + } + if (typeof event.profile !== "string" || !isProfile(event.profile)) { + unverifiable( + `unsupported or missing profile ${JSON.stringify(event.profile)}`, + ); + continue; + } + if (!isRecord(event.stateBefore)) { + unverifiable("legacy command is missing stateBefore"); + continue; + } + if (!isRecord(event.stateAfter)) { + unverifiable("legacy command is missing stateAfter"); + continue; + } + const stateError = validateControlState(event.stateBefore, event.profile); + if (stateError) { + unverifiable(`stateBefore ${stateError}`); + continue; + } + const afterError = validateControlState(event.stateAfter, event.profile); + if (afterError) { + unverifiable(`stateAfter ${afterError}`); + continue; + } + let predicted: { + state: ControllerState; + triggers?: Record; + }; + try { + predicted = applyImmediateTransition( + event.profile, + typeof event.controllerId === "string" + ? event.controllerId + : typeof event.stateBefore.id === "string" + ? event.stateBefore.id + : "offline-replay", + event.command, + event.stateBefore, + ); + } catch (error) { + unverifiable( + `unsupported or malformed ${event.command.type} command: ${errorMessage(error)}`, + ); + continue; + } + + const difference = firstControlDifference(predicted, event.stateAfter); + if (difference) { + result.mismatch = { + event: eventNumber, + line: lineNumber, + field: difference, + }; + break; + } + result.verified += 1; + } + + return result; +} + +export async function replayVerifyCommand( + inputPath: string | undefined, +): Promise { + if (!inputPath) { + throw new Error("Usage: opencontroller replay verify "); + } + const result = await verifyReplay(inputPath); + console.log("OpenController Replay Verification"); + console.log(` file: ${inputPath}`); + console.log(` events: ${result.events}`); + console.log(` verified commands: ${result.verified}`); + console.log(` unverifiable commands: ${result.unverifiable.length}`); + console.log(` context not compared: ${contextFields.join(", ")}`); + for (const item of result.unverifiable) { + console.log( + ` UNVERIFIABLE event ${item.event} line ${item.line}: ${item.reason}`, + ); + } + if (result.mismatch) { + console.log( + ` MISMATCH event ${result.mismatch.event} line ${result.mismatch.line}: ${result.mismatch.field}`, + ); + process.exitCode = 1; + } else if (result.unverifiable.length > 0) { + console.log(" result: incomplete (some commands could not be verified)"); + process.exitCode = 1; + } else { + console.log(" result: verified"); + } +} + +function parseEvent( + line: string, + path: string, + lineNumber: number, + eventNumber: number, +): Record { + let value: unknown; + try { + value = JSON.parse(line); + } catch { + throw new Error( + `Invalid JSON in replay file ${path} at line ${lineNumber} (event ${eventNumber})`, + ); + } + if (!isRecord(value) || typeof value.type !== "string") { + throw new Error( + `Invalid replay event in ${path} at line ${lineNumber} (event ${eventNumber}): expected an object with type`, + ); + } + if ( + !["command", "state", "feedback", "error", "annotation"].includes( + value.type, + ) + ) { + throw new Error( + `Unsupported replay event type ${JSON.stringify(value.type)} in ${path} at line ${lineNumber} (event ${eventNumber})`, + ); + } + if ( + typeof value.timestamp !== "number" || + !Number.isFinite(value.timestamp) + ) { + throw new Error( + `Invalid replay event in ${path} at line ${lineNumber} (event ${eventNumber}): timestamp must be a finite number`, + ); + } + switch (value.type) { + case "state": + if (typeof value.controllerId !== "string" || !isRecord(value.state)) { + throw new Error( + `Invalid state event in ${path} at line ${lineNumber} (event ${eventNumber}): expected controllerId and state`, + ); + } + break; + case "feedback": + if ( + typeof value.controllerId !== "string" || + !isRecord(value.feedback) || + !isRecord(value.stateAfter) + ) { + throw new Error( + `Invalid feedback event in ${path} at line ${lineNumber} (event ${eventNumber}): expected controllerId, feedback, and stateAfter`, + ); + } + break; + case "error": + if ( + typeof value.controllerId !== "string" || + typeof value.error !== "string" + ) { + throw new Error( + `Invalid error event in ${path} at line ${lineNumber} (event ${eventNumber}): expected controllerId and error`, + ); + } + break; + case "annotation": + if (typeof value.label !== "string") { + throw new Error( + `Invalid annotation event in ${path} at line ${lineNumber} (event ${eventNumber}): expected label`, + ); + } + break; + } + return value; +} + +function applyImmediateTransition( + profileName: ControllerProfileName, + controllerId: string, + rawCommand: Record, + stateBefore: Record, +): { state: ControllerState; triggers?: Record } { + const profile = resolveProfile(profileName); + const command = rawCommand as unknown as ControllerCommand; + const normalized = normalizeCommand(profile, controllerId, command).command; + const before = stateBefore as unknown as ControllerState; + const store = new ControllerStateStore(controllerId, profile); + store.applyPatch(stateToPatch(before)); + + let state: ControllerState; + switch (normalized.type) { + case "press": + state = store.setButton(normalized.button, true, normalized.pressure); + break; + case "release": + state = store.setButton(normalized.button, false, 0); + break; + case "setButton": + state = store.setButton( + normalized.button, + normalized.pressed, + normalized.pressed ? normalized.pressure : 0, + ); + break; + case "stick": + case "setStick": + state = store.setStick(normalized.stick, normalized.x, normalized.y); + break; + case "trigger": + case "setTrigger": + state = store.setTrigger(normalized.trigger, normalized.value); + break; + case "dpad": + state = store.setDpad(normalized.direction, true); + break; + case "setDpad": + state = store.setDpadState(normalized.direction); + break; + case "setState": + state = store.applyPatch(normalized.state); + break; + case "setStatus": + state = store.setStatus(normalized.status); + break; + case "touchpad": + state = store.setTouchpad( + normalized.contacts ?? [], + normalized.pressed ?? false, + ); + break; + case "motion": + state = store.setMotion(normalized); + break; + case "wait": + state = store.getState(); + break; + case "neutral": + state = store.neutral(); + break; + case "combo": + case "sequence": + throw new Error( + `command ${normalized.type} is not an event-boundary command`, + ); + } + + const triggers = isNumberRecord(stateBefore.triggers) + ? { ...stateBefore.triggers } + : undefined; + if (triggers) { + switch (normalized.type) { + case "press": + if (normalized.button in triggers) { + triggers[normalized.button] = normalized.pressure ?? 1; + } + break; + case "release": + if (normalized.button in triggers) triggers[normalized.button] = 0; + break; + case "setButton": + if (normalized.button in triggers) { + triggers[normalized.button] = normalized.pressed + ? (normalized.pressure ?? 1) + : 0; + } + break; + case "trigger": + case "setTrigger": + triggers[normalized.trigger] = normalized.value; + break; + case "setState": + Object.assign(triggers, normalized.state.triggers ?? {}); + break; + case "neutral": + for (const trigger of Object.keys(triggers)) triggers[trigger] = 0; + break; + } + } + return { state, ...(triggers ? { triggers } : {}) }; +} + +function stateToPatch(state: ControllerState): ControllerStatePatch { + const buttons: NonNullable = {}; + for (const [button, pressed] of Object.entries(state.buttons)) { + const pressure = state.analogButtons[button]; + buttons[button] = pressure === undefined ? pressed : { pressed, pressure }; + } + return { + buttons, + triggers: state.analogButtons, + sticks: { + LEFT: state.sticks.left, + RIGHT: state.sticks.right, + }, + dpad: dpadDirectionFromState(state.dpad), + touchpad: state.touchpad, + motion: state.motion, + }; +} + +function dpadDirectionFromState( + dpad: ControllerState["dpad"], +): NonNullable { + const directions = [ + ...(dpad.up ? ["UP"] : []), + ...(dpad.down ? ["DOWN"] : []), + ...(dpad.left ? ["LEFT"] : []), + ...(dpad.right ? ["RIGHT"] : []), + ]; + if (directions.length === 0) return "NEUTRAL"; + const combined = directions.join("_"); + const valid = [ + "UP", + "DOWN", + "LEFT", + "RIGHT", + "UP_LEFT", + "UP_RIGHT", + "DOWN_LEFT", + "DOWN_RIGHT", + ]; + if (!valid.includes(combined)) { + throw new Error("stateBefore has an unsupported dpad combination"); + } + return combined as NonNullable; +} + +function validateControlState( + value: Record, + profileName: ControllerProfileName, +): string | undefined { + const profile = resolveProfile(profileName); + if (!isBooleanRecord(value.buttons)) return "is missing a valid buttons map"; + if (!isNumberRecord(value.analogButtons)) { + return "is missing a valid analogButtons map"; + } + for (const button of profile.buttons) { + if (typeof value.buttons[button] !== "boolean") { + return `buttons.${button} is required and must be boolean`; + } + } + for (const trigger of profile.triggers) { + if (typeof value.analogButtons[trigger] !== "number") { + return `analogButtons.${trigger} is required and must be finite`; + } + } + if ( + !isRecord(value.sticks) || + !isVector2(value.sticks.left) || + !isVector2(value.sticks.right) + ) { + return "is missing valid sticks.left/right coordinates"; + } + if (!isBooleanRecord(value.dpad)) return "is missing a valid dpad map"; + for (const direction of ["up", "down", "left", "right"] as const) { + if (typeof value.dpad[direction] !== "boolean") { + return `dpad.${direction} is required and must be boolean`; + } + } + if ( + !isRecord(value.touchpad) || + typeof value.touchpad.pressed !== "boolean" || + !Array.isArray(value.touchpad.contacts) + ) { + return "is missing valid touchpad.pressed/contacts"; + } + if (!isRecord(value.motion)) return "is missing a valid motion map"; + for (const axis of ["acceleration", "gyroscope", "orientation"] as const) { + if (!isVector3(value.motion[axis])) + return `motion.${axis} is required and must be finite`; + } + if (value.triggers !== undefined && !isNumberRecord(value.triggers)) { + return "has an invalid triggers map"; + } + return; +} + +function validateCommand(command: Record): string | undefined { + const type = command.type; + const string = (key: string): boolean => + typeof command[key] === "string" && command[key].length > 0; + const number = (key: string): boolean => isFiniteNumber(command[key]); + const optionalNumber = (key: string): boolean => + command[key] === undefined || number(key); + const duration = (): boolean => + optionalNumber("durationMs") && + (command.durationMs === undefined || (command.durationMs as number) >= 0); + const direction = (key: string): boolean => + [ + "UP", + "DOWN", + "LEFT", + "RIGHT", + "UP_LEFT", + "UP_RIGHT", + "DOWN_LEFT", + "DOWN_RIGHT", + ].includes(command[key] as string); + const vector = (key: string): boolean => + command[key] === undefined || isVector3(command[key]); + + switch (type) { + case "press": + if (!string("button")) return "is missing required string button"; + if (!optionalNumber("pressure")) return "has invalid pressure"; + if (!duration()) return "has invalid durationMs"; + return; + case "release": + return string("button") ? undefined : "is missing required string button"; + case "setButton": + if (!string("button")) return "is missing required string button"; + if (typeof command.pressed !== "boolean") + return "is missing required boolean pressed"; + if (!optionalNumber("pressure")) return "has invalid pressure"; + return; + case "stick": + case "setStick": + if (command.stick !== "LEFT" && command.stick !== "RIGHT") { + return "has invalid stick (expected LEFT or RIGHT)"; + } + if (!number("x") || !number("y")) + return "is missing finite x/y coordinates"; + if (type === "stick" && !duration()) return "has invalid durationMs"; + return; + case "trigger": + case "setTrigger": + if (!string("trigger") || !number("value")) { + return "is missing required trigger and finite value"; + } + if (type === "trigger" && !duration()) return "has invalid durationMs"; + return; + case "dpad": + if (!direction("direction")) return "has invalid dpad direction"; + if (!duration()) return "has invalid durationMs"; + return; + case "setDpad": + if (command.direction !== "NEUTRAL" && !direction("direction")) { + return "has invalid dpad direction"; + } + return; + case "setState": + return isRecord(command.state) + ? undefined + : "is missing required state object"; + case "setStatus": + return isRecord(command.status) + ? undefined + : "is missing required status object"; + case "touchpad": + if ( + command.pressed !== undefined && + typeof command.pressed !== "boolean" + ) { + return "has invalid pressed value"; + } + if (command.contacts !== undefined && !Array.isArray(command.contacts)) { + return "has invalid contacts array"; + } + if (!duration()) return "has invalid durationMs"; + return; + case "motion": + if ( + !vector("acceleration") || + !vector("gyroscope") || + !vector("orientation") + ) { + return "has invalid motion vector"; + } + if (!duration()) return "has invalid durationMs"; + return; + case "wait": + return number("ms") ? undefined : "is missing required finite ms"; + case "neutral": + return; + default: + return `has unsupported command type ${JSON.stringify(type)}`; + } +} + +function firstControlDifference( + predicted: { state: ControllerState; triggers?: Record }, + actual: Record, +): string | undefined { + const predictedControls: Record = { + buttons: predicted.state.buttons, + analogButtons: predicted.state.analogButtons, + sticks: predicted.state.sticks, + dpad: predicted.state.dpad, + touchpad: predicted.state.touchpad, + motion: predicted.state.motion, + }; + for (const field of [ + "buttons", + "analogButtons", + "triggers", + "sticks", + "dpad", + "touchpad", + "motion", + ]) { + if (field === "triggers") { + if ("triggers" in actual || predicted.triggers !== undefined) { + const mismatch = compareValue( + "triggers", + predicted.triggers, + actual.triggers, + ); + if (mismatch) return mismatch; + } + continue; + } + const mismatch = compareValue( + field, + predictedControls[field], + actual[field], + ); + if (mismatch) return mismatch; + } + return; +} + +function compareValue( + path: string, + expected: unknown, + actual: unknown, +): string | undefined { + if (Object.is(expected, actual)) return; + if (Array.isArray(expected) && Array.isArray(actual)) { + if (expected.length !== actual.length) return `${path}.length`; + for (let index = 0; index < expected.length; index += 1) { + const mismatch = compareValue( + `${path}[${index}]`, + expected[index], + actual[index], + ); + if (mismatch) return mismatch; + } + return; + } + if (isRecord(expected) && isRecord(actual)) { + const keys = [ + ...new Set([...Object.keys(expected), ...Object.keys(actual)]), + ].sort(); + for (const key of keys) { + if (!(key in expected) || !(key in actual)) return `${path}.${key}`; + const mismatch = compareValue( + `${path}.${key}`, + expected[key], + actual[key], + ); + if (mismatch) return mismatch; + } + return; + } + return path; +} + +function isProfile(value: string): value is ControllerProfileName { + return profiles.includes(value as (typeof profiles)[number]); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isBooleanRecord(value: unknown): value is Record { + return ( + isRecord(value) && + Object.values(value).every((entry) => typeof entry === "boolean") + ); +} + +function isNumberRecord(value: unknown): value is Record { + return ( + isRecord(value) && + Object.values(value).every( + (entry) => typeof entry === "number" && Number.isFinite(entry), + ) + ); +} + +function isVector2(value: unknown): value is { x: number; y: number } { + return isRecord(value) && isFiniteNumber(value.x) && isFiniteNumber(value.y); +} + +function isVector3( + value: unknown, +): value is { x: number; y: number; z: number } { + return ( + isRecord(value) && + isFiniteNumber(value.x) && + isFiniteNumber(value.y) && + isFiniteNumber(value.z) + ); +} + +function isFiniteNumber(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value); +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 8b10d89..2511849 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -6,6 +6,7 @@ import { nativeCommand } from "./commands/native"; import { overlayCommand } from "./commands/overlay"; import { replayCommand } from "./commands/replay"; import { replayExportCommand } from "./commands/replay-export"; +import { replayVerifyCommand } from "./commands/replay-verify"; import { testCommand } from "./commands/test"; type Flags = Record; @@ -79,6 +80,10 @@ async function main(argv: string[]): Promise { flags.format, flags.output, ); + } else if (rest[0] === "verify") { + await replayVerifyCommand( + rest.slice(1).find((arg) => !arg.startsWith("--")), + ); } else { await replayCommand(rest.find((arg) => !arg.startsWith("--"))); } @@ -178,6 +183,7 @@ Usage: opencontroller test --profile xbox --adapter dry-run opencontroller overlay --profile xbox --port 4317 opencontroller replay ./replays/session/events.jsonl + opencontroller replay verify ./replays/session/events.jsonl opencontroller replay export ./replays/session/events.jsonl --format csv --output replay.csv opencontroller bridge --id player-1 opencontroller native doctor --backend current