diff --git a/THREADS.md b/THREADS.md
index 5502da5..437a808 100644
--- a/THREADS.md
+++ b/THREADS.md
@@ -81,6 +81,92 @@ Format: `[lane letter] YYYY-MM-DD — note`
`~/Documents/shades-laneD/` and work there** — B, C and E already have their own. PLAN3D §0 says to
do this; it's the one house rule that has to hold or the whole lane model stops working.
+[D] 2026-07-16 — Worktree collision: mine, sorry — acknowledged and fixed. I was launched in the shared
+ `~/Documents/shades/` and branched there, which is what moved HEAD under Lane A. Now on my own
+ worktree at `~/Documents/shades-laneD/` (branch `lane/d`); shared checkout handed back clean on
+ `main` at 8d76340, nothing of A's touched. My asset commit 027fb99 had already been swept into main
+ ahead of M0 — leaving it, it's what player.js loads.
+
+[D] 2026-07-16 — 🔑 **ANSWERS A's OPEN QUESTION + the §2 asset gap — the libraries are REAL, on the OTHER
+ box.** Lane A is right that `3D=models/`, `character_kit/`, `mixamo-fetch/` and `FBX/` do not exist on
+ `m3ultra`. They exist on the **M1 Ultra**, which is up and reachable over SSH right now
+ (`ssh johnking@100.91.239.7`, hostname `ultra.local`, no password needed). Verified there:
+ · `~/Documents/FBX/` — Running.fbx, Falling.fbx, Crouch To Stand.fbx, Reaction.fbx, Death.fbx…
+ · `~/Documents/3D=models/animations/` — 32 clips incl. Happy Idle.fbx, Walk.fbx, Start Walking.fbx
+ · `~/Documents/character_kit/` (rigged/, scripts/merge_anims.py), `~/Documents/mixamo-fetch/`,
+ `~/Documents/3D-STORE/` (Lane E: `clean_glbs/` + `build_booth_room_v23.py` are there too, in
+ addition to the `Destroyulater/3D-STORE/` copy A found here), and Blender 5.0.1.
+ **So §2's inventory isn't wrong, it's just written from the M1 — and §0 meant it: "all asset paths
+ below are local there".** This does NOT mean lanes should move. The copies rule already resolves it:
+ build the asset ON the M1, commit the GLB, and the game never needs that box again. That's what I did
+ — `player_anims.glb` was built there and is committed; `python3 server.py` on m3ultra needs nothing
+ remote. **Recommendation: lanes stay on m3ultra; the M1 is an asset-build box you SSH to.** Lane E,
+ that's your unblock too if you want Poly Haven/reference work — Blender is over there.
+
+[D] 2026-07-16 — ⚠️ **BLOCKS THE PLAYER SWAP — Lane A, one line in index.html.** Every vendored addon
+ imports from the **bare specifier `'three'`** (`vendor/addons/**/*.js` all end `} from 'three';`).
+ index.html has no importmap, so the moment it imports player.js it dies with "Failed to resolve
+ module specifier 'three'". Nothing hit this before because main/world/camera.js import
+ `../vendor/three.module.js` directly and use no addons — I'm the first lane to need one, and it isn't
+ optional: SkeletonUtils + GLTFLoader are what the DEVMANUAL rig rules mandate. Fix is the 90sDJsim
+ line, in `
` before the module script:
+ ``
+ (selftest.html does NOT need it — d.test.js only imports the zero-dep sim, which is why it's green.)
+ **Lane E: this will land on you too** the moment you load a GLB. Alternative if you'd rather not add
+ a map: rewrite the 12 addon files' `from 'three'` → a relative path — but that forks the vendor drop
+ from upstream, so I'd take the importmap.
+
+[D] 2026-07-16 — **PLAYER LANDED** on `lane/d`, rebased on M0, ready for the boot() swap.
+ `player.sim.js` (deterministic core, zero imports) · `player.js` (rig/view + `createPlayer`) ·
+ `interact.js` (hold-E + `wireYardActions`) · `js/tests/d.test.js` · `dev_player.html` (my mock
+ harness — Lane A owns the real shell; I never touched main.js/index.html/selftest.html).
+ Selftest: **38 pass / 3 skip**, 20 of them Lane D. Verified in a real scene, not just asserts:
+ head bone **1.715 m** at fig scale 0.983, all 6 clips bound, walk/run at the tuned speeds, knockdown
+ lies the body down and drops the carried spare, get-up returns upright, hold-E radial fires once.
+ `checkContract('player', createPlayer(...))` → **CONFORMS**, and it clamps to `world.heightAt()`.
+ **Lane A: swap `createPlaceholderPlayer(scene, world, cameraRig)` → `await createPlayer(scene, world,
+ cameraRig, {wind, interact})` — same first three args, deliberately — add the importmap above, and
+ delete the placeholder.** It's async (two GLB fetches), so boot() must await it.
+
+[D] 2026-07-16 — ❗ **CONTRACT NEEDS FROM LANE B — not urgent, but §5-D.4 can't finish without them.**
+ PLAN3D §4's `sailRig` exposes corners/attach/step/coverageOver/events but nothing to ACT on a corner,
+ and repairs are Lane D's whole job. `interact.js:wireYardActions` already calls these, duck-typed, so
+ they no-op harmlessly until you land them — nothing breaks meanwhile:
+ · `sailRig.repair(i)` — re-rig corner i (I gate it on the player carrying a spare, 2.5 s hold, and
+ I consume the spare). Needed for the M2 "one mid-storm repair must be survivable" line in §7.
+ · `sailRig.trim(i, delta)` — per-corner turnbuckle, ±tension at ONE corner (1.2 s hold). This is
+ §5-D.4's "new vs prototype, makes corners individual".
+ · `corner.pos` (or `sailRig.cornerPos(i)`) → world Vector3 — I need somewhere to put the prompt.
+ Live-read each frame, so a flogging corner's prompt tracks it.
+ Shout if the shapes fight your sim and I'll adapt — you own sail.js, I'll move.
+
+[D] 2026-07-16 — 📌 **PLAN3D §5-D.1 is not buildable as written — the peds cannot go through Blender.**
+ §5-D.1 says merge clips onto a ped via the character_kit pipeline. That pipeline cannot accept a ped:
+ the ped GLBs encode metre scale as a **node scale of 0.01 on `mixamorig*:Hips`** with every child bone
+ in centimetres (Spine T=+10.05, LeftLeg T=+42.8). Blender bones have no rest scale, so the glTF
+ importer silently drops that 0.01 — straight after import the rig already reads Hips at 0.99 (metres)
+ while HeadTop_End reads 76.88 (centimetres) and LeftToe_End sits **96 m under the floor**. Exploded
+ before a single clip is merged. (It's also why `dancer.glb` is 30x small — its base, Hum_M_1.fbx, is
+ an FBX with no such trick, head bone 0.0563 m. And `merge_anims.py`'s own comment warns about exactly
+ this class of bug from the other end.) **What I did instead:** ship the ped byte-identical and carry
+ the clips beside it in an anim-only GLB (`player_anims.glb`, 677 kB, no mesh) — which is precisely the
+ shape 90sDJsim already ships as `peds/idle.glb` + `peds/walk.glb`, so it's the house pattern, not a
+ workaround. Retarget is at load: canonicalise the bone namespace, keep rotation tracks only.
+ Rebuild: `tools/character/build_player_anims.py` (header has the full why + the ssh one-liner).
+ Two gotchas worth knowing if you touch rigs:
+ · three.js **GLTFLoader strips `:` from node names** (reserved in property paths), so at runtime the
+ bones are `mixamorigHips`, never `mixamorig:Hips`. `_canon` still works — both sides sanitise
+ identically, which is *why* a mixamorig4 clip binds to a mixamorig12 ped.
+ · Blender 5.0 **removed `Action.fcurves`** (slotted actions — they're under
+ `layers[].strips[].channelbags[]`), so `character_kit/scripts/merge_anims.py` no longer runs there
+ as written. My script handles both layouts.
+
+[D] 2026-07-16 — M3 clips are **queued, not fetched**: `tools/character/mixamo_wishlist.txt` (Climbing
+ Ladder, Turning Key, Digging + repair/storm extras). `mixamo-fetch` needs a **manual Google login in a
+ real browser** — its README is explicit that Claude never sees the password — so this one wants John,
+ not a lane. Everything M0–M2 needs is already on disk and in `player_anims.glb`.
+
[A] 2026-07-16 — ❓ **OPEN QUESTION, needs a human.** PLAN3D §0 says lanes run on "the M1 Ultra
(`johnking@100.91.239.7`, Tailscale)", but this box is `m3ultra` and already has
`~/Documents/shades-laneB/` and `shades-laneE/` checked out — so lanes are in fact running here, and
diff --git a/tools/character/mixamo_wishlist.txt b/tools/character/mixamo_wishlist.txt
new file mode 100644
index 0000000..50f3625
--- /dev/null
+++ b/tools/character/mixamo_wishlist.txt
@@ -0,0 +1,34 @@
+# SHADES animation wishlist — Mixamo search terms, one per line. (Lane D, for M3)
+#
+# Same pipeline as 90sDJsim's dj_wishlist.txt and the op-shop clerk's wishlist.txt:
+# cd ~/Documents/mixamo-fetch
+# node fetch.cjs login # a real browser opens; John logs in by hand, once
+# cp ./shades_wishlist.txt
+# node fetch.cjs anims # loops the wishlist, downloads each "Without Skin"
+# then add the new FBX to CLIPS in tools/character/build_player_anims.py and rebuild the pack.
+#
+# NOTE: fetch.cjs needs a MANUAL Google login in a real browser window (its README is explicit that
+# Claude never sees the password), so this list is queued for John to run — not something a lane
+# fetches on its own. Everything M0-M2 needs is already on disk and already in player_anims.glb:
+# Idle / Walk / Run / Falling / CrouchToStand / Reaction.
+#
+# '#' and blank lines ignored. Names are fuzzy search terms (top hit wins); a miss is skipped.
+# In-place clips only — the game translates the player itself, and player.js keeps rotation tracks
+# only, so anything with big root travel is wasted.
+
+## --- M3: the repair verbs (PLAN3D §5-D.1) ---
+Climbing Ladder
+Turning Key
+Digging
+
+## --- M3: worth having while the browser is open (DESIGN.md "limited hands" toolset) ---
+Hammering
+Sweeping Floor
+Picking Up Object
+Carrying Box
+Standing Up Ready
+
+## --- storm reactions (better than reusing Reaction for everything) ---
+Covering Head
+Bracing
+Stumble Backwards
diff --git a/web/world/dev_player.html b/web/world/dev_player.html
new file mode 100644
index 0000000..5307c17
--- /dev/null
+++ b/web/world/dev_player.html
@@ -0,0 +1,203 @@
+
+
+
+
+SHADES — Lane D player harness
+
+
+
+
+
+
+
+
+ wind 4 m/s
+ dir
+
+
+
+
+
+
+
+
+
+
diff --git a/web/world/js/interact.js b/web/world/js/interact.js
new file mode 100644
index 0000000..aca72cc
--- /dev/null
+++ b/web/world/js/interact.js
@@ -0,0 +1,181 @@
+/**
+ * interact.js — hold-E actions with radial progress. (Lane D)
+ *
+ * contracts.js: interact.register({id, pos, radius, holdSecs, label, canUse()->bool, onDone()})
+ *
+ * Zero imports (same reason as player.sim.js): the whole thing is fixed-dt and headless-testable.
+ * `pos` is duck-typed {x,y,z}, so a THREE.Vector3 or a plain object both work.
+ *
+ * The busy handshake: this module both ENTERS and LEAVES the player's `busy` state. Nothing else
+ * writes it, so a dropped release cannot strand the player — and if the world takes the player away
+ * mid-hold (a gust puts them down), we notice `player.state` is no longer 'busy' and abort without
+ * stomping on the state the world just set.
+ */
+
+export class Interact {
+ constructor() {
+ this.targets = new Map();
+ this.active = null; // the target currently being held
+ this.progress = 0; // 0..1 — the radial
+ this.latched = false; // a completed action re-arms only after E is released (see step)
+ this.events = []; // {type:'done'|'cancel', id, t} — drained by hud.js
+ }
+
+ /**
+ * @param {object} spec
+ * @param {string} spec.id
+ * @param {object} spec.pos {x,y,z} — read live each step, so it may move (a sail corner does)
+ * @param {number} [spec.radius] metres
+ * @param {number} [spec.holdSecs]
+ * @param {string|function} [spec.label] string, or (player)->string for live text
+ * @param {function} [spec.canUse] (player) -> bool
+ * @param {function} [spec.onDone] (player, t) -> void
+ * @returns {function} unregister
+ */
+ register(spec) {
+ if (!spec || !spec.id) throw new Error('interact.register: id required');
+ const target = {
+ radius: 1.6, holdSecs: 1, label: '', canUse: null, onDone: null, ...spec,
+ };
+ this.targets.set(target.id, target);
+ return () => this.unregister(target.id);
+ }
+
+ unregister(id) {
+ if (this.active && this.active.id === id) this.active = null, this.progress = 0;
+ return this.targets.delete(id);
+ }
+
+ labelOf(target, player) {
+ return typeof target.label === 'function' ? target.label(player) : target.label;
+ }
+
+ _usable(target, player) {
+ return !target.canUse || !!target.canUse(player);
+ }
+
+ /** Nearest registered target in range whose canUse() passes. */
+ nearest(player) {
+ let best = null, bestD = Infinity;
+ for (const target of this.targets.values()) {
+ const p = typeof target.pos === 'function' ? target.pos() : target.pos;
+ if (!p) continue;
+ const d = Math.hypot(p.x - player.pos.x, p.z - player.pos.z);
+ if (d <= target.radius && d < bestD && this._usable(target, player)) { best = target; bestD = d; }
+ }
+ return best;
+ }
+
+ cancel(t, player) {
+ if (!this.active) return;
+ // only hand the player back if they're still ours — a knockdown mid-hold already re-stated them
+ if (player.state === 'busy') player.setState('idle', t);
+ this.events.push({ type: 'cancel', id: this.active.id, t });
+ this.active = null;
+ this.progress = 0;
+ }
+
+ /**
+ * @param {number} dt @param {number} t
+ * @param {PlayerSim} player
+ * @param {boolean} holding is E held this frame
+ * @returns {{target, progress, label, holding}} for hud.js to draw the prompt + radial
+ */
+ step(dt, t, player, holding) {
+ // One press, one action: a completed hold latches until E is released. Without this, a held key
+ // re-arms the instant the action finishes and the same action fires every holdSecs forever
+ // (leaning on the shed table would deal you a spare a second, indefinitely).
+ if (!holding) this.latched = false;
+
+ const near = this.nearest(player);
+
+ if (this.active) {
+ const stolen = player.state !== 'busy'; // something else claimed the player
+ if (!holding || near !== this.active || !this._usable(this.active, player) || stolen) {
+ this.cancel(t, player);
+ }
+ }
+
+ if (!this.active && holding && !this.latched && near && !player.busy) {
+ this.active = near;
+ this.progress = 0;
+ player.setState('busy', t);
+ }
+
+ if (this.active) {
+ this.progress += dt / Math.max(1e-6, this.active.holdSecs);
+ if (this.progress >= 1) {
+ const done = this.active;
+ this.active = null;
+ this.progress = 0;
+ this.latched = true;
+ player.setState('idle', t); // release busy FIRST — onDone may pickUp(), which refuses while busy
+ if (done.onDone) done.onDone(player, t);
+ this.events.push({ type: 'done', id: done.id, t });
+ }
+ }
+
+ const shown = this.active || near;
+ return {
+ target: shown,
+ progress: this.progress,
+ label: shown ? this.labelOf(shown, player) : '',
+ holding: !!this.active,
+ };
+ }
+}
+
+/**
+ * Register the standard yard actions (PLAN3D §5-D.4). Duck-typed against the contracts so Lane D
+ * never edits Lane B's or Lane A's files — anything not yet landed is simply skipped.
+ *
+ * @param {Interact} interact
+ * @param {object} deps {sailRig, world, spares}
+ * sailRig.corners -> [{anchorId, hw, load, broken}] (contracts.js, Lane B)
+ * sailRig.repair(i) -> void [PROPOSED — see THREADS.md]
+ * sailRig.trim(i,d) -> void [PROPOSED — per-corner turnbuckle, see THREADS.md]
+ * world.shedTable -> {pos} (Lane A/E)
+ */
+export function wireYardActions(interact, deps = {}) {
+ const { sailRig, world } = deps;
+ const wired = [];
+
+ if (sailRig && Array.isArray(sailRig.corners)) {
+ sailRig.corners.forEach((corner, i) => {
+ // re-rig a broken corner — costs the spare you're carrying
+ wired.push(interact.register({
+ id: `rerig_${i}`,
+ pos: () => corner.pos || (sailRig.cornerPos && sailRig.cornerPos(i)),
+ radius: 1.8,
+ holdSecs: 2.5,
+ label: 're-rig corner',
+ canUse: (p) => corner.broken && p.carrying === 'spare',
+ onDone: (p) => { p.carrying = null; if (sailRig.repair) sailRig.repair(i); },
+ }));
+ // per-corner turnbuckle trim — new vs the prototype; makes corners individual
+ wired.push(interact.register({
+ id: `trim_${i}`,
+ pos: () => corner.pos || (sailRig.cornerPos && sailRig.cornerPos(i)),
+ radius: 1.8,
+ holdSecs: 1.2,
+ label: 'tighten turnbuckle',
+ canUse: () => !corner.broken && !!sailRig.trim,
+ onDone: () => sailRig.trim && sailRig.trim(i, +0.1),
+ }));
+ });
+ }
+
+ if (world && world.shedTable) {
+ wired.push(interact.register({
+ id: 'spare_table',
+ pos: world.shedTable.pos,
+ radius: 1.5,
+ holdSecs: 0.6,
+ label: (p) => (p.carrying ? 'hands full' : 'take a spare'),
+ canUse: (p) => !p.carrying, // hands-full rule
+ onDone: (p, t) => p.pickUp('spare', t),
+ }));
+ }
+
+ return () => wired.forEach((un) => un());
+}
diff --git a/web/world/js/player.js b/web/world/js/player.js
new file mode 100644
index 0000000..5a2e1a7
--- /dev/null
+++ b/web/world/js/player.js
@@ -0,0 +1,267 @@
+/**
+ * player.js — the small person: rig, clips, camera-relative control. (Lane D)
+ *
+ * The deterministic half lives in player.sim.js; this file is the view. It follows the 90sDJsim
+ * DEVMANUAL "Rigged animated characters" rules, which are law here:
+ * · SkeletonUtils.clone() for instances — a plain .clone() breaks skinned meshes
+ * · height-normalise off the HEAD BONE and plant the feet — Mixamo scale is unreliable, so the
+ * scale factor is always MEASURED, never a blind setScalar of a guessed constant
+ * · canonicalise the bone namespace so any clip binds to any character
+ *
+ * Assets (see models/MODELS.md): player_01.glb is an untouched ped (metre-scale, head bone 1.75 m);
+ * player_anims.glb is an anim-only carrier built by tools/character/build_player_anims.py. The clips
+ * are retargeted onto the ped at load — see _rotOnly for why that is safe at any carrier scale.
+ */
+import * as THREE from '../vendor/three.module.js';
+import { clone as skeletonClone } from '../vendor/addons/utils/SkeletonUtils.js';
+import { GLTFLoader } from '../vendor/addons/loaders/GLTFLoader.js';
+import { PlayerSim, STATES, TUNE } from './player.sim.js';
+
+export { PlayerSim, STATES, TUNE };
+
+export const CHAR_URL = '/world/models/player_01.glb';
+export const ANIM_URL = '/world/models/player_anims.glb';
+
+/**
+ * Canonicalise the Mixamo skeleton namespace (mixamorig4: vs mixamorig12:) so any clip binds to any
+ * character. Every Mixamo auto-rig upload gets its own numbered namespace and three.js binds tracks
+ * BY NODE NAME — a mismatched clip binds to nothing and plays a silent T-pose, with no error.
+ * (Straight from 90sDJsim index.html. The \d+ is deliberate: a bare "mixamorig:" is already canonical.)
+ */
+const _canon = (s) => s.replace(/mixamorig\d+/g, 'mixamorig');
+
+const canonRig = (r) => {
+ if (!r) return r;
+ if (r.scene) r.scene.traverse((o) => { o.name = _canon(o.name); });
+ if (r.anims) r.anims.forEach((a) => a.tracks.forEach((t) => { t.name = _canon(t.name); }));
+ return r;
+};
+
+/**
+ * Shared-clip filter: keep limb/spine rotations only. Drop ALL position tracks (a different-scale
+ * source inflates or crumples the target) AND Hips.quaternion (a different-orientation source lays
+ * the target flat). Quaternions are scale-invariant, which is the whole reason a clip carrier at any
+ * scale retargets cleanly onto the metre-scale ped.
+ *
+ * The cost is that a clip can no longer lie the body down — so the knockdown does NOT come from the
+ * Falling clip's root. player.sim.js pitches the root itself, which is also how the fall gets to go
+ * DOWNWIND of the gust that caused it. The clip only supplies the flail.
+ */
+const _rotOnly = (c) => new THREE.AnimationClip(c.name, c.duration,
+ c.tracks.filter((t) => t.name.endsWith('.quaternion') && !/Hips\.quaternion$/i.test(t.name)));
+
+const _loadGLTF = (loader, url) => new Promise((res, rej) =>
+ loader.load(url, (g) => res({ scene: g.scene, anims: g.animations }), undefined,
+ () => rej(new Error(`player: failed to load ${url}`))));
+
+const UP = new THREE.Vector3(0, 1, 0);
+
+export class PlayerView {
+ /**
+ * @param {object} rig {scene, anims} — the character
+ * @param {Array} clips retargeted AnimationClips
+ * @param {number} height metres to the top of the head
+ */
+ constructor(rig, clips, height = 1.72) {
+ // root: world position + facing + knockdown pitch. fig: the rig, lifted so its feet sit at y=0.
+ this.root = new THREE.Group();
+ this.root.name = 'player';
+
+ const fig = skeletonClone(rig.scene);
+ this.fig = fig;
+ fig.traverse((o) => { if (o.isMesh) { o.frustumCulled = false; o.castShadow = true; } });
+ this.root.add(fig);
+ this.root.updateWorldMatrix(true, true);
+
+ // measure, then scale — never a blind setScalar (DEVMANUAL). /head/i matches both Head and
+ // HeadTop_End; max() takes the crown, which is what "height" means.
+ const wp = new THREE.Vector3();
+ let headY = 0;
+ fig.traverse((o) => {
+ if (!o.isBone) return;
+ o.getWorldPosition(wp);
+ if (/head/i.test(o.name)) headY = Math.max(headY, wp.y);
+ });
+ if (headY > 1e-4) { fig.scale.setScalar(height / headY); fig.updateWorldMatrix(true, true); }
+ this.height = height;
+
+ // plant the feet: lift the rig so its lowest bone sits on the root's origin
+ let minY = Infinity;
+ fig.traverse((o) => { if (o.isBone) { o.getWorldPosition(wp); minY = Math.min(minY, wp.y); } });
+ if (minY < Infinity) fig.position.y = -minY;
+
+ this.mixer = new THREE.AnimationMixer(fig);
+ this.actions = {};
+ // some characters lack bones a shared clip animates (thumb joints, say) — bind only what exists,
+ // else three.js spams "No target node found" for every missing bone
+ const nodes = new Set();
+ fig.traverse((o) => { if (o.name) nodes.add(o.name); });
+ for (const clip of clips) {
+ const bindable = clip.tracks.filter((t) => nodes.has(t.name.split('.')[0]));
+ if (!bindable.length) continue;
+ const use = bindable.length === clip.tracks.length
+ ? clip : new THREE.AnimationClip(clip.name, clip.duration, bindable);
+ this.actions[clip.name] = this.mixer.clipAction(use);
+ }
+ this.current = null;
+ this._axis = new THREE.Vector3();
+ this._qYaw = new THREE.Quaternion();
+ this._qPitch = new THREE.Quaternion();
+ }
+
+ /** @returns {string[]} clip names that bound to at least one bone */
+ get clipNames() { return Object.keys(this.actions); }
+
+ play(name, loop = true, fade = 0.18) {
+ const next = this.actions[name];
+ if (!next || next === this.current) return;
+ next.reset();
+ next.setEffectiveWeight(1);
+ next.setLoop(loop ? THREE.LoopRepeat : THREE.LoopOnce, loop ? Infinity : 1);
+ next.clampWhenFinished = !loop;
+ if (this.current) next.crossFadeFrom(this.current, fade, false);
+ next.play();
+ this.current = next;
+ }
+
+ /** Push one sim frame onto the rig. dt drives the mixer only — the sim already stepped. */
+ sync(sim, dt) {
+ const st = STATES[sim.state];
+ this.play(st.clip, st.loop !== false);
+
+ this.root.position.set(sim.pos.x, sim.pos.y, sim.pos.z);
+
+ // yaw, then tip over about a world-horizontal axis square to the fall direction, pivoting at the
+ // feet. At pitch 0 this is exactly the yaw, so upright play is untouched.
+ this._qYaw.setFromAxisAngle(UP, sim.facing);
+ if (sim.pitch > 1e-4) {
+ this._axis.set(sim.knockDir.z, 0, -sim.knockDir.x);
+ if (this._axis.lengthSq() < 1e-8) this._axis.set(1, 0, 0);
+ this._qPitch.setFromAxisAngle(this._axis.normalize(), sim.pitch * Math.PI * 0.5);
+ this.root.quaternion.copy(this._qPitch).multiply(this._qYaw);
+ } else {
+ this.root.quaternion.copy(this._qYaw);
+ }
+
+ this.mixer.update(dt);
+ }
+
+ dispose() {
+ this.mixer.stopAllAction();
+ this.root.removeFromParent();
+ }
+}
+
+/**
+ * Load the player and attach it to a scene.
+ * @param {THREE.Scene|THREE.Object3D} scene
+ * @param {object} [opts] {start, facing, height, groundAt, tune, charUrl, animUrl}
+ * @returns {Promise<{sim: PlayerSim, view: PlayerView, step: function}>}
+ */
+export async function loadPlayer(scene, opts = {}) {
+ const loader = new GLTFLoader();
+ const [rig, animPack] = await Promise.all([
+ _loadGLTF(loader, opts.charUrl || CHAR_URL),
+ _loadGLTF(loader, opts.animUrl || ANIM_URL),
+ ]);
+ canonRig(rig);
+ canonRig(animPack);
+
+ const clips = animPack.anims.map(_rotOnly).filter((c) => c.tracks.length);
+ const view = new PlayerView(rig, clips, opts.height || 1.72);
+ scene.add(view.root);
+
+ const sim = new PlayerSim(opts);
+
+ const missing = Object.values(STATES).map((s) => s.clip).filter((c, i, a) =>
+ a.indexOf(c) === i && !view.actions[c]);
+ if (missing.length) console.warn('player: state machine wants clips that did not bind:', missing);
+
+ return {
+ sim,
+ view,
+ /** Convenience: step the sim then push it to the rig. */
+ step(dt, t, input, wind) {
+ sim.step(dt, t, input, wind);
+ view.sync(sim, dt);
+ return sim.state;
+ },
+ };
+}
+
+/**
+ * The `Player` contract factory (contracts.js) — this is the one main.js calls.
+ * Drop-in for createPlaceholderPlayer(scene, world, cameraRig): same first three args, so boot()
+ * only changes which function it calls. Everything the player needs per frame it pulls itself, so
+ * `update(dt, t)` matches the contract's signature exactly.
+ *
+ * @param {THREE.Object3D} scene
+ * @param {object} world contracts World — `heightAt(x,z)` clamps the player to the ground
+ * @param {object} cameraRig contracts Camera — `yaw` is what WASD is relative to
+ * @param {object} [opts] {wind, interact, start, facing, height, tune, charUrl, animUrl}
+ * @returns {Promise