// 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);