From 67767506f33ce08bb18b7d213d5f720ba930e596 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Tue, 4 Aug 2026 16:02:58 -0700 Subject: [PATCH] Record and store OSKIEWAR demo streams --- ac-m4l/AC-GameSignals.amxd.json | 8 +- ac-m4l/devices.json | 2 +- .../backend/tests/oskiewar-replays.test.mjs | 24 +++ system/netlify/functions/oskiewar-replays.mjs | 138 ++++++++++++++++++ xbox/live/hello.js | 120 ++++++++++++++- xbox/live/tests/hello.test.mjs | 37 ++++- xbox/native-bios/App.cpp | 82 +++++++++-- xbox/native-bios/Package.appxmanifest | 2 +- xbox/native-bios/QuickJsEngine.cpp | 17 +++ xbox/native-bios/README.md | 5 + .../tests/quickjs_engine_smoke.cpp | 9 +- xbox/runtime/include/ac/runtime.hpp | 3 + 12 files changed, 419 insertions(+), 28 deletions(-) create mode 100644 system/backend/tests/oskiewar-replays.test.mjs create mode 100644 system/netlify/functions/oskiewar-replays.mjs diff --git a/ac-m4l/AC-GameSignals.amxd.json b/ac-m4l/AC-GameSignals.amxd.json index c9453648b1..ce9ee85e09 100644 --- a/ac-m4l/AC-GameSignals.amxd.json +++ b/ac-m4l/AC-GameSignals.amxd.json @@ -18,6 +18,7 @@ { "box": { "id": "cc-label", "maxclass": "comment", "text": "WACK D#2 BOOT E2 CROSS F2 BALLED G2", "fontsize": 11, "textcolor": [0.78, 0.78, 0.78, 1.0], "patching_rect": [15, 82, 335, 20], "presentation": 1, "presentation_rect": [15, 82, 335, 20] } }, { "box": { "id": "udp", "maxclass": "newobj", "text": "udpreceive 51338 @defer 0 @outputformat message @quiet 1", "numinlets": 0, "numoutlets": 1, "outlettype": [""], "patching_rect": [20, 180, 360, 22] } }, { "box": { "id": "fan", "maxclass": "newobj", "text": "t l l b", "numinlets": 1, "numoutlets": 3, "outlettype": ["list", "list", "bang"], "patching_rect": [20, 210, 70, 22] } }, + { "box": { "id": "dedupe", "maxclass": "newobj", "text": "zl.change", "numinlets": 2, "numoutlets": 2, "outlettype": ["list", "int"], "patching_rect": [20, 240, 70, 22] } }, { "box": { "id": "seq", "maxclass": "newobj", "text": "zl.nth 5", "numinlets": 2, "numoutlets": 2, "outlettype": ["", ""], "patching_rect": [100, 210, 70, 22] } }, { "box": { "id": "seq-num", "maxclass": "number", "numinlets": 1, "numoutlets": 2, "outlettype": ["", "bang"], "patching_rect": [180, 210, 70, 22], "presentation": 1, "presentation_rect": [320, 35, 35, 20] } }, { "box": { "id": "seq-label", "maxclass": "comment", "text": "SEQ", "fontsize": 10, "patching_rect": [180, 185, 40, 20], "presentation": 1, "presentation_rect": [292, 35, 30, 20] } }, @@ -26,7 +27,7 @@ { "box": { "id": "rx-label", "maxclass": "comment", "text": "RX", "fontsize": 10, "patching_rect": [330, 185, 35, 20], "presentation": 1, "presentation_rect": [230, 35, 22, 20] } }, { "box": { "id": "load", "maxclass": "newobj", "text": "loadbang", "numinlets": 1, "numoutlets": 1, "outlettype": ["bang"], "patching_rect": [410, 180, 60, 22] } }, { "box": { "id": "queue", "maxclass": "message", "text": "maxqueuesize 2048", "numinlets": 2, "numoutlets": 1, "patching_rect": [410, 210, 130, 22] } }, - { "box": { "id": "route", "maxclass": "newobj", "text": "route /oskiewar/move /oskiewar/jump /oskiewar/ultrajump /oskiewar/dash /oskiewar/fastdrop /oskiewar/ko /oskiewar/roundwin /oskiewar/matchwin /oskiewar/tie /oskiewar/kick /oskiewar/punch /oskiewar/shield /oskiewar/block /oskiewar/wind /oskiewar/ballserve /oskiewar/wack /oskiewar/boot /oskiewar/crosswack /oskiewar/ballblock /oskiewar/balled /oskiewar/hello", "numinlets": 1, "numoutlets": 22, "outlettype": ["", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", ""], "patching_rect": [20, 240, 1180, 22] } }, + { "box": { "id": "route", "maxclass": "newobj", "text": "route /oskiewar/move /oskiewar/jump /oskiewar/ultrajump /oskiewar/dash /oskiewar/fastdrop /oskiewar/ko /oskiewar/roundwin /oskiewar/matchwin /oskiewar/tie /oskiewar/kick /oskiewar/punch /oskiewar/shield /oskiewar/block /oskiewar/wind /oskiewar/ballserve /oskiewar/wack /oskiewar/boot /oskiewar/crosswack /oskiewar/ballblock /oskiewar/balled /oskiewar/hello", "numinlets": 1, "numoutlets": 22, "outlettype": ["", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", ""], "patching_rect": [100, 240, 1180, 22] } }, { "box": { "id": "raw-send", "maxclass": "newobj", "text": "s ac-game-signals-osc", "numinlets": 1, "numoutlets": 0, "patching_rect": [550, 210, 145, 22] } }, { "box": { "id": "n42", "maxclass": "message", "text": "42", "numinlets": 2, "numoutlets": 1, "patching_rect": [20, 290, 35, 22] } }, { "box": { "id": "n36", "maxclass": "message", "text": "36", "numinlets": 2, "numoutlets": 1, "patching_rect": [65, 290, 35, 22] } }, @@ -57,8 +58,9 @@ ], "lines": [ { "patchline": { "source": ["udp", 0], "destination": ["fan", 0] } }, - { "patchline": { "source": ["fan", 0], "destination": ["route", 0] } }, - { "patchline": { "source": ["fan", 0], "destination": ["raw-send", 0] } }, + { "patchline": { "source": ["fan", 0], "destination": ["dedupe", 0] } }, + { "patchline": { "source": ["dedupe", 0], "destination": ["route", 0] } }, + { "patchline": { "source": ["dedupe", 0], "destination": ["raw-send", 0] } }, { "patchline": { "source": ["fan", 1], "destination": ["seq", 0] } }, { "patchline": { "source": ["seq", 0], "destination": ["seq-num", 0] } }, { "patchline": { "source": ["fan", 2], "destination": ["rx", 0] } }, diff --git a/ac-m4l/devices.json b/ac-m4l/devices.json index 805c4e67ba..6fd8cd0fe7 100644 --- a/ac-m4l/devices.json +++ b/ac-m4l/devices.json @@ -9,7 +9,7 @@ "height": 120, "type": "midi", "source": "AC-GameSignals.amxd.json", - "version": "1.1.1" + "version": "1.1.2" }, { "name": "notepat.com", diff --git a/system/backend/tests/oskiewar-replays.test.mjs b/system/backend/tests/oskiewar-replays.test.mjs new file mode 100644 index 0000000000..c73fa2272a --- /dev/null +++ b/system/backend/tests/oskiewar-replays.test.mjs @@ -0,0 +1,24 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { validateDemo } from "../../netlify/functions/oskiewar-replays.mjs"; + +const demo = { + format: "ac.oskiedemo", version: 1, game: "oskiewar", + simulation: "oskiewar-physics-1", tickRate: 60, + matchId: "ow-m123-abc", startedAt: 1785870000000, durationTicks: 1200, + fighters: ["@JEFFREY", "DUMMY"], winner: "@JEFFREY", + finalRoundWins: [5, 0], commands: [[0, 0, 0], [5, 0, 18]], + events: [[5, "kick", 0, 1, 0]], rounds: [[0, 1, 12, 0]], + checkpoints: [Array(26).fill(0)], +}; + +test("accepts a bounded versioned demo stream", () => { + assert.equal(validateDemo(demo), null); +}); + +test("rejects unknown formats and malformed command streams", () => { + assert.equal(validateDemo({ ...demo, format: "quake-demo" }), + "Unsupported demo format"); + assert.equal(validateDemo({ ...demo, commands: [[0, 0, 999]] }), + "Invalid commands"); +}); diff --git a/system/netlify/functions/oskiewar-replays.mjs b/system/netlify/functions/oskiewar-replays.mjs new file mode 100644 index 0000000000..4abc27a70f --- /dev/null +++ b/system/netlify/functions/oskiewar-replays.mjs @@ -0,0 +1,138 @@ +// oskiewar-replays, 26.08.04 +// Stores and serves compact, versioned AC demo streams. + +import { createHmac } from "node:crypto"; +import { connect } from "../../backend/database.mjs"; +import { respond } from "../../backend/http.mjs"; + +const COLLECTION = "oskiewar-replays"; +const MAX_BYTES = 524288; + +const finite = (value, limit = 1000000000) => + Number.isFinite(value) && Math.abs(value) <= limit; +const numericRow = (row, length) => Array.isArray(row) && row.length === length && + row.every((value) => finite(value)); + +export function validateDemo(value) { + if (!value || value.format !== "ac.oskiedemo" || value.version !== 1 || + value.game !== "oskiewar" || value.simulation !== "oskiewar-physics-1" || + value.tickRate !== 60) return "Unsupported demo format"; + if (!/^ow-[a-z0-9]+-[a-z0-9]+$/.test(value.matchId || "")) + return "Invalid matchId"; + if (!finite(value.startedAt, 10000000000000) || + !Number.isInteger(value.durationTicks) || value.durationTicks < 0 || + value.durationTicks > 216000) return "Invalid timing"; + if (!Array.isArray(value.fighters) || value.fighters.length !== 2 || + value.fighters.some((fighter) => typeof fighter !== "string" || + !/^@?[A-Z0-9_-]{1,24}$/i.test(fighter))) return "Invalid fighters"; + if (value.winner !== null && value.winner !== undefined && + !value.fighters.includes(value.winner)) return "Invalid winner"; + if (!Array.isArray(value.finalRoundWins) || value.finalRoundWins.length !== 2 || + value.finalRoundWins.some((score) => !Number.isInteger(score) || + score < 0 || score > 5)) return "Invalid result"; + if (!Array.isArray(value.commands) || value.commands.length > 50000 || + value.commands.some((row) => !numericRow(row, 3) || + !Number.isInteger(row[0]) || ![0, 1].includes(row[1]) || + !Number.isInteger(row[2]) || row[2] < 0 || row[2] > 255)) + return "Invalid commands"; + if (!Array.isArray(value.events) || value.events.length > 10000 || + value.events.some((row) => !Array.isArray(row) || row.length !== 5 || + !finite(row[0]) || typeof row[1] !== "string" || + !/^[a-z0-9_-]{1,32}$/.test(row[1]) || + !finite(row[2]) || !finite(row[3]) || !finite(row[4]))) + return "Invalid events"; + if (!Array.isArray(value.checkpoints) || value.checkpoints.length > 4000 || + value.checkpoints.some((row) => !numericRow(row, 26))) + return "Invalid checkpoints"; + if (!Array.isArray(value.rounds) || value.rounds.length > 128 || + value.rounds.some((row) => !numericRow(row, 4))) return "Invalid rounds"; + return null; +} + +function sourceDigest(event) { + const headers = event.headers || {}; + const ip = headers["cf-connecting-ip"] || + headers["x-forwarded-for"]?.split(",")[0]?.trim() || "unknown"; + const secret = process.env.REPLAY_HASH_SECRET || process.env.JWT_SECRET || + "ac-replay-rate-v1"; + return createHmac("sha256", secret).update(ip).digest("hex"); +} + +function publicReplay(document) { + if (!document) return null; + const { sourceDigest: _, ...safe } = document; + return { ...safe, id: document._id, _id: undefined }; +} + +export async function handler(event) { + if (event.httpMethod === "OPTIONS") return respond(204, ""); + let database; + try { + database = await connect(); + const collection = database.db.collection(COLLECTION); + + if (event.httpMethod === "GET") { + const params = event.queryStringParameters || {}; + if (params.id) { + const replay = await collection.findOne({ _id: params.id }); + await database.disconnect(); + return replay ? respond(200, { replay: publicReplay(replay) }) + : respond(404, { error: "Replay not found" }); + } + const limit = Math.min(50, Math.max(1, Number(params.limit || 20))); + const [matchesPlayed, latest, rows] = await Promise.all([ + collection.countDocuments({}), + collection.find({}).sort({ recordedAt: -1 }).limit(1).next(), + collection.find({}, { projection: { commands: 0, events: 0, + checkpoints: 0, rounds: 0, sourceDigest: 0 } }) + .sort({ recordedAt: -1 }).limit(limit).toArray(), + ]); + await database.disconnect(); + return respond(200, { + format: "ac.oskiedemo", version: 1, matchesPlayed, + latestAt: latest?.recordedAt || null, + replays: rows.map(publicReplay), + }); + } + + if (event.httpMethod !== "POST") { + await database.disconnect(); + return respond(405, { error: "Method not allowed" }); + } + if (Buffer.byteLength(event.body || "", "utf8") > MAX_BYTES) { + await database.disconnect(); + return respond(413, { error: "Demo exceeds 512 KiB" }); + } + let demo; + try { demo = JSON.parse(event.body || "{}"); } + catch { + await database.disconnect(); + return respond(400, { error: "Invalid JSON" }); + } + const invalid = validateDemo(demo); + if (invalid) { + await database.disconnect(); + return respond(400, { error: invalid }); + } + const digest = sourceDigest(event); + const recent = await collection.countDocuments({ sourceDigest: digest, + recordedAt: { $gte: new Date(Date.now() - 60 * 60 * 1000) } }); + if (recent >= 120) { + await database.disconnect(); + return respond(429, { error: "Replay rate limit reached" }); + } + const recordedAt = new Date(); + const result = await collection.updateOne({ _id: demo.matchId }, { + $setOnInsert: { _id: demo.matchId, ...demo, recordedAt, + sourceDigest: digest }, + }, { upsert: true }); + await database.disconnect(); + return respond(result.upsertedCount ? 201 : 200, { + ok: true, id: demo.matchId, stored: Boolean(result.upsertedCount), + replay: `/api/oskiewar-replays?id=${encodeURIComponent(demo.matchId)}`, + }); + } catch (error) { + if (database) await database.disconnect(); + return respond(500, { error: error.message || "Replay storage failed" }); + } +} diff --git a/xbox/live/hello.js b/xbox/live/hello.js index e05e09ebc9..3a6554ee7a 100644 --- a/xbox/live/hello.js +++ b/xbox/live/hello.js @@ -20,6 +20,10 @@ const roundResultUs = 3000000; const matchResultUs = 5000000; const introDurationUs = 3000000; const matchWins = 5; +const replayTickUs = 16667; +const replayCheckpointUs = 1000000; +const replayButtons = ["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", + "A", "B", "X", "Y"]; let cameraCenter = (worldLeft + worldRight) / 2; let cameraWidth = worldRight - worldLeft; let cameraCenterY = floorY - cameraWidth / cameraAspect / 2; @@ -188,8 +192,92 @@ const selectionPrevious = [[], []]; let windMph = 0; let windDirection = 1; let windAcceleration = 0; +let replay = null; +let replayLastCommand = [-1, -1]; +let replayNextCheckpointAt = 0; + +function demoTick(now) { + return replay ? Math.max(0, Math.round((now - replay.startedMonotonicUs) / + replayTickUs)) : 0; +} + +function startReplay(now) { + const run = runtime(); + const unixMs = run.unixMs || 0; + replay = { + format: "ac.oskiedemo", version: 1, game: "oskiewar", + simulation: "oskiewar-physics-1", tickRate: 60, + matchId: "ow-" + Math.max(0, unixMs).toString(36) + "-" + + Math.floor(Math.random() * 0x1000000).toString(36), + startedAt: unixMs, startedMonotonicUs: now, + fighters: players.map((player) => player.name), + commands: [], events: [], checkpoints: [], rounds: [], + }; + replayLastCommand = [-1, -1]; + replayNextCheckpointAt = now; +} + +function inputCommand(pad) { + const input = quantizedInput(pad); + let mask = 0; + if (input.horizontal < 0) mask |= 1; + if (input.horizontal > 0) mask |= 2; + if (input.vertical > 0) mask |= 4; + if (input.vertical < 0) mask |= 8; + for (let index = 4; index < replayButtons.length; index++) + if (pad.down.includes(replayButtons[index])) mask |= 1 << index; + return mask; +} + +function recordReplayCommands(now) { + if (!replay) return; + for (let pad = 0; pad < players.length; pad++) { + const command = players[pad].npc ? 0 : inputCommand(padSnapshots[pad]); + if (command !== replayLastCommand[pad]) { + replay.commands.push([demoTick(now), pad, command]); + replayLastCommand[pad] = command; + } + } +} + +function replayFlags(player) { + return (player.alive ? 1 : 0) | (player.grounded ? 2 : 0) | + (player.ducking ? 4 : 0) | (player.blocking ? 8 : 0); +} + +function recordReplayCheckpoint(now, force = false) { + if (!replay || (!force && now < replayNextCheckpointAt)) return; + replayNextCheckpointAt = now + replayCheckpointUs; + const values = [demoTick(now)]; + for (const player of players) values.push( + Math.round(player.x), Math.round(player.y), Math.round(player.z), + Math.round(player.vx), Math.round(player.vy), replayFlags(player), + player.score, player.roundWins); + values.push(Math.round(ball.x), Math.round(ball.y), Math.round(ball.z), + Math.round(ball.vx), Math.round(ball.vy), ball.active ? 1 : 0, + Math.round(cameraCenter), Math.round(cameraCenterY), Math.round(cameraWidth)); + replay.checkpoints.push(values); +} + +function finishReplay(now) { + if (!replay) return; + recordReplayCheckpoint(now, true); + replay.durationTicks = demoTick(now); + replay.winner = players[0].roundWins >= matchWins ? players[0].name + : players[1].roundWins >= matchWins ? players[1].name : null; + replay.finalRoundWins = players.map((player) => player.roundWins); + delete replay.startedMonotonicUs; + const payload = JSON.stringify(replay); + if (payload.length <= 524288 && typeof saveReplay === "function") { + saveReplay(payload); + telemetry("REPLAY", "queued " + replay.matchId + " bytes=" + payload.length); + } else telemetry("REPLAY", "not-saved bytes=" + payload.length); + replay = null; +} function emitSignal(event, player = -1, value = 0, value2 = 0) { + if (replay) replay.events.push([demoTick(runtime().monotonicUs), event, + player, Math.round(value * 1000) / 1000, Math.round(value2 * 1000) / 1000]); if (typeof gameSignal === "function") gameSignal(event, player, value, value2); } @@ -266,6 +354,7 @@ function updateSelect(now) { } if (selectionReady[0] && selectionReady[1]) { selecting = false; + startReplay(now); resetRound(now, true); emitSignal("fighters", -1, players[0].rosterIndex, players[1].rosterIndex); } @@ -273,7 +362,8 @@ function updateSelect(now) { function rollWind() { windMph = 4 + Math.floor(Math.random() * 21); - windDirection = Math.random() < .5 ? -1 : 1; + windDirection = windAcceleration === 0 + ? (Math.random() < .5 ? -1 : 1) : -windDirection; windAcceleration = windDirection * windMph * 16; emitSignal("wind", -1, windDirection, windMph); } @@ -281,9 +371,9 @@ function rollWind() { function resetBall(now) { const target = Math.random() < .5 ? 0 : 1; ball.x = (worldLeft + worldRight) / 2; - ball.y = floorY - ball.radius; + ball.y = platformY - 720; ball.z = 0; - ball.vx = target === 0 ? -900 : 900; + ball.vx = target === 0 ? -650 : 650; ball.vy = 0; ball.active = ballEnabled; ball.serveAt = now + introDurationUs + 850000; @@ -349,6 +439,8 @@ function resetRound(now, resetMatch = false) { roundStartedAt = now; rollWind(); resetBall(now); + if (replay) replay.rounds.push([demoTick(now), windDirection, windMph, + ball.vx < 0 ? 0 : 1]); cameraCenter = (worldLeft + worldRight) / 2; cameraWidth = worldRight - worldLeft; cameraCenterY = floorY - cameraWidth / cameraAspect / 2; @@ -460,6 +552,7 @@ function finishRound(now) { roundOverAt = now; for (const player of players) player.vx = 0; drum("clap", 1.2, roundPan); + if (matchOver) finishReplay(now); } function quantizedInput(pad) { @@ -603,6 +696,7 @@ function updateBall(dt, now) { const grounded = ball.y >= floorY - ball.radius - 1 && Math.abs(ball.vy) < 180; if (!grounded) ball.vx += windAcceleration * .45 * dt; ball.vy += 1900 * dt; + const previousY = ball.y; ball.x += ball.vx * dt; ball.y += ball.vy * dt; const inset = wallThickness + ball.radius; @@ -616,6 +710,14 @@ function updateBall(dt, now) { if (ball.y < ceilingY + inset) { ball.y = ceilingY + inset; ball.vy = Math.abs(ball.vy); + } + const platformTop = platformY - ball.radius; + if (ball.vy >= 0 && previousY <= platformTop && ball.y >= platformTop && + ball.x >= platformLeft + ball.radius && + ball.x <= platformRight - ball.radius) { + ball.y = platformTop; + ball.vy = Math.abs(ball.vy) > 180 ? -Math.abs(ball.vy) * .58 : 0; + ball.vx *= .994; } else if (ball.y > floorY - ball.radius) { ball.y = floorY - ball.radius; ball.vy = Math.abs(ball.vy) > 180 ? -Math.abs(ball.vy) * .62 : 0; @@ -842,6 +944,7 @@ function sim() { lastSimAt = now; padSnapshots[0] = gamepad(0); padSnapshots[1] = gamepad(1); + recordReplayCommands(now); if (roundResult) { updateCameraDoll(dt, now); const resultDuration = matchOver ? matchResultUs : roundResultUs; @@ -868,6 +971,7 @@ function sim() { resolveMelee(now); updateCamera(dt); updateCameraDoll(dt, now); + recordReplayCheckpoint(now); for (const impact of impacts) impact.life -= dt; while (impacts.length && impacts[0].life <= 0) impacts.shift(); if (players.some((player) => !player.alive) || roundElapsedUs >= roundDurationUs) @@ -1239,7 +1343,7 @@ function paint() { const floorBase = mixColor([17, 22, 36], [188, 181, 164], visualTheme.light); const floorAlt = mixColor([21, 27, 43], [174, 167, 151], visualTheme.light); const edgeWidth = Math.max(2, wallThickness * cameraScale() * .14); - if (cameraDoll.perspective < .55) { + if (cameraDoll.perspective < .08) { const floorPoint = (x, z) => { const point = projectPoint(x, floorY, z); return { x: clamp(point.x, -4096, 6016), @@ -1253,8 +1357,12 @@ function paint() { Math.min(worldFar, z + 600)); const d = floorPoint(x, Math.min(worldFar, z + 600)); const color = (column + row) % 2 ? floorAlt : floorBase; - triangle(a.x, a.y, b.x, b.y, c.x, c.y, ...color); - triangle(a.x, a.y, c.x, c.y, d.x, d.y, ...color); + const left = clamp(Math.min(a.x, b.x, c.x, d.x), 0, 1920); + const right = clamp(Math.max(a.x, b.x, c.x, d.x), 0, 1920); + const top = clamp(Math.min(a.y, b.y, c.y, d.y), stageTop, stageBottom); + const bottom = clamp(Math.max(a.y, b.y, c.y, d.y), stageTop, stageBottom); + if (right > left && bottom > top) + box(left, top, right - left + 1, bottom - top + 1, ...color); } } } diff --git a/xbox/live/tests/hello.test.mjs b/xbox/live/tests/hello.test.mjs index 5b5c7d5945..77b4d9035a 100644 --- a/xbox/live/tests/hello.test.mjs +++ b/xbox/live/tests/hello.test.mjs @@ -7,6 +7,7 @@ const source = await readFile(new URL("../hello.js", import.meta.url), "utf8"); function createFight(startImmediately = true) { let now = 0; const signals = []; + const replays = []; const triangles = []; const pads = [0, 1].map(() => ({ connected: true, down: [], leftX: 0, leftY: 0 })); const noOp = () => {}; @@ -16,12 +17,13 @@ function createFight(startImmediately = true) { triangles.push(values); }; const fight = new Function( - "runtime", "gamepad", "telemetry", "gameSignal", "drum", "wipe", "box", "line", "triangle", "write", "systemWrite", - `${source}\nreturn { boot, sim, paint, players, ball, runnerWorldGeometry, runnerDistanceToPoint, disableBall: () => { ballEnabled = false; ball.active = false; }, enableBall: () => { ballEnabled = true; ball.active = true; ball.serveAt = 0; ball.safeUntil = 0; ball.safePlayers = 0; }, setWind: (value) => { windAcceleration = value; }, wackBall: () => { players[0].attackKind = "KICK"; returnBall(players[0], runtime().monotonicUs, false); }, crossWackBall: (contact = 1) => crossWackBall(players.map((player) => ({ player, contact })), runtime().monotonicUs), startFight: () => { selecting = false; resetRound(runtime().monotonicUs, true); }, selectionState: () => ({ selecting, ready: selectionReady.slice() }), cameraState: () => ({ cameraWidth, cameraCenterY }), roundState: () => ({ roundResult, roundElapsedUs, matchOver }) };` + "runtime", "gamepad", "telemetry", "gameSignal", "saveReplay", "drum", "wipe", "box", "line", "triangle", "write", "systemWrite", + `${source}\nreturn { boot, sim, paint, players, ball, runnerWorldGeometry, runnerDistanceToPoint, disableBall: () => { ballEnabled = false; ball.active = false; }, enableBall: () => { ballEnabled = true; ball.active = true; ball.serveAt = 0; ball.safeUntil = 0; ball.safePlayers = 0; }, setWind: (value) => { windAcceleration = value; }, windState: () => ({ direction: windDirection, mph: windMph }), nextRound: () => resetRound(runtime().monotonicUs, false), wackBall: () => { players[0].attackKind = "KICK"; returnBall(players[0], runtime().monotonicUs, false); }, crossWackBall: (contact = 1) => crossWackBall(players.map((player) => ({ player, contact })), runtime().monotonicUs), startFight: () => { selecting = false; startReplay(runtime().monotonicUs); resetRound(runtime().monotonicUs, true); }, selectionState: () => ({ selecting, ready: selectionReady.slice() }), cameraState: () => ({ cameraWidth, cameraCenterY }), roundState: () => ({ roundResult, roundElapsedUs, matchOver }) };` )( - () => ({ monotonicUs: now }), + () => ({ monotonicUs: now, unixMs: 1785870000000 + Math.floor(now / 1000) }), (index = 0) => ({ ...pads[index], down: pads[index].down.slice() }), - noOp, (...signal) => signals.push(signal), noOp, noOp, noOp, noOp, + noOp, (...signal) => signals.push(signal), (payload) => replays.push(payload), + noOp, noOp, noOp, noOp, drawTriangle, noOp, noOp ); fight.boot(); @@ -42,7 +44,7 @@ function createFight(startImmediately = true) { fight.startFight(); tick(3000001); } - return { fight, pads, signals, triangles, tick, tap, now: () => now }; + return { fight, pads, signals, replays, triangles, tick, tap, now: () => now }; } test("character select offers the four AC fighters and waits for both pads", () => { @@ -156,6 +158,22 @@ test("a grounded ball ignores wind", () => { assert.equal(signals.some(([event]) => event === "boot"), false); }); +test("each round serves the ball in the air above the center platform", () => { + const { fight } = createFight(); + assert.equal(fight.ball.x, 6000); + assert.ok(fight.ball.y < 10400 - fight.ball.radius); + assert.equal(fight.ball.vy, 0); +}); + +test("wind rerolls and reverses direction every round", () => { + const { fight } = createFight(); + const first = fight.windState(); + fight.nextRound(); + const second = fight.windState(); + assert.equal(second.direction, -first.direction); + assert.ok(second.mph >= 4 && second.mph <= 24); +}); + test("running into a grounded ball boots it instead of killing the player", () => { const { fight, signals, tick } = createFight(); const player = fight.players[0]; @@ -213,7 +231,7 @@ test("round clock can end in a tie and resets", () => { }); test("first to five round wins takes the match", () => { - const { fight, tick } = createFight(); + const { fight, replays, tick } = createFight(); for (let round = 1; round <= 5; round++) { fight.players[0].score = 1; for (let frame = 0; frame < 750; frame++) tick(40000); @@ -226,4 +244,11 @@ test("first to five round wins takes the match", () => { } assert.equal(fight.players[0].roundWins, 0); assert.equal(fight.roundState().roundResult, ""); + assert.equal(replays.length, 1); + const demo = JSON.parse(replays[0]); + assert.equal(demo.format, "ac.oskiedemo"); + assert.equal(demo.version, 1); + assert.equal(demo.winner, "@JEFFREY"); + assert.ok(demo.commands.length > 0); + assert.ok(demo.checkpoints.length > 0); }); diff --git a/xbox/native-bios/App.cpp b/xbox/native-bios/App.cpp index 56423dd988..34805c4dbe 100644 --- a/xbox/native-bios/App.cpp +++ b/xbox/native-bios/App.cpp @@ -348,7 +348,7 @@ public: }; m_sound->get_rate = [this]() { return static_cast(m_sampleRate); }; m_api = std::make_unique(Api{{1920, 1080, 1}, {}, {}, {}, *m_graphics, *m_sound, {}}); - m_api->system.version = "1.0.0.32"; + m_api->system.version = "1.0.0.33"; m_api->telemetry = [](std::string_view line) { std::string safe(line); for (auto& character : safe) if (character == '\n' || character == '\r') character = ' '; @@ -358,12 +358,25 @@ public: m_api->game_signal = [this](std::string_view event, int player, float value, float value2) { std::lock_guard lock(m_gameSignalMutex); - if (m_gameSignalQueue.size() >= 64) { + constexpr std::size_t copies = 3; + if (m_gameSignalQueue.size() + copies > 192) { ++m_gameSignalsDropped; return; } - m_gameSignalQueue.push_back(GameSignalPacket(event, player, value, value2, - ++m_gameSignalSequence)); + const auto packet = GameSignalPacket(event, player, value, value2, + ++m_gameSignalSequence); + for (std::size_t copy = 0; copy < copies; ++copy) + m_gameSignalQueue.push_back(packet); + }; + m_api->replay_save = [this](std::string_view payload) { + std::lock_guard lock(m_replayMutex); + if (m_replayQueue.size() >= 4) { + ++m_replaysDropped; + return; + } + m_replayQueue.emplace_back(payload); + LogTelemetry("AC_NATIVE_REPLAY queued=1 bytes=" + + std::to_string(payload.size())); }; m_photoDisc = std::make_unique(*m_api, [this](std::shared_ptr image) { @@ -391,7 +404,7 @@ public: virtual void Load(String^) {} virtual void Uninitialize() { - DestroyGameSignals(); DestroyNetworkMidi(); DestroyMidi(); DestroyAudio(); + DestroyReplayUploads(); DestroyGameSignals(); DestroyNetworkMidi(); DestroyMidi(); DestroyAudio(); } virtual void Run() { @@ -411,6 +424,7 @@ public: PollMidi(); PollLivePiece(); FlushGameSignals(); + FlushReplayUploads(); RefreshClock(); RefreshAudioPerformance(); m_frameRects.clear(); @@ -1224,7 +1238,7 @@ private: } LogTelemetry("AC_NATIVE_OSKIEWAR_SIGNAL sent=" + std::to_string(sent) + " queued=" + std::to_string(queued) + " dropped=" + - std::to_string(m_gameSignalsDropped.load())); + std::to_string(m_gameSignalsDropped.load()) + " copies=3"); } } catch (Exception^ error) { @@ -1251,6 +1265,51 @@ private: std::to_string(dropped)); } + void FlushReplayUploads() { + if (m_replayWriteInFlight.load()) return; + std::string payload; + { + std::lock_guard lock(m_replayMutex); + if (m_replayQueue.empty()) return; + payload = std::move(m_replayQueue.front()); + m_replayQueue.pop_front(); + } + bool expected = false; + if (!m_replayWriteInFlight.compare_exchange_strong(expected, true)) return; + auto client = ref new HttpClient(); + auto content = ref new HttpStringContent( + ref new String(Wide(payload).c_str()), UnicodeEncoding::Utf8, + ref new String(L"application/json")); + auto uri = ref new Uri(L"https://aesthetic.computer/api/oskiewar-replays"); + create_task(client->PostAsync(uri, content)).then( + [this, client, content](task completed) { + try { + auto response = completed.get(); + if (response->IsSuccessStatusCode) { + const auto uploaded = ++m_replaysUploaded; + LogTelemetry("AC_NATIVE_REPLAY uploaded=" + + std::to_string(uploaded) + " status=" + + std::to_string(static_cast(response->StatusCode))); + } else { + ++m_replaysDropped; + LogTelemetry("AC_NATIVE_REPLAY_UPLOAD_ERROR status=" + + std::to_string(static_cast(response->StatusCode))); + } + } catch (Exception^ error) { + ++m_replaysDropped; + LogTelemetry("AC_NATIVE_REPLAY_UPLOAD_ERROR " + Utf8(error->Message)); + } + m_replayWriteInFlight = false; + FlushReplayUploads(); + }); + } + + void DestroyReplayUploads() { + if (m_api) m_api->replay_save = {}; + std::lock_guard lock(m_replayMutex); + m_replayQueue.clear(); + } + void OnNetworkMidiMessage(DatagramSocket^, DatagramSocketMessageReceivedEventArgs^ args) { if (!args) return; try { @@ -1608,7 +1667,7 @@ private: if (!m_networkClockRequestInFlight.compare_exchange_strong(expected, true)) return; const auto sentAt = SystemUnixMs(); auto client = ref new HttpClient(); - client->DefaultRequestHeaders->UserAgent->ParseAdd("AC-Native-BIOS/1.0.0.30 Xbox ClockSync"); + client->DefaultRequestHeaders->UserAgent->ParseAdd("AC-Native-BIOS/1.0.0.33 Xbox ClockSync"); create_task(client->GetStringAsync( ref new Uri(L"https://aesthetic.computer/api/clock"))) .then([this, client, sentAt](task completed) { @@ -1715,7 +1774,7 @@ private: if (!m_acRequestInFlight.compare_exchange_strong(expected, true)) return; auto client = ref new HttpClient(); - client->DefaultRequestHeaders->UserAgent->ParseAdd("AC-Native-BIOS/1.0.0.32 Xbox"); + client->DefaultRequestHeaders->UserAgent->ParseAdd("AC-Native-BIOS/1.0.0.33 Xbox"); std::vector> requests; const auto safeGet = [client](const std::wstring& url) { return create_task(client->GetStringAsync(ref new Uri(ref new String(url.c_str())))) @@ -2196,7 +2255,7 @@ private: std::vector glyphs(codepoints.size()); std::vector metrics(codepoints.size()); std::vector advances(codepoints.size()); - if (FAILED(m_ywftFontFace->GetGlyphIndicesW(codepoints.data(), + if (FAILED(m_ywftFontFace->GetGlyphIndices(codepoints.data(), static_cast(codepoints.size()), glyphs.data())) || FAILED(m_ywftFontFace->GetDesignGlyphMetrics(glyphs.data(), static_cast(glyphs.size()), metrics.data()))) return; @@ -2400,6 +2459,11 @@ private: std::atomic_uint64_t m_gameSignalsSent{0}; std::atomic_uint64_t m_gameSignalsDropped{0}; std::uint32_t m_gameSignalSequence = 0; + std::mutex m_replayMutex; + std::deque m_replayQueue; + std::atomic_bool m_replayWriteInFlight{false}; + std::atomic_uint64_t m_replaysUploaded{0}; + std::atomic_uint64_t m_replaysDropped{0}; std::vector m_samples; std::vector m_oscSamples; std::vector m_cpuFrame; diff --git a/xbox/native-bios/Package.appxmanifest b/xbox/native-bios/Package.appxmanifest index aa9c9b51cc..7734790a5e 100644 --- a/xbox/native-bios/Package.appxmanifest +++ b/xbox/native-bios/Package.appxmanifest @@ -5,7 +5,7 @@ IgnorableNamespaces="uap mp"> + Version="1.0.0.33" /> diff --git a/xbox/native-bios/QuickJsEngine.cpp b/xbox/native-bios/QuickJsEngine.cpp index 7f6b174430..929fc8459a 100644 --- a/xbox/native-bios/QuickJsEngine.cpp +++ b/xbox/native-bios/QuickJsEngine.cpp @@ -478,6 +478,22 @@ JSValue GameSignal(JSContext* context, JSValueConst, int argc, JSValueConst* arg return JS_UNDEFINED; } +JSValue SaveReplay(JSContext* context, JSValueConst, int argc, JSValueConst* argv) { + auto* scope = static_cast(JS_GetContextOpaque(context)); + if (!scope || !scope->api || !scope->api->replay_save || argc < 1) + return JS_UNDEFINED; + size_t length = 0; + const char* raw = JS_ToCStringLen(context, &length, argv[0]); + if (!raw) return JS_EXCEPTION; + if (length < 2 || length > 524288 || raw[0] != '{') { + JS_FreeCString(context, raw); + return JS_ThrowRangeError(context, "replay must be a JSON object under 512 KiB"); + } + scope->api->replay_save(std::string_view(raw, length)); + JS_FreeCString(context, raw); + return JS_UNDEFINED; +} + JSValue RuntimeInfo(JSContext* context, JSValueConst, int, JSValueConst*) { auto* scope = static_cast(JS_GetContextOpaque(context)); if (!scope || !scope->api) return JS_EXCEPTION; @@ -731,6 +747,7 @@ class QuickJsPiece final : public JsPiece { JS_SetPropertyStr(context_, global, "blur", JS_NewCFunction(context_, Blur, "blur", 1)); JS_SetPropertyStr(context_, global, "telemetry", JS_NewCFunction(context_, Telemetry, "telemetry", 2)); JS_SetPropertyStr(context_, global, "gameSignal", JS_NewCFunction(context_, GameSignal, "gameSignal", 4)); + JS_SetPropertyStr(context_, global, "saveReplay", JS_NewCFunction(context_, SaveReplay, "saveReplay", 1)); JS_SetPropertyStr(context_, global, "runtime", JS_NewCFunction(context_, RuntimeInfo, "runtime", 0)); JS_SetPropertyStr(context_, global, "gamepad", JS_NewCFunction(context_, GamepadState, "gamepad", 1)); JS_SetPropertyStr(context_, global, "controllers", JS_NewCFunction(context_, Controllers, "controllers", 0)); diff --git a/xbox/native-bios/README.md b/xbox/native-bios/README.md index 7adfdcf476..3f144a0a6f 100644 --- a/xbox/native-bios/README.md +++ b/xbox/native-bios/README.md @@ -76,6 +76,11 @@ the native AC block face and `systemWrite` remains the Xbox system face. Revision 32 removes the global chromatic channel offset. The point-sampled scan, one-pixel dither, and vignette remain without separating RGB edges. +Revision 33 exposes bounded `saveReplay` for versioned `.oskiedemo` match +streams. The host accepts one JSON object up to 512 KiB and uploads it to the +fixed AC replay endpoint; pieces receive neither arbitrary networking nor +storage credentials. + Revision 13 also exposes bounded `stampPainting` and `blur` primitives to the trusted host-side KidLisp compiler. See [`../KIDLISP-NATIVE.md`](../KIDLISP-NATIVE.md) for the supported subset and `$obk` wire-deploy flow. diff --git a/xbox/native-bios/tests/quickjs_engine_smoke.cpp b/xbox/native-bios/tests/quickjs_engine_smoke.cpp index 6366061bc3..f3253e5012 100644 --- a/xbox/native-bios/tests/quickjs_engine_smoke.cpp +++ b/xbox/native-bios/tests/quickjs_engine_smoke.cpp @@ -9,12 +9,16 @@ int main() { GraphicsProbe graphics; SoundProbe sound; Api api{{}, {}, {}, {}, graphics, sound, {}}; int telemetryCalls = 0; int gameSignalCalls = 0; + int replayCalls = 0; int discScans = 0, discShows = 0, discCopies = 0; api.telemetry = [&](std::string_view) { ++telemetryCalls; }; api.game_signal = [&](std::string_view event, int player, float value, float value2) { if (event == "bullet" && player == 1 && value == 0.5f && value2 == 0.25f) ++gameSignalCalls; }; + api.replay_save = [&](std::string_view replay) { + if (replay == "{\"format\":\"ac.oskiedemo\"}") ++replayCalls; + }; auto disc = std::make_shared(); disc->status = "ready"; disc->volume = "D:"; disc->name = "PHOTO.JPG"; disc->count = 3; disc->index = 1; disc->width = 1600; disc->height = 1200; @@ -30,13 +34,14 @@ int main() { api.clock.network_synced = true; api.clock.network_offset_ms = 3; api.clock.network_rtt_ms = 21; api.audio.output_latency_ms = 11.5; api.audio.midi_status = "no-input"; api.audio.midi_gate = true; api.audio.midi_pitch_bend = 9000; - auto piece = engine.compile({"smoke", "test", "function boot(){telemetry('BOOT','OK');gameSignal('bullet',1,.5,.25);ac();if(!discScan())throw Error('disc scan')} function sim(){if(!gamepad().connected||!gamepad(0).connected||!gamepad(1).connected||gamepad(2).connected||gamepad(1).leftX!==-1)throw Error('indexed gamepads');drum('kick',1,0);const r=runtime();if(!r.clockSynced||r.clockOffsetMs!==3||r.audioLatencyMs!==11.5||r.midiStatus!=='no-input'||!r.midiGate||r.midiPitchBend!==9000)throw Error('runtime telemetry');const d=disc();if(d.status!=='ready'||d.volume!=='D:'||d.name!=='PHOTO.JPG'||d.count!==3||d.index!==1||d.width!==1600||d.height!==1200||!d.currentReady)throw Error('disc state');if(!discShow(-1))throw Error('disc show');capabilities();controllers();oscillator(220,.1)} function paint(){wipe(1,2,3);box(1,2,3,4,5,6,7);line(1,2,3,4,2,5,6,7);triangle(1,2,3,4,5,6,7,8,9);const batch=new Float32Array([1,2,.1,3,4,.1,5,6,.1,7,8,9,10,20,.2,30,40,.2,50,60,.2,70,80,90]);if(triangles3d(batch)!==2)throw Error('triangle batch');const textured=new Float32Array([1,2,.1,0,0,3,4,.1,1,0,5,6,.1,0,1,255,255,255]);if(texturedTriangles3d(textured,1)!==1)throw Error('texture batch');const sprites=new Float32Array([100,200,.3,16,255,80,90,1]);if(sprites3d(sprites,1)!==1)throw Error('sprite batch');write('OK',8,9,10,11,12,13);systemWrite('HI',20,30,40);ywftWrite('YWFT',20,70,40);systemGlyph('ButtonA',50,60,70);painting(80,90,100,110);stampPainting('#j8t',200,300,1);discPhoto(0,0,1920,1080);blur(4)} function act(b){if(b==='A')synth(440,.01);if(b==='B'){oscillatorStop();if(!discCopy())throw Error('disc copy')}}", "test"}, {}, error); + auto piece = engine.compile({"smoke", "test", "function boot(){telemetry('BOOT','OK');gameSignal('bullet',1,.5,.25);saveReplay('{\"format\":\"ac.oskiedemo\"}');ac();if(!discScan())throw Error('disc scan')} function sim(){if(!gamepad().connected||!gamepad(0).connected||!gamepad(1).connected||gamepad(2).connected||gamepad(1).leftX!==-1)throw Error('indexed gamepads');drum('kick',1,0);const r=runtime();if(!r.clockSynced||r.clockOffsetMs!==3||r.audioLatencyMs!==11.5||r.midiStatus!=='no-input'||!r.midiGate||r.midiPitchBend!==9000)throw Error('runtime telemetry');const d=disc();if(d.status!=='ready'||d.volume!=='D:'||d.name!=='PHOTO.JPG'||d.count!==3||d.index!==1||d.width!==1600||d.height!==1200||!d.currentReady)throw Error('disc state');if(!discShow(-1))throw Error('disc show');capabilities();controllers();oscillator(220,.1)} function paint(){wipe(1,2,3);box(1,2,3,4,5,6,7);line(1,2,3,4,2,5,6,7);triangle(1,2,3,4,5,6,7,8,9);const batch=new Float32Array([1,2,.1,3,4,.1,5,6,.1,7,8,9,10,20,.2,30,40,.2,50,60,.2,70,80,90]);if(triangles3d(batch)!==2)throw Error('triangle batch');const textured=new Float32Array([1,2,.1,0,0,3,4,.1,1,0,5,6,.1,0,1,255,255,255]);if(texturedTriangles3d(textured,1)!==1)throw Error('texture batch');const sprites=new Float32Array([100,200,.3,16,255,80,90,1]);if(sprites3d(sprites,1)!==1)throw Error('sprite batch');write('OK',8,9,10,11,12,13);systemWrite('HI',20,30,40);ywftWrite('YWFT',20,70,40);systemGlyph('ButtonA',50,60,70);painting(80,90,100,110);stampPainting('#j8t',200,300,1);discPhoto(0,0,1920,1080);blur(4)} function act(b){if(b==='A')synth(440,.01);if(b==='B'){oscillatorStop();if(!discCopy())throw Error('disc copy')}}", "test"}, {}, error); assert(piece && error.empty()); piece->boot(api); piece->paint(api); assert(graphics.color.r == 1 && graphics.color.g == 2 && graphics.color.b == 3); assert(graphics.boxes == 1 && graphics.lines == 1 && graphics.triangles == 3 && graphics.textured == 1 && graphics.sprites == 1 && graphics.writes == 1 && graphics.systemWrites == 2 && graphics.glyphs == 1 && graphics.images == 3 && graphics.blurs == 1 && graphics.lastImage.source == "disc-photo" && - !graphics.lastImage.centered && telemetryCalls == 1 && gameSignalCalls == 1 && discScans == 1); + !graphics.lastImage.centered && telemetryCalls == 1 && gameSignalCalls == 1 && + replayCalls == 1 && discScans == 1); piece->sim(api); assert(sound.oscillators == 1 && sound.drums == 1 && discShows == 1); piece->act(api, {"A"}); assert(sound.calls == 1); piece->act(api, {"B"}); assert(sound.stops == 1 && discCopies == 1); diff --git a/xbox/runtime/include/ac/runtime.hpp b/xbox/runtime/include/ac/runtime.hpp index 7b611476b4..c139ba89fa 100644 --- a/xbox/runtime/include/ac/runtime.hpp +++ b/xbox/runtime/include/ac/runtime.hpp @@ -224,6 +224,9 @@ struct Api { // A bounded, host-routed game signal. The host owns the destination and wire // protocol; pieces can only choose a short event name and numeric payload. std::function game_signal = {}; + // A bounded, host-routed replay artifact. The host validates and uploads the + // complete demo; pieces do not receive a URL, credential, or response body. + std::function replay_save = {}; }; class Piece { -- 2.51.2