diff --git a/xbox/fiapup/index.html b/xbox/fiapup/index.html
new file mode 100644
index 0000000000..a14e85ad91
--- /dev/null
+++ b/xbox/fiapup/index.html
@@ -0,0 +1,128 @@
+
+
+
+
+
+fiapup
+
+
+
+
+
+ WASD/arrows hand
+ space A pet / pick up / throw
+ C B call
+ T X treat (hold)
+ R Y play
+ or an Xbox pad
+
+
+
+
diff --git a/xbox/fiapup/serve.mjs b/xbox/fiapup/serve.mjs
new file mode 100644
index 0000000000..05bb510c98
--- /dev/null
+++ b/xbox/fiapup/serve.mjs
@@ -0,0 +1,52 @@
+#!/usr/bin/env node
+// Serve fiapup's working tree for a browser: xbox/fiapup, plus the two scene
+// modules it borrows from xbox/live (the depth-tested WebGL renderer oskiewar
+// uses). Read from disk on every request, so a save is a reload.
+//
+// node xbox/fiapup/serve.mjs [port] → http://127.0.0.1:8124
+
+import { createServer } from "node:http";
+import { readFile } from "node:fs/promises";
+import { extname, join, normalize, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const here = resolve(fileURLToPath(new URL(".", import.meta.url)));
+const live = resolve(here, "../live");
+const port = Number(process.argv[2]) || 8124;
+const borrowed = new Map([
+ ["/live/scene3d-webgl.mjs", join(live, "scene3d-webgl.mjs")],
+ ["/live/scene3d.mjs", join(live, "scene3d.mjs")],
+]);
+const mime = { ".html": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8",
+ ".mjs": "text/javascript; charset=utf-8", ".lisp": "text/plain; charset=utf-8", ".png": "image/png" };
+
+export function fileFor(pathname) {
+ if (pathname === "/" || pathname === "/index.html") return join(here, "index.html");
+ if (borrowed.has(pathname)) return borrowed.get(pathname);
+ const target = normalize(join(here, pathname));
+ return target.startsWith(here + "/") ? target : "";
+}
+
+export function serve(listenPort = port) {
+ const server = createServer(async (request, response) => {
+ const path = fileFor(new URL(request.url, "http://127.0.0.1").pathname);
+ if (!path) { response.writeHead(403); response.end("outside xbox/fiapup"); return; }
+ try {
+ const body = await readFile(path);
+ response.writeHead(200, { "content-type": mime[extname(path)] || "application/octet-stream",
+ "cache-control": "no-store" });
+ response.end(body);
+ } catch (error) {
+ response.writeHead(error.code === "ENOENT" ? 404 : 500);
+ response.end(String(error.message));
+ }
+ });
+ return new Promise((ready) => server.listen(listenPort, "127.0.0.1", () => ready(server)));
+}
+
+if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
+ await serve();
+ console.log(`fiapup → http://127.0.0.1:${port}`);
+ console.log(" WASD/arrows hand · space A · C B (call) · T X (treat) · R Y (play) · or an Xbox pad");
+ console.log(" ?stage=fetch|pet|beg|nap|zoomies|tug[&seconds=n][&pause] to open on a moment");
+}
diff --git a/xbox/fiapup/shots.mjs b/xbox/fiapup/shots.mjs
new file mode 100644
index 0000000000..c1e8df901d
--- /dev/null
+++ b/xbox/fiapup/shots.mjs
@@ -0,0 +1,49 @@
+#!/usr/bin/env node
+// Headless screenshots of the web shell, one per staged moment, into
+// xbox/fiapup/shots/. One browser, closed at the end.
+//
+// node xbox/fiapup/shots.mjs [moment…] (default: all of them)
+
+import { mkdirSync } from "node:fs";
+import { createRequire } from "node:module";
+import { spawnSync } from "node:child_process";
+import { resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+import { serve } from "./serve.mjs";
+
+const here = fileURLToPath(new URL(".", import.meta.url));
+// A worktree has no node_modules of its own; borrow the main checkout's.
+async function puppeteer() {
+ try { return (await import("puppeteer")).default; } catch {}
+ const common = spawnSync("git", ["rev-parse", "--git-common-dir"], { cwd: here, encoding: "utf8" }).stdout.trim();
+ return createRequire(resolve(here, common, "../package.json"))("puppeteer");
+}
+
+const moments = {
+ idle: "", fetch: "stage=fetch&seconds=1.05", pet: "stage=pet&seconds=1.1",
+ rollover: "stage=pet&seconds=3.2", beg: "stage=beg&seconds=2.6", nap: "stage=nap&seconds=9",
+ zoomies: "stage=zoomies&seconds=2.4", tug: "stage=tug&seconds=3.2",
+};
+const wanted = process.argv.slice(2).length ? process.argv.slice(2) : Object.keys(moments);
+const out = resolve(here, "shots");
+mkdirSync(out, { recursive: true });
+
+const server = await serve(8125);
+const browser = await (await puppeteer()).launch({ headless: true,
+ args: ["--use-angle=swiftshader", "--enable-unsafe-swiftshader", "--autoplay-policy=no-user-gesture-required"] });
+try {
+ const page = await browser.newPage();
+ await page.setViewport({ width: 1280, height: 754 });
+ page.on("pageerror", (error) => console.error("page error:", error.message));
+ for (const name of wanted) {
+ await page.goto(`http://127.0.0.1:8125/?pause&${moments[name]}`);
+ await page.waitForFunction(() => (globalThis.__fiapupFrames || 0) > 3, { timeout: 20000 });
+ const state = await page.evaluate(() => globalThis.__fiapup.world.pup.state);
+ const file = resolve(out, `${name}.png`);
+ await page.screenshot({ path: file, clip: { x: 0, y: 0, width: 1280, height: 720 } });
+ console.log(`${name}: pup is ${state} → ${file}`);
+ }
+} finally {
+ await browser.close();
+ server.close();
+}
diff --git a/xbox/fiapup/tests/load.mjs b/xbox/fiapup/tests/load.mjs
new file mode 100644
index 0000000000..569cb61a4b
--- /dev/null
+++ b/xbox/fiapup/tests/load.mjs
@@ -0,0 +1,35 @@
+// Load fiapup.js the way the console does: one script, the host's bindings as
+// its only way out. `host` names the bindings; anything it leaves out is
+// simply not there, as on a host that lacks it.
+
+import { readFileSync } from "node:fs";
+
+export const source = readFileSync(new URL("../fiapup.js", import.meta.url), "utf8");
+
+// The bindings the Xbox host (xbox/native-bios/QuickJsEngine.cpp) gives a
+// piece that fiapup could reach for. Nothing else exists there.
+export const xboxBindings = ["wipe", "synth", "drum", "oscillator", "oscillatorStop", "write",
+ "box", "line", "triangle", "triangle3d", "triangles3d", "runtime", "gamepad", "controllers",
+ "capabilities", "telemetry"];
+
+export function load(host = {}) {
+ const names = [...new Set([...xboxBindings, "frame", ...Object.keys(host)])];
+ const make = new Function(...names, `${source}\nreturn { boot, sim, paint, act, leave, fiapup };`);
+ return make(...names.map((name) => host[name]));
+}
+
+// A host that counts what it is asked to draw, and plays nothing.
+export function countingHost(extra = {}) {
+ const drawn = { faces: 0, texts: [], wipes: 0 };
+ let us = 0;
+ const host = {
+ triangle3d: () => { drawn.faces++; },
+ write: (s) => { drawn.texts.push(s); },
+ wipe: () => { drawn.wipes++; },
+ box: () => {}, line: () => {}, synth: () => {},
+ runtime: () => ({ width: 1920, height: 1080, monotonicUs: (us += 16667) }),
+ gamepad: () => ({ down: [], leftX: 0, leftY: 0 }),
+ ...extra,
+ };
+ return { host, drawn };
+}