// The gait probe: boot the pond headless, put one duck into a clean walk on
// the winter ice lid, and shoot it side-on as a filmstrip — then measure the
// step cycle from the foot positions it recorded. Usage:
// node scripts/gait.mjs [duckIndexOrName] [frames] [outDir]
// duckIndexOrName index into quack.pond.ducks, or a duck's name
// (default: an adult already walking, else the first
// leaderless adult)
// frames filmstrip frames (default 24; more than 30 clamps)
// outDir where the PNGs and report.txt land (default temp/gait)
//
// The sim is stepped by hand — app.paused = true, app.step(1/120) in a loop —
// so the wall clock and the headless frame rate never enter the measurement.
// Only the last step of each frame is rendered at full size; the rest run
// tiny, with the shadow pass off, which keeps the capture to a couple of
// minutes on swiftshader.
//
// The dev server is the one on port 5180 (as our other probes use); point
// QUACK_URL at another if it is running elsewhere.
import puppeteer from 'puppeteer';
import { mkdirSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
const url = process.env.QUACK_URL || 'http://localhost:5180/?seed=20261018';
const sel = process.argv[2] || '';
const frames = Math.min(Number(process.argv[3]) || 24, 30);
const outDir = process.argv[4] || 'temp/gait';
const STEP = 1 / 120; // the sim slice app.step is called with
const PER_FRAME = 5; // slices per filmstrip frame: 1/24 s of sim each
const WARM_SECONDS = 1.6; // settle before the camera is pinned
const PLANT_TOL = 0.006; // m over the ground level that still counts as planted
const CAM_OUT = 2.4; // m the camera stands off the walk line
const CAM_EYE = 0.55; // m the eye rides over the lid
// The rig's step geometry, straight out of src/duck.ts (kept in step by hand:
// a change there wants a change here).
const LEG_LEN = 0.15; // hip to sole, rig units
const LEG_SWING = 0.5; // radians the hinge swings either side of centre
const LEG_STANCE = 0.62; // share of a cycle a foot spends planted
const CADENCE_CAP = 2.6; // the rig's own ceiling on steps per second
const consoleErrors = [];
const pageErrors = [];
let exitCode = 0;
// ---------------------------------------------------------------- statistics
function median(xs) {
if (!xs.length) return NaN;
const s = [...xs].sort((a, b) => a - b);
const m = s.length >> 1;
return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
}
function mean(xs) {
return xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : NaN;
}
const mm = (m) => `${(m * 1000).toFixed(1)} mm`;
const f2 = (v) => (Number.isFinite(v) ? v.toFixed(2) : '—');
const deg = (rad) => (rad * 180) / Math.PI;
/** Runs of consecutive frames where `flags[f]` is true, as [first, last] pairs. */
function runs(flags) {
const out = [];
let start = -1;
for (let f = 0; f <= flags.length; f++) {
const on = f < flags.length && flags[f];
if (on && start < 0) start = f;
else if (!on && start >= 0) {
out.push([start, f - 1]);
start = -1;
}
}
return out;
}
/**
* How far a foot travels over the ground, against how far the body travels,
* between two frames of one interval. `ratio` is the net xz pair (0 pinned,
* 1 skating), `path` the walked distance, `fwd` the along-the-walk part and
* `lat` the sideways excursion in metres.
*/
function ratioOver(a, b, footA, footB) {
const dx = b.x - a.x;
const dz = b.z - a.z;
const body = Math.hypot(dx, dz);
if (body < 1e-6) return null;
const fx = footB.x - footA.x;
const fz = footB.z - footA.z;
const ux = dx / body;
const uz = dz / body;
return {
ratio: Math.hypot(fx, fz) / body,
fwd: (fx * ux + fz * uz) / body,
lat: Math.abs(fx * -uz + fz * ux),
};
}
function pathRatio(frames, a, b, i) {
let foot = 0;
let body = 0;
for (let f = a + 1; f <= b; f++) {
const p = frames[f].feet[i];
const q = frames[f - 1].feet[i];
foot += Math.hypot(p.x - q.x, p.z - q.z);
const r = frames[f].root;
const s = frames[f - 1].root;
body += Math.hypot(r.x - s.x, r.z - s.z);
}
return body > 1e-6 ? foot / body : null;
}
// -------------------------------------------------------------------- the tool
const browser = await puppeteer.launch({
headless: true,
args: ['--enable-unsafe-swiftshader', '--hide-scrollbars'],
});
try {
const page = await browser.newPage();
await page.setViewport({ width: 800, height: 600, deviceScaleFactor: 2, isMobile: false, hasTouch: false });
page.on('console', (msg) => {
const line = `[console.${msg.type()}] ${msg.text()}`;
console.log(line);
if (msg.type() === 'error') consoleErrors.push(line);
});
page.on('pageerror', (err) => {
const line = `[pageerror] ${err.message}`;
console.log(line);
pageErrors.push(line);
});
page.on('requestfailed', (req) => console.log(`[requestfailed] ${req.url()} ${req.failure()?.errorText || ''}`));
// 1. boot, exactly as scripts/shot.mjs waits for the page
await page.goto(url, { waitUntil: 'networkidle2', timeout: 30000 });
await page.waitForFunction(() => window.quack && window.quack.app && window.quack.app.started, { timeout: 30000 });
await page.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))));
await page.evaluate(() => document.getElementById('loading')?.remove());
// 2. winter: the pond freezes to a lid ducks can walk on, and the lid's
// swell is gone where the ice is solid, so the ground line is flat.
// The year glides to the season over sim time, and the sim is about to
// be held, so the glide is burned in one update instead of stepping.
const season = await page.evaluate(() => {
const q = window.quack;
q.seasons.hold(3);
q.seasons.update(1.0);
q.world.applySeason(q.seasons.state);
q.app.paused = true;
return { name: q.seasons.label, ice: q.seasons.state.ice, snow: q.seasons.state.snow };
});
// 3. let the freeze settle, then pick the duck, put it on the lid and
// point it down a clear line. The settle matters: a moment after the
// ice closes, ducks that were paddling steer across the lid, so there
// is usually one already walking at pace to take as it stands.
const prep = await page.evaluate(({ sel, warm }) => {
const q = window.quack;
const ducks = q.pond.ducks;
const step = 1 / 120;
const tiny = () => {
q.renderer.shadowMap.enabled = false;
q.renderer.setSize(8, 8, false);
};
const full = () => {
q.renderer.setSize(window.innerWidth, window.innerHeight, false);
q.renderer.shadowMap.enabled = true;
};
tiny();
for (let i = 0; i < 360; i++) q.app.step(step); // 3 s: the flock finds the ice
let idx = -1;
if (sel !== '') {
const n = Number(sel);
if (Number.isInteger(n) && ducks[n]) idx = n;
else idx = ducks.findIndex((d) => d.name.toLowerCase() === sel.toLowerCase());
if (idx < 0) {
throw new Error(`no duck matches "${sel}" — have ` + ducks.map((d, i) => `${i}:${d.name}`).join(', '));
}
} else {
// one already walking at a real pace: a free adult at cruise, or a
// mother at the brood pace; a follower only ever strays, so it is out
idx = ducks.findIndex(
(d) =>
d.state === 'walk' &&
d.activity === 'swim' &&
!d.leader &&
d.kind !== 'duckling' &&
Math.hypot(d.vel.x, d.vel.z) > 0.25,
);
if (idx < 0) {
// to be placed: a free adult walks at cruise, where a mother or
// a follower only ever strays at 0.2 m/s
idx = ducks.findIndex(
(d) => !d.leader && d.kind !== 'duckling' && !ducks.some((o) => o.leader === d && o.kind === 'duckling'),
);
}
if (idx < 0) idx = ducks.findIndex((d) => !d.leader && d.kind !== 'duckling');
if (idx < 0) idx = 0;
}
const d = ducks[idx];
// the flat lid: solid, fully frozen (no swell riding on it) and water
const lid = (x, z) => q.world.solidAt(x, z) && q.world.iceAt(x, z) >= 0.999 && q.world.isWater(x, z);
const runClear = (x, z, dx, dz, len) => {
for (let t = 0; t <= len; t += 0.5) if (!lid(x + dx * t, z + dz * t)) return false;
return true;
};
// a duck already walking on the lid with room ahead keeps its ground;
// anything else is set down on a chosen line, along x, well clear of
// the open lead
let start = null;
let dir = null;
let tier = 'placed on the ice';
if (d.state === 'walk' && d.activity === 'swim' && Math.hypot(d.vel.x, d.vel.z) > 0.05 && lid(d.pos.x, d.pos.z)) {
for (const s of [1, -1]) {
if (runClear(d.pos.x, d.pos.z, s, 0, 6)) {
start = { x: d.pos.x, z: d.pos.z };
dir = { x: s, z: 0 };
tier = 'already walking';
break;
}
}
}
if (!start) {
let best = null;
for (let z = 4; z <= 26; z += 2) {
for (let x = -24; x <= 24; x += 2) {
for (const s of [1, -1]) {
if (!runClear(x, z, s, 0, 12)) continue;
if (!runClear(x, z + s * 2.4, 0, 0, 0.1)) continue; // the camera needs ice under it too
let score = Math.min(Math.hypot(x + 3.5, z - 29), 24);
for (const o of ducks) if (o !== d && Math.hypot(o.pos.x - x, o.pos.z - z) < 6) score -= 4;
if (!best || score > best.score) best = { x, z, s, score };
}
}
}
if (!best) throw new Error('no clear run of ice found for a walk');
start = { x: best.x, z: best.z };
dir = { x: best.s, z: 0 };
}
// what makes it walk: free adults 'swim' at cruise — including a mother
// already on the move, who holds the brood pace; a follower, or a duck
// set down with young in tow, keeps to 'stray' (its own target, 0.2 m/s)
const hasYoung = ducks.some((o) => o.leader === d && o.kind === 'duckling');
const act = d.leader || (hasYoung && tier !== 'already walking') ? 'stray' : 'swim';
// a stray duckling that loses its mother stops dead, so she is parked
// just behind the line where it can still see her
if (d.leader && d.kind === 'duckling') {
const mom = d.leader;
mom.pos.x = start.x - dir.x * 3;
mom.pos.z = start.z - dir.z * 3;
mom.vel.x = 0;
mom.vel.z = 0;
mom.target.x = mom.pos.x;
mom.target.z = mom.pos.z;
mom.setActivity('rest');
mom.activityLeft = 300;
d.lost = false;
}
// only a placed duck is teleported: one already walking is kept as it
// stands, and just sent on down the line we chose
if (tier !== 'already walking') {
d.pos.x = start.x;
d.pos.z = start.z;
d.vel.x = 0;
d.vel.z = 0;
d.roll = 0;
d.stride = 1; // skip the blend ramp: the walk starts at full amplitude
}
d.facing.x = dir.x;
d.facing.z = dir.z;
d.heading = Math.atan2(dir.x, dir.z);
d.quat.set(0, Math.sin(d.heading / 2), 0, Math.cos(d.heading / 2));
d.floating = false;
d.target.x = d.pos.x + dir.x * 10;
d.target.z = d.pos.z + dir.z * 10;
d.setActivity(act);
d.activityLeft = 300; // no rethinking the activity under the capture
d.setState('walk');
// settle: the damped pose fields (stride, legTuck, roll, the heading
// slerp) need a second or so, and stepping is cheap while the render
// is the size of a stamp and the shadows are off
for (let i = 0; i < Math.round(warm * 120); i++) q.app.step(step);
full();
const p = ducks[idx];
const sp = Math.hypot(p.vel.x, p.vel.z);
return {
idx,
name: p.name,
kind: p.kind,
tier,
activity: p.activity,
scale: p.scale,
state: p.state,
speed: sp,
start: { x: p.pos.x, z: p.pos.z },
dir,
warn: sp < 0.15 ? `pace came out at only ${sp.toFixed(2)} m/s` : '',
};
}, { sel, warm: WARM_SECONDS });
if (prep.warn) console.log(`[warn] ${prep.name}: ${prep.warn}`);
// 4. the camera: side-on, perpendicular to the walk, fixed for the whole
// capture. The rig clamps a look()'s yaw to ±0.85 rad of down-pond, so
// the gaze is put on the rig directly — it is the same three numbers
// place() reads, just without the guard.
const cam = await page.evaluate(({ i, frameCount }) => {
const q = window.quack;
const d = q.pond.ducks[i];
const sp = Math.hypot(d.vel.x, d.vel.z);
const fx = sp > 1e-4 ? d.vel.x / sp : d.facing.x;
const fz = sp > 1e-4 ? d.vel.z / sp : d.facing.z;
// aim at the middle of the stretch the capture covers
const travel = sp * frameCount * 5 * (1 / 120);
const mx = d.pos.x + fx * travel * 0.5;
const mz = d.pos.z + fz * travel * 0.5;
// either side of the walk; the one nearest down-pond keeps the duck
// crossing the frame left to right
let best = null;
for (const s of [1, -1]) {
const px = s * fz;
const pz = -s * fx;
const yaw = Math.atan2(-px, pz);
if (!best || Math.abs(yaw) < Math.abs(best.yaw)) best = { px, pz, yaw };
}
const ex = mx + best.px * 2.4;
const ez = mz + best.pz * 2.4;
const r = q.rig;
r.stand(ex, ez, 0.55);
const dx = mx - ex;
const dz = mz - ez;
const targetY = d.root.position.y + 0.02;
const yaw = Math.atan2(dx, -dz);
const pitch = Math.atan2(targetY - r.eye.y, Math.hypot(dx, dz));
r.yaw = r.wantYaw = yaw;
r.pitch = r.wantPitch = pitch;
r.fov = r.wantFov = 40;
r.camera.fov = 40;
r.camera.updateProjectionMatrix();
return {
eye: { x: r.eye.x, y: r.eye.y, z: r.eye.z },
mid: { x: mx, y: targetY, z: mz },
dist: Math.hypot(dx, dz),
yaw,
pitch,
fov: r.camera.fov,
travel,
};
}, { i: prep.idx, frameCount: frames });
// 5. the filmstrip: one screenshot per frame, the sim advanced under it
mkdirSync(outDir, { recursive: true });
const records = [];
const files = [];
const simPerFrame = PER_FRAME * STEP;
for (let f = 0; f < frames; f++) {
const rec = await page.evaluate(({ i, k }) => {
const q = window.quack;
const d = q.pond.ducks[i];
const V = d.root.position.constructor;
const v = new V();
const phaseBefore = d.walkPhase; // the clock as this frame found it
// only the last slice is drawn at full size; the rest only need the
// world matrices a render leaves behind
q.renderer.setSize(8, 8, false);
for (let s = 0; s < k - 1; s++) q.app.step(1 / 120);
q.renderer.setSize(window.innerWidth, window.innerHeight, false);
q.app.step(1 / 120);
const p = d.root.position;
return {
feet: d.feet.map((foot) => {
foot.getWorldPosition(v);
return { x: v.x, y: v.y, z: v.z };
}),
root: { x: p.x, y: p.y, z: p.z },
pos: { x: d.pos.x, z: d.pos.z },
stride: d.stride,
roll: d.roll,
phase: d.walkPhase,
phaseBefore,
speed: Math.hypot(d.vel.x, d.vel.z),
state: d.state,
};
}, { i: prep.idx, k: PER_FRAME });
rec.t = (f + 1) * simPerFrame;
records.push(rec);
const file = join(outDir, `f${String(f).padStart(2, '0')}.png`);
await page.screenshot({ path: file });
files.push(file);
}
// ---------------------------------------------------------- the report
const elapsed = records[records.length - 1].t;
const scale = prep.scale;
const names = ['L (−x)', 'R (+x)'];
// what the duck actually covered: the root's ground track between the frame
// boundaries — the ground the feet have to keep up with
let travel = 0;
for (let f = 1; f < frames; f++) {
travel += Math.hypot(records[f].pos.x - records[f - 1].pos.x, records[f].pos.z - records[f - 1].pos.z);
}
const span = (frames - 1) * simPerFrame;
const speedGround = travel / span;
// ground level: the median over frames of the lowest of the two feet
const ground = median(records.map((r) => Math.min(r.feet[0].y, r.feet[1].y)));
// planted by height: a foot this close to the ground level is down
const planted = names.map((_, i) => records.map((r) => r.feet[i].y < ground + PLANT_TOL));
// the rig's own clock: which foot is in stance by phase (feet[0] is hipL,
// which aimLeg drives from walkPhase itself)
const inStancePhase = names.map((_, i) =>
records.map((r) => {
const c = (r.phase / (Math.PI * 2) + i * 0.5) % 1;
return r.stride > 0.5 && c < LEG_STANCE;
}),
);
const block = (label, flags, foot) => {
const intervals = runs(flags).filter(([a, b]) => b > a);
const rs = [];
const fwds = [];
const lats = [];
const paths = [];
let samples = 0;
for (const [a, b] of intervals) {
const r = ratioOver(records[a].root, records[b].root, records[a].feet[foot], records[b].feet[foot]);
if (!r) continue;
rs.push(r.ratio);
fwds.push(r.fwd);
lats.push(r.lat);
const p = pathRatio(records, a, b, foot);
if (p !== null) paths.push(p);
samples += b - a + 1;
}
return { label, intervals: intervals.length, samples, mean: mean(rs), max: rs.length ? Math.max(...rs) : NaN, fwd: mean(fwds), lat: mean(lats), path: mean(paths) };
};
const notPlanted = names.map((_, i) => planted[i].map((v) => !v));
const out = [];
const line = (s = '') => out.push(s);
line(`gait probe — ${prep.name} (duck ${prep.idx}, ${prep.kind}), scale ${scale.toFixed(3)}`);
line(` ${url} · ${season.name} held (ice ${season.ice.toFixed(2)}, snow ${season.snow.toFixed(2)}) · seed 20261018`);
line(` walk: ${prep.tier}, activity '${prep.activity}', along ${prep.dir.x > 0 ? '+x' : '−x'} from (${prep.start.x}, ${prep.start.z}), warmed to ${f2(prep.speed)} m/s`);
line(` ${frames} frames × ${PER_FRAME}×1/120 s = ${elapsed.toFixed(3)} s of sim (ground covered ${mm(travel)} at ${f2(speedGround)} m/s; the rig's speed field reads ${f2(mean(records.map((r) => r.speed)))} m/s, ${records.filter((r) => r.state === 'walk').length}/${frames} frames in state 'walk')`);
line(` camera: eye (${cam.eye.x.toFixed(2)}, ${cam.eye.y.toFixed(2)}, ${cam.eye.z.toFixed(2)}) ${cam.dist.toFixed(2)} m out, yaw ${deg(cam.yaw).toFixed(1)}°, pitch ${deg(cam.pitch).toFixed(1)}°, fov ${cam.fov}° — fixed`);
line();
line(`ground level ${mm(ground)} (median of the per-frame lowest foot; a "foot" is the foot mesh origin)`);
line(` speed mean ${f2(mean(records.map((r) => r.speed)))} m/s, min ${f2(Math.min(...records.map((r) => r.speed)))} m/s, max ${f2(Math.max(...records.map((r) => r.speed)))} m/s`);
line(` stride blend mean ${f2(mean(records.map((r) => r.stride)))} of 1 (walk amplitude)`);
line();
line('STANCE SLIP — threshold planted (foot y < ground + 6 mm)');
for (let i = 0; i < 2; i++) {
const b = block('', planted[i], i);
const v = b.mean < 0.1 ? 'pinned' : b.mean <= 0.25 ? 'slight slide' : 'skating';
line(` foot ${names[i]}: ${b.intervals} stance intervals, ${b.samples}/${frames} frames down (${((100 * b.samples) / frames).toFixed(0)}%)`);
line(` slip ratio mean ${f2(b.mean)} max ${f2(b.max)} → ${v}`);
line(` of it forward ${f2(b.fwd)} sideways ${mm(b.lat)} (walked-path ratio ${f2(b.path)})`);
}
{
const b = block('', notPlanted[0], 0);
const b2 = block('', notPlanted[1], 1);
line(` moving reference (not planted): L mean ratio ${f2(b.mean)} over ${b.intervals} runs, R ${f2(b2.mean)} over ${b2.intervals}`);
}
line();
line('SWING CLEARANCE — over frames the foot is not planted');
for (let i = 0; i < 2; i++) {
const ys = records.filter((_, f) => notPlanted[i][f]).map((r) => r.feet[i].y - ground);
const max = ys.length ? Math.max(...ys) : NaN;
const med = median(ys);
line(` foot ${names[i]}: max ${mm(max)}, median ${mm(med)}, min ${mm(ys.length ? Math.min(...ys) : NaN)}, ${ys.length}/${frames} frames up → ${max < 0.008 ? 'dragging' : 'clears the ground'}`);
}
// and how steady the foot is while it counts as down: a sole that rides
// up and down through the stance is not pinned however slow it slides
for (let i = 0; i < 2; i++) {
const ys = records.filter((_, f) => planted[i][f]).map((r) => r.feet[i].y - ground);
if (ys.length > 1) {
line(` foot ${names[i]} height while planted: ${mm(Math.min(...ys))} to ${mm(Math.max(...ys))} (spread ${mm(Math.max(...ys) - Math.min(...ys))} over ${ys.length} frames)`);
}
}
line();
line('BY THE RIG CLOCK — same split, but stance/swing taken from walkPhase');
for (let i = 0; i < 2; i++) {
const b = block('', inStancePhase[i], i);
const ys = records.filter((_, f) => !inStancePhase[i][f]).map((r) => r.feet[i].y - ground);
line(` foot ${names[i]}: stance ${b.samples}/${frames} frames (${((100 * b.samples) / frames).toFixed(0)}%, stance share is ${(LEG_STANCE * 100).toFixed(0)}%)`);
line(` slip over phase-stance mean ${f2(b.mean)} max ${f2(b.max)} forward ${f2(b.fwd)} sideways ${mm(b.lat)}`);
line(` clearance over phase-swing max ${mm(ys.length ? Math.max(...ys) : NaN)} median ${mm(median(ys))}`);
}
// how flat the foot is while it is nominally down
for (let i = 0; i < 2; i++) {
const ys = records.filter((_, f) => inStancePhase[i][f]).map((r) => r.feet[i].y - ground);
line(` foot ${names[i]} height while in phase-stance min ${mm(Math.min(...ys))} max ${mm(Math.max(...ys))} spread ${mm(Math.max(...ys) - Math.min(...ys))}`);
}
line();
// cadence: lift-offs counted off the foot's own height record, and the
// walkPhase unwrapped over the same sim time
line('CADENCE');
let cadFromFeet = NaN;
let events = 0;
const rates = [];
for (let i = 0; i < 2; i++) {
const ev = [];
for (let f = 1; f < frames; f++) if (planted[i][f - 1] && !planted[i][f]) ev.push(records[f].t);
let rate = NaN;
if (ev.length >= 2) rate = (ev.length - 1) / (ev[ev.length - 1] - ev[0]);
else if (ev.length === 1) rate = 1 / elapsed;
if (Number.isFinite(rate)) rates.push(rate);
line(` lift-offs foot ${names[i]}: ${ev.length} event${ev.length === 1 ? '' : 's'}${ev.length ? ` at t=${ev.map((t) => t.toFixed(2)).join(', ')} s` : ''} → ${f2(rate)} per second`);
events += ev.length;
}
cadFromFeet = rates.length ? mean(rates) : NaN;
// the phase the rig advances inside each frame, summed over all of them —
// frame to frame would miss the first frame's own slice of the second
let cycles = 0;
for (let f = 0; f < frames; f++) cycles += (((records[f].phase - records[f].phaseBefore) % (Math.PI * 2)) + Math.PI * 2) % (Math.PI * 2);
cycles /= Math.PI * 2;
const cadPhase = cycles / elapsed;
const speedMean = mean(records.map((r) => r.speed));
const cadExpected = (speedMean * LEG_STANCE) / (2 * LEG_LEN * Math.sin(LEG_SWING) * scale);
const reach = 2 * LEG_LEN * Math.sin(LEG_SWING) * scale;
line(` from lift-offs ${f2(cadFromFeet)} cycles/s — mean of the feet's rise-event rates (a foot lifts once a cycle)`);
line(` both feet ${events} rise events over ${elapsed.toFixed(3)} s = ${f2(events / elapsed)} steps/s (two steps a cycle)`);
line(` from walkPhase ${f2(cadPhase)} cycles/s (${cycles.toFixed(2)} cycles unwrapped over ${elapsed.toFixed(3)} s)`);
line(` expected ${f2(cadExpected)} cycles/s = speed ${f2(speedMean)} × stance ${LEG_STANCE} / (2 × ${LEG_LEN} × sin(${LEG_SWING}) × ${scale.toFixed(3)})`);
if (cadExpected > CADENCE_CAP) line(` note the rig caps cadence at ${CADENCE_CAP}/s, so this walk is above what the legs can step`);
line(` step reach ${mm(reach)} of ground per stance (2·L·sin·scale); body travel per cycle ${mm(cadPhase > 0 ? speedGround / cadPhase : NaN)} (measured)`);
{
const implied = (cadPhase * reach) / LEG_STANCE;
line(` ground covered the measured cadence sweeps ${f2(implied)} m/s of ground under the duck, against the ${f2(speedGround)} m/s it covered — ${(100 * (speedGround > 0 ? implied / speedGround : 0)).toFixed(0)}% (100% = every metre is stepped, less = the feet skate with the body)`);
}
if (events < 3) line(` note ${events} lift-off${events === 1 ? '' : 's'} is a coarse count over ${elapsed.toFixed(2)} s of sim — walkPhase is the precise figure`);
line();
// bob and roll, off the same records
const ys = records.map((r) => r.root.y);
const bob = Math.max(...ys) - Math.min(...ys);
let bumps = 0;
for (let f = 1; f < frames - 1; f++) if (ys[f] >= ys[f - 1] && ys[f] > ys[f + 1]) bumps++;
const rolls = records.map((r) => r.roll);
const rollPP = Math.max(...rolls) - Math.min(...rolls);
const bobVerdict = bob <= 0.018 * scale ? 'step-weight' : bob > 0.025 * scale ? 'hop' : 'in between';
line('BOB AND ROLL');
line(` root y peak-to-peak ${mm(bob)} (${mm(bob / scale)} at scale 1), ${f2(bumps / elapsed)} bumps/s → ${bobVerdict} (thresholds ${mm(0.018 * scale)} / ${mm(0.025 * scale)} at this scale)`);
line(` roll peak-to-peak ${deg(rollPP).toFixed(1)}°, mean |roll| ${deg(mean(rolls.map(Math.abs))).toFixed(1)}°`);
line();
line(`frames — ${frames} PNGs at ${frames * 2 * PER_FRAME} ms of sim each, ${outDir}`);
for (const file of files) line(` ${file}`);
const report = out.join('\n');
console.log(`\n${report}\n`);
writeFileSync(join(outDir, 'report.txt'), report + '\n');
// 6. the run's health: a duck that never walked, an error in the console
if (records.filter((r) => r.state === 'walk').length < frames) {
console.log(`[warn] ${frames - records.filter((r) => r.state === 'walk').length} frame(s) were not in state 'walk'`);
}
if (!(ground + PLANT_TOL > 0)) console.log('[warn] ground level is not above zero — the duck may not be on the lid');
if (consoleErrors.length || pageErrors.length) {
console.log(`[fatal] ${consoleErrors.length} console error(s), ${pageErrors.length} page error(s)`);
exitCode = 1;
}
} catch (err) {
console.log(`[fatal] ${err && err.stack ? err.stack : err}`);
exitCode = 1;
} finally {
await browser.close();
}
process.exit(exitCode);