From 6539adb749d17bdc119e76e80a30815f5800aeaf Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Mon, 28 Sep 2026 19:02:08 -0700 Subject: [PATCH] fiapup: web shell, a server for it, headless shots index.html gives the game the console's bindings in a browser (triangle3d through oskiewar's WebGL scene, write, synth, gamepad(0) with keys mapped onto it). serve.mjs serves xbox/fiapup plus the two borrowed scene modules; shots.mjs stages moments and screenshots them headless. --- xbox/fiapup/index.html | 128 +++++++++++++++++++++++++++++++++++++ xbox/fiapup/serve.mjs | 52 +++++++++++++++ xbox/fiapup/shots.mjs | 49 ++++++++++++++ xbox/fiapup/tests/load.mjs | 35 ++++++++++ 4 files changed, 264 insertions(+) create mode 100644 xbox/fiapup/index.html create mode 100644 xbox/fiapup/serve.mjs create mode 100644 xbox/fiapup/shots.mjs create mode 100644 xbox/fiapup/tests/load.mjs 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 }; +} -- 2.51.2