diff --git a/xbox/live/snapshot.mjs b/xbox/live/snapshot.mjs
new file mode 100644
index 0000000000..0e5d2df051
--- /dev/null
+++ b/xbox/live/snapshot.mjs
@@ -0,0 +1,246 @@
+// snapshot.mjs — stage one of the game's own objects offline, at any size.
+//
+// The game only ever speaks in draw commands — wipe, box, line, triangle — and
+// it projects to screen space before it emits them. This tool listens to those
+// commands instead of to a screen, so a frame can be re-framed around a single
+// object and redrawn as vectors at whatever resolution the job asks for. That
+// is what separates an avatar from a cropped screenshot: nothing is resampled,
+// and the framing is computed from the object's real geometry rather than
+// eyeballed against a pixel grid.
+//
+// node xbox/live/snapshot.mjs --list
+// node xbox/live/snapshot.mjs --object=dummy-head --size=1024 --out=pfp.png
+// node xbox/live/snapshot.mjs --object=dummy --size=2048 --pad=.3 --svg
+//
+// Flags: --object (see --list) · --size px · --pad headroom as a fraction of
+// the object · --out path · --svg keep the vector next to the png · --ticks how
+// many 60Hz frames to settle before the shot · --bg override the wipe colour
+// (`--bg=none` for transparency, which is what a favicon wants).
+
+import { mkdir, readFile, writeFile } from "node:fs/promises";
+import { dirname, join, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const HERE = dirname(fileURLToPath(import.meta.url));
+const REPO = resolve(HERE, "../..");
+
+const flags = new Map(process.argv.slice(2).map((entry) => {
+ const [key, value = "true"] = entry.replace(/^--/, "").split("=");
+ return [key, value];
+}));
+
+// Each object says where to stand and what to frame. `settle` is how many
+// simulation ticks to run first — a fighter breathes, so a shot taken on tick
+// zero catches a pose nobody ever sees.
+const OBJECTS = {
+ "dummy-head": { pad: 1, part: "head", settle: 24,
+ note: "the training dummy's face — X eyes, one flat smile" },
+ "dummy": { pad: 1, part: "body", settle: 24,
+ note: "the whole training dummy, standing" },
+ "player-head": { pad: 0, part: "head", settle: 24,
+ note: "player one's head" },
+ "player": { pad: 0, part: "body", settle: 24,
+ note: "player one, whole" },
+ "frame": { part: "frame", settle: 24,
+ note: "the entire painted frame, unframed" },
+};
+
+if (flags.has("list")) {
+ console.log("objects:");
+ for (const [name, object] of Object.entries(OBJECTS))
+ console.log(` ${name.padEnd(12)} ${object.note}`);
+ process.exit(0);
+}
+
+const objectName = flags.get("object") || "dummy-head";
+const object = OBJECTS[objectName];
+if (!object) {
+ console.error(`unknown object "${objectName}" — try --list`);
+ process.exit(1);
+}
+
+const size = Number(flags.get("size") || 1024);
+const headroom = Number(flags.get("pad") ?? .18);
+const ticks = Number(flags.get("ticks") ?? object.settle);
+const viewport = { width: 1920, height: 1080 };
+
+// ---------------------------------------------------------------- the game
+
+const commands = [];
+let background = [8, 10, 20];
+let clock = 0;
+
+const source = await readFile(join(HERE, "hello.js"), "utf8");
+const noOp = () => {};
+const pads = [0, 1].map(() => ({ connected: true, down: [], leftX: 0, leftY: 0 }));
+
+// `triangle` (flat 2D) is deliberately the only triangle host wired up. The
+// 3D and batched paths hand back the same shapes with a z the screen has
+// already discarded, and 2D is what an SVG wants.
+const game = new Function(
+ "runtime", "gamepad", "capabilities", "telemetry", "gameSignal", "saveReplay",
+ "publishLive", "analytics", "drum", "wipe", "box", "line", "triangle",
+ "triangle3d", "triangles3d", "write", "systemWrite", "gameView",
+ `${source}\nreturn { boot, sim, paint, players, runnerWorldGeometry,` +
+ ` projectPoint, disableBall: () => { ballEnabled = false;` +
+ ` for (const item of balls) item.active = false; },` +
+ ` setWind: (value) => { windAcceleration = value; },` +
+ ` enterGame: () => enterGame(runtime().monotonicUs),` +
+ ` startFightAgainst: (kind) => startFightAgainst(kind, runtime().monotonicUs) };`
+)(
+ () => ({ monotonicUs: clock, unixMs: 1785870000000 + Math.floor(clock / 1000),
+ simCount: Math.floor(clock / 16667), paintCount: 0,
+ clientErrorReportStatus: "" }),
+ (index = 0) => ({ ...pads[index], down: pads[index].down.slice() }),
+ () => ({ platform: "web", inputFamily: "keyboard" }),
+ noOp,
+ noOp,
+ // Deliberately inert. The shell's saveReplay posts to the *production*
+ // replay store, so a rendering tool that forwarded it would file staged
+ // poses next to matches people actually played.
+ () => Promise.resolve(true),
+ noOp,
+ noOp,
+ noOp,
+ (r, g, b) => { background = [r, g, b]; commands.length = 0; },
+ (x, y, width, height, r, g, b) =>
+ commands.push({ kind: "box", x, y, width, height, ink: [r, g, b] }),
+ (x1, y1, x2, y2, width, r, g, b) =>
+ commands.push({ kind: "line", x1, y1, x2, y2, width, ink: [r, g, b] }),
+ (x1, y1, x2, y2, x3, y3, r, g, b) =>
+ commands.push({ kind: "triangle", points: [[x1, y1], [x2, y2], [x3, y3]],
+ ink: [r, g, b] }),
+ undefined, undefined,
+ noOp, noOp,
+ () => viewport,
+);
+
+game.boot();
+game.enterGame();
+game.disableBall();
+// No wind: a shot should be reproducible, and drift makes the same seed frame
+// differently every run.
+game.setWind(0);
+game.startFightAgainst("dummy");
+for (let step = 0; step < ticks; step++) { clock += 16667; game.sim(); }
+game.paint();
+console.log(`⛏ ${commands.length} draw commands captured at tick ${ticks}`);
+
+// --------------------------------------------------------------- framing
+
+function frameFor() {
+ if (object.part === "frame")
+ return { x: 0, y: 0, width: viewport.width, height: viewport.height };
+ const player = game.players[object.pad];
+ const geometry = game.runnerWorldGeometry(player, clock / 1e6);
+ if (object.part === "head") {
+ const head = geometry.head;
+ if (!head) throw new Error("this build's geometry has no head");
+ const centre = game.projectPoint(head.x, head.y, head.z);
+ const edge = game.projectPoint(head.x + head.radius, head.y, head.z);
+ const radius = Math.abs(edge.x - centre.x) * (1 + headroom);
+ console.log(`⛏ head at world (${head.x.toFixed(0)}, ${head.y.toFixed(0)})` +
+ ` → screen (${centre.x.toFixed(0)}, ${centre.y.toFixed(0)}) r${radius.toFixed(0)}`);
+ return { x: centre.x - radius, y: centre.y - radius,
+ width: radius * 2, height: radius * 2 };
+ }
+ // A whole body: take the union of every projected segment rather than the
+ // capsule maths, so held items and detached parts stay inside the frame.
+ let left = Infinity, top = Infinity, right = -Infinity, bottom = -Infinity;
+ for (const segment of geometry.segments) {
+ for (const point of [[segment.x1, segment.y1, segment.z1],
+ [segment.x2, segment.y2, segment.z2]]) {
+ const screen = game.projectPoint(point[0], point[1], point[2]);
+ const width = segment.width || 0;
+ left = Math.min(left, screen.x - width);
+ right = Math.max(right, screen.x + width);
+ top = Math.min(top, screen.y - width);
+ bottom = Math.max(bottom, screen.y + width);
+ }
+ }
+ const head = geometry.head;
+ if (head) {
+ const centre = game.projectPoint(head.x, head.y, head.z);
+ const edge = game.projectPoint(head.x + head.radius, head.y, head.z);
+ const radius = Math.abs(edge.x - centre.x);
+ left = Math.min(left, centre.x - radius);
+ right = Math.max(right, centre.x + radius);
+ top = Math.min(top, centre.y - radius);
+ bottom = Math.max(bottom, centre.y + radius);
+ }
+ // Square it, so the caller always gets the aspect an avatar slot wants.
+ const span = Math.max(right - left, bottom - top) * (1 + headroom);
+ const midX = (left + right) / 2, midY = (top + bottom) / 2;
+ return { x: midX - span / 2, y: midY - span / 2, width: span, height: span };
+}
+
+const frame = frameFor();
+
+// ------------------------------------------------------------------ vector
+
+const ink = ([r, g, b]) => `rgb(${Math.round(r)},${Math.round(g)},${Math.round(b)})`;
+const round = (value) => Number(value.toFixed(2));
+
+// Only what the frame can actually see. A full paint carries the whole stage,
+// and an avatar that ships the scenery it cropped out is just a big file.
+function visible(command) {
+ const inside = (x, y) => x >= frame.x - frame.width && x <= frame.x + frame.width * 2 &&
+ y >= frame.y - frame.height && y <= frame.y + frame.height * 2;
+ if (command.kind === "triangle") return command.points.some(([x, y]) => inside(x, y));
+ if (command.kind === "line") return inside(command.x1, command.y1) ||
+ inside(command.x2, command.y2);
+ return inside(command.x, command.y) ||
+ inside(command.x + command.width, command.y + command.height);
+}
+
+const drawn = commands.filter(visible);
+const body = drawn.map((command) => {
+ if (command.kind === "triangle")
+ return ``;
+ if (command.kind === "line")
+ return ``;
+ return ``;
+}).join("\n ");
+
+const backdrop = flags.get("bg") === "none" ? ""
+ : `\n `;
+
+const svg = `\n`;
+
+console.log(`⛏ ${drawn.length} of ${commands.length} commands are inside the frame`);
+
+// ----------------------------------------------------------------- output
+
+const outPath = resolve(flags.get("out") ||
+ join(REPO, "tmp", "oskiewar-snapshots", `${objectName}-${size}.png`));
+await mkdir(dirname(outPath), { recursive: true });
+const svgPath = outPath.replace(/\.png$/, ".svg");
+if (flags.has("svg") || outPath.endsWith(".svg")) {
+ await writeFile(svgPath, svg);
+ console.log(`✅ ${svgPath}`);
+}
+if (outPath.endsWith(".png")) {
+ const { default: puppeteer } = await import("puppeteer");
+ const browser = await puppeteer.launch({ headless: true,
+ executablePath: process.env.PUPPETEER_EXECUTABLE_PATH ||
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" });
+ try {
+ const page = await browser.newPage();
+ await page.setViewport({ width: size, height: size, deviceScaleFactor: 1 });
+ await page.setContent(
+ `${svg}`,
+ { waitUntil: "load" });
+ await page.screenshot({ path: outPath, type: "png",
+ omitBackground: flags.get("bg") === "none" });
+ } finally { await browser.close(); }
+ console.log(`✅ ${outPath} at ${size}×${size}`);
+}