Closed-form hinge IK forkbve
Two-bone limbs solved on a rest-measured hinge axis — one atan2, one acos, no iteration — with flexion limits, a name-based skeleton role map, a parallel Bevy solver, and a FABRIK chain for everything else.
Legs, arms, and the notes
The solver behind the mmorpg procedural gait. The lower half of this page is the working log for that integration: what was tried, what the numbers said, and where it is going next.
- Solve — closed-form hinge, flexion ceiling, reach report.
- Rig — bone roles by name, body halves for masking.
- Bevy — IkLimb goals, par_iter solve, serial apply.
What it gives you
Features
Closed-form hinge
solve_limb bends the middle joint on the rest hinge axis and swings the root to aim the tip. Exact for knees and elbows; no pole vector to author.
Flexion limits
LimbLimits plus solve_limb_with cap the bend, keep the bend side, and return Reach::Clamped when the goal needed more than the joint allows.
Skeleton roles
role_of and Skeleton::learn resolve Epic, Mixamo and Blender bone names to roles and sides, so one rig profile drives any humanoid.
Bevy plugin
IkLimb, IkLimbBones and LimbOutput; solve_limbs runs par_iter_mut over limbs, apply_limbs writes rotations serially before transform propagation.
Chain fallback
solve_chain is a FABRIK pass with an Effort budget for tails, tentacles and anything without a hinge.
Engine-agnostic core
glam only with default-features off; libm feature for deterministic transcendentals across server and client.
Questions
Frequently asked
What is the kinetree crate?
kinetree is a Rust library for two-bone limb inverse kinematics. A limb with a hinge in the middle, such as a leg or an arm, is solved in closed form on a hinge axis taken from the rest pose. One atan2 and one acos, no iteration, no pole target, no convergence threshold. The core depends only on glam; a bevy feature adds components, a plugin and a parallel solver system.
How does it pick the knee direction?
From a RestHinge, the hinge axis expressed in the root bone's basis. It is measured from a bent rest pose with from_rest, or named outright with from_local_axis when the bind pose is straight and leaves nothing to measure. The axis follows the thigh, so the knee points where the hip points, and a small pre-bend on the calf keeps the solver from picking the backward root.
Does it enforce joint limits?
Yes. LimbLimits sets a flexion ceiling; solve_limb_with clamps the hinge to it, keeps the bend on the same side, and reports Reach::Clamped so the caller can drop the pelvis or shorten the step instead of locking the knee.
Can it run outside Bevy?
Yes. With default-features off and the std feature on, the crate is glam and nothing else, so the same solver can sit inside a Godot gdext cdylib or an Unreal module. A libm feature routes every transcendental through libm for bit-for-bit results across machines, which matters when a server re-simulates a client's limb.
How does it find bones on an unknown rig?
role_of maps a bone name to a Bone role (pelvis, thigh, calf, foot, ball, spine, clavicle, upper arm, lower arm, hand, neck, head) with a side, covering Epic, Mixamo and common Blender naming. Skeleton::learn ingests a whole name list and answers by role, and Bone::half says which half of the body a bone belongs to, which is what the mmorpg uses to mask animation clips off the legs.
API at a glance
use glam::{Quat, Vec3};use kinetree::{LimbLimits, LimbPose, RestHinge, solve_limb_with};
let rest = RestHinge::from_local_axis(Vec3::NEG_X).unwrap();let pose = LimbPose { root: hip, mid: knee, tip: ankle, root_basis: thigh_world_rotation };let solve = solve_limb_with(&pose, &rest, goal, LimbLimits::flexion(160f32.to_radians()));// solve.hinge_turn about solve.hinge_axis at the knee, then solve.root_swing about the hip;// solve.reach is Reach::Exact, Clamped or Degenerate; solve.tip_after(&pose) previews the ankle.| Item | Purpose |
|---|---|
RestHinge::from_rest(root, mid, tip, root_basis) | Measure the hinge axis from a bent rest pose. |
RestHinge::from_local_axis(axis) | Name the axis when the bind pose is straight. |
solve_limb, solve_limb_with | Closed-form solve, optionally with LimbLimits. |
hinge_angle, clamp_flexion | Read and cap the current bend. |
solve_chain, segment_lengths, tip_error | FABRIK for chains without a hinge. |
role_of, Bone, Side, Half, Skeleton | Rig-independent bone identity. |
IkLimb, IkLimbBones, LimbOutput, KinetreePlugin, KinetreeSystems, bone_world_transform | Bevy integration (feature bevy). |
Tests live in crates/kinetree/tests (limb, chain, rig, plugin). Run them with cargo test -p kinetree.
Field notes
Working log for the apps/arcade/kbve-mmorpg integration. Written for whoever picks this up next, human or agent. Dates are absolute.
Goal
Replace shipped animation clips for locomotion with compact data that IK and procedural code can drive, so gaits can be changed, scaled and generated (PCG) without re-exporting FBX/GLB, and so the same data can later feed the Unreal client (rareicon). Order of work: Kinetree → Bevy first, then Bevy → UE5.
Where the data comes from
Epic’s Game Animation Sample locomotion set (GASP), kept locally in ~/Downloads/Animations-2. Not in the repo, 5.5 GB, UE-only licence, never shipped. It is baked with the headless Blender tool kbve-blender-gait (packages/python/kbve/kbve/blender/gait_bake.py) into apps/arcade/kbve-mmorpg/assets/gaits/neutral.gait.ron: 20 loops (idle, walk and jog in 8 directions, sprint in 3), 71 KB. Per gait: direction, speed, hip height, stride period, pelvis bob, yaw and roll, and per foot contact phase, duty, lift, forward, side and pitch as 32 samples on a shared clock that starts at left-foot contact. Everything is normalised by leg length so it is rig-independent.
Bake rules learned the hard way:
- Contact = heel OR ball low and still. Ankle-only stillness cut contact at heel-off and gave walk duty 0.43, which is flight in a walk, which is both legs forward at once. Walk duty must come out ≥ 0.5.
- Hip height is measured above the mean stance ankle, not the lowest ankle frame. The min-frame reference sat the hip 2.4% high and locked knees mid-stance.
- Foot pitch is relative to the flat-stance median. The ball bone sits below the ankle, so absolute pitch reads −25°.
- Strafe loops in GASP are fast cross-over walks; travel direction only counts when a heading (target lock) is set, and direction lanes are blended by angle.
How the mmorpg used it before pose playback (state on 2026-09-09, afternoon)
- Animation clips are masked off the lower body with
AnimationGraphmask groups (Bone::halfdecides the group). While moving, the clip plays on a torso-only branch and is seek-driven from the stride phase; pelvis, legs and feet are procedural. Idle, jump and one-shots still play clips. - The stride phase is the only clock. Stance is the data’s contact window, swing is the rest. A planted foot the body has walked or turned away from may lift early, but its landing time never moves; strain speeds the whole clock up (×1.25) rather than rushing one foot. Independent per-foot clocks were tried and drifted off phase (duty 0.6, 20% double support). Never again.
- Facing lives on the character model child, not the physics capsule. The stride frame reads
model.rotation * Vec3::Z. Reading the capsule’s forward was the original cause of crossing legs. - Ground probe is a 6 cm sphere sweep from body height with a ray fallback, returning point and normal, excluding every character body. Landing spot is damped early in swing and frozen at 65%.
- Stop: freeze the gait blend, run the phase until the airborne foot lands, then fade over 0.25 s. Walk→run: capsule accelerates at 14 m/s², the gait mix chases a smoothed pace. Turns: ground heading swings at 4 rad/s, reversals over 120° are instant, model facing capped at 6 rad/s.
- Debug:
MMORPG_AUTOWALK=walk|jog|turn|jogturn|zigzag|reverse|stopgo|gear,MMORPG_CAMERA=dist,pitch,yaw,focus, keys P (procedural / lock / follow), G (gizmos and a 1 Hz report), I (IK off). Probes logCROSSandFLIGHT.
Verify with numbers, not screenshots
MMORPG_TRACE=<file.csv> writes one row per frame per character: phase, clock rate, speed, turn, WASD wish, hip height, and per foot the ankle in the pelvis frame (leg units), IK goal, plant and strain state, lift phase, knee angle, thigh-to-ankle reach. The comparison script in the session scratchpad (compare.py) prints the same statistics from GASP clips sampled through Blender (sample_fbx.py), so a game run and a mocap clip are read side by side. Targets from the neutral walk loop: foot forward ±0.55 leg, duty 0.50, double support 0.02, knee 5–72°, thigh-to-ankle reach by stride tenth [0.97, 0.97, 0.995, 0.98, 0.97, 0.945, 0.83, 0.90, 0.99, 1.00].
Bugs this found in one pass that weeks of looking at frames missed: a hurry loop (landing predicted with the nominal period while the clock ran 1.6×, so the foot overshot, which read as strain, which kept the hurry on); per-foot clocks drifting off phase; feet placed relative to the capsule while the data is pelvis-relative (0.2 m bias); a landing-frame re-prediction that jumped every pin 20–35 cm; a pelvis reach drop and a pin slide that fought each other into squats and skating.
Why it still reads wrong, and the pivot (decided 2026-09-09)
The bake keeps ankle positions and discards every joint rotation. Legs rebuilt from foot positions by two-bone IK are “IK legs”: feet right, everything above synthetic. Turns, starts and stops are separate mocap clips with real weight shifts that rules cannot fake; every rule fought another. The reviewer’s point 6 in apps/arcade/kbve-mmorpg/FEEDBACK.md was right.
Next architecture, still no GLB, still our own data:
- Bake lower-body joint rotations per frame from the GASP locomotion clips into a compact rig-independent pose database (rotations relative to each bone’s rest, by role; pelvis height; root motion; contacts). Roughly 60 clips × 100 frames × 8 bones × 8 bytes ≈ 400 KB.
- Play those rotations onto any rig through
role_of. - Use kinetree only to correct contact: plant, slope, stance lock.
- Select the segment by motion matching on the steered trajectory. Arcs, box steps, pivots, starts and stops come from their clips.
- Upper body, PCG scaling and the UE5 consumer stay data-driven off the same file.
Blender is the bake and retarget-check tool; Bevy owns selection and playback.
Pose playback, first result (2026-09-09, evening)
kbve-blender-pose(packages/python/kbve/kbve/blender/pose_bake.py) writesassets/gaits/neutral.pose.ron: 26 clips (idle, walk/jog in 8 lanes, sprint in 3, six walk arcs), 1.9 MB as text before any quantisation. Per frame and per role bone: rotation as a delta from rest in a canonical frame (Y up, facing +Z, right −X), the unit direction to the child joint, plus hip-centre height above the mean stance ankle in leg lengths, root motion and foot contacts.src/mmorpg/pose.rssamples the first full stride of the chosen clip by the existing stride phase and writes it onto the rig. Thigh, calf and foot are retargeted by direction (from_rotation_arc(rest_dir, baked_dir) * rest), pelvis and ball by the delta. The pelvis gets its full local translation from the hip-centre height; its parent is rotated −90° about X, so height is local Z, not Y. Lower-bone translations and scale are reset to bind first, because the torso clip leaves stale translations behind (the leg measured 0.83 m instead of 0.888 m until it did).- Clip choice: lane by travel angle, then nearest speed in that lane.
MMORPG_POSE=0returns to the old procedural stride. - Result on the circle run, no contact IK at all: knee, hip-to-ankle vertical and foot clearance profiles match the mocap walk loop within a few percent at every tenth of the stride. Compare that with the weeks spent on rules.
- Contact correction over the pose (
hold_footinfoot_ik.rs): while the clip says a foot is down, the ball’s ground position is held with 5 cm of slack (the capture’s own feet creep that much in stance), the ankle follows the pose’s foot geometry from the ball, and the ball never goes below the ground surface. In swing a foot is only lifted out of the ground. Nothing else. Every stricter variant (hard ankle pin, re-centred ankle path, ball held 2 cm up) bent stance knees 10–17° over the mocap because near a straight leg 1.5 cm of reach is 13° of knee. - The stride clock in pose mode is the clip’s own stride travel over the body’s speed, not the gait-curve period (the lane mix cancelled the stretch and the foot skated 4%).
- Travel direction always chooses the lane in pose mode: when the facing lags a reversal the body is briefly walking backward and the backward clip is right; the forward clip marched a planted foot 0.8 m.
- Measure on
MMORPG_FLAT=1(one-height world) withMMORPG_AUTOWALK=reverse(6 s legs) and steady frames only; every earlier probe ran on a hillside, which is why the pelvis reach drop looked necessary. - Result on flat, straight and circle, lock on: knee within 4° of mocap at every tenth, clearance within 2 cm, planted ankle 0.23 m/s (mocap 0.10–0.15).
- Open at that point: turning still plays the straight clip while the body arcs, so the lock absorbs the turn in the knee; the arc, box, pivot, start and stop clips and a trajectory-matched selector fix that.
Pose owns the body (2026-09-09, night)
The user still saw the walk as bad, with a “snap right when the leg gets placed”. Slow motion (MMORPG_SLOW=0.15, burst screenshots by window id) plus the trace found six separate causes, none visible at speed. Each was measured, fixed and re-measured; the numbers below are from MMORPG_FLAT=1 MMORPG_NPCS=0 MMORPG_AUTOWALK=walk|stopgo.
- The plant snap: the rig’s bind ankle sits 0.0865 m over the sole, but the same joint angles put the ankle 0.066 m up on this rig (leg proportions differ from Manny). The swing sink clamp held the ankle at bind height, and the plant released it: 2 cm drop, 6° knee pop, every step. Fix:
Cadence.floor_fix, learned from the lowest ankle height of each clean stance (first few average in, then a quarter each) and added to the pose floor. Converges to 0.02 m in a few strides. - The start snap: the first pin was set at 7% blend weight, on the idle clip’s foot, and held while the walk pose moved 50 cm away; the leg straightened behind and snapped forward at release. Pins now need the pose fully in (weight ≥ 0.99).
- Start and stop jumps at the weight 0 ↔ 0.07 boundary were the Bevy clip switch (idle ↔ walk on the unmasked leg branch) landing under the pose. The pose now owns the legs whenever the character is grounded, idle included: the baked mocap idle plays by time, walk ↔ idle is a slerp of two baked frames over the existing 0.25 s fade,
settleis off in pose mode, and the torso branch (legs masked) is used whenever the pose owns them. Grounded idle also goes throughhold_foot; the clip-era foot branch pulled the idle ankle to bind height and bent the near-straight knee 20°. - Torso hunch and dead arms: the Quaternius clip stands hunched by itself, and its walk swings the arms barely at all. The bake now carries chest (highest spine), neck, head, clavicles and arms; the rig’s three spines share the chest delta by rank, arms retarget by direction, hands and clavicles by delta. Actions keep the upper body (
Has<Action>skips upper roles). Pose file is 3.8 MB as text now. - Every 4th stride, an 18–20° knee step at phase 0.04: the exported loop ends on two copies of its first frame, so the last window stalled for two mocap frames; and the last left landing is the first one seen across the seam, giving a 2–6 frame window that spent a whole stride’s clock in a few frames. Load now trims trailing frames that repeat, closes contact gaps of up to 3 frames, drops onsets that start a window under half the median, and plays every stride window of the loop in turn (
Cadence.stride_count) instead of one window on repeat. The loop’s translation at the seam is (last − first) × n/(n−1); without the extra frame step the root stalls for a frame there. - A lock that pins the ball at heel strike pins a point 3–12 cm in the air; the foot then rolls about it and drags the ankle. The pin now sits under the heel (ankle) until the ball comes within 2 cm of the ground, then hands over to the ball at its real touchdown spot. A held foot is never pulled past the larger of the pose’s own reach and 98.5% of the leg; past that the pin slides instead of the knee locking straight (kinetree’s clamp gave a 5.9° knee for six frames at every toe-off). What the lock still held at release fades over 80 ms into the swing.
- Zero slack was tried and rejected again: the capture’s own stance foot creeps ~4 cm backward per stance (median ankle 0.13 m/s), so a hard pin forces 10° of extra knee into every toe-off. 5 cm of slack over the heel/ball pin never engages on flat straight walking and only corrects turns, acceleration and terrain.
- The pose frame is now chosen by the clip’s root distance within the window (
frame_at_distance), not by time, so the body’s uniform motion and the clip’s uneven root motion cannot separate a planted pose foot from the ground.
Result, steady walk on flat: left knee by tenth [27.6, 27.8, 12.6, 20.7, 31.1, 45.1, 68.2, 51.2, 14.2, 10.0] against mocap [29, 28, 11, 21, 29, 38, 67, 52, 11, 4]; ankle clearance within 1 cm at every tenth; planted ankle speed median 0.18 m/s (mocap 0.13); no knee step over 6° at any contact edge; the only per-frame knee changes above 9° are mid-swing, where the mocap itself does 8.3° per 60 Hz frame. Stop-go: clean apart from the very first stride after launch while the floor fix is still learning.
Still open: turning (arc, box, pivot clips with a trajectory-matched selector; the arcs are baked but excluded from pick), start and stop clips instead of the idle blend, jump and airborne still from the clip, lane and speed blending, quantising the pose file, the UE5 consumer.
Debug switches added: MMORPG_SLOW=<0..1> scales virtual time, MMORPG_NPCS=0 spawns no dummies or companions (they walked into every autowalk), MMORPG_IK=0 plays the pure pose with no contact correction. The trace gained stride and period columns; drop now carries the floor fix.
Torso, head and turns from data (2026-09-10)
The user’s play trace said where the time went: 83% of every second moving was a turn at 1.3–6 rad/s, and planted feet slid at 1.1–1.4 m/s there against 0.18 m/s on a straight. The tightest arc loop in the capture is 0.73 rad/s, so no loop, slack or blend covers it. The torso and head were wrong for a different reason.
- Rotation deltas carry the capture skeleton’s bind. The mannequin’s top spine bind leans back 10° and its neck bone 14°, so the chest delta read 15–19° of pitch on a walk whose joint positions lean 1–2°; sharing that delta by rank gave the rig’s long top spine the full 17° while the mocap’s lower spine goes the other way. Rig torso lean measured 8.4°, mocap 1–2°. Chest and neck now retarget by joint direction like the limbs (
spine_01→neck_01,neck_01→head) with the twist taken from the delta’s forward rejected onto the axis (torso_arc,twist_about). Rig lean is 3.6°, chest counter-rotation ±9° against mocap ±10°. - The GASP FBX export has no head bone at all: the chain ends at neck_02. The “head” role was a cervical vertebra pitching 11° further down on top of a neck craned 32° forward, which is the bowed, jutting head the user saw. The head now keeps only that bone’s yaw and stays level; the neck’s 32° is the actor’s real posture (14° in bind, 16° idle) and stays.
- The idle clip’s pelvis faces 15.7° off its frame, so every stop and start blended the whole body through a 16° yaw. Still clips are un-turned by their mean pelvis heading at bake.
- Turns, starts and stops are one-shot clips now, the whole GASP neutral walk set baked (102 clips, 16.6 MB):
walk_turn_{l,r}_{045,090,135,180}_{lf,rf},stand_turn_*,walk_start_*,walk_stop_*,walk_pivot_*.Cadence.shotplays a window by time and owns the model facing from the clip’s own low-passed root heading and the body velocity from its root motion (heading_at,body_velocity_at; un-turn is a rotation by +heading about Y, checked against the data with the body’s local velocity staying +Z). The turn clips are 5 s and 10 m with a walk in and out;turn_windowplays from the last landing of the foot the loop just planted before the heading starts moving to the first landing after it settles. A turn of 60° or more is requested and waits for a foot landing with facing and velocity held (pending); without the hold the steer bled the angle away before the landing and the arcs took over. Cutting on the same foot’s landing matters: both variants’ windows started on a left landing, and a right-landing cut paired early swing with mid-swing for a 25° knee pop. A stop plays from the landed foot to rest and is interruptible by input, which chains straight into a start; its trigger keys on the previous frame’s speed because braking is instant and the stride clock stalls. A start plays from standing to the landing after 90% of the clip’s speed. Every cut goes throughInertia, which decays per-bone rotation, direction and pelvis offsets over 0.2 s.
Result: zigzag 90° flips each play one turn clip and land within 20° (yaw 4 → 89 → 0 → 86), a reversal plays one 180 and the arcs finish it, stop-go plays stop → start → loop with the body travelling 0.8 m after release. Planted slide on zigzag median 0.25 m/s (was 0.39), knee steps over 10° at cuts down from 25° to 10–16°.
Still open: turn windows undershoot about 20° because the heading is low-passed, and the arcs finish the turn; 15–22° knee pops at stop cuts; the jog and run turn, start and stop sets are not baked; stand turns and reface starts are baked but unused; angled starts fall back to arcs; the pose file wants quantising.
Reversals, interruptions and the jog set (2026-09-10, later)
The user’s complaint was exact: walking on W, pressing S stopped the character instead of turning it round. The play trace showed why, and none of it was the clips.
- A human key swap takes 0.10–0.14 s. The stop fired after 0.1 s of no input, so every W→S was a stop. The release debounce is 0.2 s now, with the body holding its pace meanwhile. Stops, starts and turns all abort on input: a turn or start whose goal facing is 60° or more off the stick is replaced the same frame, a stick dropped mid-turn becomes a stop, input during a stop becomes a start.
- After a stop, facing the stick used
forward.lerp(wish). For an exact 180° those are antipodal vectors: the lerp collapses to zero and the normalize hands backforward, so the character never turned and stood facing the wrong way for as long as the key was held. Rotation by a stepped angle now, and the standing hold is sticky so the frame after a restart does not walk a loop for 0.25 s on the stale speed. - Two bake bugs made the turn-in-place and reface clips unusable. Still clips are un-turned by their mean pelvis heading, and a stand turn travels 0.03 m/s, so its whole pose was twisted 60° against its facing (foot twist read −148° at the end of a turn). The heading low-pass truncated its window at the clip ends, so a turn that starts on frame 0 baked as 102° of sweep for a 180° pelvis rotation. The window now shrinks symmetrically toward the ends (first and last frames exact) and the mean-heading un-turn applies only to clips that do not turn. Stand turns now sweep 180.0 for 180, reface starts 194 (the actor overshoots), walk turns 177.7.
- A shot carries
steer: the extra yaw, up to 45°, between the stick angle and the clip window’s own sweep, spread evenly over the window so the shot ends facing the stick exactly. It replaced a first attempt that scaled the clip heading, which rotated the pose against its pinned feet. The lf/rf turn variant is chosen by how far its phase-matched start sits from the turn onset (lead-in frames weighted double), because the old choice by window length could skip a whole stride of the turn and leave 30° for the steer to finish after the cut. - The jog set is baked (
jog_turn_*,jog_start_f_*,jog_stop_f_*) and the walk reface starts (walk_reface_{f,b}_{l,r}_{090,180}); 130 clips, 20.5 MB. Turns, starts and stops pickjog_above 3.5 m/s. From standing, a stick 60° or more off plays a reface start (turn in place and walk out in one capture), 22.5–60° a stand turn then a start, running always a stand turn then the jog start. The one-shot test in the loader matches on_turn_,_start_,_stop_,_pivot_,_reface_; the first jog run pickedjog_start_f_rfas a running loop because the old test knew only the walk prefixes. - The stop-cut knee pop was the stride clock, not the cut. Braking is instant, so the release frame reads speed 0, the stride counts as not moving and
paceresets to 0; the pose clock then ran at a quarter speed through the 0.2 s hold while the body still moved at 2.2 m/s. The planted foot’s pose drifted 30 cm from its pin, the reach clamp locked the knee straight at 0.985 leg, and release popped it 14°. The moving test keeps the previous frame’s speed for one frame and counts a hold as moving; the drift is under 6 cm and the pop is gone.
Measured with the swap (4 s each way, 0.15 s gap) and stopswap (1 s gap) autowalks, MMORPG_AUTORUN=1 for the jog set: walking W→S plays one 180 clip in 1.0–1.5 s and ends at 180.0 with no arc after; running W→S plays the jog 180 in 1.3 s; stop then reverse plays stop → reface → walking in 1.07 s; the standing hold path faces 160° in 0.45 s when no clip fits. Planted slide medians 0.21–0.28 walking, 0.55–0.70 running.
Still open: the running slide; angled starts under 60° still turn in place before stepping; heading mode does not use the stand turns yet; single-frame 10–12° knee deltas in the reface and start data; the pose file wants quantising; jump from data; NPC avoidance; the UE5 consumer.
The idle keeps the stance it arrived in (2026-09-10, night)
The user saw the legs teleport when a walk ended. The trace showed the stop clip playing to its end and then, for the 0.27 s idle fade, the moving half of the blend being the walk loop at phase 0.03 with the feet 45 cm apart, so both feet glided 10 cm into the idle’s stance. Handing the stop straight to the idle through the inertia blend left 8 cm of glide, because the idle’s stance is narrower than the stop’s; the real fix is the one symbios-avatar makes for its idle: the feet stay where they came to rest.
- Each foot goal keeps a
restpoint once the body is under 0.4 m/s and the pose is not at full blend, and the solver serves that point back at full weight until the body moves again. The pelvis drops up to 4 cm with a 2% leg reserve so a wider stance does not lock the knees (1% left the leg on the lock’s 98.5% reach limit); the drop is applied instantly at rest, and it is measured against the undropped hip, because measuring after its own drop halved it every frame. - When a clip starts from rest, the first pins are seeded from the held feet, and the lock’s slack now measures the pose’s creep since the pin was set rather than the distance to the pin, or the whole 5 cm of slack was spent on the first frame. Pins engage and contacts count during a shot regardless of the fade-in, and a seeded pin that the new pose puts beyond reach shortens along the leg instead of sliding toward the clip’s foot.
Measured with the rest autowalk (walk, stop, three seconds idle, start): the stop ends with knees at 20°, the idle holds the feet within 1 mm at 25°, the start’s first frame moves the foot 1 mm and the knee 7°; before, the feet moved 10 cm and 4.5 cm respectively. Planted slide on the walk unchanged at 0.19–0.25 m/s.
The body sinks and the idle drifts (2026-09-10, night, later)
Two more reports from play, both measured before touched.
- “The thigh area looks weird when walking”: the model root sank 2 cm per stop. The pelvis drop was applied with
translation.y -=and nothing under pose playback ever set the root back, so ten stops put the hips 20 cm low and the floor learner chased the sink to its 0.10 cap. The drop is now kept inCadence.root_drop, restored before the new one is applied. The learner measures each stance against the body floor rather than the previous sample, takes samples only from loops that have run two strides since the last clip change, and learns at 0.02; flat rig constant settles at 0.017–0.022 and holds through rest cycles. - “A couple of seconds into the idle the legs snap”: the physics body creeps while idle (3.3 cm/s on that slope, 3 mm/s on the spawn floor, reported speed 0), the rest hold pinned each foot to a world point, and the 0.4-leg self-heal snapped a foot back every ten seconds. Held rest points are body-relative (
rest_localoff the body position andcadence.yaw), so creep and camera turns carry the stance. A body that has never walked waits for the pose to sit still for three frames before capturing, because the first frame held a bind-pose foot and a 91° knee.
Speed blends and re-aimed turns (2026-09-10, late)
The user asked for the walk to become the run as the speed rises, and for W+D to mean the diagonal. The second was already true in world space; the trace showed what actually felt wrong: a 90° turn clip that had been committed on D kept its facing when W joined 0.3 s later, because a 41° change was under the 60° abandon threshold, so the character finished the right turn and only then came back 45°.
- Loops are a one-dimensional blend space per direction lane now.
pickreturns the two loops bracketing the pace in leg lengths per second (walk and jog, or jog and sprint); both are sampled at the same stride phase, which the stride windows align on the left landing, and mixed by where the eased pace sits between their speeds. Contacts come from the nearer loop. The stride period is the mixed stride travel over the pace. The pair is locked per stride like the single clip was, but re-picked the moment the pace leaves its range, since the shared loop makes that continuous; without it the walk-run crossing waited for the next stride and popped 21°. - Speed still climbs at 14 m/s² but settles to a slower gait at 9 m/s² instead of instantly; letting go still brakes instantly so the stop clips keep their trigger. Run to walk now takes 0.35 s with the stride period sliding 0.61 → 0.95 s, no knee step over 20°.
- A shot re-aims every frame the stick moves under the abandon threshold.
steeris split into what has been added up tosteer_fromand what remains, so re-aiming never moves what is already applied; the remainder is capped at 45° and 3 rad/s of the window left. Withnudge(straight, 90° for 0.3 s, then 45°) the turn lands at 54° instead of 90° and the loop finishes the last 9°. - Turn windows ran to the last frame of every 90° clip because the bake’s low-pass closes to nothing there and leaves raw sway (93° → 102° on the final frame), so 95% of the sweep was never reached earlier. The sweep now ends at the mean heading over the clip’s last sixth: the 90° walk turn plays 1.6 s, the 180 0.9 s and ends at 179°.
Still open: jog stop cuts step the swing knee 22°/frame for three frames because the stop window is matched by phase, not pose; picking the window frame by pose distance is the next step. The running slide, angled starts, stand turns in heading mode and quantising are unchanged.
Pose-matched stops and strafe lanes (2026-09-10, latest)
- Stop windows were placed by stride phase; a jog stop landed mid-swing against a landing and stepped the knee 27° a frame.
stop_window_matchedsearches the clip for the frame whose leg joints, and where they are a tenth of a stride on, best match the loop (match_frame: squared joint angles now and ahead, plus root speed against the pace at 0.5 per (m/s)²), across both foot variants. Jog stop cuts now step at most 18° a frame, which is the jog swing’s own rate; walk cuts show nothing over 12°. - Tried and dropped: an input settle (commit a turn clip only after the stick has held 0.12 s) and 45° turn clips at a 30° trigger. The loop and the standing hold both steer the facing at 4–6 rad/s during the settle, so by the time the stick settles the angle is under the trigger and no clip plays; and a walking 45° course change through the clip took 1.3 s against 0.25 s of loop steer. Diagonals stay on the loop’s steer with re-aimed shots covering the key-gap case.
- Lock-on strafing was quantised to eight lanes and, worse, the turn trigger still fired at 60° off the locked facing and spun it. With a heading the loops are a two-dimensional blend now: the speed pair in the travel’s lane and the pair one lane on, mixed by the angle past the lane centre (
lane_split,pick_lane,Cadence.pair2/lane_base/locked). Turn clips, reface starts, stand turns, the standing hold and shot re-steer are all off while locked; starts and stops pick the lane’s own clip (walk_start_bl_*and the rest) and fall back to the forward one, which is all the jog set has. A lane change re-picks at once and goes throughInertia; a speed re-pick is continuous and does not.MMORPG_STRAFE=1locks the facing north for tests and theorbitautowalk sweeps the stick through 360° in 16 s: facing holds at 0° all the way round, lanes hand over FL → LL → BL → B → BR → RR → FR → F with the stride period sliding, no knee rate over 1500°/s.
Still open: strafe at run with 90° stick flips every 0.6 s shows 17° single-frame knee steps from the foot lock as lanes swap, and planted slide near 0.9 m/s; the jog set has no lane starts or stops; free-mode diagonals still slide on the loop steer.
Soft lock: the strafe runs at its own speed (2026-09-10, later still)
The user’s reminder that combat (a selected target, the hybrid soft lock) plays its own animations. Four things were wrong in that mode once the lanes blended.
- The body ran at 5.5 m/s in every lane, but the captured sideways jog is 3.5 m/s and the backward one 3.0, so the strafe loops were overdriven and the planted feet slid 0.75 m/s.
Cadence.lane_capis the fastest loop of the lane the stick points down, in leg lengths per second times the rig’s leg, mixed between neighbouring lanes; the movement caps a locked body’s target speed by it. Sideways now runs 3.6, backward 3.1, planted slide 0.62. - Starts and stops played the forward walk clips under the lock because the gait came from the shift key, while a heading forces running. A locked body counts as running, uses the lane’s own start and stop when the gait has one, and otherwise fades through the loops rather than playing a forward clip sideways.
- A key gap during a strafe held the body along its facing, so a backpedal turned into a forward lunge for 0.13 s.
Cadence.travelremembers the last direction the body actually moved at walking pace, and the hold and the lane pick use it while locked, since the velocity is already zero on the brake frame. - A 180° reversal under the lock drops the ground probe for one frame; the pose skipped that frame and the raw animation clip showed through (left knee 152°, foot at the hip, one frame).
Cadence.aircounts the miss and the pose stays grounded for 0.1 s of it.
Measured with MMORPG_STRAFE=1 and the swap, stopgo, orbit and jitter autowalks: no knee rate over 1500°/s on orbit or stop-go, jog start and stop clips on every stop, reversal cuts 4° or under; free-mode swap and stop-go unchanged.
Jumps from the data (2026-09-10, last)
Idle, walk and run jumps were one Quaternius clip on a physics hop of 8 m/s. The GASP jump set is 145 clips; 29 are baked: jump_start_{stand,walk,run,sprint}_{lf,rf}, jump_off_{walk,run}_*, jump_fall, jump_land_{stand,walk,run,sprint}_{light,heavy}_{lf,rf}.
- The captures jump off a 7 m ledge, so the bake’s single floor put the takeoff stance seven metres in the air and found no contacts there. The floor is local now: the lowest any ankle gets within half a second of each frame finds the contacts, and the hip height is measured against the floor under the last planted frame (or the first one ahead), so a flight keeps the floor it left and a landing measures against the one it reaches. Flat clips bake identically.
- A jump is three stages driven by the body, not the clock. The takeoff plays as a grounded shot from the matched frame to the clip’s first sustained airborne frame (a run’s stride flights are shorter than nine frames and do not count); on that frame the pose raises
launchand the movement applies the impulse. The rise maps vertical speed onto the clip’s frames between takeoff and apex, so any jump height lands on the apex pose. Past the apex the fall loop runs by time. Touchdown picks the landing by stick (stand, walk, run, sprint past 6 m/s), by fall speed (heavy past 7.5 m/s) and by which foot variant’s touchdown frames match the falling pose, and plays it as a plain shot from its touchdown frame to where the hips settle or the gait resumes. A standing landing counts as a stop, so input during it chains into a start. - In the air the hips sit at the idle’s stance height over the physics body, because the fall loop is baked thirty metres over its own landing floor; the feet release from the ground the frame the body leaves it instead of fading over 0.3 s, which had the legs locked straight on the floor while the body rose.
- Two older faults surfaced: the pose weight faded to idle whenever the pace was zero, so every standing shot and the whole standing jump played blended toward the idle; and with the weight held, the procedural blend’s zero stride period at zero pace divided the phase clock into NaN and crashed the animation curves. Shots and flights keep the weight; a zero period is ignored.
Measured with the stillhop and hop autowalks (jump every 3 s, MMORPG_AUTORUN=1 for the run): stand jump = 0.4 s takeoff crouch, 0.46 s flight, 0.72 s landing to idle; walk = matched takeoff, walk landing 1.1 s back to the loop; run = 0.55 s takeoff, run landing 0.65 s back to the jog. No knee step over 26° outside the push-off frame, which the data itself steps 28°. Jump impulse is 5.5 m/s now (0.63 m under 24 m/s² gravity); the rise plays the clip 1.7× faster than captured because the capture’s hang time is longer.
Feet on the wall (2026-09-10, last of the day)
“Walking towards a pillar, his feet get stuck to the wall.” Reproduced with the walk autowalk, which runs straight into the pillar at (0, −42) after 18 s (the “map edge at z ≈ −40” in earlier notes was this pillar).
- The foot probe is a sphere cast down from above the body at the pose ankle’s x, z. When the swing foot’s pose enters the pillar the cast starts inside the collider and returns its own origin, 1.5 m up the wall, and the foot planted there. A hit now has to be walkable: normal at least 0.7 upright, below the cast origin, and no more than 0.35 m above the pose ankle. A foot the pose swings into a wall follows the pose instead.
- The body kept being driven straight into the wall: the collision zeroed its speed, the pose read standing and played a start whose root motion pushed in again, the lateral part slid the body along the wall, the facing followed the slide, the stick was 60° off and a turn clip swung it back. A sphere cast along the wish, a fifth of a metre past the body’s radius, finds the wall and the wish and every shot velocity slide along it; a wish within 20° of straight in counts as not pushing, so the pose stops and idles facing the wall while the key is held.
Measured: at the wall the yaw holds within 0.03 rad, the body does not drift along it, and no foot goal rises above the floor. Zigzag and terrain walking unchanged.
A held foot yields at a bound (2026-09-11)
Audit against the two feedback files and symbios-avatar’s foothold ledger. Most of the foot-IK critique was already in (ground normal and sole tilt, late-swing landing commit, pelvis reach with fast drop and slow rise, animated legs kept, contacts from the bake, inertialization). What symbios does that the hold here did not: a held contact may stand only so far from where the stride itself puts the foot, along the travel and across it, and past that bound the foot slides along the bound’s edge instead of being dragged at the end of a straight leg.
Measured on the strafe swap autowalk at run (forward and backward flips every 4 s with the facing locked): during a flip the body’s velocity reverses over the next third of a second while the lane loop flips at once, so the pose’s stance foot runs away from its pin at about 2.5 m/s. The pin held, the leg hit the 0.985 reach cap, and the foot was dragged 0.4–0.57 m at a 20° knee before release.
HOLD_ALONG0.3 andHOLD_ACROSS0.2 leg lengths bound the hold’s correction from the pose’s own foot, along the travel direction and across it. The correction now caps at 0.22–0.27 m and the foot slides at the bound instead of locking.HOLD_REACH0.96 (was 0.985): a held leg keeps a bend unless the pose itself is straighter. Knee while held away is now at least 22–30° (was 20°, locked).- The trace has
l_carry/r_carry: planar distance the solve holds the foot away from the pose.
Straight run, orbit strafe, zigzag turns, walk 180° reversals and terrain walking are unchanged by it (carry ≤ 0.07 m planted, same knee ranges, no new knee jumps over 25° in 50 ms).
The pose database is packed (2026-09-11)
neutral.pose.ron had grown to 25 MB of text (159 clips, 18,040 frames, 1.4 KB a frame) and took 0.6 s to parse at boot. kbve-pose-pack in.pose.ron out.pose.bin (pure Python, no Blender) writes the same numbers as neutral.pose.bin: rotations and bone directions as signed 16-bit fractions, root, pelvis and clip metadata as float32, one contact byte, little-endian, magic KPOS. The bake writes the packed form directly when --out ends in .bin. The loader sniffs the magic and still reads RON. 5.5 MB, and the game reaches its first pose 0.46 s after launch instead of 0.98 s; run metrics are unchanged. Scripts that need clip names read the header with pose_pack.read_names.
Feet on slopes (2026-09-11)
The terrain traces so far ran over ground within 3° of flat, so nothing had tested the slope paths. MMORPG_HEADING=220 with the walk autowalk climbs a 10–17° ramp 15–35 m from spawn (found by porting the terrain noise to a script and scanning headings; pillars sit every 30° at radius 42, so keep off those bearings). The trace now carries l_ball_gnd/r_ball_gnd (ball bone height over the terrain under it) and l_slope/r_slope (degrees).
Two things were off:
level_feetskipped every character under pose playback, so the sole never tilted: the mocap’s flat-ground foot orientation stood on a 15° slope with its toe 5.5 cm in the ground while planted and 4.3 cm in during swing. Under the pose each foot is now rotated about its ankle onto the probed ground normal (goal.normal, capped atMAX_SOLE_TILT), weighted by how grounded the foot is, so swing feet follow the ground too.MAX_REACH_DROPwas 0 with no leg reserve, so the pelvis never dropped for a downhill foot while walking and planted knees reached 8°. 0.15 m cap with a 2% reserve now; planted knee minimum is 12–15° on the hill and rises to 15–21° on flat walks and turns.
Planted toe sink at 15–25° (5th percentile): walk −5.5 → −2.6 cm, run −4.7 → −2.3 cm; swing toe minimum walk −4.3 → −2.3 cm, run −2.5 → −0.1 cm. Flat ground reads −1.5 cm for the same statistic before and after (the heel-strike dip in the data), so that is the floor of what this metric can show. Flat run, walk reversals and zigzag turns unchanged otherwise.
The toe gets its own probe (2026-09-11)
With the sole tilted, the swing toe still clipped 2.3 cm on the hill walk, because only the ankle was probed and the toe reaches uphill of it. Each foot’s ball now casts its own probe; its height above the sole comes from the bind (ball rest minus foot rest plus the ankle height). The lift a foot gets, whether swinging or heel-pinned, is the larger of the ankle’s and the toe’s. Swing toe minimum at 15–25°: walk −2.3 → +0.7 cm, run −0.1 → +3.3 cm. Flat run and the planted statistics unchanged. A toe inside a pillar has no walkable probe and is left to the pose, as the ankle is.
The pelvis reach solver, made honest (2026-09-11)
Turning MAX_REACH_DROP on had a side effect the slope numbers hid: the drop was computed against the leg’s nominal reach, and a jog’s own toe-off puts the leg at 0.97–0.99 of it, so the pelvis sat a constant 1.5–4.5 cm low on flat ground and planted knees read 10° more bent than the data (hip over ground on the walk reversals 0.912 → 0.867 m). It also could not do its actual job: the hold’s reach clamp pulled a far foot in before the pelvis solve ever saw it. Now the hold records what the clamp took off (FootGoal.short), the pelvis solves against the unclamped target, and its reach is never less than the pose’s own hip-to-ankle distance. Flat hip heights and knees match the pre-change traces again; MMORPG_SPAWN=23.1,27.6 with heading 40 walks the ramp down (spawn sits in a basin, every heading from it climbs).
The gaze (2026-09-11)
src/mmorpg/gaze.rs: a character with a selected Target looks at it. Each frame the bearing from the head to the target’s head bone (or 0.6 m above its origin) is taken as yaw and pitch from the body’s facing, clamped to what a neck does (±75° yaw, 29° up, 40° down), chased at 8/s, and faded in and out at 4/s; Has<Action> suspends it so attack clips keep their heads. The rotation applied is the arc from the head’s current facing to that direction, so it replaces the pose’s own head sway while a target is held rather than adding to it; the chest takes a fifth of the remainder, the neck a third of what is left, the head the rest, so the head lands on the target exactly (symbios-avatar’s gaze spreads the same way). It runs after the posture pass (PostureSystems), before propagation.
MMORPG_TARGET=1 selects the nearest hostile at start for tests; trace columns gaze_yaw, gaze_pitch, gaze_w. Measured with the rest and orbit autowalks against a training dummy: head yaw and pitch match the gaze to 0.1° at the 95th percentile, no head jump above 2.2° in 50 ms, and a run without a target is byte-identical in head yaw to before. Under the soft lock the body already faces the target, so most of what the gaze adds is the pitch and a head that holds still on the target through the walk cycle.
Rules for this work
- Nothing here is committed or pushed until the legs are verified in play. Commits are small, titles validated with
node tools/commit/validate.mjs --title, no Co-Authored-By. - One-line rustdoc only, no inline comments.
- When a hand rule is needed to fix motion, the missing quantity should be baked from the mocap instead.