diff --git a/xbox/fiapup/embed.mjs b/xbox/fiapup/embed.mjs
new file mode 100644
index 0000000000..78227dc163
--- /dev/null
+++ b/xbox/fiapup/embed.mjs
@@ -0,0 +1,98 @@
+#!/usr/bin/env node
+// embed.mjs — seal what fiapup.js borrows from oskiewar's pipeline into it.
+//
+// fiapup.js is one global script, like oskiewar.js, because that is all the
+// console host loads. It carries three things it doesn't own, each read from
+// the file oskiewar uses so the two games share one pipeline:
+//
+// xbox/live/object-lisp.mjs the object compiler (KidLisp → baked sketches)
+// xbox/live/frame-vm.mjs the frame-program interpreter, run in-game on
+// hosts that have no `frame` (the console until
+// R6, and this game's own web shell)
+// xbox/fiapup/objects/*.lisp the pup, the yard and the props
+//
+// It writes them between `// ` and `// ` and nothing else.
+//
+// node xbox/fiapup/embed.mjs rewrite the block
+// node xbox/fiapup/embed.mjs --check exit 1 if the block is stale
+
+import { readFileSync, writeFileSync } from "node:fs";
+import { fileURLToPath } from "node:url";
+
+const here = new URL("./", import.meta.url);
+const live = new URL("../live/", import.meta.url);
+const read = (url) => readFileSync(url, "utf8");
+export const gamePath = fileURLToPath(new URL("fiapup.js", here));
+export const start = "// ";
+export const end = "// ";
+
+// What the game draws, by the name it asks for, and the file each compiles from.
+export const objects = { puppy: "puppy-flat", yard: "yard-flat", ball: "ball-flat",
+ rope: "rope-flat", hand: "hand-flat", treat: "treat-flat" };
+
+// A module's source with its `export` keywords taken off, and the names it
+// exported. `export default x;` lines go; the named export already covers x.
+function unexport(source, file) {
+ const names = [];
+ const body = source.replace(/^export default .*$/gm, "")
+ .replace(/^export (async function\*?|function\*?|const|let|class) (\w+)/gm, (_, kind, name) => {
+ names.push(name);
+ return `${kind} ${name}`;
+ });
+ if (/^import /m.test(body)) throw new Error(`${file}: the game can't carry imports`);
+ return { body, names };
+}
+
+const indent = (text) => text.trimEnd().split("\n").map((line) => (line ? " " + line : line)).join("\n");
+
+export function generate() {
+ const lisp = unexport(read(new URL("object-lisp.mjs", live)), "object-lisp.mjs");
+ const vm = unexport(read(new URL("frame-vm.mjs", live)), "frame-vm.mjs");
+ const sources = Object.entries(objects).map(([name, file]) =>
+ ` ${name}: ${JSON.stringify(read(new URL(`objects/${file}.lisp`, here)))},`);
+ return [
+ start,
+ "// Generated by xbox/fiapup/embed.mjs from xbox/live/object-lisp.mjs,",
+ "// xbox/live/frame-vm.mjs and xbox/fiapup/objects/*.lisp. Don't edit here:",
+ "// edit those and rerun the tool.",
+ "const fiapupObjects = (() => {",
+ " const objectLisp = (() => {",
+ indent(lisp.body),
+ ` return { ${lisp.names.join(", ")} };`,
+ " })();",
+ " const sources = {",
+ ...sources,
+ " };",
+ " const compiled = {};",
+ " for (const name in sources) compiled[name] = objectLisp.compile(sources[name], name);",
+ " return compiled;",
+ "})();",
+ "const fiapupFrameVm = (() => {",
+ indent(vm.body),
+ ` return { ${vm.names.join(", ")} };`,
+ "})();",
+ end,
+ ].join("\n");
+}
+
+export function embedded(source = read(gamePath)) {
+ const a = source.indexOf(start), b = source.indexOf(end);
+ return a < 0 || b < 0 ? null : source.slice(a, b + end.length);
+}
+
+function write() {
+ const source = read(gamePath), block = generate(), current = embedded(source);
+ if (!current) throw new Error("fiapup.js: no // block to fill");
+ const next = source.replace(current, () => block);
+ if (next !== source) writeFileSync(gamePath, next);
+ return next !== source;
+}
+
+if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
+ if (process.argv.includes("--check")) {
+ const fresh = embedded() === generate();
+ console.log(fresh ? "fiapup.js sealed block is current" : "fiapup.js sealed block is stale: run node xbox/fiapup/embed.mjs");
+ process.exit(fresh ? 0 : 1);
+ }
+ console.log(write() ? "sealed the compiler, the frame vm and the objects into fiapup.js" : "sealed block already current");
+}
diff --git a/xbox/fiapup/fiapup.js b/xbox/fiapup/fiapup.js
new file mode 100644
index 0000000000..6cb31ad53b
--- /dev/null
+++ b/xbox/fiapup/fiapup.js
@@ -0,0 +1,2604 @@
+// fiapup — a puppy in a backyard, and your hand. (xbox/FIAPUP.md)
+//
+// One global script, like oskiewar.js: the console host (xbox/native-bios)
+// evaluates it as-is and calls boot/sim/paint/act/leave; the web shell
+// (xbox/fiapup/index.html) does the same through `new Function`. It reads the
+// pad through `gamepad(0)` and draws by writing one frame program a paint
+// (xbox/OSKIEWAR-HOSTS.md §3.0). A host with `frame` runs the program itself;
+// one without it (the console until R6, and the web shell) runs the copy of
+// frame-vm.mjs sealed in below against its own `triangle3d`.
+//
+// The pup is a KidLisp object (objects/puppy-flat.lisp): balls and limbs hung
+// on a small skeleton, every shape baked, only the bones moving. A tick hands
+// it a pose; the pose comes from the behaviour below.
+
+//
+// Generated by xbox/fiapup/embed.mjs from xbox/live/object-lisp.mjs,
+// xbox/live/frame-vm.mjs and xbox/fiapup/objects/*.lisp. Don't edit here:
+// edit those and rerun the tool.
+const fiapupObjects = (() => {
+ const objectLisp = (() => {
+ // object-lisp.mjs — oskiewar objects written in KidLisp, compiled once.
+ //
+ // An object (a monowheel, a hat, a weapon) is a small .lisp program that says
+ // what the thing looks like, in its own space: x forward, y up, z to the
+ // owner's right. `compile` does all the work when the object loads:
+ //
+ // - every part that doesn't change tick to tick is run once, at each of three
+ // levels of detail, and baked into meshes (`object.meshes`), for the host to
+ // keep as ASSETs;
+ // - what is left is closures, never the tree-walker, because the tree-walker
+ // repaints a 61-line program about every 6 s on the oven.
+ //
+ // A tick, `object(inputs, place, out)`, then emits one MODEL op per baked part
+ // (`out.model`: a mesh by handle under a 12-number placement) and WORLD faces
+ // (`out.face`) only for what really moves. The high-level forms (revolve,
+ // radial, mirror) compile away here; hosts see faces and meshes and never
+ // expand anything. The dialect: xbox/OBJECT-DIALECT.md.
+ //
+ // No imports and plain top-level exports: xbox/tools/embed-objects.mjs (next
+ // pass) seals this file into oskiewar.js the way embed-spine.mjs does, and
+ // every host (QuickJS, JavaScriptCore, the browser) runs the same compiler.
+
+ // What the game hands an object each tick. Seconds, world units, radians;
+ // `hit` and `land` count seconds since the event (large when it never was).
+ const objectInputs = ["time", "distance", "speed", "lean", "heading",
+ "pitch", "turbo", "hit", "land"];
+ const unset = [0, 0, 0, 0, 0, 0, 0, 1e9, 1e9];
+ // Switches are 0 or 1, so a baked part that reads one is baked once per value.
+ const switches = new Set(["turbo"]);
+ // `detail` is the level a baked part is drawn at: 0 near, 2 far. The host
+ // picks it per MODEL op; outside baked parts it reads 0.
+ const detailSlot = objectInputs.length;
+ const objectLevels = 3;
+
+ // The game's sun (`globalLight` in oskiewar.js) and its flat-shading rule:
+ // .72 ambient plus .28 of the face turned toward the light, decided in world
+ // space from the face's own winding — so an object shades exactly like a
+ // worldQuad beside it. tests/object-lisp.test.mjs holds the two equal.
+ const sun = (() => {
+ const x = -.42, y = 1, z = -.28, m = Math.hypot(x, y, z);
+ return [x / m, y / m, z / m];
+ })();
+ const objectLight = sun;
+
+
+ // ——— reading: KidLisp's own rules, so Aesel's KidLisp authoring applies ———
+ // A bare line that starts with a word is a call (`ring 12` → `(ring 12)`),
+ // commas separate calls on a line, `;` comments, missing `)` auto-close.
+ // tests hold this reader to KidLisp's `parse` on every object in objects/.
+
+ const tokenPattern = /\s*(;.*|[(),]|"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|[^\s()";',]+)/g;
+
+ // Strings and comments masked with "_" (quotes and ";" kept), so line passes
+ // can count parens and find commas without cutting through text.
+ function mask(text) {
+ let out = "", i = 0;
+ while (i < text.length) {
+ const ch = text[i];
+ if (ch === ";") { out += ";" + "_".repeat(text.length - i - 1); break; }
+ if (ch === '"' || ch === "'") {
+ let j = i + 1;
+ while (j < text.length && text[j] !== ch) j += text[j] === "\\" ? 2 : 1;
+ if (j < text.length) { out += ch + "_".repeat(j - i - 1) + ch; i = j + 1; continue; }
+ }
+ out += ch; i++;
+ }
+ return out;
+ }
+ const startsWord = (text) => /^[a-zA-Z_]\w*/.test(text);
+ const count = (text, ch) => text.split(ch).length - 1;
+
+ function read(source) {
+ const lines = source.split("\n").map((line) => {
+ const cut = mask(line).indexOf(";");
+ return (cut < 0 ? line : line.slice(0, cut)).trim();
+ }).filter(Boolean);
+ const wrapped = lines.map((line, index) => {
+ const masked = mask(line);
+ if (masked.includes(",")) {
+ const parts = [];
+ let from = 0;
+ for (let i = 0; i <= masked.length; i++)
+ if (i === masked.length || masked[i] === ",") { parts.push(line.slice(from, i).trim()); from = i + 1; }
+ return parts.filter(Boolean).map((part) =>
+ part.startsWith("(") && part.endsWith(")") ? part : /^[a-zA-Z_$]\w*/.test(part) ? `(${part})` : part).join(" ");
+ }
+ // A word-led line inside an open call is a continuation, not a new call.
+ const before = index > 0 ? mask(lines[index - 1]) : "";
+ const continues = index > 0 && count(before, "(") > count(before, ")");
+ return !line.startsWith("(") && startsWord(line) && !continues ? `(${line})` : line;
+ }).join(" ");
+ const tokens = [];
+ for (const match of wrapped.matchAll(tokenPattern))
+ if (!match[1].startsWith(";")) tokens.push(match[1]);
+ let open = 0;
+ for (const t of tokens) open += t === "(" ? 1 : t === ")" ? -1 : 0;
+ while (open-- > 0) tokens.push(")");
+ let at = 0;
+ const form = () => {
+ const t = tokens[at++];
+ if (t === ")") throw new Error("unexpected )");
+ // Numbers read as KidLisp's do; its timing words (`1s`, `2s...`) stay words.
+ if (t !== "(") { const n = parseFloat(t); return Number.isNaN(n) || /^\d*\.?\d+s/.test(t) ? t : n; }
+ const list = [];
+ while (at < tokens.length && tokens[at] !== ")") {
+ if (tokens[at] === ",") { at++; continue; }
+ list.push(form());
+ }
+ at++;
+ return list;
+ };
+ const forms = [];
+ while (at < tokens.length) {
+ if (tokens[at] === ",") { at++; continue; }
+ forms.push(form());
+ }
+ return forms;
+ }
+
+
+ // ——— compiling ———
+
+ const show = (form) => Array.isArray(form) ? `(${form.map(show).join(" ")})` : String(form);
+
+ // Pure math the dialect knows. Everything is a number; comparisons are 1 or 0.
+ const math = {
+ "+": (...v) => v.reduce((a, b) => a + b, 0),
+ "-": (a, ...v) => v.length ? v.reduce((x, y) => x - y, a) : -a,
+ "*": (...v) => v.reduce((a, b) => a * b, 1),
+ "/": (a, b) => a / b,
+ "%": (a, b) => ((a % b) + b) % b,
+ min: Math.min, max: Math.max, abs: Math.abs, sign: Math.sign,
+ sin: Math.sin, cos: Math.cos, tan: Math.tan, atan: Math.atan2, sqrt: Math.sqrt,
+ pow: Math.pow, floor: Math.floor, round: Math.round,
+ clamp: (x, lo, hi) => x < lo ? lo : x > hi ? hi : x,
+ mix: (a, b, t) => a + (b - a) * t,
+ "=": (a, b) => +(a === b), "<": (a, b) => +(a < b), ">": (a, b) => +(a > b),
+ "<=": (a, b) => +(a <= b), ">=": (a, b) => +(a >= b),
+ and: (...v) => +v.every(Boolean), or: (...v) => +v.some(Boolean), not: (a) => +!a,
+ };
+ const words = { pi: Math.PI, tau: Math.PI * 2 };
+ // A few names for `ink`; anything else is three numbers.
+ const inks = { white: [255, 255, 255], black: [0, 0, 0], gray: [128, 128, 128],
+ red: [255, 0, 0], pink: [255, 105, 180], cyan: [0, 255, 255], yellow: [255, 255, 0] };
+
+ const maxDepth = 16;
+ const owner = -1; // a read of the owner's pose, which moves every tick
+ const shapes = { tri: 9, quad: 12, disc: 1, hoop: 2, band: 2, capsule: 7, line: 6 };
+ // Flat shapes: object-space anchors, projected here, drawn as 2D ops.
+ const flats = { ball: 4, limb: 7, ring: 2, drum: 3, stroke: 7, plate: 9, slab: 6 };
+ const forms = new Set(["def", "let", "if", "repeat", "ink", "glow", "move", "rotate",
+ "scale", "radial", "mirror", "revolve", "outline", "nudge", "toward"]);
+ const isStatement = (f) => Array.isArray(f) && (forms.has(f[0]) || f[0] in shapes || f[0] in flats);
+ const union = (...sets) => { const out = new Set(); for (const s of sets) for (const x of s) out.add(x); return out; };
+
+ function compile(source, name = "object") {
+ const program = typeof source === "string" ? read(source) : source;
+ const fail = (why, form) => { throw new Error(`${name}: ${why}${form === undefined ? "" : ` in ${show(form)}`}`); };
+ let slots = detailSlot + 1;
+ // Per slot: does it change from tick to tick? Switches and `detail` don't.
+ const moving = objectInputs.map((n) => !switches.has(n));
+ moving[detailSlot] = false;
+ const isSwitch = (slot) => slot >= 0 && slot < objectInputs.length && switches.has(objectInputs[slot]);
+ const ticks = (reads) => { for (const r of reads) if (r < 0 || moving[r]) return true; return false; };
+ const parts = [];
+
+ // A scope maps a name to a slot or a constant (a `def`).
+ const lookup = (scope, word) => {
+ for (let s = scope; s; s = s.up) if (word in s.names) return s.names[word];
+ return word in words ? { value: words[word] } : null;
+ };
+ const top = { names: Object.fromEntries([...objectInputs, "detail"].map((n, i) => [n, { slot: i }])), up: null };
+
+ // An expression folds to a constant, or is a closure with the slots it reads.
+ function expr(form, scope) {
+ if (typeof form === "number") return { value: form };
+ if (typeof form === "string") {
+ const found = lookup(scope, form);
+ if (!found) fail(`unknown word \`${form}\``);
+ if ("value" in found) return found;
+ const i = found.slot;
+ return { run: (s) => s.v[i], reads: new Set([i]) };
+ }
+ if (!Array.isArray(form) || !form.length) fail("empty expression", form);
+ const [head, ...rest] = form;
+ if (head === "owner") {
+ // (owner part axis): a point of the owner's pose, handed over in object space.
+ const part = rest[0], axis = "xyz".indexOf(rest[1]);
+ if (typeof part !== "string" || axis < 0) fail("owner wants a part and x, y or z", form);
+ return { run: (s) => s.owner?.[part]?.[axis] ?? 0, reads: new Set([owner]) };
+ }
+ const fn = math[head];
+ if (!fn) fail(`unknown function \`${head}\``, form);
+ const args = rest.map((a) => expr(a, scope));
+ if (args.every((a) => "value" in a)) return { value: fn(...args.map((a) => a.value)) };
+ const reads = union(...args.map((a) => a.reads || []));
+ const run = args.map((a) => "value" in a ? () => a.value : a.run);
+ if (run.length === 1) { const [a] = run; return { run: (s) => fn(a(s)), reads }; }
+ if (run.length === 2) { const [a, b] = run; return { run: (s) => fn(a(s), b(s)), reads }; }
+ if (run.length === 3) { const [a, b, c] = run; return { run: (s) => fn(a(s), b(s), c(s)), reads }; }
+ return { run: (s) => fn(...run.map((r) => r(s))), reads };
+ }
+ // A number as a closure; what it reads is added to `reads`.
+ const num = (form, scope, reads) => {
+ const e = expr(form, scope);
+ if ("value" in e) return () => e.value;
+ for (const r of e.reads) reads.add(r);
+ return e.run;
+ };
+
+ // Every statement compiles to a node: its closure, the slots it reads from
+ // outside itself, whether it draws, and how it uses the sticky ink (reads
+ // the ink it came in with; sets it 0 never, 1 maybe, 2 always). `ctx`
+ // follows the ink and glow in effect as the compile walks in run order.
+ const inkIn = (nodes) => { for (const n of nodes) { if (n.inkIn) return true; if (n.inkSets === 2) return false; } return false; };
+ function body(list, scope, depth, ctx) {
+ const inner = { names: {}, up: scope };
+ const nodes = [], entries = [];
+ for (const form of list) {
+ const entry = { ink: ctx.ink, glow: ctx.glow, edge: ctx.edge, nudge: ctx.nudge }, first = parts.length;
+ const node = statement(form, inner, depth, ctx);
+ if (node) { node.parts = [first, parts.length]; nodes.push(node); entries.push(entry); }
+ }
+ const steps = bakeRuns(nodes, entries, depth);
+ const bound = union(...nodes.map((n) => n.binds || []));
+ const reads = union(...nodes.map((n) => n.reads));
+ for (const b of bound) reads.delete(b);
+ return { run: (s) => { for (let i = 0; i < steps.length; i++) steps[i](s); },
+ reads, draws: nodes.some((n) => n.draws), inkIn: inkIn(nodes),
+ inkSets: Math.max(0, ...nodes.map((n) => n.inkSets)) };
+ }
+
+ // Runs of statements that don't move from tick to tick become baked parts.
+ function bakeRuns(nodes, entries, depth) {
+ const steps = [];
+ for (let i = 0; i < nodes.length;) {
+ if (ticks(nodes[i].reads)) { steps.push(nodes[i].run); i++; continue; }
+ let j = i;
+ while (j < nodes.length && !ticks(nodes[j].reads)) j++;
+ const group = nodes.slice(i, j), part = partOf(group, entries[i], depth);
+ if (part) steps.push(part);
+ else for (const n of group) steps.push(n.run);
+ i = j;
+ }
+ return steps;
+ }
+
+ // A part bakes if everything it reads is a switch, `detail`, or bound
+ // inside it, and it knows the ink it starts with.
+ function partOf(group, entry, depth) {
+ if (!group.some((n) => n.draws)) return null;
+ const bound = union(...group.map((n) => n.binds || []));
+ const free = union(...group.map((n) => n.reads));
+ for (const b of bound) free.delete(b);
+ for (const r of free) if (!isSwitch(r) && r !== detailSlot) return null;
+ if (inkIn(group) && !entry.ink) return null;
+ // An outline or nudge that moves per tick can't be baked into it either.
+ if (!entry.edge || entry.nudge === null) return null;
+ // Parts inside this one are baked into it, never emitted on their own.
+ for (const n of group) for (let i = n.parts[0]; i < n.parts[1]; i++) parts[i].inner = true;
+ const part = { depth, runs: group.map((n) => n.run), lets: group.filter((n) => n.binds).map((n) => n.run),
+ switches: [...free].filter(isSwitch), detail: free.has(detailSlot),
+ ink: entry.ink || [255, 255, 255], glow: entry.glow, edge: entry.edge, nudge: entry.nudge, variants: null };
+ parts.push(part);
+ const { runs, lets } = part, at = depth * 13;
+ return (s) => {
+ if (!part.variants || s.rec || (part.meshed && !s.model) || (part.sketched && !s.sketch)) {
+ for (let k = 0; k < runs.length; k++) runs[k](s);
+ return;
+ }
+ for (let k = 0; k < lets.length; k++) lets[k](s); // what follows may read them
+ let index = 0;
+ for (let k = 0; k < part.switches.length; k++) if (s.v[part.switches[k]] >= .5) index |= 1 << k;
+ const v = part.variants[index];
+ if (v.levels[0] >= 0) s.model(v.radius, v.levels[0], v.levels[1], v.levels[2], s.m, at);
+ if (v.shapes >= 0) s.sketch(v.shapes, s.m, at);
+ s.r = v.ink[0]; s.g = v.ink[1]; s.b = v.ink[2];
+ };
+ }
+
+ // A transform pushes a new frame at a depth known here, so nesting is
+ // checked once and the tick never counts.
+ function framed(form, scope, depth, ctx, arity, apply) {
+ if (depth + 1 >= maxDepth) fail(`nested deeper than ${maxDepth}`, form);
+ const reads = new Set();
+ const args = form.slice(1, arity + 1).map((a) => num(a, scope, reads));
+ const inside = body(form.slice(arity + 1), scope, depth + 1, ctx);
+ const from = depth * 13, to = from + 13, run = inside.run;
+ return { ...inside, reads: union(reads, inside.reads), run: (s) => {
+ const m = s.m;
+ for (let i = 0; i < 13; i++) m[to + i] = m[from + i];
+ apply(m, to, args, s);
+ run(s);
+ } };
+ }
+ // An iterator slot: it moves only if the count it walks does.
+ function iterator(scope, word, countReads, rest) {
+ const named = typeof word === "string" && rest > 0 && !lookup(scope, word);
+ if (!named) return -1;
+ const slot = slots++;
+ moving[slot] = ticks(countReads);
+ scope.names[word] = { slot };
+ return slot;
+ }
+ // A body that might run zero times or twice leaves the ink unknown after.
+ const maybe = (ctx, run) => {
+ const inside = run({ ...ctx });
+ if (inside.inkSets) ctx.ink = null;
+ return inside;
+ };
+
+ function statement(form, scope, depth, ctx) {
+ if (!Array.isArray(form)) fail(`\`${show(form)}\` on its own does nothing`);
+ const [head, ...rest] = form;
+ switch (head) {
+ case "def": {
+ // KidLisp's def binds once. Here that is at compile time, so a def
+ // can't read a per-tick input — that is what `let` is for.
+ let value;
+ try { value = expr(rest[1], scope); } catch (e) { fail(e.message.replace(`${name}: `, ""), form); }
+ if (!("value" in value)) fail("def binds once and can't read a per-tick input; use let", form);
+ scope.names[rest[0]] = value;
+ return null;
+ }
+ case "let": {
+ const reads = new Set(), run = num(rest[1], scope, reads), slot = slots++;
+ moving[slot] = ticks(reads);
+ scope.names[rest[0]] = { slot };
+ return { run: (s) => { s.v[slot] = run(s); }, reads, binds: [slot], inkSets: 0 };
+ }
+ case "if": {
+ // No else, as in KidLisp: the body is every form after the test.
+ const reads = new Set(), test = num(rest[0], scope, reads);
+ const inside = maybe(ctx, (c) => body(rest.slice(1), scope, depth, c)), run = inside.run;
+ return { ...inside, reads: union(reads, inside.reads), inkSets: inside.inkSets ? 1 : 0,
+ run: (s) => { if (test(s)) run(s); } };
+ }
+ case "repeat": {
+ const reads = new Set(), times = num(rest[0], scope, reads);
+ const inner = { names: {}, up: scope };
+ const slot = iterator(inner, rest[1], reads, rest.length - 2);
+ const inside = maybe(ctx, (c) => body(rest.slice(slot >= 0 ? 2 : 1), inner, depth, c)), run = inside.run;
+ const own = union(reads, inside.reads);
+ own.delete(slot);
+ return { ...inside, reads: own, inkSets: inside.inkSets ? 1 : 0, run: (s) => {
+ const n = times(s);
+ for (let i = 0; i < n; i++) { if (slot >= 0) s.v[slot] = i; run(s); }
+ } };
+ }
+ case "ink": {
+ if (rest.length === 1 && inks[rest[0]]) {
+ const [r, g, b] = inks[rest[0]];
+ ctx.ink = [r, g, b];
+ return { run: (s) => { s.r = r; s.g = g; s.b = b; }, reads: new Set(), inkSets: 2 };
+ }
+ if (rest.length !== 3) fail("ink wants a name or r g b", form);
+ const known = rest.map((a) => expr(a, scope));
+ ctx.ink = known.every((e) => "value" in e) ? known.map((e) => e.value) : null;
+ const reads = new Set(), [r, g, b] = rest.map((a) => num(a, scope, reads));
+ return { run: (s) => { s.r = r(s); s.g = g(s); s.b = b(s); }, reads, inkSets: 2 };
+ }
+ case "glow": {
+ // Unlit inside: lamps, turbo trim, anything that makes its own light.
+ const was = ctx.glow;
+ ctx.glow = true;
+ const inside = body(rest, scope, depth, ctx), run = inside.run;
+ ctx.glow = was;
+ return { ...inside, run: (s) => { const before = s.glow; s.glow = true; run(s); s.glow = before; } };
+ }
+ case "move": return framed(form, scope, depth, ctx, 3, move);
+ case "rotate": {
+ const axis = "xyz".indexOf(rest[0]);
+ if (axis < 0) fail("rotate wants x, y or z first", form);
+ return framed(["rotate", ...rest.slice(1)], scope, depth, ctx, 1,
+ (m, at, [a], s) => rotate(m, at, axis, a(s)));
+ }
+ case "scale": {
+ // One number scales evenly; three scale each axis (a negative one
+ // mirrors, and faces keep facing out).
+ const three = rest.length >= 3 && !isStatement(rest[1]);
+ return framed(three ? form : ["scale", rest[0], rest[0], rest[0], ...rest.slice(1)],
+ scope, depth, ctx, 3, scale);
+ }
+ case "radial": {
+ // (radial axis n [k] body…): the body n times, turned evenly about the axis.
+ const axis = "xyz".indexOf(rest[0]);
+ if (axis < 0) fail("radial wants x, y or z first", form);
+ if (depth + 1 >= maxDepth) fail(`nested deeper than ${maxDepth}`, form);
+ const reads = new Set(), times = num(rest[1], scope, reads);
+ const inner = { names: {}, up: scope };
+ const slot = iterator(inner, rest[2], reads, rest.length - 3);
+ const inside = maybe(ctx, (c) => body(rest.slice(slot >= 0 ? 3 : 2), inner, depth + 1, c)), run = inside.run;
+ const own = union(reads, inside.reads);
+ own.delete(slot);
+ const from = depth * 13, to = from + 13;
+ return { ...inside, reads: own, inkSets: inside.inkSets ? 1 : 0, run: (s) => {
+ const n = times(s), m = s.m;
+ for (let i = 0; i < n; i++) {
+ for (let k = 0; k < 13; k++) m[to + k] = m[from + k];
+ rotate(m, to, axis, i / n * Math.PI * 2);
+ if (slot >= 0) s.v[slot] = i;
+ run(s);
+ }
+ } };
+ }
+ case "mirror": {
+ // (mirror axis body…): the body, then its reflection across that axis.
+ const axis = "xyz".indexOf(rest[0]);
+ if (axis < 0) fail("mirror wants x, y or z first", form);
+ if (depth + 1 >= maxDepth) fail(`nested deeper than ${maxDepth}`, form);
+ const inside = body(rest.slice(1), scope, depth + 1, ctx), run = inside.run;
+ const from = depth * 13, to = from + 13;
+ return { ...inside, run: (s) => {
+ const m = s.m;
+ for (const side of [1, -1]) {
+ for (let k = 0; k < 13; k++) m[to + k] = m[from + k];
+ if (side < 0) { for (let k = 0; k < 3; k++) m[to + 3 + axis * 3 + k] *= -1; m[to + 12] *= -1; }
+ run(s);
+ }
+ } };
+ }
+ case "outline": {
+ // (outline w [r g b] body…): the body's flat shapes drawn with an ink
+ // edge w world units wide, sized where the scope starts.
+ const lead = rest.findIndex((a) => isStatement(a));
+ const opening = rest.slice(0, lead < 0 ? rest.length : lead);
+ if (opening.length !== 1 && opening.length !== 4) fail("outline wants w, or w r g b", form);
+ const reads = new Set(), [w, r, g, b] = opening.map((a) => num(a, scope, reads));
+ const known = opening.map((a) => expr(a, scope)), wasEdge = ctx.edge;
+ ctx.edge = !wasEdge || !known.every((e) => "value" in e) ? null
+ : [known[0].value, ...(known.length === 4 ? known.slice(1).map((e) => e.value) : wasEdge.slice(1))];
+ const inside = body(rest.slice(opening.length), scope, depth, ctx), run = inside.run;
+ ctx.edge = wasEdge;
+ return { ...inside, reads: union(reads, inside.reads), run: (s) => {
+ const was = s.outline.slice();
+ // Baked, the width stays in world units and the host sizes it.
+ s.outline[0] = s.sketching ? w(s) * frameSize(s.m, depth * 13) : w(s) * scaleAt(s, depth * 13);
+ if (r) { s.outline[1] = r(s); s.outline[2] = g(s); s.outline[3] = b(s); }
+ run(s);
+ s.outline.splice(0, 4, ...was);
+ } };
+ }
+ case "nudge": {
+ // (nudge d body…): the body's flat shapes d world units further back,
+ // to settle what covers what where two shapes share a depth.
+ const reads = new Set(), d = num(rest[0], scope, reads);
+ const known = expr(rest[0], scope), wasNudge = ctx.nudge;
+ ctx.nudge = wasNudge === null || !("value" in known) ? null : wasNudge + known.value;
+ const inside = body(rest.slice(1), scope, depth, ctx), run = inside.run;
+ ctx.nudge = wasNudge;
+ return { ...inside, reads: union(reads, inside.reads), run: (s) => {
+ const was = s.nudge;
+ s.nudge += d(s);
+ run(s);
+ s.nudge = was;
+ } };
+ }
+ case "toward": {
+ // (toward axis body…): the body on whichever side of that axis faces
+ // the camera, so a wheel shows the face you can see.
+ const axis = "xyz".indexOf(rest[0]);
+ if (axis < 0) fail("toward wants x, y or z first", form);
+ if (depth + 1 >= maxDepth) fail(`nested deeper than ${maxDepth}`, form);
+ const inside = body(rest.slice(1), scope, depth + 1, ctx), run = inside.run;
+ const from = depth * 13, to = from + 13;
+ const flip = (m) => { for (let k = 0; k < 3; k++) m[to + 3 + axis * 3 + k] *= -1; m[to + 12] *= -1; };
+ return { ...inside, run: (s) => {
+ const m = s.m;
+ if (s.sketching) {
+ // Baked: both faces, each marked one-sided, and the host shows the
+ // one turned its way.
+ for (const side of [1, -1]) {
+ for (let k = 0; k < 13; k++) m[to + k] = m[from + k];
+ if (side < 0) flip(m);
+ const was = s.facing;
+ s.facing = [m[to + 3 + axis * 3], m[to + 4 + axis * 3], m[to + 5 + axis * 3]];
+ run(s);
+ s.facing = was;
+ }
+ return;
+ }
+ const V = s.view;
+ for (let k = 0; k < 13; k++) m[to + k] = m[from + k];
+ let away = 0;
+ for (let k = 0; k < 3; k++) away += m[to + 3 + axis * 3 + k] * (m[to + k] - V[k]);
+ if (away > 0) flip(m);
+ run(s);
+ } };
+ }
+ case "revolve": {
+ // (revolve axis [turn] r h r h …): a closed profile turned about the axis.
+ const axis = "xyz".indexOf(rest[0]);
+ if (axis < 0) fail("revolve wants x, y or z first", form);
+ if (rest.length < 7) fail("revolve wants at least three r h points", form);
+ const reads = new Set(), args = rest.slice(1).map((a) => num(a, scope, reads));
+ const values = new Float64Array(args.length);
+ return { reads, draws: true, inkIn: true, inkSets: 0, run: (s) => {
+ for (let i = 0; i < args.length; i++) values[i] = args[i](s);
+ revolve(s, depth * 13, axis, values);
+ } };
+ }
+ }
+ if (head in flats) {
+ if (rest.length < flats[head]) fail(`${head} wants ${flats[head]} arguments`, form);
+ const axial = head === "ring" || head === "drum";
+ const axis = axial ? "xyz".indexOf(rest[0]) : -1;
+ if (axial && axis < 0) fail(`${head} wants x, y or z first`, form);
+ const reads = new Set(), args = rest.slice(axial ? 1 : 0).map((a) => num(a, scope, reads));
+ const values = new Float64Array(args.length), draw = flatShapes[head], record = recorders[head];
+ return { reads, draws: true, inkIn: true, inkSets: 0, run: (s) => {
+ for (let i = 0; i < args.length; i++) values[i] = args[i](s);
+ if (s.sketching) record(s, depth * 13, values, axis);
+ else { inkUp(s); draw(s, depth * 13, values, axis); }
+ } };
+ }
+ if (head in shapes) {
+ const want = shapes[head];
+ if (rest.length < want) fail(`${head} wants ${want} numbers`, form);
+ const reads = new Set(), args = rest.map((a) => num(a, scope, reads));
+ const values = new Float64Array(args.length);
+ const draw = primitives[head];
+ return { reads, draws: true, inkIn: true, inkSets: 0, run: (s) => {
+ for (let i = 0; i < args.length; i++) values[i] = args[i](s);
+ draw(s, depth * 13, values);
+ } };
+ }
+ fail(`unknown form \`${head}\``, form);
+ }
+
+ // What the compile knows is in effect as it walks: the ink, glow, and the
+ // ink edge and nudge a part would bake with (null when they move per tick).
+ const run = body(program, top, 0, { ink: [255, 255, 255], glow: false, edge: [0, 24, 20, 30], nudge: 0 }).run;
+ const state = { v: new Float64Array(slots), m: new Float64Array(maxDepth * 13),
+ r: 255, g: 255, b: 255, glow: false, face: null, model: null, owner: null, rec: null,
+ out: null, view: null, nudge: 0, outline: [0, 24, 20, 30], sketch: null, sketching: null, facing: null,
+ inked: [0, 0, 0, 0] };
+
+ // Bake every part: per switch value, per level, run once into a mesh. Twin
+ // meshes (a level that didn't change anything) share one handle.
+ const meshes = [];
+ const store = (mesh) => {
+ const same = meshes.findIndex((o) => o.count === mesh.count &&
+ o.vertices.every((x, i) => x === mesh.vertices[i]) && o.faces.every((x, i) => x === mesh.faces[i]));
+ return same >= 0 ? same : meshes.push(mesh) - 1;
+ };
+ const sketches = [];
+ const keep = (shapes) => {
+ const same = sketches.findIndex((o) => o.count === shapes.count && o.records.length === shapes.records.length &&
+ o.records.every((x, i) => x === shapes.records[i]));
+ return same >= 0 ? same : sketches.push(shapes) - 1;
+ };
+ const outer = parts.filter((part) => !part.inner);
+ for (const part of outer) {
+ part.variants = [];
+ for (let index = 0; index < 1 << part.switches.length; index++) {
+ part.switches.forEach((slot, k) => { state.v[slot] = (index >> k) & 1; });
+ const levels = [];
+ let radius = 0, ink = part.ink, shapes = -1;
+ for (let level = 0; level < objectLevels; level++) {
+ if (level && !part.detail) { levels.push(levels[0]); continue; }
+ state.v[detailSlot] = level;
+ state.m.set(identity, part.depth * 13);
+ [state.r, state.g, state.b] = part.ink;
+ state.glow = part.glow;
+ state.rec = builder();
+ state.sketching = sketcher();
+ state.outline.splice(0, 4, ...part.edge);
+ state.nudge = part.nudge;
+ state.facing = null;
+ for (const step of part.runs) step(state);
+ const mesh = state.rec.done(), sketch = state.sketching.done();
+ state.rec = state.sketching = null;
+ if (!level) {
+ ink = [state.r, state.g, state.b];
+ // Flat shapes don't change with level: the host picks their sides.
+ shapes = sketch.count ? keep(sketch) : -1;
+ }
+ radius = Math.max(radius, mesh.radius);
+ levels.push(mesh.count ? store(mesh) : -1);
+ }
+ part.meshed ||= levels[0] >= 0;
+ part.sketched ||= shapes >= 0;
+ part.variants.push({ levels, radius, ink, shapes });
+ }
+ }
+ state.v.fill(0);
+
+ // place: origin, then where object x, y and z point, in world space —
+ // twelve numbers, as the game's rig frames already know them. out: a face
+ // function (everything drawn as WORLD faces, level 0), or { face, model }.
+ function object(inputs, place, out) {
+ const v = state.v, m = state.m;
+ for (let i = 0; i < objectInputs.length; i++) {
+ const x = inputs[objectInputs[i]];
+ v[i] = x === undefined ? unset[i] : +x;
+ }
+ v[detailSlot] = 0;
+ for (let i = 0; i < 12; i++) m[i] = place[i];
+ m[12] = handedness(m, 0);
+ state.r = state.g = state.b = 255;
+ state.glow = false;
+ state.face = typeof out === "function" ? out : out.face;
+ state.model = typeof out === "function" ? null : out.model || null;
+ state.sketch = typeof out === "function" ? null : out.sketch || null;
+ state.owner = inputs.owner || null;
+ state.out = out;
+ state.view = out.view || null;
+ state.nudge = 0;
+ state.outline[0] = 0;
+ state.inked[0] = 0;
+ run(state);
+ if (state.inked[0]) out.outline(0, 0, 0, 0); // no ink edge left on for what follows
+ }
+ object.meshes = meshes;
+ object.sketches = sketches;
+ object.parts = outer.length;
+ return object;
+ }
+
+ const identity = [0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1, 1];
+
+ // ——— frames: 13 numbers each, origin · x axis · y axis · z axis · winding ———
+ // The winding flag is -1 under a mirror, so a face that faced out still does.
+
+ function handedness(m, at) {
+ const ax = m[at + 3], ay = m[at + 4], az = m[at + 5];
+ const bx = m[at + 6], by = m[at + 7], bz = m[at + 8];
+ const cx = m[at + 9], cy = m[at + 10], cz = m[at + 11];
+ return ax * (by * cz - bz * cy) - ay * (bx * cz - bz * cx) + az * (bx * cy - by * cx) < 0 ? -1 : 1;
+ }
+ function move(m, at, [x, y, z], s) {
+ const dx = x(s), dy = y(s), dz = z(s);
+ for (let k = 0; k < 3; k++) m[at + k] += dx * m[at + 3 + k] + dy * m[at + 6 + k] + dz * m[at + 9 + k];
+ }
+ function scale(m, at, [x, y, z], s) {
+ const fx = x(s), fy = y(s), fz = z(s);
+ for (let k = 0; k < 3; k++) { m[at + 3 + k] *= fx; m[at + 6 + k] *= fy; m[at + 9 + k] *= fz; }
+ m[at + 12] = handedness(m, at);
+ }
+ // Right-handed turns: about x carries y toward z, about y carries z toward x,
+ // about z carries x toward y.
+ function rotate(m, at, axis, angle) {
+ const c = Math.cos(angle), sn = Math.sin(angle);
+ const p = at + 3 + ((axis + 1) % 3) * 3, q = at + 3 + ((axis + 2) % 3) * 3;
+ for (let k = 0; k < 3; k++) {
+ const u = m[p + k], w = m[q + k];
+ m[p + k] = u * c + w * sn;
+ m[q + k] = w * c - u * sn;
+ }
+ }
+
+ // ——— faces ———
+
+ // A baked mesh in the ASSET layout: vertices (x y z), and per face four ids
+ // (a triangle repeats its third), the unlit rgb, and a unit normal — zero for
+ // a glowing face, which a host draws unlit.
+ function builder() {
+ const vertices = [], faces = [], ids = new Map();
+ let radius = 0;
+ const id = (w, o) => {
+ const key = `${w[o]},${w[o + 1]},${w[o + 2]}`;
+ let i = ids.get(key);
+ if (i === undefined) {
+ i = vertices.length / 3;
+ ids.set(key, i);
+ vertices.push(w[o], w[o + 1], w[o + 2]);
+ radius = Math.max(radius, Math.hypot(w[o], w[o + 1], w[o + 2]));
+ }
+ return i;
+ };
+ return {
+ add(w, n, glow, r, g, b, nx, ny, nz) {
+ const a = id(w, 0), bb = id(w, 3), c = id(w, 6);
+ faces.push(a, bb, c, n === 4 ? id(w, 9) : c, r, g, b, glow ? 0 : nx, glow ? 0 : ny, glow ? 0 : nz);
+ },
+ done: () => ({ vertices: Float64Array.from(vertices), faces: Float64Array.from(faces),
+ count: faces.length / 10, radius }),
+ };
+ }
+
+ const world = new Float64Array(12);
+ function put(m, at, o, x, y, z) {
+ for (let k = 0; k < 3; k++) world[o + k] = m[at + k] + x * m[at + 3 + k] + y * m[at + 6 + k] + z * m[at + 9 + k];
+ }
+ // A triangle (n 3) or quad (n 4) already in `world`, wound to face out: shaded
+ // by the game's rule and emitted, or kept for a bake. No area, nothing drawn.
+ function finish(s, n) {
+ const w = world;
+ const ux = w[3] - w[0], uy = w[4] - w[1], uz = w[5] - w[2];
+ const vx = w[6] - w[0], vy = w[7] - w[1], vz = w[8] - w[2];
+ const nx = uy * vz - uz * vy, ny = uz * vx - ux * vz, nz = ux * vy - uy * vx;
+ const length = Math.hypot(nx, ny, nz);
+ if (!length) return;
+ if (s.rec) { s.rec.add(w, n, s.glow, s.r, s.g, s.b, nx / length, ny / length, nz / length); return; }
+ let r = s.r, g = s.g, b = s.b;
+ if (!s.glow) {
+ const toward = -(nx * sun[0] + ny * sun[1] + nz * sun[2]) / length;
+ const k = .72 + (toward > 0 ? toward : 0) * .28;
+ r = Math.round(r * k); g = Math.round(g * k); b = Math.round(b * k);
+ }
+ s.face(w[0], w[1], w[2], w[3], w[4], w[5], w[6], w[7], w[8], r, g, b);
+ if (n === 4) s.face(w[0], w[1], w[2], w[6], w[7], w[8], w[9], w[10], w[11], r, g, b);
+ }
+ function tri(s, at, ax, ay, az, bx, by, bz, cx, cy, cz) {
+ const m = s.m;
+ put(m, at, 0, ax, ay, az);
+ if (m[at + 12] < 0) { put(m, at, 3, cx, cy, cz); put(m, at, 6, bx, by, bz); }
+ else { put(m, at, 3, bx, by, bz); put(m, at, 6, cx, cy, cz); }
+ finish(s, 3);
+ }
+ // A quad is two faces lit as one, off its first three corners, as worldQuad
+ // does. A corner on an axis (a revolve's cap) makes it a triangle.
+ function quad(s, at, ax, ay, az, bx, by, bz, cx, cy, cz, dx, dy, dz) {
+ const same = (x1, y1, z1, x2, y2, z2) => x1 === x2 && y1 === y2 && z1 === z2;
+ if (same(ax, ay, az, bx, by, bz)) return tri(s, at, ax, ay, az, cx, cy, cz, dx, dy, dz);
+ if (same(bx, by, bz, cx, cy, cz) || same(cx, cy, cz, dx, dy, dz)) return tri(s, at, ax, ay, az, bx, by, bz, dx, dy, dz);
+ if (same(dx, dy, dz, ax, ay, az)) return tri(s, at, ax, ay, az, bx, by, bz, cx, cy, cz);
+ const m = s.m;
+ put(m, at, 0, ax, ay, az);
+ if (m[at + 12] < 0) { put(m, at, 3, dx, dy, dz); put(m, at, 6, cx, cy, cz); put(m, at, 9, bx, by, bz); }
+ else { put(m, at, 3, bx, by, bz); put(m, at, 6, cx, cy, cz); put(m, at, 9, dx, dy, dz); }
+ finish(s, 4);
+ }
+ // Sides asked for (at least `least`), or picked by radius and level — the
+ // only place a level changes a shape.
+ const levelShare = [1, .67, .5];
+ const sidesFor = (s, r, v, i, least = 3) => v.length > i ? Math.min(64, Math.max(least, Math.floor(v[i])))
+ : Math.max(3, Math.round((r < 6 ? 6 : r < 13 ? 8 : r < 26 ? 12 : 16) * levelShare[s.v[detailSlot]]));
+
+ const primitives = {
+ tri: (s, at, v) => tri(s, at, v[0], v[1], v[2], v[3], v[4], v[5], v[6], v[7], v[8]),
+ quad: (s, at, v) => quad(s, at, v[0], v[1], v[2], v[3], v[4], v[5], v[6], v[7], v[8], v[9], v[10], v[11]),
+ // (disc r [sides]) — flat, at the origin, facing +z.
+ disc: (s, at, v) => {
+ const r = v[0], n = sidesFor(s, r, v, 1);
+ for (let i = 0; i < n; i++) {
+ const a = i / n * Math.PI * 2, b = (i + 1) / n * Math.PI * 2;
+ tri(s, at, 0, 0, 0, Math.cos(a) * r, Math.sin(a) * r, 0, Math.cos(b) * r, Math.sin(b) * r, 0);
+ }
+ },
+ // (hoop inner outer [sides]) — a flat ring facing +z.
+ hoop: (s, at, v) => {
+ const r1 = v[0], r2 = v[1], n = sidesFor(s, r2, v, 2);
+ for (let i = 0; i < n; i++) {
+ const a = i / n * Math.PI * 2, b = (i + 1) / n * Math.PI * 2;
+ const ca = Math.cos(a), sa = Math.sin(a), cb = Math.cos(b), sb = Math.sin(b);
+ quad(s, at, ca * r1, sa * r1, 0, ca * r2, sa * r2, 0, cb * r2, sb * r2, 0, cb * r1, sb * r1, 0);
+ }
+ },
+ // (band radius width [sides] [turn]) — a tube's outside around z; `turn`
+ // (0–1) draws only that much of it, from +x toward +y.
+ band: (s, at, v) => {
+ const r = v[0], h = v[1] / 2, n = sidesFor(s, r, v, 2, 1), turn = v.length > 3 ? v[3] : 1;
+ for (let i = 0; i < n; i++) {
+ const a = i / n * Math.PI * 2 * turn, b = (i + 1) / n * Math.PI * 2 * turn;
+ const ca = Math.cos(a) * r, sa = Math.sin(a) * r, cb = Math.cos(b) * r, sb = Math.sin(b) * r;
+ quad(s, at, ca, sa, -h, cb, sb, -h, cb, sb, h, ca, sa, h);
+ }
+ },
+ // (capsule x1 y1 z1 x2 y2 z2 width [sides]) — a rod between two points.
+ capsule: (s, at, v) => rod(s, at, v, v[6], v.length > 7 ? v[7] : 6),
+ // (line x1 y1 z1 x2 y2 z2 [width]) — a thin three-sided rod.
+ line: (s, at, v) => rod(s, at, v, v.length > 6 ? v[6] : 1.5, 3),
+ };
+
+ // A closed (radius, height) profile turned about an axis. Walked either way:
+ // it is turned counter-clockwise (radius right, height up) so faces face out.
+ // An odd count leads with `turn`, the share of a full circle to sweep.
+ const corner = new Float64Array(12);
+ function revolve(s, at, axis, v) {
+ const turn = v.length % 2 ? v[0] : 1, from = v.length % 2, n = (v.length - from) / 2;
+ let area = 0, reach = 0;
+ for (let i = 0; i < n; i++) {
+ const r = v[from + i * 2], h = v[from + i * 2 + 1];
+ const r2 = v[from + ((i + 1) % n) * 2], h2 = v[from + ((i + 1) % n) * 2 + 1];
+ area += r * h2 - r2 * h;
+ reach = Math.max(reach, r);
+ }
+ const sides = Math.max(1, Math.round(sidesFor(s, reach, [], 0) * turn));
+ const u = (axis + 1) % 3, w = (axis + 2) % 3;
+ const point = (o, angle, r, h) => {
+ corner[o + u] = Math.cos(angle) * r; corner[o + w] = Math.sin(angle) * r; corner[o + axis] = h;
+ };
+ for (let e = 0; e < n; e++) {
+ const p = area < 0 ? n - 1 - e : e, q = area < 0 ? (2 * n - 2 - e) % n : (e + 1) % n;
+ const r1 = v[from + p * 2], h1 = v[from + p * 2 + 1], r2 = v[from + q * 2], h2 = v[from + q * 2 + 1];
+ if (!r1 && !r2) continue;
+ for (let i = 0; i < sides; i++) {
+ const a = i / sides * Math.PI * 2 * turn, b = (i + 1) / sides * Math.PI * 2 * turn;
+ point(0, a, r1, h1); point(3, b, r1, h1); point(6, b, r2, h2); point(9, a, r2, h2);
+ const c = corner;
+ quad(s, at, c[0], c[1], c[2], c[3], c[4], c[5], c[6], c[7], c[8], c[9], c[10], c[11]);
+ }
+ }
+ }
+
+ function rod(s, at, v, width, sides) {
+ let dx = v[3] - v[0], dy = v[4] - v[1], dz = v[5] - v[2];
+ const length = Math.hypot(dx, dy, dz);
+ if (length < 1e-6) return;
+ dx /= length; dy /= length; dz /= length;
+ // u: any unit vector across the rod; w = d × u completes the frame.
+ let ux = -dy, uy = dx, uz = 0;
+ if (Math.abs(dz) > .9) { ux = 0; uy = -dz; uz = dy; }
+ const um = Math.hypot(ux, uy, uz); ux /= um; uy /= um; uz /= um;
+ const wx = dy * uz - dz * uy, wy = dz * ux - dx * uz, wz = dx * uy - dy * ux;
+ const r = width / 2, n = Math.max(3, Math.floor(sides));
+ for (let i = 0; i < n; i++) {
+ const a = i / n * Math.PI * 2, b = (i + 1) / n * Math.PI * 2;
+ const ca = Math.cos(a) * r, sa = Math.sin(a) * r, cb = Math.cos(b) * r, sb = Math.sin(b) * r;
+ const ax = ux * ca + wx * sa, ay = uy * ca + wy * sa, az = uz * ca + wz * sa;
+ const bx = ux * cb + wx * sb, by = uy * cb + wy * sb, bz = uz * cb + wz * sb;
+ quad(s, at, v[0] + ax, v[1] + ay, v[2] + az, v[0] + bx, v[1] + by, v[2] + bz,
+ v[3] + bx, v[4] + by, v[5] + bz, v[3] + ax, v[4] + ay, v[5] + az);
+ }
+ }
+
+ // ——— flat shapes ———
+ // Anchors go through the camera the frame program already carries (the
+ // CAMERA op's 24 numbers, `out.view`), by frame-vm's own projection, and come
+ // out as 2D ops with one flat depth each: out.disc, out.capsule,
+ // out.ellipse, out.plate, out.outline. No lighting; ink is the colour.
+
+ const seen = new Float64Array(4 * 16);
+ // Project a point of the current frame: x, y, depth, and px per world unit.
+ function see(s, at, x, y, z, o) {
+ put(s.m, at, 0, x, y, z);
+ const V = s.view;
+ const dx = world[0] - V[0], dy = world[1] - V[1], dz = world[2] - V[2];
+ const vz = dx * V[9] + dy * V[10] + dz * V[11];
+ if (vz < V[19]) return false;
+ const vx = dx * V[3] + dy * V[4] + dz * V[5], vy = dx * V[6] + dy * V[7] + dz * V[8];
+ const k = V[14] + (V[15] / vz - V[14]) * V[16];
+ seen[o] = V[12] + vx * k; seen[o + 1] = V[13] - vy * k; seen[o + 2] = vz; seen[o + 3] = k;
+ return true;
+ }
+ const depthOf = (s, vz) => {
+ const z = (vz + s.nudge) * s.view[17] + s.view[18];
+ return z < -1.499 ? -1.499 : z > 1.4 ? 1.4 : z;
+ };
+ // How big a unit of this frame is in world units (its axes' mean length).
+ function frameSize(m, at) {
+ const ax = m[at + 3], ay = m[at + 4], az = m[at + 5], bx = m[at + 6], by = m[at + 7], bz = m[at + 8];
+ const cx = m[at + 9], cy = m[at + 10], cz = m[at + 11];
+ return Math.cbrt(Math.abs(ax * (by * cz - bz * cy) - ay * (bx * cz - bz * cx) + az * (bx * cy - by * cx)));
+ }
+ // Px per unit of this frame at its origin.
+ function scaleAt(s, at) { return see(s, at, 0, 0, 0, 0) ? seen[3] * frameSize(s.m, at) : 0; }
+ // A circle about `axis`, `along` it from the frame's origin, as its centre
+ // (seen 0) and two conjugate half-axes (the points at seen 4 and seen 8).
+ function circle(s, at, axis, r, along = 0) {
+ const u = (axis + 1) % 3, w = (axis + 2) % 3, c = [0, 0, 0];
+ c[axis] = along;
+ const p = c.slice(), q = c.slice();
+ p[u] = r; q[w] = r;
+ return see(s, at, c[0], c[1], c[2], 0) && see(s, at, p[0], p[1], p[2], 4) && see(s, at, q[0], q[1], q[2], 8);
+ }
+ // A sketch being baked: records in the SHAPES layout (frame-vm.mjs, op 18),
+ // anchors in the part's own space.
+ function sketcher() {
+ const records = [];
+ let count = 0;
+ return {
+ add(s, kind, ...geometry) {
+ const f = s.facing || [0, 0, 0];
+ records.push(kind, s.outline[0], s.outline[1], s.outline[2], s.outline[3], s.nudge,
+ s.r, s.g, s.b, f[0], f[1], f[2], ...geometry);
+ count++;
+ },
+ done: () => ({ count, records: Float64Array.from(records) }),
+ };
+ }
+ const SHAPE = { ball: 1, limb: 2, ring: 3, plate: 4, drum: 5 };
+ // A point of the frame, in the part's space (baking) — into world[o…].
+ const at3 = (s, at, x, y, z) => { put(s.m, at, 0, x, y, z); return [world[0], world[1], world[2]]; };
+ // An axis of the frame times a length: a vector in the part's space.
+ const axis3 = (s, at, axis, length) => [0, 1, 2].map((k) => s.m[at + 3 + axis * 3 + k] * length);
+ const recorders = {
+ ball: (s, at, v) => s.sketching.add(s, SHAPE.ball, ...at3(s, at, v[0], v[1], v[2]), v[3] * frameSize(s.m, at)),
+ limb: (s, at, v) => s.sketching.add(s, SHAPE.limb, ...at3(s, at, v[0], v[1], v[2]), ...at3(s, at, v[3], v[4], v[5]),
+ v[6] * frameSize(s.m, at)),
+ ring: (s, at, v, axis) => s.sketching.add(s, SHAPE.ring, ...at3(s, at, 0, 0, 0),
+ ...axis3(s, at, (axis + 1) % 3, v[0]), ...axis3(s, at, (axis + 2) % 3, v[0])),
+ drum: (s, at, v, axis) => s.sketching.add(s, SHAPE.drum, ...at3(s, at, 0, 0, 0),
+ ...axis3(s, at, (axis + 1) % 3, v[0]), ...axis3(s, at, (axis + 2) % 3, v[0]), ...axis3(s, at, axis, v[1] / 2)),
+ stroke: (s, at, v) => {
+ for (let i = 1; i + 5 < v.length; i += 3)
+ s.sketching.add(s, SHAPE.limb, ...at3(s, at, v[i], v[i + 1], v[i + 2]), ...at3(s, at, v[i + 3], v[i + 4], v[i + 5]),
+ v[0] / 2 * frameSize(s.m, at));
+ },
+ plate: (s, at, v) => {
+ const n = Math.min(16, Math.floor(v.length / 3)), points = [];
+ for (let i = 0; i < n; i++) points.push(...at3(s, at, v[i * 3], v[i * 3 + 1], v[i * 3 + 2]));
+ s.sketching.add(s, SHAPE.plate, n, ...points);
+ },
+ // A box baked as its six faces, each one-sided: the host shows the three
+ // turned its way, each outlined, which reads as a drawn box.
+ slab: (s, at, v) => {
+ const lo = [v[0], v[1], v[2]], hi = [v[3], v[4], v[5]], was = s.facing;
+ for (let axis = 0; axis < 3; axis++) for (const end of [lo, hi]) {
+ const u = (axis + 1) % 3, w = (axis + 2) % 3, sign = end === hi ? 1 : -1;
+ const corner = (a, b) => { const c = [0, 0, 0]; c[axis] = end[axis]; c[u] = a ? hi[u] : lo[u]; c[w] = b ? hi[w] : lo[w]; return at3(s, at, ...c); };
+ s.facing = axis3(s, at, axis, sign * Math.sign(hi[axis] - lo[axis] || 1));
+ s.sketching.add(s, SHAPE.plate, 4, ...corner(0, 0), ...corner(1, 0), ...corner(1, 1), ...corner(0, 1));
+ }
+ s.facing = was;
+ },
+ };
+ // The ink edge in effect, sent as an OUTLINE op only when a shape drawn this
+ // tick needs a different one than the host already has.
+ function inkUp(s) {
+ const o = s.outline, sent = s.inked;
+ if (o[0] === sent[0] && (!o[0] || (o[1] === sent[1] && o[2] === sent[2] && o[3] === sent[3]))) return;
+ sent[0] = o[0]; sent[1] = o[1]; sent[2] = o[2]; sent[3] = o[3];
+ s.out.outline(o[0], o[1], o[2], o[3]);
+ }
+ const flatShapes = {
+ // (ball x y z r)
+ ball: (s, at, v) => {
+ if (!see(s, at, v[0], v[1], v[2], 0)) return;
+ // As an ELLIPSE, the fan a baked ball gets, so both paths draw it alike.
+ const rad = v[3] * seen[3] * frameSize(s.m, at);
+ s.out.ellipse(seen[0], seen[1], depthOf(s, seen[2]), rad, 0, 0, rad, s.r, s.g, s.b);
+ },
+ // (limb x1 y1 z1 x2 y2 z2 r): a stadium between two ends
+ limb: (s, at, v) => {
+ if (!see(s, at, v[0], v[1], v[2], 0) || !see(s, at, v[3], v[4], v[5], 4)) return;
+ s.out.capsule(seen[0], seen[1], seen[4], seen[5], depthOf(s, (seen[2] + seen[6]) / 2),
+ v[6] * (seen[3] + seen[7]) * frameSize(s.m, at), s.r, s.g, s.b);
+ },
+ // (ring axis r): a circle about the axis, as its projected ellipse
+ ring: (s, at, v, axis) => {
+ if (!circle(s, at, axis, v[0])) return;
+ s.out.ellipse(seen[0], seen[1], depthOf(s, seen[2]), seen[4] - seen[0], seen[5] - seen[1],
+ seen[8] - seen[0], seen[9] - seen[1], s.r, s.g, s.b);
+ },
+ // (drum axis r width): a cylinder as its silhouette — the far end's
+ // ellipse, the band between the two ends' tangent points, the near end's
+ // ellipse — each at its own depth, so the near end covers the band.
+ drum: (s, at, v, axis) => {
+ const caps = [];
+ for (const side of [-1, 1]) {
+ if (!circle(s, at, axis, v[0], side * v[1] / 2)) return;
+ caps.push({ x: seen[0], y: seen[1], vz: seen[2], ax: seen[4] - seen[0], ay: seen[5] - seen[1],
+ bx: seen[8] - seen[0], by: seen[9] - seen[1] });
+ }
+ caps.sort((p, q) => q.vz - p.vz);
+ const [far, near] = caps, cap = (c) => s.out.ellipse(c.x, c.y, depthOf(s, c.vz), c.ax, c.ay, c.bx, c.by, s.r, s.g, s.b);
+ cap(far);
+ // Where each end's ellipse runs parallel to the drum's length.
+ const dx = near.x - far.x, dy = near.y - far.y;
+ if (Math.hypot(dx, dy) > .5) {
+ const tangent = (c) => {
+ const t = Math.atan2(c.bx * dy - c.by * dx, c.ax * dy - c.ay * dx);
+ return [c.ax * Math.cos(t) + c.bx * Math.sin(t), c.ay * Math.cos(t) + c.by * Math.sin(t)];
+ };
+ const [fx, fy] = tangent(far), [nx, ny] = tangent(near);
+ s.out.plate(4, [far.x + fx, far.y + fy, near.x + nx, near.y + ny, near.x - nx, near.y - ny,
+ far.x - fx, far.y - fy], depthOf(s, (far.vz + near.vz) / 2), s.r, s.g, s.b);
+ }
+ cap(near);
+ },
+ // (stroke w x y z x y z …): a thick polyline, w world units wide
+ stroke: (s, at, v) => {
+ for (let i = 1; i + 5 < v.length; i += 3) {
+ if (!see(s, at, v[i], v[i + 1], v[i + 2], 0) || !see(s, at, v[i + 3], v[i + 4], v[i + 5], 4)) continue;
+ s.out.capsule(seen[0], seen[1], seen[4], seen[5], depthOf(s, (seen[2] + seen[6]) / 2),
+ v[0] * (seen[3] + seen[7]) / 2 * frameSize(s.m, at), s.r, s.g, s.b);
+ }
+ },
+ // (plate x y z …): a flat polygon through projected points
+ plate: (s, at, v) => {
+ const n = Math.min(16, Math.floor(v.length / 3)), points = [];
+ let vz = 0;
+ for (let i = 0; i < n; i++) {
+ if (!see(s, at, v[i * 3], v[i * 3 + 1], v[i * 3 + 2], 0)) return;
+ points.push(seen[0], seen[1]);
+ vz += seen[2];
+ }
+ s.out.plate(n, points, depthOf(s, vz / n), s.r, s.g, s.b);
+ },
+ // (slab x1 y1 z1 x2 y2 z2): a box as its silhouette — the hull of its
+ // eight projected corners, one flat plate.
+ slab: (s, at, v) => {
+ const corners = [];
+ let vz = 0;
+ for (let i = 0; i < 8; i++) {
+ if (!see(s, at, v[i & 1 ? 3 : 0], v[i & 2 ? 4 : 1], v[i & 4 ? 5 : 2], 0)) return;
+ corners.push([seen[0], seen[1]]);
+ vz += seen[2];
+ }
+ const hull = convexHull(corners);
+ s.out.plate(hull.length, hull.flat(), depthOf(s, vz / 8), s.r, s.g, s.b);
+ },
+ };
+ // Andrew's monotone chain.
+ function convexHull(points) {
+ const p = points.slice().sort((a, b) => a[0] - b[0] || a[1] - b[1]);
+ const cross = (o, a, b) => (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]);
+ const lower = [], upper = [];
+ for (const q of p) { while (lower.length > 1 && cross(lower.at(-2), lower.at(-1), q) <= 0) lower.pop(); lower.push(q); }
+ for (const q of p.reverse()) { while (upper.length > 1 && cross(upper.at(-2), upper.at(-1), q) <= 0) upper.pop(); upper.push(q); }
+ return lower.slice(0, -1).concat(upper.slice(0, -1));
+ }
+ return { objectInputs, objectLevels, objectLight, read, compile };
+ })();
+ const sources = {
+ puppy: "; puppy-flat — fia's pup, drawn flat: balls and limbs hung on a small\n; skeleton, inked at the silhouette. x forward (nose), y up, z to the pup's\n; right, the origin on the floor under the middle of the body.\n;\n; Every shape is still; only the frames move. The game hands the pose over as\n; the owner's parts (three numbers each, radians unless noted), so each bone\n; is one baked sketch under a moving frame: a tick is about a dozen SKETCH\n; ops and nothing is projected in JS.\n;\n; body bob (units) · pitch about the hips (nose up +) · roll along the spine\n; head yaw · nod (up +) · cock (the curious tilt)\n; ears flop (out from the head) · perk (forward)\n; tail wag · droop (down +)\n; fl fr bl br swing (paw forward +) · splay (out to the side +)\n; face eyes open (0/1) · mouth open (0/1) · tongue (0/1)\n;\n; fiapup.js mirrors the measurements it needs (neck, snout) in `pupRig`;\n; change them together.\n\ndef stand 21\ndef hip 9\ndef side 5.2\ndef leg 14\ndef edge 1.3\n\n(let bob (owner body x))\n(let pitch (owner body y))\n(let roll (owner body z))\n\n; the shadow stays on the floor, behind the pup's own shapes\n(nudge 40\n (ink 58 104 60)\n (scale 1.9 1 1.25 (ring y 13)))\n\n(move 0 (+ stand bob) 0\n (rotate x roll\n (move (- hip) 0 0 (rotate z pitch (move hip 0 0\n\n ; far legs first, near legs last: depth sorts them anyway, the order\n ; only settles ties\n (move hip -3 (- side) (rotate z (owner fl x) (rotate x (owner fl y))\n (ink 226 170 110) (limb 0 0 0 0 (- leg) 0 3.1)\n (ink 250 236 214) (ball .8 (- -.5 leg) 0 3.4)))\n (move hip -3 side (rotate z (owner fr x) (rotate x (- (owner fr y)))\n (ink 226 170 110) (limb 0 0 0 0 (- leg) 0 3.1)\n (ink 250 236 214) (ball .8 (- -.5 leg) 0 3.4)))\n (move (- hip) -2 (- side) (rotate z (owner bl x) (rotate x (owner bl y))\n (ink 214 156 98) (limb 0 0 0 0 (- -2.4 leg) 0 3.5)\n (ink 250 236 214) (ball .8 (- -3.4 leg) 0 3.6)))\n (move (- hip) -2 side (rotate z (owner br x) (rotate x (- (owner br y)))\n (ink 214 156 98) (limb 0 0 0 0 (- -2.4 leg) 0 3.5)\n (ink 250 236 214) (ball .8 (- -3.4 leg) 0 3.6)))\n\n ; the body: a bean with a cream chest, a brown saddle, a red collar\n (ink 232 178 118)\n (outline edge 74 44 30 (limb -8 0 0 8 1 0 9.5))\n (nudge -1.5\n (ink 250 236 214) (ball 9 -2.5 0 6.5)\n (ink 168 104 62) (ball -4 5.5 0 6))\n (nudge -3\n (ink 222 58 64) (limb 12 5 -5 12 5 5 1.7)\n (ink 255 208 70) (ball 13.6 2.5 0 1.6))\n\n ; the tail, from the rump, up and back\n (move -15 3 0 (rotate y (owner tail x) (rotate z (owner tail y)\n (ink 214 156 98)\n (outline edge 74 44 30 (limb 0 0 0 -6 6 0 2.3))\n (ink 250 236 214) (ball -6.5 6.8 0 2.4))))\n\n ; the head on the neck, turned, nodded and cocked\n (move 14 9 0 (rotate y (owner head x) (rotate z (owner head y) (rotate x (owner head z)\n (ink 232 178 118)\n (outline edge 74 44 30 (ball 0 0 0 9))\n (ink 250 236 214) (ball 7 -3 0 5.4)\n (nudge -1 (ink 40 28 30) (ball 12 -1.4 0 2.3))\n (if (> (owner face x) .5)\n (ink 36 26 30) (ball 5.2 2.6 -4.3 1.8) (ball 5.2 2.6 4.3 1.8)\n (nudge -.5 (ink 255 255 255) (ball 6 3.3 -4.5 .6) (ball 6 3.3 4.5 .6)))\n (if (< (owner face x) .5)\n (ink 60 38 34) (limb 4.4 2.3 -5 6.6 2.3 -3.6 .5) (limb 4.4 2.3 5 6.6 2.3 3.6 .5))\n (if (> (owner face z) .5)\n (ink 236 96 120) (limb 9 -6 0 10 -9.5 0 1.9))\n ; floppy ears, the brown of the saddle\n (move -1 5.5 -6 (rotate x (owner ears x) (rotate z (owner ears y)\n (ink 168 104 62) (outline edge 74 44 30 (limb 0 0 0 0 -8 -2.4 3.3)))))\n (move -1 5.5 6 (rotate x (- (owner ears x)) (rotate z (owner ears y)\n (ink 168 104 62) (outline edge 74 44 30 (limb 0 0 0 0 -8 2.4 3.3))))))))))))))\n",
+ yard: "; yard-flat — the pup's backyard, drawn flat. World space: x across, y up,\n; z toward the camera; the lawn runs from the fence (z -220) forward past\n; where the camera stands. All of it is still, so it bakes into one sketch\n; and a tick sends one SKETCH for the whole yard.\n;\n; Big flat shapes carry one depth each, so the ground is pushed far back\n; (nudge) and everything that stands on it draws over it. The fence and the\n; hills behind it are pushed back less than the lawn but more than anything\n; the pup can reach.\n\ndef tile 80\ndef fence -220\n\n; past the fence: a meadow, and a hedge along it\n(nudge 6000\n (ink 146 188 108)\n (plate -1600 0 -1500 1600 0 -1500 1600 0 -222 -1600 0 -222))\n(nudge 3200\n (outline 1.6 52 96 58\n (repeat 19 i\n (ink (mix 104 92 (% i 2)) (mix 166 152 (% i 2)) (mix 92 84 (% i 2)))\n (ball (- (* i 70) 630) 18 -262 (+ 30 (* 6 (% i 3)))))))\n\n; a round little tree over the fence, back left\n(nudge 3400\n (ink 132 92 66) (limb -250 0 -300 -250 120 -300 7)\n (ink 96 158 88)\n (outline 1.6 52 96 58\n (ball -250 150 -300 40) (ball -216 130 -290 28) (ball -284 132 -290 30)))\n\n; the fence: posts and two rails across the back and down both sides\n(nudge 3000\n (ink 244 232 208)\n (outline 1.2 150 120 96\n (repeat 9 i\n (plate (- (* i tile) 326) 0 fence (- (* i tile) 314) 0 fence\n (- (* i tile) 314) 84 fence (- (* i tile) 326) 84 fence))\n (plate -330 50 fence 330 50 fence 330 58 fence -330 58 fence)\n (plate -330 20 fence 330 20 fence 330 28 fence -330 28 fence)\n (mirror x\n (repeat 5 j\n (plate 320 0 (- (* (+ j 1) tile) 226) 320 0 (- (* (+ j 1) tile) 214)\n 320 84 (- (* (+ j 1) tile) 214) 320 84 (- (* (+ j 1) tile) 226)))\n (plate 320 50 fence 320 50 180 320 58 180 320 58 fence)\n (plate 320 20 fence 320 20 180 320 28 180 320 28 fence))))\n\n; flowers along the fence\n(nudge 2600\n (repeat 7 i\n (ink 88 140 70) (limb (- (* i 90) 280) 0 -206 (- (* i 90) 280) 18 -206 1)\n (ink (mix 255 250 (% i 2)) (mix 150 214 (% i 2)) (mix 190 90 (% i 2)))\n (ball (- (* i 90) 280) 20 -206 4.5)))\n\n; the lawn, mown in squares\n(nudge 5000\n (repeat 16 i\n (repeat 9 j\n (ink (mix 128 116 (% (+ i j) 2)) (mix 184 170 (% (+ i j) 2)) (mix 98 88 (% (+ i j) 2)))\n (plate (- (* i tile) 640) 0 (+ fence (* j tile))\n (- (* i tile) 560) 0 (+ fence (* j tile))\n (- (* i tile) 560) 0 (+ fence (* (+ j 1) tile))\n (- (* i tile) 640) 0 (+ fence (* (+ j 1) tile))))))\n\n; the bed, back left: a cushion and its soft middle, a little behind what\n; lies on it\n(move -230 0 -140\n (nudge 30\n (ink 110 140 196)\n (outline 1.4 50 60 96 (move 0 5 0 (drum y 34 10)))\n (ink 176 198 232) (move 0 10.3 0 (ring y 25))))\n\n; the water bowl, back right\n(move 230 0 -150\n (nudge 12\n (ink 222 84 72)\n (outline 1.2 110 40 36 (move 0 3.5 0 (drum y 11 7)))\n (ink 130 196 240) (move 0 7.2 0 (ring y 8))))\n",
+ ball: "; ball-flat — the fetch ball. The origin is on the floor under the ball;\n; x points the way it rolls. `distance` rolls it; the owner's `ball x` lifts\n; it (a throw, or the pup's mouth). The band is a flat bar across the face,\n; so turning it is what reads as rolling.\n\ndef r 5\n\n(let roll (/ distance r))\n\n(nudge 20 (ink 58 104 60) (scale 1.2 1 1 (ring y 4.4)))\n\n(move 0 (+ r (owner ball x)) 0\n (rotate z (- roll)\n (ink 232 72 76)\n (outline 1 96 30 36 (ball 0 0 0 r))\n (nudge -1 (ink 255 214 84) (limb 0 -4.3 0 0 4.3 0 1.3))))\n",
+ rope: "; rope-flat — the tug rope: a knot at each end and the rope between. The\n; origin is the first knot; the owner's `end` is the second knot, in world\n; units from the first (the object is placed unturned). The first knot bakes;\n; the rope and the far knot move every tick, so they go out as a CAPSULE and\n; an ELLIPSE — the one object here that uses the per-tick flat path.\n\n(ink 70 118 206)\n(outline 1 30 50 96 (ball 0 0 0 3.4))\n(ink 244 244 250)\n(limb 0 0 0 (owner end x) (owner end y) (owner end z) 1.5)\n(ink 70 118 206)\n(outline 1 30 50 96 (ball (owner end x) (owner end y) (owner end z) 3.4))\n",
+ hand: "; hand-flat — you: a white-gloved hand over the lawn, fingers reaching into\n; the yard (x; the game turns it in from the right), palm down, thumb on -z. The origin is on the floor\n; under the hand; the owner's `hand` is height · grip (0 open, 1 fist).\n; The shadow stays on the floor so you can tell where the hand is.\n\n(let h (owner hand x))\n\n(nudge 30 (ink 58 104 60) (scale 1.5 1 1.1 (ring y 8)))\n\n(move 0 h 0\n (rotate z -.3\n ; the cuff sits under the palm, whatever the depth of its middle says\n (nudge 22 (ink 236 96 128) (outline 1.2 96 50 70 (limb -15 0 0 -8 0 0 5.2)))\n (ink 255 252 246)\n (outline 1.2 96 90 100 (ball 0 0 0 7.4))\n (if (< (owner hand y) .5)\n (nudge -6 (outline 1.2 96 90 100\n (limb 4 .5 -4.6 13 -1 -5.6 2.1) (limb 5 .5 -1.5 15 -1 -1.8 2.1)\n (limb 5 .5 1.5 15 -1 1.8 2.1) (limb 4 .5 4.6 12 -1 5.4 2)\n (limb 0 0 -6 5 1 -11 2.2))))\n (if (> (owner hand y) .5)\n (nudge -6 (outline 1.2 96 90 100\n (ball 6 -1 -4 2.6) (ball 7 -1 -1.3 2.6) (ball 7 -1 1.3 2.6) (ball 6 -1 4 2.5)\n (limb 1 0 -6 5 1 -6.5 2.2))))))\n",
+ treat: "; treat-flat — a bone biscuit, lying along z. The origin is its middle.\n\n(ink 214 160 98)\n(outline 1 110 70 40\n (limb 0 0 -4 0 0 4 1.6)\n (ball 0 1 -5 1.9) (ball 0 -1 -5 1.9) (ball 0 1 5 1.9) (ball 0 -1 5 1.9))\n",
+ };
+ const compiled = {};
+ for (const name in sources) compiled[name] = objectLisp.compile(sources[name], name);
+ return compiled;
+})();
+const fiapupFrameVm = (() => {
+ // The web's interpreter for the oskiewar frame program.
+ //
+ // The game hands the shell one Float32Array per paint — a program of
+ // scene-level ops — and this runs it against the shell's own drawing
+ // functions. It is one reader of the format; the game's immediate path and
+ // the console's native interpreter are the others. Keep the op table equal
+ // to the one at the head of the frame-program section in oskiewar.js:
+ //
+ // 1 VIEW clipX clipY clipW clipH NaN clipX = the whole stage
+ // 2 FACE x1 y1 z1 x2 y2 z2 x3 y3 z3 r g b
+ // 3 DISC x y depth radius r g b
+ // 4 CAPSULE x1 y1 x2 y2 depth width r g b
+ // 5 TEXT font x y size r g b string font 0 comic 1 ywft 2 system 3 block
+ // 6 BOX x y w h r g b a
+ // 7 LINE x1 y1 x2 y2 width r g b
+ // 8 WIPE r g b
+ // 9 CAMERA position(3) right(3) up(3) forward(3) centerX centerY
+ // orthoScale focal perspective depthSlope depthBase near
+ // bandMinX bandMaxX bandMinY bandMaxY
+ // 10 WORLD x1 y1 z1 x2 y2 z2 x3 y3 z3 r g b a world-space face
+ // 11 DEPTH mode value WORLD depth from here on: 0 projected, 1 flat at
+ // value, 2 projected plus value
+ // 12 ASSET handle vertexCount faceCount, vertices (x y z), faces (four
+ // ids, r g b, normal x y z) a retained mesh, kept by handle
+ // 13 MESH handle lightX lightY lightZ draw a retained mesh, each quad
+ // lit .72 + .28 * max(0, -normal·light), as two WORLD faces
+ // 14 MODEL radius handle0 handle1 handle2 · origin(3) x(3) y(3) z(3) ·
+ // lightX lightY lightZ a retained mesh placed by a matrix, at
+ // one of three baked levels: 0 while `radius` projects to 56 px
+ // or more, 1 down to 20 px, 2 below. Vertices go to origin + x·X
+ // + y·Y + z·Z; a normal goes by the cofactor matrix (exact under
+ // any scale or mirror) and lights as MESH does, except a zero
+ // normal, which is unlit. A face whose fourth id repeats its
+ // third is one triangle. Nothing is tessellated here: the levels
+ // are meshes the game baked (xbox/live/object-lisp.mjs).
+ // 15 ELLIPSE x y depth · ax ay bx by · r g b a projected circle: the
+ // points x + a·cos t + b·sin t, a and b conjugate half-axes
+ // 16 PLATE n · n×(x y) · depth · r g b a flat convex polygon, n 3–16
+ // 17 OUTLINE width r g b from here on, every DISC, CAPSULE, ELLIPSE and
+ // PLATE is drawn first in this ink, `width` px bigger, a hair
+ // behind its own depth; width 0 stops. Flat objects draw so.
+ // 18 SHAPES handle count length · records an object's flat shapes, kept
+ // by handle: per record kind (1 ball, 2 limb, 3 ring, 4 plate,
+ // 5 drum) · outline width (world units) and its ink rgb ·
+ // nudge (world units back) · rgb · facing xyz (zero = both
+ // sides), then object-space anchors: ball x y z radius; limb
+ // two ends and a radius; ring centre and two radius vectors;
+ // plate n and n points; drum centre, two radius vectors and
+ // half its length as a vector
+ // 19 SKETCH handle · origin(3) x(3) y(3) z(3) draw kept shapes under a
+ // placement: anchors projected here, filled flat, outlined,
+ // one-sided ones skipped when turned away, ellipse sides picked
+ // by projected size. Nothing else is expanded here.
+ //
+ // A WORLD face is taken to the current CAMERA here: to camera space, cut at
+ // the near plane (Sutherland-Hodgman, before the divide), projected with the
+ // ortho-to-perspective blend, cut to the band, and fanned. That is the one
+ // clip rule for world geometry; the game no longer projects it.
+ // Discs and capsules arrive whole and are fanned here; a face inside a second
+ // view (the P1 inset) is scissored to that view's rectangle here. The host's
+ // own coordinate limit is honoured the way the console's is: a face that
+ // asks for a coordinate past ±32200 is dropped, not drawn.
+
+ const FRAME_VIEW = 1, FRAME_FACE = 2, FRAME_DISC = 3, FRAME_CAPSULE = 4,
+ FRAME_TEXT = 5, FRAME_BOX = 6, FRAME_LINE = 7, FRAME_WIPE = 8,
+ FRAME_CAMERA = 9, FRAME_WORLD = 10, FRAME_DEPTH = 11, FRAME_ASSET = 12,
+ FRAME_MESH = 13, FRAME_MODEL = 14, FRAME_ELLIPSE = 15, FRAME_PLATE = 16,
+ FRAME_OUTLINE = 17, FRAME_SHAPES = 18, FRAME_SKETCH = 19;
+ // Fixed sizes; ASSET is variable and measured from its own header.
+ const opSize = [0, 5, 13, 8, 10, 9, 9, 9, 4, 25, 13, 3, 0, 5, 20, 11, 0, 5, 0, 14];
+ const sizeAt = (p, at) => p[at] === FRAME_ASSET ? 4 + p[at + 2] * 3 + p[at + 3] * 10
+ : p[at] === FRAME_PLATE ? 6 + p[at + 1] * 2 : p[at] === FRAME_SHAPES ? 4 + p[at + 3] : opSize[p[at]] || 0;
+ // An outline sits this far behind its own shape, so the shape covers it and
+ // anything nearer covers both.
+ const outlineBehind = 2e-6;
+
+ // Ring tables by radius bucket, as the game fans them: a small disc is a
+ // hexagon, a large one thirty-two sides.
+ const discRings = [6, 8, 12, 16, 24, 32].map((sides) => {
+ const ring = [];
+ for (let side = 0; side < sides; side++) {
+ const a = side * Math.PI * 2 / sides;
+ ring.push(Math.cos(a), Math.sin(a));
+ }
+ return ring;
+ });
+ const discRingFor = (radius) => discRings[
+ radius < 6 ? 0 : radius < 13 ? 1 : radius < 26 ? 2
+ : radius < 52 ? 3 : radius < 110 ? 4 : 5];
+ const capsuleArcs = Object.fromEntries([3, 4, 6, 8, 12].map((n) => [n,
+ Array.from({ length: n + 1 }, (_, i) =>
+ [Math.cos(i * Math.PI / n), Math.sin(i * Math.PI / n)]).flat()]));
+
+ const safe = (value) => value > -32200 && value < 32200;
+
+ function createFrameVm(host) {
+ const { triangle3d, triangle, box, line, wipe, write, systemWrite,
+ comicWrite, ywftWrite } = host;
+ const fonts = [
+ comicWrite || ywftWrite || systemWrite,
+ ywftWrite || systemWrite,
+ systemWrite,
+ write || systemWrite,
+ ];
+ // A face with its own depth per vertex, or, on a host without a depth
+ // buffer, the flat triangle the game drew before it had one.
+ const face = triangle3d
+ ? triangle3d
+ : (x1, y1, z1, x2, y2, z2, x3, y3, z3, r, g, b) =>
+ triangle(x1, y1, x2, y2, x3, y3, r, g, b);
+ let clip = null;
+ const scissorPoly = new Float64Array(28), scissorNext = new Float64Array(28);
+
+ function flatFace(x1, y1, x2, y2, x3, y3, depth, r, g, b) {
+ if (!(safe(x1) && safe(y1) && safe(x2) && safe(y2) && safe(x3) && safe(y3))) return;
+ if (!clip) { face(x1, y1, depth, x2, y2, depth, x3, y3, depth, r, g, b); return; }
+ const left = clip.x, right = clip.x + clip.w, top = clip.y, bottom = clip.y + clip.h;
+ const inside = (x, y) => x >= left && x <= right && y >= top && y <= bottom;
+ if (inside(x1, y1) && inside(x2, y2) && inside(x3, y3)) {
+ face(x1, y1, depth, x2, y2, depth, x3, y3, depth, r, g, b);
+ return;
+ }
+ if ((x1 < left && x2 < left && x3 < left) || (x1 > right && x2 > right && x3 > right) ||
+ (y1 < top && y2 < top && y3 < top) || (y1 > bottom && y2 > bottom && y3 > bottom)) return;
+ let poly = scissorPoly, next = scissorNext, n = 3;
+ poly[0] = x1; poly[1] = y1; poly[2] = x2; poly[3] = y2; poly[4] = x3; poly[5] = y3;
+ for (let edge = 0; edge < 4; edge++) {
+ const axis = edge < 2 ? 0 : 1;
+ const bound = edge === 0 ? left : edge === 1 ? right : edge === 2 ? top : bottom;
+ const sign = edge === 0 || edge === 2 ? 1 : -1;
+ let m = 0;
+ for (let i = 0; i < n; i++) {
+ const j = (i + 1) % n;
+ const ax = poly[i * 2], ay = poly[i * 2 + 1], bx = poly[j * 2], by = poly[j * 2 + 1];
+ const da = ((axis ? ay : ax) - bound) * sign, db = ((axis ? by : bx) - bound) * sign;
+ if (da >= 0) { next[m * 2] = ax; next[m * 2 + 1] = ay; m++; }
+ if ((da >= 0) !== (db >= 0)) {
+ const t = da / (da - db);
+ next[m * 2] = ax + (bx - ax) * t; next[m * 2 + 1] = ay + (by - ay) * t; m++;
+ }
+ }
+ const swap = poly; poly = next; next = swap; n = m;
+ if (n < 3) return;
+ }
+ for (let i = 1; i + 1 < n; i++)
+ face(poly[0], poly[1], depth, poly[i * 2], poly[i * 2 + 1], depth,
+ poly[i * 2 + 2], poly[i * 2 + 3], depth, r, g, b);
+ }
+
+ // A FACE op carries a depth per vertex; scissoring flattens it to the first
+ // vertex's depth, which is what the game's own scissor did for the inset.
+ function fullFace(x1, y1, z1, x2, y2, z2, x3, y3, z3, r, g, b) {
+ if (!clip) {
+ if (safe(x1) && safe(y1) && safe(z1) && safe(x2) && safe(y2) && safe(z2) &&
+ safe(x3) && safe(y3) && safe(z3))
+ face(x1, y1, z1, x2, y2, z2, x3, y3, z3, r, g, b);
+ return;
+ }
+ flatFace(x1, y1, x2, y2, x3, y3, z1, r, g, b);
+ }
+
+ function disc(x, y, depth, radius, r, g, b) {
+ const ring = discRingFor(radius);
+ const originX = x + ring[0] * radius, originY = y + ring[1] * radius;
+ let lastX = x + ring[2] * radius, lastY = y + ring[3] * radius;
+ for (let side = 4; side < ring.length; side += 2) {
+ const nextX = x + ring[side] * radius;
+ const nextY = y + ring[side + 1] * radius;
+ flatFace(originX, originY, lastX, lastY, nextX, nextY, depth, r, g, b);
+ lastX = nextX;
+ lastY = nextY;
+ }
+ }
+
+ function capsule(x1, y1, x2, y2, depth, width, r, g, b) {
+ const dx = x2 - x1, dy = y2 - y1;
+ const length = Math.hypot(dx, dy);
+ const radius = width / 2;
+ if (length < .001) { disc(x1, y1, depth, radius, r, g, b); return; }
+ const nx = -dy / length * radius, ny = dx / length * radius;
+ flatFace(x1 + nx, y1 + ny, x1 - nx, y1 - ny, x2 + nx, y2 + ny, depth, r, g, b);
+ flatFace(x1 - nx, y1 - ny, x2 - nx, y2 - ny, x2 + nx, y2 + ny, depth, r, g, b);
+ const steps = radius < 6 ? 3 : radius < 13 ? 4 : radius < 26 ? 6 : radius < 52 ? 8 : 12;
+ const ring = capsuleArcs[steps], ux = dx / length, uy = dy / length;
+ for (const end of [-1, 1]) {
+ const cx = end < 0 ? x1 : x2, cy = end < 0 ? y1 : y2;
+ let ax = cx + nx, ay = cy + ny;
+ for (let i = 1; i <= steps; i++) {
+ const side = ring[i * 2], along = ring[i * 2 + 1] * end * radius;
+ const bx = cx + nx * side + ux * along, by = cy + ny * side + uy * along;
+ flatFace(cx, cy, ax, ay, bx, by, depth, r, g, b);
+ ax = bx; ay = by;
+ }
+ }
+ }
+
+ // The camera WORLD faces are seen through; filled by a CAMERA op.
+ const cam = new Float64Array(24);
+ let hasCamera = false;
+ const lerp = (a, b, t) => a + (b - a) * t;
+ const toView = (x, y, z) => {
+ const dx = x - cam[0], dy = y - cam[1], dz = z - cam[2];
+ return { x: dx * cam[3] + dy * cam[4] + dz * cam[5],
+ y: dx * cam[6] + dy * cam[7] + dz * cam[8],
+ z: dx * cam[9] + dy * cam[10] + dz * cam[11] };
+ };
+ const project = (v) => {
+ const depth = Math.max(cam[19], v.z);
+ const perspective = cam[16];
+ const orthoX = cam[12] + v.x * cam[14], orthoY = cam[13] - v.y * cam[14];
+ const perspectiveX = cam[12] + v.x * cam[15] / depth;
+ const perspectiveY = cam[13] - v.y * cam[15] / depth;
+ const z = depth * cam[17] + cam[18];
+ return { x: lerp(orthoX, perspectiveX, perspective),
+ y: lerp(orthoY, perspectiveY, perspective),
+ z: z < -1.499 ? -1.499 : z > 1.4 ? 1.4 : z };
+ };
+ const mixVertex = (a, b, t) => ({ x: lerp(a.x, b.x, t), y: lerp(a.y, b.y, t),
+ z: lerp(a.z, b.z, t) });
+ function clipPolygon(polygon, distance) {
+ const kept = [];
+ for (let i = 0; i < polygon.length; i++) {
+ const current = polygon[i], next = polygon[(i + 1) % polygon.length];
+ const here = distance(current), there = distance(next);
+ if (here >= 0) kept.push(current);
+ if ((here >= 0) !== (there >= 0)) kept.push(mixVertex(current, next, here / (here - there)));
+ }
+ return kept;
+ }
+ const bandEdges = [
+ (v) => v.x - cam[20], (v) => cam[21] - v.x,
+ (v) => v.y - cam[22], (v) => cam[23] - v.y,
+ ];
+ const inBand = (v) => v.x >= cam[20] && v.x <= cam[21] && v.y >= cam[22] && v.y <= cam[23];
+ let depthMode = 0, depthValue = 0;
+ function emitProjected(a, b, c, r, g, bl) {
+ if (!(safe(a.x) && safe(a.y) && safe(a.z) && safe(b.x) && safe(b.y) && safe(b.z) &&
+ safe(c.x) && safe(c.y) && safe(c.z))) return;
+ if (depthMode === 0) { face(a.x, a.y, a.z, b.x, b.y, b.z, c.x, c.y, c.z, r, g, bl); return; }
+ const flat = depthMode === 1;
+ face(a.x, a.y, flat ? depthValue : a.z + depthValue,
+ b.x, b.y, flat ? depthValue : b.z + depthValue,
+ c.x, c.y, flat ? depthValue : c.z + depthValue, r, g, bl);
+ }
+ function worldFace(ax, ay, az, bx, by, bz, cx, cy, cz, r, g, b) {
+ if (!hasCamera) return;
+ const va = toView(ax, ay, az);
+ const vb = toView(bx, by, bz);
+ const vc = toView(cx, cy, cz);
+ const near = cam[19];
+ let polygon;
+ if (va.z >= near && vb.z >= near && vc.z >= near) {
+ const pa = project(va), pb = project(vb), pc = project(vc);
+ if (inBand(pa) && inBand(pb) && inBand(pc)) { emitProjected(pa, pb, pc, r, g, b); return; }
+ polygon = [pa, pb, pc];
+ } else {
+ const cut = clipPolygon([va, vb, vc], (v) => v.z - near);
+ if (cut.length < 3) return;
+ polygon = cut.map(project);
+ if (polygon.some((v) => !Number.isFinite(v.x) || !Number.isFinite(v.y))) return;
+ }
+ for (const edge of bandEdges) {
+ if (polygon.length < 3) return;
+ polygon = clipPolygon(polygon, edge);
+ }
+ for (let corner = 2; corner < polygon.length; corner++)
+ emitProjected(polygon[0], polygon[corner - 1], polygon[corner], r, g, b);
+ }
+
+ // Retained meshes, by the handle the game gave them. They live as long as
+ // this interpreter does; a page has one.
+ const meshes = new Map();
+ function storeMesh(p, at) {
+ const handle = p[at + 1], vc = p[at + 2], fc = p[at + 3];
+ const vertices = Float64Array.from(p.subarray(at + 4, at + 4 + vc * 3));
+ const faces = Float64Array.from(p.subarray(at + 4 + vc * 3, at + 4 + vc * 3 + fc * 10));
+ meshes.set(handle, { vertices, faces, count: fc });
+ }
+ function drawMesh(handle, lx, ly, lz) {
+ const mesh = meshes.get(handle);
+ if (!mesh) return;
+ const v = mesh.vertices, f = mesh.faces;
+ for (let i = 0; i < mesh.count; i++) {
+ const o = i * 10;
+ const a = f[o] * 3, b = f[o + 1] * 3, c = f[o + 2] * 3, d = f[o + 3] * 3;
+ const k = .72 + Math.max(0, -f[o + 7] * lx - f[o + 8] * ly - f[o + 9] * lz) * .28;
+ const r = Math.round(f[o + 4] * k), g = Math.round(f[o + 5] * k), bl = Math.round(f[o + 6] * k);
+ worldFace(v[a], v[a + 1], v[a + 2], v[b], v[b + 1], v[b + 2], v[c], v[c + 1], v[c + 2], r, g, bl);
+ worldFace(v[a], v[a + 1], v[a + 2], v[c], v[c + 1], v[c + 2], v[d], v[d + 1], v[d + 2], r, g, bl);
+ }
+ }
+
+ // The level a MODEL draws at, from how big its radius looks here.
+ function levelOf(p, at) {
+ if (!hasCamera) return 0;
+ const v = toView(p[at + 5], p[at + 6], p[at + 7]);
+ if (v.z <= cam[19]) return 0;
+ const px = p[at + 1] * lerp(cam[14], cam[15] / v.z, cam[16]);
+ return px >= 56 ? 0 : px >= 20 ? 1 : 2;
+ }
+ const placed = new Float64Array(12);
+ function drawModel(p, at) {
+ const mesh = meshes.get(p[at + 2 + levelOf(p, at)]);
+ if (!mesh) return;
+ const ox = p[at + 5], oy = p[at + 6], oz = p[at + 7];
+ const xx = p[at + 8], xy = p[at + 9], xz = p[at + 10];
+ const yx = p[at + 11], yy = p[at + 12], yz = p[at + 13];
+ const zx = p[at + 14], zy = p[at + 15], zz = p[at + 16];
+ const lx = p[at + 17], ly = p[at + 18], lz = p[at + 19];
+ // Cofactor columns (Y×Z, Z×X, X×Y) carry normals; a mirror flips them
+ // back out and swaps the winding, as the object's own faces do.
+ const ax = yy * zz - yz * zy, ay = yz * zx - yx * zz, az = yx * zy - yy * zx;
+ const bx = zy * xz - zz * xy, by = zz * xx - zx * xz, bz = zx * xy - zy * xx;
+ const cx = xy * yz - xz * yy, cy = xz * yx - xx * yz, cz = xx * yy - xy * yx;
+ const flip = xx * ax + xy * ay + xz * az < 0 ? -1 : 1;
+ const v = mesh.vertices, f = mesh.faces, w = placed;
+ const put = (o, id) => {
+ const x = v[id * 3], y = v[id * 3 + 1], z = v[id * 3 + 2];
+ w[o] = ox + x * xx + y * yx + z * zx;
+ w[o + 1] = oy + x * xy + y * yy + z * zy;
+ w[o + 2] = oz + x * xz + y * yz + z * zz;
+ };
+ for (let i = 0; i < mesh.count; i++) {
+ const o = i * 10, tri = f[o + 3] === f[o + 2];
+ put(0, f[o]);
+ if (flip < 0) { put(3, f[o + (tri ? 2 : 3)]); put(6, f[o + (tri ? 1 : 2)]); put(9, f[o + 1]); }
+ else { put(3, f[o + 1]); put(6, f[o + 2]); put(9, f[o + 3]); }
+ const nx = f[o + 7], ny = f[o + 8], nz = f[o + 9];
+ let k = 1;
+ if (nx || ny || nz) {
+ const wx = (nx * ax + ny * bx + nz * cx) * flip, wy = (nx * ay + ny * by + nz * cy) * flip;
+ const wz = (nx * az + ny * bz + nz * cz) * flip;
+ const toward = -(wx * lx + wy * ly + wz * lz) / (Math.hypot(wx, wy, wz) || 1);
+ k = .72 + (toward > 0 ? toward : 0) * .28;
+ }
+ const r = Math.round(f[o + 4] * k), g = Math.round(f[o + 5] * k), bl = Math.round(f[o + 6] * k);
+ worldFace(w[0], w[1], w[2], w[3], w[4], w[5], w[6], w[7], w[8], r, g, bl);
+ if (!tri) worldFace(w[0], w[1], w[2], w[6], w[7], w[8], w[9], w[10], w[11], r, g, bl);
+ }
+ }
+
+ // Flat shapes. An ellipse gets as many sides as keep its chords within
+ // 2 px of the curve (4 to 24): a lamp far off is a diamond, a near wheel
+ // round. One under a pixel isn't drawn at all.
+ const sidesFor = (radius) => radius < 3 ? 4 : radius < 6 ? 5
+ : Math.max(6, Math.min(24, Math.ceil(Math.PI / Math.acos(Math.max(-1, 1 - 2 / radius)))));
+ // `half`: only the half from angle `from` round to `from + π`, as a fan
+ // from the centre — a drum's far end, whose other half the band covers.
+ function ellipse(x, y, depth, ax, ay, bx, by, r, g, b, grow = 0, half = false, from = 0) {
+ if (grow) {
+ const ka = 1 + grow / (Math.hypot(ax, ay) || 1), kb = 1 + grow / (Math.hypot(bx, by) || 1);
+ ax *= ka; ay *= ka; bx *= kb; by *= kb;
+ }
+ const reach = Math.max(Math.hypot(ax, ay), Math.hypot(bx, by));
+ if (reach < .75) return;
+ // Sides by its mean radius, so a long thin ellipse isn't paid for as a circle.
+ const n = sidesFor(Math.max(Math.sqrt(Math.abs(ax * by - ay * bx)), reach * .35));
+ if (half) {
+ const steps = Math.max(2, Math.ceil(n / 2));
+ let lx = x + ax * Math.cos(from) + bx * Math.sin(from), ly = y + ay * Math.cos(from) + by * Math.sin(from);
+ for (let i = 1; i <= steps; i++) {
+ const t = from + i / steps * Math.PI, nx = x + ax * Math.cos(t) + bx * Math.sin(t), ny = y + ay * Math.cos(t) + by * Math.sin(t);
+ flatFace(x, y, lx, ly, nx, ny, depth, r, g, b);
+ lx = nx; ly = ny;
+ }
+ return;
+ }
+ const ox = x + ax, oy = y + ay;
+ let lx = x + ax * Math.cos(Math.PI * 2 / n) + bx * Math.sin(Math.PI * 2 / n);
+ let ly = y + ay * Math.cos(Math.PI * 2 / n) + by * Math.sin(Math.PI * 2 / n);
+ for (let i = 2; i < n; i++) {
+ const c = Math.cos(i / n * Math.PI * 2), s = Math.sin(i / n * Math.PI * 2);
+ const nx = x + ax * c + bx * s, ny = y + ay * c + by * s;
+ flatFace(ox, oy, lx, ly, nx, ny, depth, r, g, b);
+ lx = nx; ly = ny;
+ }
+ }
+ // A convex polygon, fanned; `grow` pushes each corner out from the middle.
+ function plate(p, at, n, depth, r, g, b, grow = 0) {
+ let cx = 0, cy = 0;
+ if (grow) { for (let i = 0; i < n; i++) { cx += p[at + i * 2] / n; cy += p[at + i * 2 + 1] / n; } }
+ const x = (i) => { const v = p[at + i * 2]; if (!grow) return v; const dx = v - cx, dy = p[at + i * 2 + 1] - cy; return v + dx / (Math.hypot(dx, dy) || 1) * grow; };
+ const y = (i) => { const v = p[at + i * 2 + 1]; if (!grow) return v; const dx = p[at + i * 2] - cx, dy = v - cy; return v + dy / (Math.hypot(dx, dy) || 1) * grow; };
+ for (let i = 2; i < n; i++) flatFace(x(0), y(0), x(i - 1), y(i - 1), x(i), y(i), depth, r, g, b);
+ }
+ const ink = { width: 0, r: 0, g: 0, b: 0 };
+ function outlined(op, p, at) {
+ const w = ink.width, d = outlineBehind;
+ if (op === FRAME_DISC) disc(p[at + 1], p[at + 2], p[at + 3] + d, p[at + 4] + w, ink.r, ink.g, ink.b);
+ else if (op === FRAME_CAPSULE) capsule(p[at + 1], p[at + 2], p[at + 3], p[at + 4], p[at + 5] + d, p[at + 6] + w * 2, ink.r, ink.g, ink.b);
+ else if (op === FRAME_ELLIPSE)
+ ellipse(p[at + 1], p[at + 2], p[at + 3] + d, p[at + 4], p[at + 5], p[at + 6], p[at + 7], ink.r, ink.g, ink.b, w);
+ else plate(p, at + 2, p[at + 1], p[at + 2 + p[at + 1] * 2] + d, ink.r, ink.g, ink.b, w);
+ }
+
+ // Sketches: an object's flat shapes, kept by handle (SHAPES) and drawn under
+ // a placement (SKETCH). Each record: kind · outline width, ink rgb · nudge ·
+ // rgb · facing (3; zero = both sides), then its anchors in object space.
+ const sketches = new Map();
+ function storeShapes(p, at) {
+ sketches.set(p[at + 1], { count: p[at + 2], records: Float64Array.from(p.subarray(at + 4, at + 4 + p[at + 3])) });
+ }
+ const seen = new Float64Array(4 * 4), flatPoly = new Float64Array(16);
+ // A placed point to the screen: x, y, view depth, px per world unit.
+ function seePlaced(m, x, y, z, o) {
+ const wx = m[0] + x * m[3] + y * m[6] + z * m[9], wy = m[1] + x * m[4] + y * m[7] + z * m[10];
+ const wz = m[2] + x * m[5] + y * m[8] + z * m[11];
+ const dx = wx - cam[0], dy = wy - cam[1], dz = wz - cam[2];
+ const vz = dx * cam[9] + dy * cam[10] + dz * cam[11];
+ if (vz < cam[19]) return false;
+ const k = cam[14] + (cam[15] / vz - cam[14]) * cam[16];
+ seen[o] = cam[12] + (dx * cam[3] + dy * cam[4] + dz * cam[5]) * k;
+ seen[o + 1] = cam[13] - (dx * cam[6] + dy * cam[7] + dz * cam[8]) * k;
+ seen[o + 2] = vz; seen[o + 3] = k;
+ return true;
+ }
+ const flatDepth = (vz, nudge) => {
+ const z = (vz + nudge) * cam[17] + cam[18];
+ return z < -1.499 ? -1.499 : z > 1.4 ? 1.4 : z;
+ };
+ const shapeSize = (R, i) => 12 + [0, 4, 7, 9, 1 + 3 * R[i + 12], 12][R[i]];
+ const placedModel = new Float64Array(12);
+ function drawSketch(p, at) {
+ const sketch = sketches.get(p[at + 1]);
+ if (!sketch || !hasCamera) return;
+ const m = placedModel;
+ for (let k = 0; k < 12; k++) m[k] = p[at + 2 + k];
+ const unit = Math.cbrt(Math.abs(m[3] * (m[7] * m[11] - m[8] * m[10]) - m[4] * (m[6] * m[11] - m[8] * m[9]) +
+ m[5] * (m[6] * m[10] - m[7] * m[9])));
+ const R = sketch.records;
+ for (let n = 0, i = 0; n < sketch.count; n++, i += shapeSize(R, i)) {
+ const kind = R[i], line = R[i + 1], nudge = R[i + 5], r = R[i + 6], g = R[i + 7], b = R[i + 8], a = i + 12;
+ const ax = kind === 4 ? R[a + 1] : R[a], ay = kind === 4 ? R[a + 2] : R[a + 1], az = kind === 4 ? R[a + 3] : R[a + 2];
+ // One-sided: skip it when its facing turns from the camera.
+ const fx = R[i + 9], fy = R[i + 10], fz = R[i + 11];
+ if (fx || fy || fz) {
+ const wx = fx * m[3] + fy * m[6] + fz * m[9], wy = fx * m[4] + fy * m[7] + fz * m[10], wz = fx * m[5] + fy * m[8] + fz * m[11];
+ const px = m[0] + ax * m[3] + ay * m[6] + az * m[9], py = m[1] + ax * m[4] + ay * m[7] + az * m[10];
+ const pz = m[2] + ax * m[5] + ay * m[8] + az * m[11];
+ if (wx * (px - cam[0]) + wy * (py - cam[1]) + wz * (pz - cam[2]) > 0) continue;
+ }
+ const shape = (grow, depthShift, cr, cg, cb) => {
+ if (kind === 1) {
+ if (!seePlaced(m, R[a], R[a + 1], R[a + 2], 0)) return;
+ const rad = R[a + 3] * seen[3] * unit;
+ ellipse(seen[0], seen[1], flatDepth(seen[2], nudge) + depthShift, rad, 0, 0, rad, cr, cg, cb, grow);
+ } else if (kind === 2) {
+ if (!seePlaced(m, R[a], R[a + 1], R[a + 2], 0) || !seePlaced(m, R[a + 3], R[a + 4], R[a + 5], 4)) return;
+ capsule(seen[0], seen[1], seen[4], seen[5], flatDepth((seen[2] + seen[6]) / 2, nudge) + depthShift,
+ R[a + 6] * (seen[3] + seen[7]) * unit + grow * 2, cr, cg, cb);
+ } else if (kind === 3) {
+ if (!seePlaced(m, R[a], R[a + 1], R[a + 2], 0) || !seePlaced(m, R[a] + R[a + 3], R[a + 1] + R[a + 4], R[a + 2] + R[a + 5], 4) ||
+ !seePlaced(m, R[a] + R[a + 6], R[a + 1] + R[a + 7], R[a + 2] + R[a + 8], 8)) return;
+ ellipse(seen[0], seen[1], flatDepth(seen[2], nudge) + depthShift, seen[4] - seen[0], seen[5] - seen[1],
+ seen[8] - seen[0], seen[9] - seen[1], cr, cg, cb, grow);
+ } else if (kind === 4) {
+ const count = R[a];
+ let vz = 0;
+ for (let k = 0; k < count; k++) {
+ if (!seePlaced(m, R[a + 1 + k * 3], R[a + 2 + k * 3], R[a + 3 + k * 3], 0)) return;
+ flatPoly[k * 2] = seen[0]; flatPoly[k * 2 + 1] = seen[1]; vz += seen[2];
+ }
+ plate(flatPoly, 0, count, flatDepth(vz / count, nudge) + depthShift, cr, cg, cb, grow);
+ } else drum(m, R, a, nudge, grow, depthShift, cr, cg, cb);
+ };
+ // The ink edge is world units wide, sized where the shape is anchored.
+ // Under half a pixel it is left off: a far object keeps its fills.
+ if (line && seePlaced(m, ax, ay, az, 12) && line * seen[15] * unit >= .5)
+ shape(line * seen[15] * unit, outlineBehind, R[i + 2], R[i + 3], R[i + 4]);
+ shape(0, 0, r, g, b);
+ }
+ }
+ // A drum (a cylinder: centre, two radius vectors, half its length along the
+ // axis): the far end's ellipse, the band between the ends' tangent points,
+ // the near end's ellipse, each at its own depth.
+ const drumEnds = [new Float64Array(7), new Float64Array(7)];
+ function drum(m, R, a, nudge, grow, depthShift, cr, cg, cb) {
+ for (const side of [0, 1]) {
+ const s = side ? 1 : -1, e = drumEnds[side];
+ const cx = R[a] + R[a + 9] * s, cy = R[a + 1] + R[a + 10] * s, cz = R[a + 2] + R[a + 11] * s;
+ if (!seePlaced(m, cx, cy, cz, 0) || !seePlaced(m, cx + R[a + 3], cy + R[a + 4], cz + R[a + 5], 4) ||
+ !seePlaced(m, cx + R[a + 6], cy + R[a + 7], cz + R[a + 8], 8)) return;
+ e[0] = seen[0]; e[1] = seen[1]; e[2] = seen[2];
+ e[3] = seen[4] - seen[0]; e[4] = seen[5] - seen[1]; e[5] = seen[8] - seen[0]; e[6] = seen[9] - seen[1];
+ }
+ const [far, near] = drumEnds[0][2] > drumEnds[1][2] ? drumEnds : [drumEnds[1], drumEnds[0]];
+ const cap = (e, half, from) => ellipse(e[0], e[1], flatDepth(e[2], nudge) + depthShift,
+ e[3], e[4], e[5], e[6], cr, cg, cb, grow, half, from);
+ const dx = near[0] - far[0], dy = near[1] - far[1];
+ if (Math.hypot(dx, dy) <= .5) cap(far);
+ else {
+ const t = (e) => Math.atan2(e[5] * dy - e[6] * dx, e[3] * dy - e[4] * dx);
+ const tf = t(far), tn = t(near);
+ // Only the far end's outer half shows past the band.
+ const mid = tf + Math.PI / 2;
+ const outward = (far[3] * Math.cos(mid) + far[5] * Math.sin(mid)) * dx + (far[4] * Math.cos(mid) + far[6] * Math.sin(mid)) * dy < 0;
+ cap(far, true, outward ? tf : tf + Math.PI);
+ const fx = far[3] * Math.cos(tf) + far[5] * Math.sin(tf), fy = far[4] * Math.cos(tf) + far[6] * Math.sin(tf);
+ const nx = near[3] * Math.cos(tn) + near[5] * Math.sin(tn), ny = near[4] * Math.cos(tn) + near[6] * Math.sin(tn);
+ flatPoly[0] = far[0] + fx; flatPoly[1] = far[1] + fy; flatPoly[2] = near[0] + nx; flatPoly[3] = near[1] + ny;
+ flatPoly[4] = near[0] - nx; flatPoly[5] = near[1] - ny; flatPoly[6] = far[0] - fx; flatPoly[7] = far[1] - fy;
+ plate(flatPoly, 0, 4, flatDepth((far[2] + near[2]) / 2, nudge) + depthShift, cr, cg, cb, grow);
+ }
+ cap(near);
+ }
+
+ const clipRect = { x: 0, y: 0, w: 0, h: 0 };
+ function run(p, length, strings) {
+ clip = null;
+ hasCamera = false;
+ depthMode = 0;
+ depthValue = 0;
+ ink.width = 0;
+ let at = 0;
+ while (at < length) {
+ const op = p[at];
+ switch (op) {
+ case FRAME_VIEW:
+ if (Number.isNaN(p[at + 1])) clip = null;
+ else {
+ clipRect.x = p[at + 1]; clipRect.y = p[at + 2];
+ clipRect.w = p[at + 3]; clipRect.h = p[at + 4];
+ clip = clipRect;
+ }
+ at += 5; break;
+ case FRAME_FACE:
+ fullFace(p[at + 1], p[at + 2], p[at + 3], p[at + 4], p[at + 5], p[at + 6],
+ p[at + 7], p[at + 8], p[at + 9], p[at + 10], p[at + 11], p[at + 12]);
+ at += 13; break;
+ case FRAME_DISC:
+ if (ink.width) outlined(op, p, at);
+ disc(p[at + 1], p[at + 2], p[at + 3], p[at + 4], p[at + 5], p[at + 6], p[at + 7]);
+ at += 8; break;
+ case FRAME_CAPSULE:
+ if (ink.width) outlined(op, p, at);
+ capsule(p[at + 1], p[at + 2], p[at + 3], p[at + 4], p[at + 5], p[at + 6],
+ p[at + 7], p[at + 8], p[at + 9]);
+ at += 10; break;
+ case FRAME_TEXT:
+ fonts[p[at + 1]](strings[p[at + 8]], p[at + 2], p[at + 3], p[at + 4],
+ p[at + 5], p[at + 6], p[at + 7]);
+ at += 9; break;
+ case FRAME_BOX:
+ if (p[at + 8] >= 255) box(p[at + 1], p[at + 2], p[at + 3], p[at + 4], p[at + 5], p[at + 6], p[at + 7]);
+ else box(p[at + 1], p[at + 2], p[at + 3], p[at + 4], p[at + 5], p[at + 6], p[at + 7], p[at + 8]);
+ at += 9; break;
+ case FRAME_LINE:
+ line(p[at + 1], p[at + 2], p[at + 3], p[at + 4], p[at + 5], p[at + 6], p[at + 7], p[at + 8]);
+ at += 9; break;
+ case FRAME_WIPE:
+ wipe(p[at + 1], p[at + 2], p[at + 3]);
+ at += 4; break;
+ case FRAME_CAMERA:
+ for (let i = 0; i < 24; i++) cam[i] = p[at + 1 + i];
+ hasCamera = true;
+ at += 25; break;
+ case FRAME_WORLD:
+ worldFace(p[at + 1], p[at + 2], p[at + 3], p[at + 4], p[at + 5], p[at + 6],
+ p[at + 7], p[at + 8], p[at + 9], p[at + 10], p[at + 11], p[at + 12]);
+ at += 13; break;
+ case FRAME_DEPTH:
+ depthMode = p[at + 1];
+ depthValue = p[at + 2];
+ at += 3; break;
+ case FRAME_ASSET:
+ storeMesh(p, at);
+ at += sizeAt(p, at); break;
+ case FRAME_MESH:
+ drawMesh(p[at + 1], p[at + 2], p[at + 3], p[at + 4]);
+ at += 5; break;
+ case FRAME_MODEL:
+ drawModel(p, at);
+ at += 20; break;
+ case FRAME_ELLIPSE:
+ if (ink.width) outlined(op, p, at);
+ ellipse(p[at + 1], p[at + 2], p[at + 3], p[at + 4], p[at + 5], p[at + 6], p[at + 7],
+ p[at + 8], p[at + 9], p[at + 10]);
+ at += 11; break;
+ case FRAME_PLATE: {
+ const n = p[at + 1];
+ if (ink.width) outlined(op, p, at);
+ plate(p, at + 2, n, p[at + 2 + n * 2], p[at + 3 + n * 2], p[at + 4 + n * 2], p[at + 5 + n * 2]);
+ at += 6 + n * 2; break;
+ }
+ case FRAME_SHAPES:
+ storeShapes(p, at);
+ at += 4 + p[at + 3]; break;
+ case FRAME_SKETCH:
+ drawSketch(p, at);
+ at += 14; break;
+ case FRAME_OUTLINE:
+ ink.width = p[at + 1]; ink.r = p[at + 2]; ink.g = p[at + 3]; ink.b = p[at + 4];
+ at += 5; break;
+ default:
+ // An op this interpreter does not know ends the program rather than
+ // walking into its arguments as if they were ops.
+ throw new Error(`frame program: unknown op ${op} at ${at}`);
+ }
+ }
+ }
+
+ // Decode a program into plain objects, for tests and recorders.
+ function decode(p, length, strings) {
+ const ops = [];
+ let at = 0;
+ while (at < length) {
+ const op = p[at];
+ const size = sizeAt(p, at);
+ if (!size) throw new Error(`frame program: unknown op ${op} at ${at}`);
+ const args = Array.from(p.subarray(at + 1, at + size));
+ if (op === FRAME_TEXT) args[7] = strings[args[7]];
+ ops.push({ op, args });
+ at += size;
+ }
+ return ops;
+ }
+
+ return { run, decode };
+ }
+ return { FRAME_VIEW, createFrameVm };
+})();
+//
+
+// ——— the yard, in world units (about a centimetre) ———
+
+const yard = { minX: -300, maxX: 300, minZ: -196, maxZ: 170 };
+const bed = { x: -230, z: -140 };
+const bowl = { x: 230, z: -150 };
+const tick = 1 / 60;
+const gravity = 640;
+
+// Where the pup's neck and mouth sit, in the frames puppy-flat.lisp builds
+// them in; `pupRig` walks the same chain so a carried ball lands in its mouth.
+const rig = { stand: 21, hip: 9, neck: [14, 9, 0], mouth: [11, -5.5, 0] };
+
+// A small seeded generator, so a run and its test replay alike.
+function seeded(seed) {
+ let a = seed >>> 0;
+ return () => {
+ a = (a + 0x6d2b79f5) >>> 0;
+ let t = a;
+ t = Math.imul(t ^ (t >>> 15), t | 1);
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
+ };
+}
+
+const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
+const lerp = (a, b, t) => a + (b - a) * t;
+const ease = (rate, dt) => 1 - Math.exp(-rate * dt);
+const wrapAngle = (a) => Math.atan2(Math.sin(a), Math.cos(a));
+const dist = (ax, az, bx, bz) => Math.hypot(bx - ax, bz - az);
+
+// ——— the world ———
+
+let world = null;
+
+function freshWorld(seed = 7) {
+ return {
+ t: 0, rng: seeded(seed), frames: 0,
+ pup: { x: 40, z: -40, heading: Math.PI * .8, speed: 0, gait: 0,
+ state: "idle", time: 0, phase: "", goal: null, energy: .7, joy: .55,
+ carrying: null, pettedFor: 0, unpetted: 0, lastBark: -9, blinkAt: 2,
+ pose: freshPose() },
+ hand: { x: 0, z: 90, vx: 0, vz: 0, height: 38, grip: 0, holding: null,
+ petting: false, rub: 0 },
+ ball: { x: 110, y: 0, z: 30, vx: 0, vy: 0, vz: 0, dir: 0, roll: 0, heldBy: null, thrown: false },
+ rope: { x: -120, z: 70, angle: .4, heldBy: null },
+ treat: { onFloor: false, x: 0, y: 0, z: 0, vy: 0 },
+ input: { down: new Set(), was: new Set(), x: 0, y: 0 },
+ sounds: [], heard: [], log: [], hearts: [],
+ camera: { x: 20, z: 10, reach: 300 },
+ };
+}
+
+function freshPose() {
+ return { bob: 0, pitch: 0, roll: 0, crouch: 0, yaw: 0, nod: 0, cock: 0,
+ flop: .25, perk: 0, wag: 0, wagAmp: .3, wagRate: 8, droop: 0,
+ legs: [0, 0, 0, 0], splay: [0, 0, 0, 0], eyes: 1, mouth: 0, tongue: 0, shake: 0 };
+}
+
+// ——— sound: one-shot sines, which every host has ———
+
+function sound(f, d, after = 0) { world.sounds.push({ at: world.t + after, f, d }); }
+const noises = {
+ bark: () => { sound(700, .06); sound(540, .09, .09); },
+ yip: () => { sound(1100, .05); sound(1320, .05, .07); },
+ whistle: () => { sound(1250, .1); sound(1650, .12, .12); },
+ squeak: () => sound(1500, .04),
+ growl: () => sound(130 + world.rng() * 30, .22),
+ chomp: () => { sound(320, .03); sound(280, .03, .12); sound(320, .03, .24); },
+ snore: () => sound(92, .35),
+ whine: () => { sound(900, .14); sound(760, .2, .16); },
+ yawn: () => { sound(420, .2); sound(330, .3, .2); },
+};
+function flushSounds() {
+ const due = world.sounds.filter((s) => s.at <= world.t);
+ if (!due.length) return;
+ world.sounds = world.sounds.filter((s) => s.at > world.t);
+ for (const s of due) {
+ world.heard.push(s.f);
+ if (typeof synth === "function") synth(s.f, s.d);
+ }
+}
+
+// ——— input: pad 0, sticks and buttons; the web shell maps keys onto it ———
+
+const pressed = (name) => world.input.down.has(name) && !world.input.was.has(name);
+const released = (name) => !world.input.down.has(name) && world.input.was.has(name);
+const holding = (name) => world.input.down.has(name);
+
+function readPad() {
+ const input = world.input;
+ input.was = input.down;
+ // A staged run (fiapup.stage) presses buttons through a script instead.
+ const script = globalThis.__fiapupScript;
+ const pad = script ? script : typeof gamepad === "function" ? gamepad(0) : null;
+ input.down = new Set(pad?.down || []);
+ let x = pad?.leftX || 0, y = pad?.leftY || 0;
+ if (Math.hypot(x, y) < .2) { x = 0; y = 0; }
+ if (input.down.has("ArrowLeft")) x = -1;
+ if (input.down.has("ArrowRight")) x = 1;
+ if (input.down.has("ArrowUp")) y = 1;
+ if (input.down.has("ArrowDown")) y = -1;
+ input.x = x; input.y = y;
+}
+
+// ——— the hand: you ———
+
+const reachBall = 26, reachRope = 26, reachPup = 44;
+
+function tickHand(dt) {
+ const { hand, ball, rope, pup, input } = world;
+ const speed = 250;
+ hand.vx = input.x * speed; hand.vz = -input.y * speed;
+ hand.x = clamp(hand.x + hand.vx * dt, yard.minX, yard.maxX);
+ hand.z = clamp(hand.z + hand.vz * dt, yard.minZ - 10, yard.maxZ + 20);
+
+ if (pressed("A")) {
+ if (hand.holding === "ball") throwBall();
+ else if (hand.holding === "rope") {
+ hand.holding = null;
+ if (rope.heldBy === "both") { rope.heldBy = "pup"; pup.carrying = "rope"; }
+ else { rope.heldBy = null; rope.x = hand.x; rope.z = hand.z - 10; }
+ } else if (!hand.holding && !ball.heldBy && ball.y < 8 && dist(hand.x, hand.z, ball.x, ball.z) < reachBall) {
+ hand.holding = "ball"; ball.heldBy = "hand"; noises.squeak();
+ } else if (!hand.holding && !rope.heldBy && dist(hand.x, hand.z, rope.x, rope.z) < reachRope) {
+ hand.holding = "rope"; rope.heldBy = "hand";
+ }
+ }
+ if (pressed("B")) call();
+ if (pressed("Y")) play();
+ if (pressed("X") && !hand.holding) hand.holding = "treat";
+ if (released("X") && hand.holding === "treat") dropTreat();
+
+ hand.petting = holding("A") && !hand.holding && dist(hand.x, hand.z, pup.x, pup.z) < reachPup;
+ hand.rub = hand.petting ? hand.rub + dt : 0;
+ const lift = hand.holding === "treat" ? 50 : hand.petting ? 31 + Math.sin(hand.rub * 10) * 3 : 38;
+ hand.height = lerp(hand.height, lift, ease(12, dt));
+ hand.grip = lerp(hand.grip, hand.holding ? 1 : 0, ease(18, dt));
+}
+
+// The hand reaches in from the right: fingers along `handAxis` (into the
+// yard and to the left), so the camera sees them beside the palm.
+const handAxis = [-.6, 0, -.8];
+const fingertip = () => ({ x: world.hand.x + handAxis[0] * 13, y: world.hand.height - 3,
+ z: world.hand.z + handAxis[2] * 13 });
+
+function throwBall() {
+ const { hand, ball } = world;
+ const tip = fingertip();
+ let dx = hand.vx, dz = hand.vz;
+ if (Math.hypot(dx, dz) < 40) { dx = 0; dz = -1; }
+ const m = Math.hypot(dx, dz);
+ Object.assign(ball, { x: tip.x, y: tip.y, z: tip.z, vx: dx / m * 330, vz: dz / m * 330,
+ vy: 170, heldBy: null, thrown: true });
+ hand.holding = null;
+ noises.squeak();
+}
+
+function dropTreat() {
+ const tip = fingertip();
+ Object.assign(world.treat, { onFloor: true, x: tip.x, y: tip.y, z: tip.z, vy: 0 });
+ world.hand.holding = null;
+}
+
+function call() {
+ const pup = world.pup;
+ noises.whistle();
+ if (["nap", "sleepy"].includes(pup.state)) enter("stretch", "come");
+ else if (["idle", "sniff", "parade"].includes(pup.state)) enter("come");
+}
+
+function play() {
+ const pup = world.pup;
+ if (!["idle", "sniff", "come"].includes(pup.state)) return;
+ if (pup.energy > .35) enter("playbow");
+ else { noises.yawn(); pup.pose.mouth = 1; }
+}
+
+// ——— the ball and the treat fall; the ball rolls ———
+
+function tickBall(dt) {
+ const b = world.ball;
+ if (b.heldBy) return;
+ b.vy -= gravity * dt;
+ b.x += b.vx * dt; b.y += b.vy * dt; b.z += b.vz * dt;
+ if (b.y <= 0) {
+ b.y = 0;
+ b.vy = Math.abs(b.vy) > 60 ? -b.vy * .45 : 0;
+ const drag = Math.exp(-1.4 * dt);
+ b.vx *= drag; b.vz *= drag;
+ if (Math.hypot(b.vx, b.vz) < 6) { b.vx = 0; b.vz = 0; b.thrown = false; }
+ }
+ if (b.x < yard.minX || b.x > yard.maxX) { b.x = clamp(b.x, yard.minX, yard.maxX); b.vx = -b.vx * .6; }
+ if (b.z < yard.minZ || b.z > yard.maxZ) { b.z = clamp(b.z, yard.minZ, yard.maxZ); b.vz = -b.vz * .6; }
+ const v = Math.hypot(b.vx, b.vz);
+ if (v > 1) b.dir = Math.atan2(b.vz, b.vx);
+ b.roll += v * dt;
+}
+
+function tickTreat(dt) {
+ const t = world.treat;
+ if (!t.onFloor || t.y <= 2) return;
+ t.vy -= gravity * dt;
+ t.y = Math.max(2, t.y + t.vy * dt);
+}
+
+// ——— the pup: what it wants, and how it moves toward it ———
+
+// Turn toward a spot and go at `pace`, easing in as it arrives. True once
+// within `stop` of it.
+function goTo(gx, gz, pace, stop, dt) {
+ const p = world.pup;
+ const d = dist(p.x, p.z, gx, gz);
+ if (d <= stop) { p.speed = lerp(p.speed, 0, ease(10, dt)); return true; }
+ const want = Math.atan2(gz - p.z, gx - p.x);
+ const off = wrapAngle(want - p.heading);
+ const turn = (pace > 150 ? 7 : 5) * dt;
+ p.heading = wrapAngle(p.heading + clamp(off, -turn, turn));
+ const target = pace * clamp((d - stop) / 40, .25, 1) * clamp(Math.cos(off), .2, 1);
+ p.speed = lerp(p.speed, target, ease(6, dt));
+ return false;
+}
+
+function stand(dt) { world.pup.speed = lerp(world.pup.speed, 0, ease(8, dt)); }
+
+function turnTo(gx, gz, dt, rate = 4) {
+ const p = world.pup;
+ const off = wrapAngle(Math.atan2(gz - p.z, gx - p.x) - p.heading);
+ p.heading = wrapAngle(p.heading + clamp(off, -rate * dt, rate * dt));
+}
+
+function enter(state, then = null) {
+ const p = world.pup;
+ if (p.state !== state) world.log.push(state);
+ p.state = state; p.time = 0; p.phase = ""; p.then = then; p.goal = null;
+}
+
+// What the pup notices, in order. Returns the state it should drop into, or
+// null. Each state says which of these may interrupt it.
+function notice(allow) {
+ const { pup, hand, ball, rope, treat } = world;
+ const near = (x, z, r) => dist(pup.x, pup.z, x, z) < r;
+ if (allow.treat && treat.onFloor) return "eat";
+ if (allow.treat && hand.holding === "treat" && near(hand.x, hand.z, 320)) return "beg";
+ if (allow.pet && hand.petting) return "petted";
+ if (allow.ball && ball.thrown && !ball.heldBy) return "fetch";
+ if (allow.rope && hand.holding === "rope" && rope.heldBy === "hand" && near(hand.x, hand.z, 240)) return "tug";
+ if (allow.tired && pup.energy < .22) return "sleepy";
+ if (allow.zoom && pup.energy > .8 && pup.joy > .75 && world.rng() < tick * .08) return "playbow";
+ return null;
+}
+const all = { treat: 1, pet: 1, ball: 1, rope: 1, tired: 1, zoom: 1 };
+
+// Each state: `tick(dt)` moves the pup and may `enter` another state;
+// `pose(t)` says how it holds itself (poseFor fills the rest).
+const states = {
+ idle: {
+ tick(dt) {
+ const p = world.pup, h = world.hand;
+ const next = notice(all);
+ if (next) return enter(next);
+ stand(dt);
+ turnTo(h.x, h.z, dt, 1.2);
+ if (p.time > 4 + world.rng() * 40 && world.rng() < tick * .5) enter("sniff");
+ },
+ pose: (p) => ({ sit: p.time > 1.2 ? 1 : 0, look: "hand", wag: .35 + p.joy * .5 }),
+ },
+ sniff: {
+ tick(dt) {
+ const p = world.pup;
+ const next = notice(all);
+ if (next) return enter(next);
+ if (!p.goal) {
+ const a = world.rng() * Math.PI * 2, r = 50 + world.rng() * 90;
+ p.goal = { x: clamp(p.x + Math.cos(a) * r, yard.minX + 20, yard.maxX - 20),
+ z: clamp(p.z + Math.sin(a) * r, yard.minZ + 20, yard.maxZ - 20) };
+ }
+ if (p.phase !== "smell") { if (goTo(p.goal.x, p.goal.z, 55, 6, dt)) { p.phase = "smell"; p.time = 0; } }
+ else { stand(dt); if (p.time > 1.4) enter("idle"); }
+ },
+ pose: (p) => ({ nose: true, wag: .3, sniffing: p.phase === "smell" }),
+ },
+ come: {
+ tick(dt) {
+ const h = world.hand;
+ const next = notice({ ...all, zoom: 0, tired: 0 });
+ if (next) return enter(next);
+ if (goTo(h.x, h.z - 6, 180, 34, dt)) { world.pup.joy = Math.min(1, world.pup.joy + .05); enter("idle"); }
+ },
+ pose: () => ({ look: "hand", wag: .9, ears: "bounce" }),
+ },
+ fetch: {
+ tick(dt) {
+ const p = world.pup, b = world.ball, h = world.hand;
+ const next = notice({ treat: 1 });
+ if (next) return enter(next);
+ if (!p.phase) p.phase = "chase";
+ if (p.phase === "chase") {
+ if (b.heldBy === "hand") return enter("idle");
+ const lead = .25;
+ goTo(b.x + b.vx * lead, b.z + b.vz * lead, 200, 0, dt);
+ const m = pupRig(p).mouth;
+ if (dist(m.x, m.z, b.x, b.z) < 10 && b.y < 12) {
+ b.heldBy = "pup"; b.thrown = false; p.carrying = "ball"; p.phase = "return"; noises.squeak();
+ }
+ } else if (p.phase === "return") {
+ if (goTo(h.x, h.z - 4, 170, 32, dt)) { p.phase = "drop"; p.time = 0; }
+ } else {
+ stand(dt); turnTo(h.x, h.z, dt);
+ if (p.time > .35 && p.carrying === "ball") {
+ const m = pupRig(p).mouth;
+ Object.assign(b, { heldBy: null, x: m.x, y: Math.max(0, m.y - 5), z: m.z, vx: 0, vy: 0, vz: 0 });
+ p.carrying = null; p.joy = Math.min(1, p.joy + .12); p.energy -= .06; noises.yip();
+ }
+ if (p.time > .9) enter("idle");
+ }
+ },
+ pose: (p) => ({ look: p.phase === "chase" ? "ball" : "hand", wag: 1, ears: "bounce",
+ tongue: p.phase === "return" ? 0 : 1 }),
+ },
+ tug: {
+ tick(dt) {
+ const p = world.pup, h = world.hand, r = world.rope;
+ if (r.heldBy === "pup") { p.carrying = "rope"; return enter("parade"); }
+ if (r.heldBy !== "hand" && r.heldBy !== "both") return enter("idle");
+ const end = ropeGrip();
+ if (!p.phase) p.phase = "approach";
+ if (p.phase === "approach") {
+ const m = pupRig(p).mouth;
+ goTo(end.x, end.z, 170, 0, dt);
+ if (dist(m.x, m.z, end.x, end.z) < 11) { p.phase = "pull"; p.time = 0; r.heldBy = "both"; noises.growl(); }
+ return;
+ }
+ // Hold the rope taut: back away while it is slack, get dragged when
+ // the hand pulls past the rope's length.
+ turnTo(h.x, h.z, dt, 6);
+ const d = dist(p.x, p.z, h.x, h.z);
+ const away = Math.atan2(p.z - h.z, p.x - h.x);
+ const want = 58 + Math.sin(p.time * 2.3) * 8;
+ const step = clamp(want - d, -90 * dt, 40 * dt);
+ p.x = clamp(p.x + Math.cos(away) * step, yard.minX, yard.maxX);
+ p.z = clamp(p.z + Math.sin(away) * step, yard.minZ, yard.maxZ);
+ p.speed = Math.abs(step) / dt * .4;
+ p.energy -= .03 * dt; p.joy = Math.min(1, p.joy + .05 * dt);
+ if (Math.floor((p.time - dt) / .5) !== Math.floor(p.time / .5)) noises.growl();
+ if (p.energy < .15) { r.heldBy = "hand"; p.carrying = null; enter("idle"); }
+ },
+ pose: (p) => ({ look: "hand", wag: 1, shake: p.phase === "pull" ? 1 : 0, lean: p.phase === "pull" ? 1 : 0 }),
+ },
+ parade: {
+ tick(dt) {
+ const p = world.pup, r = world.rope;
+ const next = notice({ treat: 1 });
+ if (next) { dropCarried(); return enter(next); }
+ const a = p.time * 1.8;
+ goTo(p.x + Math.cos(p.heading + .9) * 40, p.z + Math.sin(p.heading + .9) * 40, 110, 0, dt);
+ if (p.time > 3.2 + Math.sin(a) * .1) { dropCarried(); noises.bark(); enter("idle"); }
+ },
+ pose: () => ({ wag: 1, ears: "bounce", chin: 1 }),
+ },
+ petted: {
+ tick(dt) {
+ const p = world.pup, h = world.hand;
+ const next = notice({ treat: 1 });
+ if (next) return enter(next);
+ stand(dt); turnTo(h.x, h.z, dt, 1.5);
+ if (world.hand.petting) { p.pettedFor += dt; p.unpetted = 0; } else p.unpetted += dt;
+ p.joy = Math.min(1, p.joy + .12 * dt);
+ if (p.pettedFor > 1.8) return enter("rollover");
+ if (p.unpetted > .4) { p.pettedFor = 0; enter("idle"); }
+ if (world.rng() < dt * 1.2) world.hearts.push({ x: p.x, y: 40, z: p.z, t: 0 });
+ },
+ pose: () => ({ sit: 1, look: "hand", wag: 1.2, eyes: 0, lean: .5, ears: "soft" }),
+ },
+ rollover: {
+ tick(dt) {
+ const p = world.pup;
+ stand(dt);
+ if (world.hand.petting) { p.unpetted = 0; p.joy = Math.min(1, p.joy + .15 * dt); } else p.unpetted += dt;
+ if (world.rng() < dt * 2) world.hearts.push({ x: p.x, y: 26, z: p.z, t: 0 });
+ if (p.unpetted > 1.1) { p.pettedFor = 0; enter("idle"); }
+ },
+ pose: () => ({ roll: 1, eyes: 0, wag: .8, tongue: 1 }),
+ },
+ beg: {
+ tick(dt) {
+ const p = world.pup, h = world.hand;
+ if (world.treat.onFloor) return enter("eat");
+ if (h.holding !== "treat") return enter("idle");
+ if (goTo(h.x, h.z - 4, 150, 24, dt)) {
+ turnTo(h.x, h.z, dt, 5);
+ p.phase = "beg";
+ if (Math.floor((p.time - dt) / 2.2) !== Math.floor(p.time / 2.2)) noises.whine();
+ } else p.phase = "";
+ },
+ pose: (p) => ({ beg: p.phase === "beg" ? 1 : 0, look: "treat", wag: 1.1, ears: "perk" }),
+ },
+ eat: {
+ tick(dt) {
+ const p = world.pup, t = world.treat;
+ if (!t.onFloor) return enter("idle");
+ if (!p.phase) {
+ const m = pupRig(p).mouth;
+ goTo(t.x, t.z, 160, 0, dt);
+ if (dist(m.x, m.z, t.x, t.z) < 9) { p.phase = "chomp"; p.time = 0; noises.chomp(); }
+ return;
+ }
+ stand(dt);
+ if (p.time > 1.2) {
+ t.onFloor = false; p.joy = Math.min(1, p.joy + .25); p.energy = Math.min(1, p.energy + .08);
+ noises.yip(); enter("idle");
+ }
+ },
+ pose: (p) => ({ nose: true, chomp: p.phase === "chomp" ? 1 : 0, wag: 1.1 }),
+ },
+ sleepy: {
+ tick(dt) {
+ const next = notice({ treat: 1 });
+ if (next) return enter(next);
+ if (goTo(bed.x, bed.z, 60, 6, dt)) enter("nap");
+ if (world.pup.time < dt * 1.5) noises.yawn();
+ },
+ pose: () => ({ wag: .1, droop: 1, ears: "soft", yawn: 1 }),
+ },
+ nap: {
+ tick(dt) {
+ const p = world.pup;
+ stand(dt);
+ p.energy = Math.min(1, p.energy + .07 * dt);
+ if (Math.floor((p.time - dt) / 2.6) !== Math.floor(p.time / 2.6)) noises.snore();
+ if (p.energy > .97) enter("stretch");
+ },
+ pose: () => ({ lie: 1, eyes: 0, wag: 0, curl: 1, breathe: .4 }),
+ },
+ stretch: {
+ tick(dt) {
+ const p = world.pup;
+ stand(dt);
+ if (p.time > 1.3) { const then = p.then; enter(then || "idle"); if (!then) noises.yip(); }
+ },
+ pose: () => ({ bow: 1, wag: .6, yawn: 1 }),
+ },
+ playbow: {
+ tick(dt) {
+ stand(dt); turnTo(world.hand.x, world.hand.z, dt, 5);
+ if (world.pup.time < dt * 1.5) noises.bark();
+ if (world.pup.time > .8) enter("zoomies");
+ },
+ pose: () => ({ bow: 1, wag: 1.4, ears: "perk", look: "hand" }),
+ },
+ zoomies: {
+ tick(dt) {
+ const p = world.pup;
+ const next = notice({ treat: 1 });
+ if (next) return enter(next);
+ const cx = 0, cz = -15, r = 125;
+ const a = Math.atan2(p.z - cz, p.x - cx) + .55;
+ goTo(cx + Math.cos(a) * r, cz + Math.sin(a) * r, 270, 0, dt);
+ p.energy -= .06 * dt; p.joy = Math.min(1, p.joy + .04 * dt);
+ if (p.time > 4.5) enter("flop");
+ },
+ pose: () => ({ wag: 1.3, ears: "flap", tongue: 1 }),
+ },
+ flop: {
+ tick(dt) {
+ stand(dt);
+ if (world.pup.time > 1.4) enter("idle");
+ },
+ pose: () => ({ lie: 1, tongue: 1, wag: .6, pant: 1 }),
+ },
+};
+
+function dropCarried() {
+ const p = world.pup, m = pupRig(p).mouth;
+ if (p.carrying === "rope") Object.assign(world.rope, { heldBy: null, x: m.x, z: m.z, angle: p.heading + Math.PI });
+ if (p.carrying === "ball") Object.assign(world.ball, { heldBy: null, x: m.x, y: Math.max(0, m.y - 5), z: m.z });
+ p.carrying = null;
+}
+
+// Where the pup takes the rope from the hand: a rope-length toward the pup.
+function ropeGrip() {
+ const { pup, hand } = world, tip = fingertip();
+ const a = Math.atan2(pup.z - tip.z, pup.x - tip.x);
+ return { x: tip.x + Math.cos(a) * 30, z: tip.z + Math.sin(a) * 30 };
+}
+
+function tickPup(dt) {
+ const p = world.pup;
+ p.time += dt;
+ const moving = p.speed > 12;
+ p.energy = clamp(p.energy - dt * (p.speed > 150 ? .025 : moving ? .008 : -.002), 0, 1);
+ p.joy = clamp(lerp(p.joy, .5, dt * .01), 0, 1);
+ states[p.state].tick(dt);
+ // Walk: the heading carries it; the yard keeps it.
+ p.x = clamp(p.x + Math.cos(p.heading) * p.speed * dt, yard.minX, yard.maxX);
+ p.z = clamp(p.z + Math.sin(p.heading) * p.speed * dt, yard.minZ, yard.maxZ);
+ p.gait += p.speed * dt / 42 * Math.PI * 2;
+ if (p.carrying === "ball") {
+ const m = pupRig(p).mouth;
+ Object.assign(world.ball, { x: m.x, y: Math.max(0, m.y - 5), z: m.z, dir: p.heading });
+ }
+ world.hearts = world.hearts.filter((h) => (h.t += dt) < 1.4);
+ settlePose(dt);
+}
+
+// ——— the pose: what each state wants, eased, with breath, blinks and a tail ———
+
+function settlePose(dt) {
+ const p = world.pup, o = p.pose, want = states[p.state].pose(p);
+ const t = world.t, k = ease(9, dt);
+ const run = clamp(p.speed / 150, 0, 1);
+ const target = { bob: 0, pitch: 0, roll: 0, yaw: 0, nod: 0, cock: 0, flop: .25, perk: 0,
+ droop: 0, eyes: 1, mouth: 0, tongue: 0, legs: [0, 0, 0, 0], splay: [0, 0, 0, 0] };
+
+ // Where the eyes go: the hand, the ball, the treat, or ahead.
+ const lookAt = want.look === "ball" ? world.ball : want.look === "treat" ? fingertip()
+ : want.look === "hand" ? { x: world.hand.x, y: world.hand.height, z: world.hand.z } : null;
+ if (lookAt) {
+ target.yaw = clamp(wrapAngle(p.heading - Math.atan2(lookAt.z - p.z, lookAt.x - p.x)), -.9, .9);
+ const rise = (lookAt.y ?? 0) - 30, far = Math.max(20, dist(p.x, p.z, lookAt.x, lookAt.z));
+ target.nod = clamp(Math.atan2(rise, far), -.4, .7);
+ }
+ if (want.nose) target.nod = -.6;
+ if (want.sit) { target.pitch = .5 * want.sit; target.bob = -4; target.legs = [-.5, -.5, 1.05, 1.05]; }
+ if (want.beg) { target.pitch = 1.05; target.bob = -3; target.legs = [1, 1, -.45, -.45]; target.nod = -.4; }
+ if (want.lie) {
+ target.bob = -11.5; target.legs = [1.45, 1.45, -1.35, -1.35]; target.splay = [.25, .25, .3, .3];
+ target.nod = want.curl ? -.45 : -.1; target.yaw = want.curl ? .5 : 0;
+ }
+ if (want.bow) { target.pitch = -.32; target.bob = -3; target.legs = [.75, .75, .3, .3]; target.nod = .45; }
+ if (want.roll) {
+ target.roll = Math.PI; target.bob = -10.5; target.cock = -.9; target.nod = .3;
+ target.legs = [0, 1, 2, 3].map((i) => Math.sin(t * 7 + i * 1.7) * .45);
+ target.splay = [.4, .4, .4, .4];
+ }
+ if (want.lean) target.cock = .32 * want.lean;
+ if (want.droop) { target.nod -= .3; target.droop = .8; }
+ if (want.chin) target.nod = .25;
+ if (want.eyes === 0) target.eyes = 0;
+ if (want.tongue || (run > .6 && p.state !== "fetch")) target.tongue = 1;
+ if (want.yawn && p.time < 1) target.mouth = 1;
+ if (want.chomp) target.nod = -.7;
+ target.perk = want.ears === "perk" ? .35 : 0;
+ target.flop = want.ears === "soft" ? .05 : want.ears === "perk" ? .15 : .25;
+
+ for (const key of ["bob", "pitch", "roll", "yaw", "nod", "cock", "flop", "perk", "droop"])
+ o[key] = lerp(o[key], target[key], key === "roll" ? ease(5, dt) : k);
+ for (let i = 0; i < 4; i++) {
+ o.legs[i] = lerp(o.legs[i], target.legs[i], k);
+ o.splay[i] = lerp(o.splay[i], target.splay[i], k);
+ }
+ o.eyes = target.eyes; o.mouth = target.mouth; o.tongue = target.tongue;
+
+ // The gait rides on top: a trot (diagonal pairs) that becomes a gallop.
+ const amp = clamp(p.speed / 160, 0, .75), g = p.gait;
+ if (amp > .02) {
+ const gallop = p.speed > 190 ? 1 : 0;
+ const front = [g, g + Math.PI - gallop * (Math.PI - .5)], back = [g + Math.PI, g + gallop * .5 * 1];
+ o.legs[0] += Math.sin(front[0]) * amp; o.legs[1] += Math.sin(front[1]) * amp;
+ o.legs[2] += Math.sin(back[0]) * amp; o.legs[3] += Math.sin(back[1]) * amp;
+ o.bob -= Math.abs(Math.sin(g)) * amp * 3.2;
+ o.pitch += Math.sin(g) * amp * .08 * (1 + gallop * 2);
+ }
+
+ // Breath, a blink every few seconds, sniffs and head shakes.
+ const breathe = want.breathe ?? 1;
+ o.bob += Math.sin(t * (want.pant ? 12 : 2.6 * breathe)) * (want.pant ? .6 : .45);
+ if (t > p.blinkAt) { if (t > p.blinkAt + .12) p.blinkAt = t + 2.5 + world.rng() * 3; else o.eyes = 0; }
+ o.shake = want.shake ? Math.sin(t * 15) * .38 : want.sniffing ? Math.sin(t * 22) * .06 : 0;
+ if (want.chomp) o.shake = Math.sin(t * 18) * .1;
+ if (want.chomp) o.mouth = Math.sin(t * 14) > 0 ? 1 : 0;
+
+ // The tail: wag rate and swing climb with joy and with what's happening.
+ const wag = want.wag ?? .4, joy = p.joy;
+ o.wagRate = lerp(o.wagRate, 6 + wag * 9 + joy * 5, ease(4, dt));
+ o.wagAmp = lerp(o.wagAmp, clamp(wag * (.35 + joy * .5), 0, .95), ease(4, dt));
+ o.wagPhase = (o.wagPhase || 0) + o.wagRate * dt;
+ o.wag = Math.sin(o.wagPhase) * o.wagAmp;
+ o.droopTail = want.lie ? .3 : -.1 + o.droop;
+ // Ears bounce with the gait and stream out when running.
+ o.earFlop = o.flop + (want.ears === "flap" ? .5 + Math.sin(t * 18) * .35 : 0)
+ + (want.ears === "bounce" ? Math.abs(Math.sin(g)) * .3 * amp : 0) + run * .3;
+ o.earPerk = o.perk - run * .6;
+}
+
+// ——— the rig in JS: where the head and mouth are, for carried things ———
+// A frame is origin(3) + x(3) y(3) z(3), turned as object-lisp.mjs turns them.
+
+function frameMove(m, x, y, z) { for (let k = 0; k < 3; k++) m[k] += x * m[3 + k] + y * m[6 + k] + z * m[9 + k]; }
+function frameTurn(m, axis, a) {
+ const c = Math.cos(a), s = Math.sin(a);
+ // about z carries x toward y; about x carries y toward z; about y carries z toward x
+ const [u, v] = axis === "z" ? [3, 6] : axis === "x" ? [6, 9] : [9, 3];
+ for (let k = 0; k < 3; k++) {
+ const pu = m[u + k], pv = m[v + k];
+ m[u + k] = pu * c + pv * s; m[v + k] = pv * c - pu * s;
+ }
+}
+const framePoint = (m, x, y, z) => ({ x: m[0] + x * m[3] + y * m[6] + z * m[9],
+ y: m[1] + x * m[4] + y * m[7] + z * m[10], z: m[2] + x * m[5] + y * m[8] + z * m[11] });
+
+function pupPlace(p, m = new Float64Array(12)) {
+ const c = Math.cos(p.heading), s = Math.sin(p.heading);
+ m[0] = p.x; m[1] = 0; m[2] = p.z;
+ m[3] = c; m[4] = 0; m[5] = s;
+ m[6] = 0; m[7] = 1; m[8] = 0;
+ m[9] = -s; m[10] = 0; m[11] = c;
+ return m;
+}
+
+function pupRig(p) {
+ const o = p.pose, m = pupPlace(p);
+ frameMove(m, 0, rig.stand + o.bob, 0);
+ frameTurn(m, "x", o.roll);
+ frameMove(m, -rig.hip, 0, 0); frameTurn(m, "z", o.pitch); frameMove(m, rig.hip, 0, 0);
+ frameMove(m, ...rig.neck);
+ frameTurn(m, "y", o.yaw + o.shake); frameTurn(m, "z", o.nod); frameTurn(m, "x", o.cock);
+ return { head: framePoint(m, 0, 0, 0), mouth: framePoint(m, ...rig.mouth) };
+}
+
+// What the lisp reads as the owner's parts.
+function pupOwner(p) {
+ const o = p.pose;
+ return {
+ body: [o.bob, o.pitch, o.roll], head: [o.yaw + o.shake, o.nod, o.cock],
+ ears: [o.earFlop, o.earPerk, 0], tail: [o.wag, o.droopTail, 0],
+ fl: [o.legs[0], o.splay[0], 0], fr: [o.legs[1], o.splay[1], 0],
+ bl: [o.legs[2], o.splay[2], 0], br: [o.legs[3], o.splay[3], 0],
+ face: [o.eyes, o.mouth, o.tongue],
+ };
+}
+
+// ——— one tick ———
+
+function step(dt = tick) {
+ world.t += dt;
+ readPad();
+ tickHand(dt);
+ tickBall(dt);
+ tickTreat(dt);
+ tickPup(dt);
+ followCamera(dt);
+ flushSounds();
+}
+
+// ——— the camera: above and behind, framing the pup and the hand ———
+
+const camera = new Float64Array(24);
+let screenW = 1920, screenH = 1080;
+
+function followCamera(dt) {
+ const c = world.camera, p = world.pup, h = world.hand;
+ const tx = lerp(p.x, h.x, .35), tz = lerp(p.z, h.z, .35);
+ const reach = clamp(215 + dist(p.x, p.z, h.x, h.z) * .75, 215, 470);
+ const k = ease(2.5, dt);
+ c.x = lerp(c.x, tx, k); c.z = lerp(c.z, tz, k); c.reach = lerp(c.reach, reach, k);
+}
+
+function aimCamera() {
+ const c = world.camera, tilt = .5, scale = screenH / 1080;
+ const s = Math.sin(tilt), co = Math.cos(tilt);
+ const m = camera;
+ m[0] = c.x; m[1] = 16 + s * c.reach; m[2] = c.z + co * c.reach;
+ m[3] = 1; m[4] = 0; m[5] = 0; // right
+ m[6] = 0; m[7] = co; m[8] = -s; // up
+ m[9] = 0; m[10] = -s; m[11] = -co; // forward
+ m[12] = screenW / 2; m[13] = screenH * .54;
+ m[14] = 1; m[15] = 1300 * scale; m[16] = 1; // orthoScale, focal, all perspective
+ m[17] = 2.8 / 16000; m[18] = -1.4; m[19] = 12; // depth slope and base, near
+ m[20] = -screenW * .25; m[21] = screenW * 1.25; m[22] = -screenH * .25; m[23] = screenH * 1.25;
+}
+
+// A world point on the screen (for hearts and zzz), or null behind the lens.
+function onScreen(x, y, z) {
+ const m = camera, dx = x - m[0], dy = y - m[1], dz = z - m[2];
+ const vz = dx * m[9] + dy * m[10] + dz * m[11];
+ if (vz < m[19]) return null;
+ const k = m[15] / vz;
+ return { x: m[12] + (dx * m[3] + dy * m[4] + dz * m[5]) * k,
+ y: m[13] - (dx * m[6] + dy * m[7] + dz * m[8]) * k, depth: clamp(vz * m[17] + m[18], -1.499, 1.4), k };
+}
+
+// ——— the frame program ———
+
+const OP = { FACE: 2, DISC: 3, CAPSULE: 4, TEXT: 5, WIPE: 8, CAMERA: 9, ELLIPSE: 15,
+ PLATE: 16, OUTLINE: 17, SHAPES: 18, SKETCH: 19 };
+let program = new Float32Array(8192), length = 0, strings = [];
+const stats = { numbers: 0, ops: {} };
+
+function room(n) {
+ if (length + n <= program.length) return;
+ const grown = new Float32Array(Math.max(program.length * 2, length + n));
+ grown.set(program.subarray(0, length));
+ program = grown;
+}
+function op(code, ...args) {
+ room(args.length + 1);
+ program[length++] = code;
+ for (const a of args) program[length++] = a;
+ stats.ops[code] = (stats.ops[code] || 0) + 1;
+}
+
+// Each object's baked sketches get handles once; a handle goes up as SHAPES
+// the first time a host sees it.
+const handles = new Map();
+let sent = new Set(), nextHandle = 1;
+function handleFor(object, index) {
+ let list = handles.get(object);
+ if (!list) handles.set(object, list = []);
+ return list[index] || (list[index] = nextHandle++);
+}
+
+function outFor(object) {
+ return {
+ view: camera,
+ face() {},
+ sketch(index, frame, at) {
+ const handle = handleFor(object, index);
+ if (!sent.has(handle)) {
+ const s = object.sketches[index];
+ room(4 + s.records.length);
+ program[length++] = OP.SHAPES; program[length++] = handle;
+ program[length++] = s.count; program[length++] = s.records.length;
+ program.set(s.records, length); length += s.records.length;
+ sent.add(handle);
+ }
+ room(14);
+ program[length++] = OP.SKETCH; program[length++] = handle;
+ for (let k = 0; k < 12; k++) program[length++] = frame[at + k];
+ stats.ops[OP.SKETCH] = (stats.ops[OP.SKETCH] || 0) + 1;
+ },
+ ellipse: (x, y, d, ax, ay, bx, by, r, g, b) => op(OP.ELLIPSE, x, y, d, ax, ay, bx, by, r, g, b),
+ capsule: (x1, y1, x2, y2, d, w, r, g, b) => op(OP.CAPSULE, x1, y1, x2, y2, d, w, r, g, b),
+ disc: (x, y, d, rad, r, g, b) => op(OP.DISC, x, y, d, rad, r, g, b),
+ plate: (n, points, d, r, g, b) => op(OP.PLATE, n, ...points.slice(0, n * 2), d, r, g, b),
+ outline: (w, r, g, b) => op(OP.OUTLINE, w, r, g, b),
+ };
+}
+
+function text(s, x, y, size, r, g, b) {
+ strings.push(s);
+ op(OP.TEXT, 3, x, y, size, r, g, b, strings.length - 1);
+}
+// A flat screen quad in front of everything (HUD panels): triangles, not
+// `box`, because the console draws boxes under the scene.
+function panel(x, y, w, h, r, g, b, depth = -1.49) {
+ op(OP.FACE, x, y, depth, x + w, y, depth, x + w, y + h, depth, r, g, b);
+ op(OP.FACE, x, y, depth, x + w, y + h, depth, x, y + h, depth, r, g, b);
+}
+
+const objects = fiapupObjects;
+const outs = Object.fromEntries(Object.keys(objects).map((name) => [name, outFor(objects[name])]));
+const identityAt = (x, y, z, m = new Float64Array(12)) => { m.set([x, y, z, 1, 0, 0, 0, 1, 0, 0, 0, 1]); return m; };
+const turnedAt = (x, y, z, a, m = new Float64Array(12)) => {
+ const c = Math.cos(a), s = Math.sin(a);
+ m.set([x, y, z, c, 0, s, 0, 1, 0, -s, 0, c]); return m;
+};
+const inputs = { owner: null, distance: 0, time: 0 };
+
+function drawYard() { objects.yard(inputs, identityAt(0, 0, 0), outs.yard); }
+
+function drawPup() {
+ const p = world.pup;
+ inputs.owner = pupOwner(p);
+ objects.puppy(inputs, pupPlace(p), outs.puppy);
+}
+
+function drawBall() {
+ const b = world.ball;
+ let x = b.x, y = b.y, z = b.z;
+ if (b.heldBy === "hand") { const tip = fingertip(); x = tip.x; y = tip.y - 6; z = tip.z; }
+ inputs.owner = { ball: [y, 0, 0] };
+ inputs.distance = b.roll;
+ objects.ball(inputs, turnedAt(x, 0, z, b.dir), outs.ball);
+}
+
+function drawRope() {
+ const r = world.rope, p = world.pup;
+ let a, e;
+ const mouth = () => pupRig(p).mouth;
+ if (r.heldBy === "both") { const t = fingertip(); a = t; const m = mouth(); e = [m.x - t.x, m.y - t.y, m.z - t.z]; }
+ else if (r.heldBy === "hand") { a = fingertip(); e = [0, 3.4 - a.y, 14]; }
+ else if (r.heldBy === "pup") {
+ a = mouth();
+ e = [-Math.cos(p.heading) * 18 + Math.sin(p.heading) * 8, 3.4 - a.y, -Math.sin(p.heading) * 18 - Math.cos(p.heading) * 8];
+ } else { a = { x: r.x, y: 3.4, z: r.z }; e = [Math.cos(r.angle) * 30, 0, Math.sin(r.angle) * 30]; }
+ inputs.owner = { end: e };
+ objects.rope(inputs, identityAt(a.x, a.y, a.z), outs.rope);
+}
+
+function drawTreat() {
+ const t = world.treat, h = world.hand;
+ if (h.holding === "treat") { const tip = fingertip(); objects.treat(inputs, identityAt(tip.x, tip.y - 2, tip.z - 2), outs.treat); }
+ else if (t.onFloor) objects.treat(inputs, turnedAt(t.x, t.y, t.z, .6), outs.treat);
+}
+
+function drawHand() {
+ const h = world.hand;
+ inputs.owner = { hand: [h.height, h.grip, 0] };
+ // Object x along handAxis, y up, z = x × y.
+ const [ax, , az] = handAxis;
+ const m = new Float64Array([h.x, 0, h.z, ax, 0, az, 0, 1, 0, -az, 0, ax]);
+ objects.hand(inputs, m, outs.hand);
+}
+
+function drawHearts() {
+ for (const heart of world.hearts) {
+ const at = onScreen(heart.x + Math.sin(heart.t * 5) * 4, heart.y + heart.t * 30, heart.z);
+ if (!at) continue;
+ const s = at.k * 3.2 * (1 - heart.t / 1.6), d = at.depth - .002;
+ op(OP.DISC, at.x - s * .55, at.y, d, s * .62, 240, 80, 110);
+ op(OP.DISC, at.x + s * .55, at.y, d, s * .62, 240, 80, 110);
+ op(OP.FACE, at.x - s * 1.12, at.y + s * .2, d, at.x + s * 1.12, at.y + s * .2, d, at.x, at.y + s * 1.5, d, 240, 80, 110);
+ }
+ const p = world.pup;
+ if (p.state === "nap") {
+ const head = pupRig(p).head;
+ for (let i = 0; i < 3; i++) {
+ const u = (world.t * .45 + i / 3) % 1;
+ const at = onScreen(head.x + u * 14, head.y + 8 + u * 34, head.z);
+ if (at) text("z", at.x, at.y, 26 + u * 26, 90, 90, 150);
+ }
+ }
+}
+
+const moods = {
+ idle: "hangin' out", sniff: "sniff sniff", come: "coming!", fetch: "fetch!", tug: "tug of war!",
+ parade: "mine mine mine", petted: "good pup", rollover: "belly rubs!", beg: "please?",
+ eat: "nom nom", sleepy: "sleepy...", nap: "zzz", stretch: "big stretch", playbow: "wanna play?",
+ zoomies: "ZOOMIES!", flop: "phew",
+};
+
+function drawHud() {
+ const p = world.pup, s = screenH / 1080;
+ panel(40 * s, 34 * s, 420 * s, 118 * s, 255, 250, 238);
+ text("fiapup", 64 * s, 50 * s, 40 * s, 120, 72, 50);
+ text(moods[p.state] || p.state, 64 * s, 104 * s, 26 * s, 90, 110, 150);
+ // joy and energy, as two little bars
+ const bar = (y, v, r, g, b) => {
+ panel(300 * s, y, 132 * s, 16 * s, 226, 220, 208, -1.491);
+ panel(300 * s, y, 132 * s * clamp(v, 0, 1), 16 * s, r, g, b, -1.492);
+ };
+ bar(62 * s, p.joy, 240, 90, 120);
+ bar(96 * s, p.energy, 110, 180, 110);
+ const hint = world.hand.holding === "ball" ? "A throw" : world.hand.holding === "rope" ? "A let go"
+ : world.hand.holding === "treat" ? "let go of X to drop it" : "A pet / pick up B call X treat Y play";
+ text(hint, 64 * s, screenH - 76 * s, 26 * s, 255, 255, 255);
+}
+
+let vm = null, batch = null, batched = 0;
+
+// The console's `triangles3d` takes a frame's faces in one crossing; collect
+// the interpreter's faces into it when the host has it.
+function hostFace(x1, y1, z1, x2, y2, z2, x3, y3, z3, r, g, b) {
+ if (batched >= 8192) flushFaces();
+ const at = batched++ * 12;
+ batch[at] = x1; batch[at + 1] = y1; batch[at + 2] = z1; batch[at + 3] = x2; batch[at + 4] = y2; batch[at + 5] = z2;
+ batch[at + 6] = x3; batch[at + 7] = y3; batch[at + 8] = z3; batch[at + 9] = r; batch[at + 10] = g; batch[at + 11] = b;
+}
+function flushFaces() { if (batched) triangles3d(batch, batched); batched = 0; }
+
+function interpreter() {
+ if (vm) return vm;
+ const face = typeof triangles3d === "function" ? (batch = new Float32Array(8192 * 12), hostFace)
+ : typeof triangle3d === "function" ? triangle3d : undefined;
+ const none = () => {};
+ vm = fiapupFrameVm.createFrameVm({
+ triangle3d: face, triangle: typeof triangle === "function" ? triangle : undefined,
+ box: typeof box === "function" ? box : none, line: typeof line === "function" ? line : none,
+ wipe: typeof wipe === "function" ? wipe : none, write: typeof write === "function" ? write : undefined,
+ });
+ return vm;
+}
+
+function paintFrame() {
+ const run = typeof runtime === "function" ? runtime() : null;
+ screenW = run?.width || 1920; screenH = run?.height || 1080;
+ length = 0; strings = []; stats.ops = {};
+ aimCamera();
+ op(OP.WIPE, 196, 224, 240);
+ op(OP.CAMERA, ...camera);
+ drawYard();
+ drawRope();
+ drawTreat();
+ drawBall();
+ drawPup();
+ drawHand();
+ drawHearts();
+ drawHud();
+ stats.numbers = length;
+ if (typeof frame === "function") frame(program, length, strings);
+ else { interpreter().run(program, length, strings); flushFaces(); }
+}
+
+// ——— lifecycle ———
+
+let clockUs = null, owed = 0;
+
+function boot() {
+ world = freshWorld();
+ sent = new Set();
+}
+
+// The host calls sim once a paint; the pup lives on a fixed 60 Hz clock.
+function sim() {
+ const now = typeof runtime === "function" ? runtime().monotonicUs : null;
+ if (now == null || clockUs == null) { clockUs = now; step(); return; }
+ owed = Math.min(owed + (now - clockUs) / 1e6, tick * 4);
+ clockUs = now;
+ while (owed >= tick) { owed -= tick; step(); }
+}
+
+function paint() { paintFrame(); }
+function act() {}
+function leave() {}
+
+// For tests and the web shell: the world, one tick, a staged moment.
+const fiapup = {
+ get world() { return world; }, step, paint: paintFrame, stats, rig: pupRig, owner: pupOwner,
+ program: () => program.subarray(0, length), strings: () => strings, states: Object.keys(states),
+ resend() { sent = new Set(); vm = null; },
+ // Put the yard in a named moment and let it run `seconds`. The shell's
+ // ?stage= and the screenshot tool use these; the game never does.
+ stage(name, seconds = 2) {
+ world = freshWorld();
+ const w = world, p = w.pup, press = [];
+ const hold = (buttons, t) => press.push({ buttons, t });
+ if (name === "fetch") { w.hand.x = 60; w.hand.z = 110; w.hand.holding = "ball"; w.ball.heldBy = "hand"; hold(["A"], .05); }
+ if (name === "pet") { p.x = 0; p.z = 60; p.heading = Math.PI / 2; w.hand.x = 8; w.hand.z = 62; hold(["A"], 99); }
+ if (name === "beg") { w.hand.x = 30; w.hand.z = 40; hold(["X"], 99); }
+ if (name === "nap") { p.energy = .1; p.x = -200; p.z = -120; }
+ if (name === "zoomies") { p.energy = .95; p.joy = .9; hold(["Y"], .05); }
+ if (name === "tug") { w.hand.x = 20; w.hand.z = 60; w.hand.holding = "rope"; w.rope.heldBy = "hand"; p.x = 10; p.z = -20; }
+ const script = { down: [] };
+ globalThis.__fiapupScript = script;
+ for (let t = 0; t < seconds; t += tick) {
+ script.down = press.filter((q) => t < q.t).flatMap((q) => q.buttons);
+ step();
+ }
+ globalThis.__fiapupScript = null;
+ return world;
+ },
+};
diff --git a/xbox/fiapup/objects/ball-flat.lisp b/xbox/fiapup/objects/ball-flat.lisp
new file mode 100644
index 0000000000..e303ee6854
--- /dev/null
+++ b/xbox/fiapup/objects/ball-flat.lisp
@@ -0,0 +1,16 @@
+; ball-flat — the fetch ball. The origin is on the floor under the ball;
+; x points the way it rolls. `distance` rolls it; the owner's `ball x` lifts
+; it (a throw, or the pup's mouth). The band is a flat bar across the face,
+; so turning it is what reads as rolling.
+
+def r 5
+
+(let roll (/ distance r))
+
+(nudge 20 (ink 58 104 60) (scale 1.2 1 1 (ring y 4.4)))
+
+(move 0 (+ r (owner ball x)) 0
+ (rotate z (- roll)
+ (ink 232 72 76)
+ (outline 1 96 30 36 (ball 0 0 0 r))
+ (nudge -1 (ink 255 214 84) (limb 0 -4.3 0 0 4.3 0 1.3))))
diff --git a/xbox/fiapup/objects/hand-flat.lisp b/xbox/fiapup/objects/hand-flat.lisp
new file mode 100644
index 0000000000..cd48ff1904
--- /dev/null
+++ b/xbox/fiapup/objects/hand-flat.lisp
@@ -0,0 +1,24 @@
+; hand-flat — you: a white-gloved hand over the lawn, fingers reaching into
+; the yard (x; the game turns it in from the right), palm down, thumb on -z. The origin is on the floor
+; under the hand; the owner's `hand` is height · grip (0 open, 1 fist).
+; The shadow stays on the floor so you can tell where the hand is.
+
+(let h (owner hand x))
+
+(nudge 30 (ink 58 104 60) (scale 1.5 1 1.1 (ring y 8)))
+
+(move 0 h 0
+ (rotate z -.3
+ ; the cuff sits under the palm, whatever the depth of its middle says
+ (nudge 22 (ink 236 96 128) (outline 1.2 96 50 70 (limb -15 0 0 -8 0 0 5.2)))
+ (ink 255 252 246)
+ (outline 1.2 96 90 100 (ball 0 0 0 7.4))
+ (if (< (owner hand y) .5)
+ (nudge -6 (outline 1.2 96 90 100
+ (limb 4 .5 -4.6 13 -1 -5.6 2.1) (limb 5 .5 -1.5 15 -1 -1.8 2.1)
+ (limb 5 .5 1.5 15 -1 1.8 2.1) (limb 4 .5 4.6 12 -1 5.4 2)
+ (limb 0 0 -6 5 1 -11 2.2))))
+ (if (> (owner hand y) .5)
+ (nudge -6 (outline 1.2 96 90 100
+ (ball 6 -1 -4 2.6) (ball 7 -1 -1.3 2.6) (ball 7 -1 1.3 2.6) (ball 6 -1 4 2.5)
+ (limb 1 0 -6 5 1 -6.5 2.2))))))
diff --git a/xbox/fiapup/objects/puppy-flat.lisp b/xbox/fiapup/objects/puppy-flat.lisp
new file mode 100644
index 0000000000..2e2ef89993
--- /dev/null
+++ b/xbox/fiapup/objects/puppy-flat.lisp
@@ -0,0 +1,87 @@
+; puppy-flat — fia's pup, drawn flat: balls and limbs hung on a small
+; skeleton, inked at the silhouette. x forward (nose), y up, z to the pup's
+; right, the origin on the floor under the middle of the body.
+;
+; Every shape is still; only the frames move. The game hands the pose over as
+; the owner's parts (three numbers each, radians unless noted), so each bone
+; is one baked sketch under a moving frame: a tick is about a dozen SKETCH
+; ops and nothing is projected in JS.
+;
+; body bob (units) · pitch about the hips (nose up +) · roll along the spine
+; head yaw · nod (up +) · cock (the curious tilt)
+; ears flop (out from the head) · perk (forward)
+; tail wag · droop (down +)
+; fl fr bl br swing (paw forward +) · splay (out to the side +)
+; face eyes open (0/1) · mouth open (0/1) · tongue (0/1)
+;
+; fiapup.js mirrors the measurements it needs (neck, snout) in `pupRig`;
+; change them together.
+
+def stand 21
+def hip 9
+def side 5.2
+def leg 14
+def edge 1.3
+
+(let bob (owner body x))
+(let pitch (owner body y))
+(let roll (owner body z))
+
+; the shadow stays on the floor, behind the pup's own shapes
+(nudge 40
+ (ink 58 104 60)
+ (scale 1.9 1 1.25 (ring y 13)))
+
+(move 0 (+ stand bob) 0
+ (rotate x roll
+ (move (- hip) 0 0 (rotate z pitch (move hip 0 0
+
+ ; far legs first, near legs last: depth sorts them anyway, the order
+ ; only settles ties
+ (move hip -3 (- side) (rotate z (owner fl x) (rotate x (owner fl y))
+ (ink 226 170 110) (limb 0 0 0 0 (- leg) 0 3.1)
+ (ink 250 236 214) (ball .8 (- -.5 leg) 0 3.4)))
+ (move hip -3 side (rotate z (owner fr x) (rotate x (- (owner fr y)))
+ (ink 226 170 110) (limb 0 0 0 0 (- leg) 0 3.1)
+ (ink 250 236 214) (ball .8 (- -.5 leg) 0 3.4)))
+ (move (- hip) -2 (- side) (rotate z (owner bl x) (rotate x (owner bl y))
+ (ink 214 156 98) (limb 0 0 0 0 (- -2.4 leg) 0 3.5)
+ (ink 250 236 214) (ball .8 (- -3.4 leg) 0 3.6)))
+ (move (- hip) -2 side (rotate z (owner br x) (rotate x (- (owner br y)))
+ (ink 214 156 98) (limb 0 0 0 0 (- -2.4 leg) 0 3.5)
+ (ink 250 236 214) (ball .8 (- -3.4 leg) 0 3.6)))
+
+ ; the body: a bean with a cream chest, a brown saddle, a red collar
+ (ink 232 178 118)
+ (outline edge 74 44 30 (limb -8 0 0 8 1 0 9.5))
+ (nudge -1.5
+ (ink 250 236 214) (ball 9 -2.5 0 6.5)
+ (ink 168 104 62) (ball -4 5.5 0 6))
+ (nudge -3
+ (ink 222 58 64) (limb 12 5 -5 12 5 5 1.7)
+ (ink 255 208 70) (ball 13.6 2.5 0 1.6))
+
+ ; the tail, from the rump, up and back
+ (move -15 3 0 (rotate y (owner tail x) (rotate z (owner tail y)
+ (ink 214 156 98)
+ (outline edge 74 44 30 (limb 0 0 0 -6 6 0 2.3))
+ (ink 250 236 214) (ball -6.5 6.8 0 2.4))))
+
+ ; the head on the neck, turned, nodded and cocked
+ (move 14 9 0 (rotate y (owner head x) (rotate z (owner head y) (rotate x (owner head z)
+ (ink 232 178 118)
+ (outline edge 74 44 30 (ball 0 0 0 9))
+ (ink 250 236 214) (ball 7 -3 0 5.4)
+ (nudge -1 (ink 40 28 30) (ball 12 -1.4 0 2.3))
+ (if (> (owner face x) .5)
+ (ink 36 26 30) (ball 5.2 2.6 -4.3 1.8) (ball 5.2 2.6 4.3 1.8)
+ (nudge -.5 (ink 255 255 255) (ball 6 3.3 -4.5 .6) (ball 6 3.3 4.5 .6)))
+ (if (< (owner face x) .5)
+ (ink 60 38 34) (limb 4.4 2.3 -5 6.6 2.3 -3.6 .5) (limb 4.4 2.3 5 6.6 2.3 3.6 .5))
+ (if (> (owner face z) .5)
+ (ink 236 96 120) (limb 9 -6 0 10 -9.5 0 1.9))
+ ; floppy ears, the brown of the saddle
+ (move -1 5.5 -6 (rotate x (owner ears x) (rotate z (owner ears y)
+ (ink 168 104 62) (outline edge 74 44 30 (limb 0 0 0 0 -8 -2.4 3.3)))))
+ (move -1 5.5 6 (rotate x (- (owner ears x)) (rotate z (owner ears y)
+ (ink 168 104 62) (outline edge 74 44 30 (limb 0 0 0 0 -8 2.4 3.3))))))))))))))
diff --git a/xbox/fiapup/objects/rope-flat.lisp b/xbox/fiapup/objects/rope-flat.lisp
new file mode 100644
index 0000000000..4eaa5e1459
--- /dev/null
+++ b/xbox/fiapup/objects/rope-flat.lisp
@@ -0,0 +1,12 @@
+; rope-flat — the tug rope: a knot at each end and the rope between. The
+; origin is the first knot; the owner's `end` is the second knot, in world
+; units from the first (the object is placed unturned). The first knot bakes;
+; the rope and the far knot move every tick, so they go out as a CAPSULE and
+; an ELLIPSE — the one object here that uses the per-tick flat path.
+
+(ink 70 118 206)
+(outline 1 30 50 96 (ball 0 0 0 3.4))
+(ink 244 244 250)
+(limb 0 0 0 (owner end x) (owner end y) (owner end z) 1.5)
+(ink 70 118 206)
+(outline 1 30 50 96 (ball (owner end x) (owner end y) (owner end z) 3.4))
diff --git a/xbox/fiapup/objects/treat-flat.lisp b/xbox/fiapup/objects/treat-flat.lisp
new file mode 100644
index 0000000000..fa5f4a89e2
--- /dev/null
+++ b/xbox/fiapup/objects/treat-flat.lisp
@@ -0,0 +1,6 @@
+; treat-flat — a bone biscuit, lying along z. The origin is its middle.
+
+(ink 214 160 98)
+(outline 1 110 70 40
+ (limb 0 0 -4 0 0 4 1.6)
+ (ball 0 1 -5 1.9) (ball 0 -1 -5 1.9) (ball 0 1 5 1.9) (ball 0 -1 5 1.9))
diff --git a/xbox/fiapup/objects/yard-flat.lisp b/xbox/fiapup/objects/yard-flat.lisp
new file mode 100644
index 0000000000..aac1d91f29
--- /dev/null
+++ b/xbox/fiapup/objects/yard-flat.lisp
@@ -0,0 +1,77 @@
+; yard-flat — the pup's backyard, drawn flat. World space: x across, y up,
+; z toward the camera; the lawn runs from the fence (z -220) forward past
+; where the camera stands. All of it is still, so it bakes into one sketch
+; and a tick sends one SKETCH for the whole yard.
+;
+; Big flat shapes carry one depth each, so the ground is pushed far back
+; (nudge) and everything that stands on it draws over it. The fence and the
+; hills behind it are pushed back less than the lawn but more than anything
+; the pup can reach.
+
+def tile 80
+def fence -220
+
+; past the fence: a meadow, and a hedge along it
+(nudge 6000
+ (ink 146 188 108)
+ (plate -1600 0 -1500 1600 0 -1500 1600 0 -222 -1600 0 -222))
+(nudge 3200
+ (outline 1.6 52 96 58
+ (repeat 19 i
+ (ink (mix 104 92 (% i 2)) (mix 166 152 (% i 2)) (mix 92 84 (% i 2)))
+ (ball (- (* i 70) 630) 18 -262 (+ 30 (* 6 (% i 3)))))))
+
+; a round little tree over the fence, back left
+(nudge 3400
+ (ink 132 92 66) (limb -250 0 -300 -250 120 -300 7)
+ (ink 96 158 88)
+ (outline 1.6 52 96 58
+ (ball -250 150 -300 40) (ball -216 130 -290 28) (ball -284 132 -290 30)))
+
+; the fence: posts and two rails across the back and down both sides
+(nudge 3000
+ (ink 244 232 208)
+ (outline 1.2 150 120 96
+ (repeat 9 i
+ (plate (- (* i tile) 326) 0 fence (- (* i tile) 314) 0 fence
+ (- (* i tile) 314) 84 fence (- (* i tile) 326) 84 fence))
+ (plate -330 50 fence 330 50 fence 330 58 fence -330 58 fence)
+ (plate -330 20 fence 330 20 fence 330 28 fence -330 28 fence)
+ (mirror x
+ (repeat 5 j
+ (plate 320 0 (- (* (+ j 1) tile) 226) 320 0 (- (* (+ j 1) tile) 214)
+ 320 84 (- (* (+ j 1) tile) 214) 320 84 (- (* (+ j 1) tile) 226)))
+ (plate 320 50 fence 320 50 180 320 58 180 320 58 fence)
+ (plate 320 20 fence 320 20 180 320 28 180 320 28 fence))))
+
+; flowers along the fence
+(nudge 2600
+ (repeat 7 i
+ (ink 88 140 70) (limb (- (* i 90) 280) 0 -206 (- (* i 90) 280) 18 -206 1)
+ (ink (mix 255 250 (% i 2)) (mix 150 214 (% i 2)) (mix 190 90 (% i 2)))
+ (ball (- (* i 90) 280) 20 -206 4.5)))
+
+; the lawn, mown in squares
+(nudge 5000
+ (repeat 16 i
+ (repeat 9 j
+ (ink (mix 128 116 (% (+ i j) 2)) (mix 184 170 (% (+ i j) 2)) (mix 98 88 (% (+ i j) 2)))
+ (plate (- (* i tile) 640) 0 (+ fence (* j tile))
+ (- (* i tile) 560) 0 (+ fence (* j tile))
+ (- (* i tile) 560) 0 (+ fence (* (+ j 1) tile))
+ (- (* i tile) 640) 0 (+ fence (* (+ j 1) tile))))))
+
+; the bed, back left: a cushion and its soft middle, a little behind what
+; lies on it
+(move -230 0 -140
+ (nudge 30
+ (ink 110 140 196)
+ (outline 1.4 50 60 96 (move 0 5 0 (drum y 34 10)))
+ (ink 176 198 232) (move 0 10.3 0 (ring y 25))))
+
+; the water bowl, back right
+(move 230 0 -150
+ (nudge 12
+ (ink 222 84 72)
+ (outline 1.2 110 40 36 (move 0 3.5 0 (drum y 11 7)))
+ (ink 130 196 240) (move 0 7.2 0 (ring y 8))))