Skip to content

Vessel construction unification — Dolphin rig swap, measured audits, vestige retirement [flight 15: the boosters' roll floor removed] - #802

Draft
gradies wants to merge 40 commits into
bleeding-edgefrom
claude/vessel-construction-unify-hkleam
Draft

Vessel construction unification — Dolphin rig swap, measured audits, vestige retirement [flight 15: the boosters' roll floor removed]#802
gradies wants to merge 40 commits into
bleeding-edgefrom
claude/vessel-construction-unify-hkleam

Conversation

@gradies

@gradies gradies commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator

The fleet is built two incompatible ways — a skinned family (one armature, one skinned hull, element shapes) and a part-per-mesh family (a static mesh per part, no armature, therefore no morph and no puppetry). This branch moves the Dolphin from the second to the first, which is the only time a vessel has ever changed families, and fixes the instruments that were reporting on it.

The governing rule was that nothing is deleted until what it carries has been measured and, if valuable, extracted. Phases 0 and 1 exist to honour that, and two "vestiges" turned out to be load-bearing.

DRAFT — the ship flies right; flight 13 found the depth-cue inversion that drove the whole booster-seat loop, and flights 14–15 pinned the boosters to the body. Fifteen flights, each finding something the previous one masked, until a rebuild in which every number is a measurement and a four-lens adversarial panel signed the diff. Flight 10 is the one worth reading: it caught a process defect rather than a code one, and it now has a build gate. The verification history below is the honest record and is the most useful thing on this PR.

What landed

Phase 0 — re-survey before touching anything. Two rows of VESSEL_CONSTRUCTION.md §4 were wrong about liveness. urchan_shapekey_with_animations.fbx (7 clips → 9 vessels) and SparrowModel4.fbx (the Missile Launch states) have zero prefab references and are load-bearing — an AnimatorController references clips by the model's guid, so a sweep measured against prefabs and scenes cannot see them. Both were on the deletion list.

Phase 1 — salvage inventory. Every candidate checked for shapes, bones, takes, referrers, and geometry not present elsewhere, written down before anything moved. RhinoModel.fbx is held: it is the only copy of the Rhino's pre-subdivision wings.

Phase 2 — the Dolphin rig swap. dolphin_shapekey_with_animations.fbx carries a real four-element morph, the 28-bone armature RiptideAnimation names, and six exhaust nozzles the shipped hull scaled to 0.00095 units and never drew. The rig is the shipped hull, in the same place, so each gameplay volume is re-homed onto its bone with the world pose preserved and no collider is re-fitted.

Phase 3 — Rhino and Urchin, decided explicitly. Their rigs' element shapes are labelled but inert — one vertex, moved 0.0000 units. Challenged ("are we sure there are no rhino blend shapes on any model?") and re-verified across all 93 distinct FBX blobs in project history, after discovering this clone was shallow with 2 of 363 branches fetched. One blob ever, always empty. Decision: swap for the puppetry, leave the morph honestly absent.

Phase 4 — audits measure the thing, not its label. The morph audit compared shape names and would have reported the Rhino green after its swap. It now measures travel relative to the mesh's own diagonal and reports LABELLED BUT INERT.

Phase 5 — retire the cleared vestiges. 11.7 MB, zero dangling references.

Verification history — the part worth reading

Flight 1 — every asset check passed; the ship flew broken. The rig's .meta had externalObjects: {}, so it imported its own DCC materials — and accent.001, the material that should carry the DOMAIN colour, is EmissiveColor (1.0, 0.395, 0.0) at EmissiveFactor 10. Worse, submesh order is not a convention (Sparrow puts Domain at 1, the Manta family at 2, this rig's body at 1), so the default _domainMaterialSlot: 1 painted the team colour over the whole body — what that field's own tooltip forbids. Fixed by remapping the three roles and moving to the order-independent _domainReplacesMaterials.

Flight 2 — colours fixed, puppetry wrong. "Roll and pitch are mixed, pitch is inverted, the wings fold up when drifting", with the hypothesis that the models had different coordinate systems. Correct. Quaternion.Euler(...) on localRotation turns about the parent's axes — the ship's on root-parented art, a bone's on a rig. Measured, for a commanded pitch: jetT.l turned about (−0.541, 0.840, −0.043) (mostly yaw), winghold.l about (−1.000, 0, 0) — the ship's pitch axis exactly negated, which is the inverted pitch, because that bone rests at (77.08, 0, −180). Separately, parts re-parented onto the drift handle replayed a bone-captured rest under a foreign parent: 164.50° (wings) and 152.38° (engines) off, on drift entry, with no input. That is the fold.

Flight 3 — roll still wrong, engines never moved, drift re-aimed. The engines' "backward" and "default" offsets were the same vector, so half the drift clearance had never existed — and translating the constants faithfully preserved the omission. A degenerate constant pair is not a design.

Flight 4 — the drift was backwards, and that one was mine. A drift splits the ship: the fuselage and jaws aim, while the wings and engines hold Course so the hull reads as slewing across its own path. I had read the clearance statement as the whole intent and deleted the Course re-parenting as dead machinery. It wasn't dead — it looked inert only because the frames were still wrong. Restored as a frame rather than a re-parent, which is strictly better on a rig.

Flight 5 — roll is the HULL, not the wings. Reported inverted, negated, reported still swapped. A pure sign flip is one of the few edits whose effect is certain, so a symptom surviving one is evidence about where the defect is. It is VesselTransformer.Roll() — byte-identical to bleeding-edge, shared by six vessels, and untouched by this branch. My negation was reverted. Separately the wings bank against the hull in a way the math says is impossible (their delta is provably identical to the chassis's), so that is now an authored parameter rather than a fourth guess.

Flight 6 — "the best yet" on base puppetry; the drift asked to be redesigned, not restored. Three asks: jets further back at rest, appendages holding Course orientation during a drift (not frozen at entry), wings forward / jets back. The frozen-at-entry frame became a stateless one — the hull re-aimed onto Course by the shortest arc, rebuilt every frame (§4.6.2: a frame that must hold still cannot be a Transform parented under the thing that moves; the parented handle accumulated 24.6° of twist). The wings' own pitch is Brake(throttle) — zero unless braking — so their whole pitch response was the chassis term the old hierarchy delivered free; it is composed in explicitly, bit-identical to the legacy hierarchy at one ULP (§4.6.1).

Flight 7 — the offsets did not read, then read 100× too big. The positional layer ran only on the puppetry path (Idle() writes rotations only — §4.6.3), and the offsets were absolute units whose scale neither model states. Rebasing them on the occlusion corridor's hull measure imported the very ambiguity it fled — that helper measures through the root bone, exactly where the disputed armature factor lives — and threw the parts off the ship (§4.6.4).

Flight 8 — PASSED: the measured rebuild. "This is close to perfect. with only 2 minor adjustments." Instead of an eighth guess, a four-agent hunt measured the ground truth (§4.6.5). The import model is now PINNED — fitted against the colliders the swap itself preserved (residual 3e-5): Unity's import is a 0.01·mirror-X conjugation; the bones keep lossyScale 100 while their world poses land exactly on the hull. Both prior readings were half-right, and everything re-derives from it:

  • Every number is a measurement. driftWingForward 2.2 = the proven bleeding-edge lunge (its own constants, mapped through the rig's rest — the reach-fraction scheme's |f|≤1 clamp on a 1.96 basis could not express it at any authoring). jetRestBackward 0 — the "jets pushed forward" report inverted on measurement: the old runtime dragged its engine pivots from authored z −2.047 to −1.7 every frame; the rig, which retires that drag, already rests its drawn nozzles 0.40 behind the old screen. driftJetBackward 0.5 is the one ask rather than parity (bleeding-edge's "backward" and "default" engine constants were the same vector — its engines never moved).
  • The cage holds ONE frame — Course — for positions and orientations (the legacy DriftHandle re-parent did both; holding only orientation swept the cage sideways with the aiming hull), and the pose is written exactly in cage coordinates with the entry pose adopted and blended to station: a lerp toward a hull-independent target trails a 110°/s aim by ~53°/1.7 wu of visible drag — the standing "the wings and jets still appear to move as I am drifting and aiming".
  • Guards the adversarial panel demanded: the cage is slew-limited to 360°/s (FromToRotation's antipode churn is 68°/frame at 3° off a reverse aim, and the exact write removed the lerp that used to hide it); the rest anchor is a capture-time constant (a live read applies the chassis term twice on legacy art — 27.8°); updateWhenOffscreen on the skinned renderer (rest-pose culling bounds vs a 2.2 wu lunge); the entry blend is exponential (legacy feel); and the three clearance fields are serialized into the prefab deliberately.

Flight 9 — PASSED ("everything is perfect. just move the flying position of the boosters back further.") — the two flight-8 feel asks (050b7eee2): the wings' and all six boosters' ROLL response is mirrored (roll negated everywhere it reaches those parts — own term and composed chassis term; the chassis keeps the true roll, the aileron cross-coupling and the drift cage's roll-follow are untouched), and the boosters' rest seat moves to exactly halfway to the drift station (0.25 + 0.25) with the station itself — called perfect — byte-identical at 0.50 and pinned by the verifier as a balanced pair — that pin turned out to be the mistake flight 10 uncovered; it is retired below. Deliberate deltas from bleeding-edge on record in §4.6.5: the deeper engine rest, engines moving on a drift at all, and the mirrored appendage roll. (Flight 15 revisits the boosters' half of that mirror — direction preserved, see below.)

Flight 10 — "oddly this seemed to do nothing", and it was exactly right (f38a4e777). The code was never at fault: a tuning step below the threshold of visibility is indistinguishable from a change that never applied. Flights 9 and 10 each moved the booster seat 0.125 wu — 3.6% of the 3.45 hull, a handful of pixels at chase distance — so two playtests bought no information. The cause was a constraint this branch imposed on itself: an earlier pass PINNED the drift station at 0.50 off one flight's sign-off, so every later "further back" had to be paid out of the shrinking gap to that ceiling and halved its own step.

Fixed by measuring the ship instead of guessing. From the rig's own skin clusters (vessel frame): nozzles sculpt at z −2.290…−1.887, fuselage tail at −2.471 — so at any seat under 0.584 the boosters sit tucked alongside the tail, which is precisely the report. Shipped jetRestBackward 0.375 → 0.6 (clears the tail; the cluster reads as engines behind the body), driftJetBackward 0.125 → 0.25 (the drift SLIDE restored to a visible 7.3%), station free to follow at 0.85. Pin the motion, not the absolute station. The verifier's station pin is retired for a 5%-of-hull visibility floor — on the seat, on the slide, and on any step away from a value a playtest could not see — so a sub-threshold tune fails the build rather than costing a flight.

Flight 11 — "the wings don't travel far enough forward to get clearance while drifting, and the boosters still return to a position far to forward" (55afcd933). Both answered geometrically. The wing lunge stops being old-game parity and becomes a clearance: the aiming hull's jaw tip sweeps a 2.835-radius sphere about the vessel origin (max |r| over the jaw skin verts), and at 2.2 the wings' nearest vertex sat at |r| 1.76 — inside it, exactly as reported (the old game interpenetrated too; parity preserved the defect). Shipped driftWingForward 2.2 → 3.5, rounding up the bisected L* = 3.492 — the smallest lunge putting every wing vertex outside that sweep ×1.05 gape margin, so the jaws cannot reach the wings at any drift aim angle (within the ±4 clamp; binding vert the wing root at (−0.760, 0, −0.614)). The booster seat goes bold after three escalating asks: jetRestBackward 0.6 → 1.0 — 0.6 cleared the tail by a sub-visible 0.017; 1.0 lands the nozzles' leading edge 0.42 wu behind the tail (29% of hull, a 0.4 step, both far above the floor). driftJetBackward stays 0.25. The verifier gains a geometric clearance assertion in check 8 (binding vert vs jaw sweep), so an under-clearing retune fails the build.

Flight 12 — "everything is perfect now (even the wings and the drifting position of the boosters) except the home position of the boosters ... they continue to rest near the origin of the fuselage" (50d568fab). Drift is now locked in as correct. The rest seat had been called too far forward across three flights (10, 11's ask, 12) against three successively deeper, individually-measured seats (0.6, then 1.0) — each one clear of the 5%-of-hull visibility floor (flight 10's actual defect) but still not enough, which is a different failure than flight 10's. Two changes: decoupleBackwardThrusterOffset used to read jetRestBackward + driftJetBackward, so every rest-seat bump silently moved the drift clearance too; flight 11's 0.6 → 1.0 bump happened to land the drift total at 1.25, and flight 12 confirms that reads as perfect, so a further rest bump risked dragging a value nobody had asked to move. driftJetBackwardTotal is now its own field, locked at 1.25, with no path back to jetRestBackward. The seat was then pushed to 1.8 on the theory that three above-floor increases surviving the same verdict meant "not enough" — flight 13 reversed that; the decoupling stands and is what made the reversal a one-field change.

Flight 13 — "I had to look at the scene view to realize the boosters were far more back than I realized. it is just getting puppeteer[ed] in a way that made me perceive them as much closer" (62677def8). The seat was applying all along, and no seat could ever have fixed the report: the Dolphin's chase camera is on-axis and level (followOffset (0, 0, −20)), so a part's station along the hull projects to almost nothing on screen — the surviving depth cues are SIZE and OCCLUSION, and both invert the intent. A booster seated 1.8 aft sits 3.7 wu nearer the lens than the wings, renders ~24% larger, and draws over the hull wherever silhouettes overlap — occlusion, the strongest monocular cue, says "in front of the wings". Every seat increase made the in-game read worse-or-same while the true geometry marched backwards, which is the loop flights 10→12 lived in. On top of that, the puppetry flailed each case up to 100° at full stick (chassis 25° composed with the shared exaggerated 75°), sweeping six enlarged, hull-occluding silhouettes across the wing region. The fix tunes the read, not the depth: jetRestBackward 1.8 → 1.0 (the true-geometry value — nozzle leading edge 0.42 wu behind the tail, judged in the scene view where depth is honest) and a new thrusterAnimationScaler = 25 for the boosters' own puppetry term (full-stick deflection halves to 50°; the wings keep their signed-off terms). The verifier gains a swing-envelope assertion: the most-forward z any of the 7,500 jet-cluster skin verts reaches over the full input cube, rotated about the six measured bone pivots, must stay behind the fuselage tail plane at the shipped seat + amplitude (margin 0.244 wu; the retired 75° term at the 0.6 seat crossed by 0.39). General rule: on an on-axis chase camera, moving a part aft moves it toward the lens — answer a "too far forward" report by tuning silhouette separation and motion amplitude, never by more depth.

Flight 14 — "less separation between boosters and fuselage … when turning the boosters should stay more pinned to the fuselage puppeteering. it should still separate, just closer to pinned than now" (4b7cdbac5). Read off the code rather than assumed: Chassis resolves to the fuse bone — the fuselage itself — and turns Euler(p·25, y·25, r·25), while the boosters compose appendageChassisTurn = Euler(p·25, y·25, −r·25) before their own term. So on pitch and yaw the boosters' chassis component tracks the fuselage exactly and thrusterAnimationScaler is the whole of what separates them from it — one number, with 0 meaning literally pinned. Shipped 25 → 12: measured peak swing of a booster off a perfectly-pinned one (over the input cube, at the farthest jet vertex, 0.444 wu from its pivot) 75 → 0.847 wu, 25 → 0.345, 12 → 0.165, 0 → 0, and relative-to-fuselage angle on pitch/yaw 35.2° → 17.0° — half the swing, still legibly separating. The flight-13 envelope invariant strengthens with it (margin 0.24 → 0.38 wu behind the tail plane). On ROLL a 50° floor survives own = 0 and this dial cannot reach it: the appendage chassis term mirrors roll (the signed-off legacy-parity ask) while the fuselage keeps true roll, so roll separation only falls 86.6° → 65.3° — recorded as a named constant in the verifier so a future "still too loose when I roll" goes to the mirror, not to another amplitude cut that cannot reach it. Check 8 gains a separation assertion (shipped swing strictly smaller than what it replaced and strictly non-zero — "still separates" is the ask). General rule: when one term is shared with the body a part is measured against, it is not separation — separation is only what the part adds on top, so identify which terms cancel before tuning any of them.

Flight 15 — "the boosters need to remain much closer to the back of the fuselage" (903017d0a). Third ask in the same direction, and flight 14's own measurement said the dial could not deliver it: at thrusterAnimationScaler = 0 a pure roll still separated a booster from the body by 50°, because the boosters took appendageChassisTurn — whose roll is mirrored — while the fuselage keeps true roll, so on that axis the two added where on pitch and yaw they cancel. The boosters now compose a chassis term carrying the body's true roll (thrusterChassisTurn) and keep the mirror in their own term only, so the chassis component cancels on all three axes and the dial bounds the whole separation; thrusterAnimationScaler 12 → 5 on top of that. Measured over the input cube: worst-case relative-to-fuselage angle 65.3° → 8.8° (7.4×), peak swing off a perfectly-pinned booster 0.165 → 0.068 wu, flight-13 envelope margin 0.375 → 0.409 wu. The signed-off flight-9 mirror is preserved, and by sign rather than by argument: relative roll is −5.00°/+5.00° at full stick where it was −62.00°/+62.00° — same direction, smaller magnitude — and the verifier now asserts that sign, so a construction that cancelled the own term's mirror too would fail rather than quietly lose the ask. The wings are untouched (nobody has reported them; flight 12 called them perfect). General rule: a term shared with the body cancels on the axes where it matches it and DOUBLES on any axis where it is negated — mirror what a part CONTRIBUTES, never what it INHERITS.

Verification status

  • Editor-verified: Audit Vessel Elemental Morphs reports the Dolphin at all four elements — Mass 12.056%, Charge 4.314%, Space 3.140%, Time 13.538% of the hull diagonal. Fleet line: 9/12 vessels ship element shapes that move the hull (shape keys; the merged auditor now counts procedural morphs alongside them — see the merge note).
  • Asset-verified against the prefab YAML, differentially: six sub-pixel Engine GameObjects removed and nothing else · MeshRenderer 18→1, MeshFilter 19→2 · 11 Box + 1 Sphere colliders unchanged · 0 dangling fileIDs introduced · §3 reachability clean · Dolphin absent from the §3.4 duplicate-hull list, proving the old renderers were removed rather than merely disabled.
  • Compile-verified: Roslyn, stubs transcribed from real declarations and proven by negative controls that fail correctly. It caught an invented type name (SwapSpec; it is RigSwap). Note unity-ci.yml's unity job is gated on an unset UNITY_RUNNER_LABEL, so nothing in CI compiles C# on any PR here.
  • Math-verified: Tools/Build/verify_vessel_rig_puppetry_frames.py — now 13 checks against the rig's measured bones: the two original invariants (ship-aligned part bit-identical, 0.000000000°; zero-input drift entry moves nothing, 0.00°), plus the world-unit round trip exact at chain scale 1 and 100, the cage bit-still under single-axis aim with the vessel-frame control reproducing the 2.5 wu sweep defect, the 30° roll-following orbit at radius drift 2e-16, legacy-art single chassis application (1.1e-16 vs the 27.8° live-anchor control), the antipode guard + slew limit (raw 20°/step vs shipped 6.00), the lag control (a lerped cage trails a full-rate aim by 53°; the exact write, 0), and the visibility floor — 5% of hull on the booster seat, on the drift slide, and on any step away from a value a playtest could not see, plus the measured tail-clearance anchor — the wing clearance assertion (the binding wing vert at the shipped lunge must sit outside the jaw-tip sweep ×1.05) — the swing envelope, the separation assertion, and flight 15's floor-removed + mirror-direction assertions — and, since flight 12 decoupled them, an independent tail-clearance + visibility-floor check on the drift total and the rest seat.
  • Measurement-verified: the import model was fitted against the twelve colliders the swap preserved (full-matrix residual 3.4e-5–5.8e-5; five independent negative controls, including the old prefab's −2.047034 reproduced to six decimals and the rig's jaw centroids equal to the old nose geometry to four). The old game's on-screen part positions were reconstructed from bleeding-edge's own constants — the drift numbers are that record, not feel values.
  • Panel-verified: a four-lens adversarial review (frame math / lifecycle / assets / simulated playtest) ran over the final diff; its one blocker (the antipode churn band, exact-written) and every risk it raised are fixed in 2db1a87ed.
  • Flight-verified: flights 8 through 15 ("close to perfect""everything is perfect""even the wings and the drifting position of the boosters" now perfect) — base puppetry, the mirrored roll, the wing lunge, and the drift cage all read on screen and are signed off. The one open item is the read tune — flight 13's true-geometry seat, the booster flail cut twice more, and flight 15's removal of the 50° roll floor (903017d0a) — awaiting its flight. All four dials are live inspector fields on Dolphin.prefab (RiptideAnimation.driftWingForward / jetRestBackward / driftJetBackwardTotal / thrusterAnimationScaler), draggable in play mode.

⚠️ This branch is no longer editor-only. The playtest fixes added runtime changes under Assets/_Scripts/Controller/Animation/, so it now ships code.

What a reviewer should know

A rig swap is three jobs — geometry, materials, animation — and passing the first says nothing about the other two, because both of those only fail on screen (VESSEL_CONSTRUCTION.md §4.6). VesselRigSwapper now refuses a rig whose hull wears none of the fleet's materials; the Rhino and Urchin rigs have the same empty externalObjects and would have reproduced flight 1 exactly.

Guid ownership. An FBX's .meta can carry a material remap into another FBX, so grep -rl | head -1 returns a plausible false positive — it had put two passes of Rhino jets on a placeholder hull, and CLAUDE.md itself repeated the wrong answer. Resolve with grep -c "^guid: $g" per candidate.

A magnitude is only comparable to numbers from the same tool. The in-editor audit and the offline mirror report the same four shapes 1.283× apart, identical to four digits — the denominator, not a discrepancy: mesh.bounds on a skinned mesh is the culling volume in root-bone space. Do not reconcile them (§4.5).

Also fixed: measure_vessel_prefabs.py read the model only from m_Mesh, which a stripped renderer does not carry — it reported the swapped Dolphin as UNRESOLVED and had been blind to Scarab, Serpent, Sparrow and Squirrel all along.

Merge note

Conflicted with #801 (skyburst missile tail) on two adjacent CLAUDE.md index rows. Each side changed exactly one row and left the other at the merge base, so the resolution is disjoint and lossless: ours for VESSEL_CONSTRUCTION.md, theirs for VESSEL_TAIL_AND_JETS.md. Both verified byte-identical to the side they came from.

Second merge (a2f492665), and this one was substantive. bleeding-edge absorbed the Scarab pass (#821), which improved the same auditor for the same reason from a different direction — so the resolution is a union, not a pick:

  • ours measures magnitude — a labelled shape travelling less than the measured threshold (real shapes move 2.46%–17.94% of their mesh diagonal, fake ones 0.0000%) is reported INERT rather than counted;
  • theirs handles procedural sources (IProceduralElementMorphSource — the Scarab re-blends baked geometry deltas) and shapes under a hidden legacy model root, which draw nothing.

A shape now counts only if it moves the hull AND is actually drawn; the two inert reasons are disjoint and both apply, and procedural coverage contributes elements on top. Reporting keeps ours' per-shape travel verdict and theirs' distinct "hidden legacy model — never drawn" line, so the two failure modes stay tellable apart. Docs resolved the same way: CLAUDE.md keeps our Dolphin fleet line (theirs predates the swap and still calls the Dolphin shape-less) and gains their Scarab paragraph; the verification checklist keeps both new entries; VESSEL_CONSTRUCTION.md takes their Scarab row and keeps ours' Serpent row, whose dagger anchors a footnote below it.

The merged auditor was compiled against transcribed stubs with two negative controls — a typo in the merged path, and a wrong member name on the procedural interface (8 and 4 errors, restore clean) — since a merge resolution of a C# file that nothing in CI compiles is exactly where a silent syntax error would land.

🤖 Generated with Claude Code

https://claude.ai/code/session_01HVDmUarLeSWQa8kRBnubqw

Claude and others added 16 commits August 26, 2026 00:00
Re-runs the per-model and per-prefab survey VESSEL_CONSTRUCTION.md §1/§4 was
measured from, and diffs it before anything is touched.

Confirmed unchanged: §1's twelve-row renderer table exactly; §3's invariant
(zero unreachable nested instances); §3.4's duplicate Manta-family renderers;
§4's central finding (dolphin shapes real, rhino/urchan shapes empty at one
indexed vertex and delta 0); §2's guid ownership.

Corrected, and this is what re-derives the plan:

- urchan_shapekey_with_animations.fbx is NOT a vestige. It has zero prefab
  references and supplies SEVEN LIVE ANIMATION CLIPS to nine vessels, through
  four AnimatorControllers. A model can be referenced by no prefab and still be
  load-bearing, because a controller references clips by the model's guid.
- SparrowModel4.fbx is a ninth model §4 never listed, in the same position: it
  supplies the Missile Launch states to Sparrow and Scarab.
- The mirror image: Assets/_Animations/MantaAnimatorController.controller is
  referenced by nothing. The live one is _Models/Animations/. A reference count
  is not a liveness measurement until each referrer is itself resolved.

New evidence, added as §4.3/§4.4:

- The Dolphin rig IS the shipped hull, with NO offset — bounding boxes agree on
  all six faces to three decimals, 8,311 of 12,583 vertices within 5.5e-5. Do
  not generalise the Rhino's 1.5545 to it.
- The rig also carries 4,284 vertices the shipped hull does not: the six engine
  EXHAUST NOZZLES, which Dolphin_Test ships at localScale 0.01 — 0.00095 units
  in a 5.3-unit ship, so never drawn. Rendered and confirmed by eye. They are
  exactly where the six jets mount.
- A rig's REST POSE is not the shipped pose. Rhino: fuselage is a rigid +1.5550
  (all 29 lathe rings) but the wings are posed 1.38x wider. Urchin: a uniform
  2.105x SCALE (2,500/2,500 match after rescaling, median residual 0.04%).

Tooling: measure_vessel_models.py and measure_vessel_prefabs.py reproduce all of
it; fbx_binary.py gains 64-bit (FBX 7500+) READ support so serpent.fbx can be
measured instead of assumed — writing 7700 is refused loudly rather than
emitting a 32-bit body under a 64-bit version stamp.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVDmUarLeSWQa8kRBnubqw
… copy of

Answers, per file, the question the governing rule asks: what is the only copy
of something here? Nothing is deleted.

Split three ways rather than the original two, because Phase 0 found the third:

1a NOT vestiges (live, do not touch): urchan_shapekey_with_animations (7 clips
   on 9 vessels), SparrowModel4 (the Missile Launch states on Sparrow+Scarab),
   Vessel_Wedge_Scene (4) (Grizzly, plus TimeDandruff and ExplodableProjectile).

1b Carries something unique: the dolphin rig (a real morph, the 28-bone
   armature, 10 takes, and the six exhaust nozzles the shipped hull scales to
   0.00095 units); the rhino rig (armature + 9 takes, shapes empty); RhinoModel
   (nothing of its own — it is Rhino_Test before the wings were subdivided — but
   BOTH placeholder .metas remap materials into it).

1c Cleared for Phase 5: Riptide (the Dolphin's low-poly ancestor, and where the
   name RiptideAnimation comes from), Dolphin_split and Hammerhead_split (137
   and 116-vert fauna blockouts filed under Vessel Models), both placeholders,
   and Assets/_Animations/MantaAnimatorController (a duplicate of the live
   controller, referenced by nothing).

Vessel_Placeholder_1's 288 takes are not 288 animations: 288 stacks x 288 layers
x 864 curve-nodes is ~3 TRS channels each, the one-node-per-take artefact of a
Sketchfab/Blender import. Both placeholders are third-party downloads, which is
a licensing question flagged for Garrett rather than a cleanup decision.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVDmUarLeSWQa8kRBnubqw
…umbers

VesselRigSwapPlanner only ever reported, and its mapping table was authored by
hand. This adds the writer and re-measures the table against the rig.

Two of the brief's premises changed, both in the easier direction:

- The rig is the shipped hull IN THE SAME PLACE (bounds agree on all six faces
  to three decimals), so NO collider is re-fitted — each gameplay volume is
  re-homed onto its bone with the world pose preserved. That does not carry to
  the Rhino (offset 1.5545 in z, wings re-posed) or the Urchin (uniform 2.105x),
  which is why only the Dolphin is enabled.
- The jets do NOT belong on jetT/jetm/jetB. The skin weights settle it: those
  bones drive 538 verts each — an engine CASE — while jetint/jetinm/jetinb drive
  712 each, which is exactly the six inner meshes the shipped hull carries at
  localScale 0.01 and therefore never draws. The rig restores them as real
  exhaust bells. Each jet is parked at the mouth centre measured off its own
  bone's rearmost lip band.

The tool dry-runs, is idempotent, refuses and writes nothing if a single mapped
bone or object is missing, records to the ledger and draws the ship panel.

Verification status is honest: this session had no Unity CLI, so the tool is
type-checked against transcribed stubs and nothing more. The type check was
proven to FAIL on a wrong member, an undeclared identifier and a wrong arity
before being trusted. The branch carries the TOOL, not a swapped prefab; the
run-and-verify steps are filed in Docs/UNITY_VERIFICATION_CHECKLIST.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVDmUarLeSWQa8kRBnubqw
DECISIONS (Garrett, 2026-08-26), recorded rather than taken silently:
  Rhino  — swap for the PUPPETRY, morph honestly absent.
  Urchin — swap at 1/2.105 to PRESERVE THE SHIPPED SIZE.

The empty shapes were challenged and re-verified exhaustively, and the first
sweep would not have answered it: this clone is SHALLOW with 2 of 363 remote
branches fetched, so "nothing found" was the confident-wrong answer waiting to
happen. After fetching every branch blob-filtered, across 93 distinct FBX blobs
in the project's whole history: rhino_shapekey_with_animations.fbx has exactly
ONE blob ever, its four element shapes are 1 indexed vertex at delta 0.0000, and
no other Rhino-named model has ever carried a blend shape. The Rhino has never
had a hull morph anywhere.

Measurement removed most of what this phase was expected to cost. Each rig
instance is placed at a FITTED transform that lands its hull on the shipped hull
— Rhino z -1.5545 (2000/2000 fuselage verts, median residual 0.00019), Urchin
localScale 0.474905 (2000/2000, median 0.00010 in a 0.43-unit hull) — so every
collider and FX mount keeps its world position. The eight Rhino body-jet mounts
and five BoxColliders the brief said must be re-fitted do not move at all.

The Rhino bone map is verified rather than inherited: each legacy collider
contains 100% of its mapped bone's skinned geometry, Wing back L/R -> wing2.l/r
included.

New rule, learned by nearly mis-reporting the Urchin: an overlap score is
meaningless when the baseline overlap is already ~0 — run the control against the
SHIPPED asset before reading a low score as a regression. The Urchin's colliders
bound the shipped hull just as poorly as the rig (3.58% vs 3.54%), so they were
already loose and the swap is collider-neutral. Two weaker discriminators
(nearest centroid, nearest skinned vertex) both collapse onto the body bone on a
radially symmetric hull and flagged correct mappings as wrong.

Accepted cost, stated: the Rhino rig's bind pose has the wings 1.38x wider, so
its resting silhouette changes. The morph audit must KEEP reporting both vessels
un-morphed — Phase 4 is what makes that honest instead of a false green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVDmUarLeSWQa8kRBnubqw
The four checks whose absence let each VESSEL_CONSTRUCTION.md discrepancy
through. All asset-only. Together they are what stops the Rhino/Urchin swaps
from reporting a false green.

1. Audit Vessel Elemental Morphs now measures shape MAGNITUDE. Travel() reads
   each shape's authored extreme frame and reports its farthest vertex travel as
   a fraction of the mesh's own bounding-box diagonal; below threshold it says
   INERT, and a vessel with only inert shapes reads "LABELLED BUT INERT — the
   hull morphs by NOTHING".

   The threshold is RELATIVE because an absolute epsilon is not enough: a
   historical Sparrow Missile carried Charge and Time shapes indexing 243 and 309
   vertices and moving them 4e-6 units — non-zero, and 0.00004% of the model.
   Measured over every shipped vessel model the populations separate with nothing
   between them: real shapes 2.46%..17.94% of the diagonal, inert ones 0.0000%.
   0.001 is picked from inside that measured gap, 24x below the smallest real
   shape.

   Watched failing before being trusted: on the same data Rhino and Urchin now
   report LABELLED BUT INERT where the old audit said "all four elements", and
   the Dolphin reports mass 15.471% / time 17.372%.

2-4. New VesselConstructionAuditor:
   - guid ownership, by the ^guid: line of each candidate's OWN .meta (currently
     every vessel model is owned by exactly its own);
   - nested-instance reachability for plain-Transform parents, with the
     stripped-parent case exempt and counted separately (currently zero
     unreachable, which is the invariant). Reads YAML on purpose: a loaded prefab
     presents the merged hierarchy, so the defect is invisible there;
   - duplicate coincident hull renderers (currently Manta/Falcon/Shrike/Termite,
     one 3,351-vertex hull from two files each) — reported, not deleted, because
     §3.4 is still an open question.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVDmUarLeSWQa8kRBnubqw
Only what Phase 1 cleared, in its own commit so a revert of this is not a revert
of the swapper or the audits, and with a FRESH reference check immediately before
each deletion rather than trusting the Phase 1 table.

Retired: Riptide.fbx (the Dolphin's low-poly ancestor, and where the class name
RiptideAnimation comes from — recorded in the doc so the name keeps its
explanation), Dolphin_split.fbx and Hammerhead_split.fbx (137- and 116-vert fauna
blockouts filed under Vessel Models), both Vessel_Placeholder FBXs, their CC
attribution file, the now-empty Placeholder/ folder, and
Assets/_Animations/MantaAnimatorController.controller (a duplicate of the live
_Models/Animations/ one, referenced by nothing).

60.0 -> 48.2 MB of vessel models, 20 -> 15 FBX. Every retired guid was swept
across Assets afterwards: ZERO dangling references created.

Held deliberately, with reasons in the doc: RhinoModel.fbx, which becomes
unreferenced only as a consequence of this commit and is the only copy of the
Rhino's pre-subdivision front wings (245 verts against the shipped 476);
urchan_shapekey_with_animations.fbx and SparrowModel4.fbx, which are not vestiges
at all and supply live animation clips to nine and two vessels.

Also corrects Phase 1's licensing flag. It escalated the two placeholders as an
open question "for Garrett, not a cleanup decision" — and the answer was in their
own folder: CC_Attribution_For_Placeholder_Models.txt names both as CC-BY 4.0
Sketchfab models by VertaScan, properly attributed. No liability, only an
attribution obligation that was being met. Look in the folder before escalating a
licensing question about its contents.

Audits before/after are identical on every check (renderer table, reachability
0 unreachable, the four duplicate hull draws, 8/12 morphing, guid ownership,
conditional-compilation OK) — which is what a purely subtractive commit should
produce.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVDmUarLeSWQa8kRBnubqw
CLAUDE.md's rigged-model-swap section said "Rhino wires Vessel_Placeholder_1.fbx
(a literal placeholder)". That is the project's canonical document repeating the
exact false positive VESSEL_CONSTRUCTION.md §2 exists to warn about: the Rhino
wires Rhino_Test.fbx, whose own .meta declares guid 4a58…, and the placeholder
merely remapped its materials into it.

Also refreshes the VESSEL_CONSTRUCTION.md index row, which described state this
branch changed: "referenced by nothing" is measured against prefabs and misses
animator CLIP references (two live models were nearly retired that way); the
morph audit now measures magnitude rather than labels; the Dolphin rig is the
shipped hull with NO offset plus six undrawn exhaust nozzles; and a rig's rest
pose is not the shipped pose, which is why each instance gets a fitted transform
instead of re-measuring every mount.

Kept the placeholder example in §2 as written — the mechanism is what matters and
it is unchanged — with a pointer to the commit that still has the files.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVDmUarLeSWQa8kRBnubqw
…alse

Found by the /ship-deep D2 blast-radius and D6 doc-drift sweeps, not by reading.

D2: VesselRigSwapPlanner's own doc comment still said "Rhino wires
Vessel_Placeholder_1" — the same guid trap corrected in CLAUDE.md last commit,
and now naming a file the branch deleted. The tool a future session opens FIRST
was the one still carrying it.

D6, in CLAUDE.md's rigged-model-swap section:
  - "Their *_shapekey_with_animations rigs are one skinned mesh on an armature
    PLUS the four element shapes" — false for two of the three, and it is the
    exact claim this branch spent Phase 0 and Phase 3 disproving.
  - "The prefab half is a hands-on editor pass, not an automated one ... collider
    volumes must be re-fitted by eye" — the branch shipped the writer, and the
    fitted instance transform means no collider is re-fitted at all.

D6, in the /vessel skill:
  - CONTRACT.md said a rig swap forces every mount to be RE-MEASURED. It does not
    if you fit the instance transform, which is the cheaper and more exact move.
  - CONTRACT.md said the morph audit "reports presence, not magnitude ... treat a
    green audit as necessary, not sufficient, UNTIL it measures deltas". That
    until-condition is now met; the note would otherwise have kept telling readers
    to distrust a gate that works.
  - SKILL.md's auditor list gains Audit Vessel Construction and Swap Vessel Rig.

D1 also found one real hazard and guarded it: every vessel model imports with
isReadable 0, and if GetBlendShapeFrameVertices ever returned zeros rather than
throwing, a REAL morph would report INERT — indistinguishable from an empty
shape. It is now wrapped so a throw becomes "CANNOT MEASURE" and is reported,
never silently converted into a verdict.

Skill capture (/ship §3.5), both into asset-surgery's trap list:
  - "referenced by nothing" is measured against whatever you grepped, and an
    AnimatorController references clips by the model's guid — resolve liveness in
    two steps, and resolve each referring controller to the prefabs that use IT.
  - an overlap score is meaningless when the baseline overlap is already ~0; run
    the control against the shipped asset. Plus its companion, that a weak
    discriminator collapses onto the dominant element.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVDmUarLeSWQa8kRBnubqw
The /ship-deep §2.5 tool-output gate resolved to HOLD rather than merge
tool-only. Recording that in the checklist rather than only in chat, so the
blocker and its unblocking condition live in the repo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVDmUarLeSWQa8kRBnubqw
The swap ran and the morph audit reported green. This verifies that claim
against the asset rather than against the tool's own log, differentially
versus the pre-swap revision, and corrects every document that still
described the Dolphin as un-swapped.

Verified on the pushed prefab:
- 31 -> 25 GameObjects: exactly the six `Engine Left/Right.N` sub-pixel
  nozzles, nothing else
- MeshRenderer 18 -> 1, MeshFilter 19 -> 2 (legacy hull art stripped; the
  remainder is the skimmer sphere + crackle overlay, both built-in meshes)
- 11 Box + 1 Sphere colliders UNCHANGED - the fitted identity transform
  meant nothing needed re-fitting, as measured
- rig sourced from the guid OWNED by dolphin_shapekey_with_animations.fbx
  (resolved by declaration count), parented under OrientationHandle at
  identity; six jets on jetint/jetinm/jetinb x .l/.r, all six names present
  among the rig's 30 Model nodes
- RiptideAnimation's 13 Transform fields cleared to bind by name; its
  SkinnedMeshRenderer bound; _shipGeometries 11 -> 1
- newly-serialized fields are exactly the C# initializers, not invented
- 0 dangling fileIDs introduced (one pre-existing dangle cleared); missing
  script set identical before and after; §3 reachability clean
- Dolphin ABSENT from the §3.4 duplicate-hull list: the old renderers were
  removed, not merely disabled. A swap that only disables them passes every
  functional check and silently doubles the vessel's draw calls.

measure_vessel_prefabs.py: read the model through a nested prefab
instance's m_SourcePrefab, and never report Unity's built-in-resources guid
as a model. A stripped SkinnedMeshRenderer carries no m_Mesh, so the survey
reported the swapped Dolphin as UNRESOLVED - and had been silently blind to
Scarab, Serpent, Sparrow and Squirrel for the same reason all along. Phase 0
re-runs on a tool that cannot see the skinned family are worth little.

VESSEL_CONSTRUCTION.md §4.5 (new): the in-editor audit and the offline mirror
report the same four Dolphin shapes 1.283x apart - identical to four digits
across all four, so it is the DENOMINATOR. mesh.bounds on a skinned mesh is
the culling volume in root-bone space, not the raw vertex AABB; the same trap
the occlusion corridor already records. Neither is wrong, the verdict is
unaffected (the threshold gap is four orders of magnitude wide), and the two
must not be reconciled - compare a magnitude only against its own tool.

The Dolphin is the only vessel that has ever changed construction families;
§1's table, its family paragraphs, and CLAUDE.md's three claims are updated
to the measured post-swap state. The /ship-deep §2.5 PR hold is released;
flying it remains open and human-only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVDmUarLeSWQa8kRBnubqw
…l-construction-unify-hkleam

Resolves the CLAUDE.md index-row conflict with #801 (skyburst missile tail).

The two sides touched adjacent rows and each left the other at the merge
base, so the resolution is disjoint and lossless:

- `VESSEL_CONSTRUCTION.md` row - only this branch changed it; kept ours
  (the Phase 0-5 rewrite: the Dolphin leaving the part-per-mesh family, the
  labelled-but-inert finding, the same-tool magnitude rule).
- `VESSEL_TAIL_AND_JETS.md` row - only bleeding-edge changed it; kept
  theirs (a TAIL is defined by its PURPOSE, not its owner - the skyburst
  missile carries one).

Both rows verified byte-identical to the side they came from.
`Docs/VESSEL_TAIL_AND_JETS.md` itself auto-merged; this branch's placeholder
correction and #801's missile section are both present.

Re-validated against the new base: conditional-compilation OK (1767 files),
all 8 retired assets still referenced by nothing, the prefab survey still
resolves the swapped Dolphin, and the FBX codec regression
(subdivide_sparrow_missile.py --check) still passes against #801's missile.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVDmUarLeSWQa8kRBnubqw
The checklist said the new editor tools were type-checked against stubs and
nothing more. That understated it, and the reason generalises.

unity-ci.yml's `unity` job is gated on `vars.UNITY_RUNNER_LABEL != ''`, which
is unset - so it is skipped on every PR in this repo and nothing in CI
compiles C#. But Unity compiles Assembly-CSharp-Editor as ONE unit, and there
is no .asmdef under Assets/_Scripts/Editor/, so all four editor files this
branch touches share that assembly. Garrett ran two of them: the brand-new
Swap Vessel Rig menu item, and the morph audit reporting magnitude
percentages only this branch's code can produce. A stale assembly could
contain neither. The assembly therefore compiled, and
VesselConstructionAuditor.cs - which nothing else has ever exercised -
compiled with it.

An assembly-wide compile is transitive evidence: a passing menu item vouches
for its whole assembly, not just its own file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVDmUarLeSWQa8kRBnubqw
Playtest found the swapped Dolphin puppeteering wrongly and wearing a yellow
left over from the Blender import. Both are defects in the swap, and both are
in things the swap did not touch rather than in what it moved: the geometry,
colliders, bones and morph it DID move all verified correct. Two independent
root causes.

1. MATERIALS. The fleet's convention is that a model's .meta REMAPS its DCC
   material names onto three project assets (Body/Domain/Window) via
   externalObjects - the Squirrel and Sparrow both do. The Dolphin rig's meta
   had `externalObjects: {}`, so it imported its own Blender materials, and
   `accent.001` - the material that should carry the DOMAIN colour - is
   EmissiveColor (1.0, 0.395, 0.0) at EmissiveFactor 10. That is the yellow:
   saturated orange, emissive 10x, on the accent.

   Worse, and invisible without measuring: submesh ORDER is not a convention.
   The rig emits [accent, BASE, windsheild, N], so the platform's default
   `_domainMaterialSlot: 1` painted the team colour onto BASE - the BODY -
   which VesselCustomization's own tooltip says never to do ("turns the whole
   hull one uniform material and erases the two-tone read"). Fixed by remapping
   the three roles AND switching the Dolphin to the order-independent path,
   `_domainReplacesMaterials: [GreenAccentVesselMaterial]`, which resolves the
   slot by material identity instead of by index.

2. REST POSITIONS. VesselAnimation grew rest-relative ROTATION when the first
   rig landed (RotatePartFromRest) and never grew the position half, so
   RiptideAnimation.AnimatePart still wrote `part.localPosition` absolutely.
   That assumes a part is a direct child of the model root. On a rig it is
   false twice over: a bone's local position is relative to its PARENT BONE,
   and in Blender's convention it is (0, boneLength, 0). Measured on this rig -
   every animated bone rests at (0, L, 0) with large rest rotations (jetT.l at
   -2, 172, 57) - so writing (0, .15, -1.7) detached each of the six engines
   and flung it 1.7 units along a DIFFERENT axis, and drove the chassis and
   wings off their parents entirely.

   Adds CaptureRestPositions / RestPositionOf / MovePartFromRest as the sibling
   of the rotation trio. Two halves, both load-bearing: the ANCHOR resolves
   through the part's HOME parent (so a part stays attached where it belongs),
   while the OFFSET resolves through its CURRENT parent - because these parts
   are re-parented onto a drift handle mid-flight and `parent =` preserves the
   world pose, so a lerp toward a rest captured under the other parent would
   teleport them, and the offset following the drift handle is the entire point
   of that re-parenting.

   Translating the constants surfaced that default and backward thruster
   positions were the SAME vector - the thrusters never had a positional
   animation at all, the constant only ever pinned them. The one real
   positional effect in this puppetry is the wings sliding forward on a drift,
   and it is now the only non-zero offset.

3. RECURRENCE. The Rhino and Urchin rigs have the same empty externalObjects,
   so running either swap would reproduce the colour bug exactly. VesselRigSwapper
   now REFUSES a swap whose rig wears none of the fleet's materials, naming the
   importer's Remapped Materials list. It checks rather than fixes: which
   material carries the domain is a decision, and one made silently is one
   nobody can review.

Verification: the changed runtime code compiles (Roslyn, stubs transcribed
from real declarations, proven by two negative controls that fail correctly);
the editor tool compiles with the existing harness - which caught an invented
type name; every Unity API used is confirmed against independent in-repo usage;
prefab and meta both parse, 0 dangling fileIDs introduced. NOT verified: the
ship in motion. That needs a flight.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVDmUarLeSWQa8kRBnubqw
…lew broken

The Dolphin swap passed every structural check on the branch and was still
unusable in flight. That is the finding worth keeping, so it is recorded where
the next rig swap will read it rather than buried in a fix commit.

- UNITY_VERIFICATION_CHECKLIST.md: the entry goes back to RED with a table of
  what the flight found, what caused it, and specifically WHY each static check
  was blind to it. DO NOT MERGE until it is re-flown.
- VESSEL_CONSTRUCTION.md §4.6 (new): a rig swap is three jobs - geometry,
  materials, animation - and passing the first says nothing about the other
  two, because both of those only fail on screen. Carries the measured
  cross-fleet submesh-order table showing that _domainMaterialSlot's default
  of 1 is a coincidence, not a contract.
- CLAUDE.md: the same, compressed, in the VESSEL_CONSTRUCTION index row.

§4.6 is deliberately filed next to §4.4 ("a rig's REST POSE is not the shipped
pose"), because the position bug IS §4.4 - the lesson had been applied to
rotation only, and the missing half sat latent until a rig actually bound to it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVDmUarLeSWQa8kRBnubqw
@gradies gradies changed the title Unify vessel construction: swap the Dolphin onto its rig, and make the audits measure magnitude Unify vessel construction: swap the Dolphin onto its rig, and make the audits measure magnitude [needs re-flight] Aug 26, 2026
@gradies
gradies marked this pull request as draft August 26, 2026 03:48
claude added 12 commits August 26, 2026 04:21
Second playtest: colours fixed, puppetry still wrong - "roll and pitch are
mixed, pitch is inverted, the wings fold up when drifting". That diagnosis was
right, and it is one root cause with two halves.

Quaternion.Euler(pitch, yaw, roll) assigned to localRotation turns about the
PARENT's axes. On part-per-mesh art every animated part hangs off the model
root, so those ARE the ship's axes. A bone's parent is another bone, pointing
wherever the skeleton points, so the identical call turns about the wrong axes.
Measured against this rig's own bone rest rotations, for a commanded PITCH:

  jetT.l      -> (-0.541, 0.840, -0.043)   mostly YAW, pitch inverted
  winghold.l  -> (-1.000, 0.000,  0.000)   the ship's pitch axis, NEGATED
  fuse/wing.l -> ( 1.000, 0.000,  0.000)   correct - these rest ship-aligned

winghold.l is the whole "pitch is inverted" report: it rests at (77.08, 0, -180)
and that -180 roll flips X, so the wings pitched backwards while the engines
yawed.

The second half is the drift. These parts are re-parented onto the drift handle
in flight and `parent =` preserves the world pose, so a rest rotation captured
under a BONE and replayed under the handle lands somewhere the part never was:
164.50 degrees off for the wings, 152.38 for the engines, on drift entry, with
no pilot input at all. That is the fold.

RotatePartFromRestInFrame fixes both together - conjugate the turn into the
frame the animation MEANT (the vessel, or the drift handle while drifting) and
anchor the rest through the part's HOME parent. MovePartFromRest takes the same
frame, because its offset had the identical defect: at rest its basis was the
part's own parent, which is a bone.

Deliberately a SEPARATE method from RotatePartFromRest rather than a change to
it: that one is shared with ScarabAnimation, whose animation was authored and
play-tested against its current behaviour, and silently re-framing it is a
regression with no evidence behind it. The Scarab is untouched and may have the
same latent bug - that is a separate, evidenced call.

Verified: compiles (Roslyn, stubs from real declarations); and
Tools/Build/verify_vessel_rig_puppetry_frames.py re-proves the math offline
against the rig's measured bones, asserting the two invariants that keep it
honest - a ship-aligned part is BIT-IDENTICAL under both formulas (fuse, wing.l:
0.000000000 deg), and entering a drift with no input moves nothing (0.00 deg).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVDmUarLeSWQa8kRBnubqw
…at re-aimed

Third playtest: "much better", with three specifics. All three are real, and
two of them were only VISIBLE once the frames were right - while every part
turned about its own bone there was no consistent roll to be wrong about.

1. ROLL WAS INVERTED. Roll arrives as InputStatus.YDiff and now turns parts
   about the ship's +Z. Unity's positive Z rotation is counter-clockwise seen
   from IN FRONT of the ship, so a pilot rolling right got a left bank. Negated
   once at the top of PerformShipPuppetry, NOT at the call sites: the wings'
   roll term is (roll +/- pitch), and flipping the whole expression would
   invert the pitch DIFFERENTIAL - the aileron read - with it.

2. THE ENGINES NEVER MOVED. A drifting Dolphin swings its hull to aim while it
   keeps sliding, so the wings slide FORWARD and the engines slide BACK to open
   a gap the fuselage and jaws can turn through without clipping. The engines'
   "backward" and "default" offsets were the SAME vector, so half that
   clearance had never existed - and translating the constants faithfully
   preserved the omission. A degenerate constant pair is not a design; it is
   indistinguishable from a value nobody finished, and it is a question to ask
   rather than a fact to carry forward. Both magnitudes are now authored
   (driftWingForward / driftJetBackward), because how much room the jaws need
   is a feel question, not a fact about the model.

3. THE DRIFT RE-AIMED THE PARTS IT SHOULD HAVE MOVED. Wings and engines were
   re-parented onto a handle aimed along Course, so they swung WITH the aim -
   engines chasing the nose instead of clearing it. The frame is now always the
   vessel and the drift is purely translational, which is what "wings forward,
   engines back" asks for.

That makes the re-parenting machinery inert, so it is deleted rather than left
lying in a file that has now cost three playtests: CaptureHomeParents,
DriftParts, ReparentToDrift, ReparentHome, SetParent and _homeParents, 45 lines.
DriftHandle survives as an empty anchor (no children, nothing reads it) in case
a course-aligned frame is wanted back; git carries the methods.

verify_vessel_rig_puppetry_frames.py gains two checks: a positive roll input
must drop the RIGHT wing tip (-0.1736 now, +0.1736 before - the inverted bank),
and the engines must have non-zero backward clearance so the gap actually opens.

Verified: compiles (Roslyn); the verifier passes all five checks;
conditional-compilation OK. NOT verified: the ship in motion.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVDmUarLeSWQa8kRBnubqw
…l aims

Fourth playtest: "the jet models and wings both stayed with the fuselage when
they are supposed to be signaling the course direction and letting only the
fuselage and jaws aim." I had this backwards, and the previous commit made it
worse by deleting the machinery that did it.

A drift splits the Dolphin in two. The fuselage and jaws turn to AIM wherever
the pilot points; the wings and engines stay lined up with COURSE - the
direction the ship is actually travelling - so the hull reads as slewing across
its own path. The appendages are the instrument telling everyone where the ship
is really going. The clearance (wings forward, engines back) is the SECOND half
of the manoeuvre, not the whole of it: I read the clearance statement as the
entire intent and removed the signalling.

That is the lesson worth keeping: the re-parenting onto a Course-aligned handle
had no observable effect ONLY because the frames were still wrong, so "it does
nothing, delete it" was a conclusion drawn from a broken build. Machinery that
looks inert may be the feature seen through a bug.

Restored as a FRAME rather than a re-parent, which is strictly better on a rig:
RotatePartFromRestInFrame now expresses the part's rest pose in the VESSEL's
frame and carries it onto whatever frame it is handed, so handing it the
Course-aligned drift handle puts the part on Course holding its own shape, with
nothing moved in the hierarchy and no bone rest pose replayed under a foreign
parent (the 164-degree wing fold). Handed the vessel - the default, and what the
chassis and jaws get - it reduces exactly to the previous behaviour, which the
verifier asserts bit-identically.

verify_vessel_rig_puppetry_frames.py gains check 4: with the hull slewed 40
degrees off Course, a course-framed part must sit exactly 40 degrees off its
hull-framed pose. Measured 40.00 for both a wing and an engine.

STILL OPEN: roll left/right. Reported inverted, negated, still reported
swapped - so the negation is not the lever and I am not going to guess a third
time. Asking rather than shipping another sign flip.

Verified: compiles (Roslyn); verifier passes all six checks;
conditional-compilation OK (1767 files). NOT verified: the ship in motion.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVDmUarLeSWQa8kRBnubqw
…e wings

Roll was reported inverted, negated inside RiptideAnimation, then reported
STILL swapped. A pure sign flip is one of the few edits whose effect is
certain, so a symptom that survives one is evidence about WHERE the defect is,
not about which way it points. Put to the pilot, who answered in a sentence:
it's the whole ship, not the wings.

It is VesselTransformer.Roll(), which banks the hull about transform.forward
from InputStatus.YDiff. Measured, not assumed:

  * this branch has touched ZERO files under Controller/Vessel/;
  * Roll() is byte-identical to origin/bleeding-edge;
  * that transformer is shared by SIX vessels - Manta, Squirrel, Falcon,
    Rhino, Shrike and the Dolphin.

So the bank direction is pre-existing, fleet-wide, and nothing to do with the
rig swap. Negating inside the animation only made the wings disagree with the
hull they are bolted to, which is strictly worse than both being wrong
together. Reverted: roll passes through untouched and the puppetry follows the
flight model wherever it goes.

Flipping it for real is a flight-model change across six shipped vessels. That
is a deliberate decision, not a side effect of a vessel-construction pass, so
it is left alone and written up instead (VESSEL_CONSTRUCTION.md 4.6).

The verifier's roll-sign check went with the negation it was asserting; the
other five checks are unchanged and still pass.

Verified: compiles (Roslyn); verifier passes; conditional-compilation OK.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVDmUarLeSWQa8kRBnubqw
…rable

Fifth playtest: body reads right, wings bank opposite it, and the wings and
engines keep responding to the stick mid-drift when they should be nothing but
a course indicator.

1. APPENDAGES GO QUIET WHILE DRIFTING. Holding Course is the whole of their job
   during a drift - only the fuselage and jaws answer the stick. The wings and
   all six engines now take zero puppetry input while IsDrifting, so they sit on
   Course with the clearance gap open and say nothing else. Leaving them
   responsive read as the ship still flying while it is supposed to be sliding.

2. THE WING BANK IS NOW A PARAMETER, NOT A GUESS. The conjugation turns every
   part about the ship's +Z, so the wings' delta is provably identical in axis
   AND sign to the chassis's - measured, 25/75 degrees about (0,0,1) for both -
   and yet on screen they read opposite. I cannot derive that, and three sign
   guesses have now cost three flights, so it stops being a constant:
   wingRollResponse (default -1) scales the wings' roll contribution, +1 with
   the hull, -1 against, 0 not at all. That is honest authoring rather than a
   flag - how hard the wings bank relative to the hull is a feel parameter - and
   it makes the question answerable in the editor instead of by round-trip.

   When the math and the screen disagree and the math is verified, the answer is
   a parameter.

Also corrects my own reading: "the rings" was the WINGS. The engine cases were
measured first and are fine - their delta is the same ship-space turn as
everything else's.

Verified: compiles (Roslyn); puppetry verifier passes;
conditional-compilation OK (1767 files). NOT verified: the ship in motion.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVDmUarLeSWQa8kRBnubqw
… YAW

Sixth playtest, two findings.

1. THE COURSE FRAME WAS TWISTING. "The wings and thrusters look like they are
   trying to keep their up vector in a position relative to the camera instead
   of just letting the camera rotate about them" - which names the mechanism
   exactly. SafeLookRotation(Course, transform.up) pins the frame's roll to the
   hull's up, so as the hull aims away from Course that up swings and drags the
   frame around the Course axis. The appendages rotated with ZERO pilot input.

   Measured over a 0->50 degree aim sweep with Course held fixed:

     LookRotation(Course, hull.up)        19.77 deg   <- what shipped
     rebuild from the hull's current nose  7.03 deg
     rotation-minimizing (now)             0.000024 deg

   The middle row is the trap and I nearly shipped it: it is the obvious fix,
   it is 3x better, and it is still wrong, because the shortest arc from a
   MOVING nose changes as the nose moves. The frame is now seeded from the hull
   at drift entry and thereafter turned by the shortest arc from ITS OWN
   previous forward onto Course - every step a pure swing, no twist by
   construction.

   "Point this at that" does not define a frame. A direction leaves the roll
   about it free, and every look-at helper fills that in from an up-vector that
   is usually moving.

2. IT WAS THE WINGS' YAW, NOT THEIR ROLL. Last round's wingRollResponse = -1
   flipped an axis that was already right, so the wings ended up wrong on two.
   Roll returns to +1 (the hull's bank is correct and the wings follow it) and
   yaw goes to -1. All three axes are now exposed - wingPitchResponse,
   wingYawResponse, wingRollResponse - so which axis reads backwards on a rig is
   an editor question rather than a code guess.

SafeLookRotation was this file's only user of CosmicShore.Utility; the using
goes with it.

Verified: compiles - and the harness was extended to actually INCLUDE
PerformShipPuppetry first, because the previous extraction stopped at the field
block and was reporting green on code it never saw; a negative control now
breaks on the drift-frame line itself. verify_vessel_rig_puppetry_frames.py
gains check 5 (no twist with no input), which fails correctly at 7.03 deg when
fed the old construction. conditional-compilation OK (1767 files).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVDmUarLeSWQa8kRBnubqw
Seventh playtest, with a photo: the wings no longer cross each other in plane -
one folds up and one folds down, each out of the plane - and the wings' roll
still reads backwards while the vessel rolls.

Three sign guesses across three flights never touched the real cause. The two
wings' arguments differ in exactly two places: `pitch` bleeds into their ROLL
and `throttle` into their YAW, one wing plus and the other minus. That
antisymmetry is what takes them out of a common plane. Measured:

  roll 1.0 pitch 0.0   right -25.0  left -25.0   split  0 deg  coplanar bank
  roll 1.0 pitch 0.3   right -17.5  left -32.5   split 15 deg  FOLDED
  roll 0.5 pitch 0.6   right  +2.5  left -27.5   split 30 deg  FOLDED

A bank is both wings turning by the SAME angle - the wing plane tilts and stays
a plane. Unequal angles is the fold in the photo. On the legacy art the coupling
read as wing sweep; on the rig, with the wings resting under 'winghold' bones at
77 / -102.9 degrees, it reads as the wings coming apart.

The coupling is now `wingDifferential`, DEFAULT 0, so the wings bank together
and the authored sweep is opt-in. `wingRollResponse` goes to -1, which is the
sign this flight actually reported.

A symptom that survives every sign you can flip is not a sign. Signs are cheap
to try and that is what makes them a trap - three flights went into flipping
roll, then yaw, then roll again, while the thing breaking the silhouette was a
coupling nobody had looked at. When flipping stops paying, enumerate what makes
the two sides DIFFER rather than what makes them wrong.

Also corrects a bad measurement of my own along the way: I first tested the fold
by comparing the two wing TIPS' vertical motion, which is opposite for a correct
bank too, so it reported "folded" in every case including the fixed one. The
invariant is equality of the two ROTATIONS, not opposition of the two tips.

verify_vessel_rig_puppetry_frames.py gains check 6: with the coupling off the
worst wing-to-wing roll split must be 0 (it is; at 1 it is 30 degrees).

Verified: compiles, with the new code confirmed present in the compiled unit;
verifier passes all seven checks; conditional-compilation OK (1767 files).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVDmUarLeSWQa8kRBnubqw
Three passes of per-axis sign scalers on the wings were all wrong on
playtest, because the defect was never a sign. Measured from the FBX and
from the pre-swap prefab:

* The legacy art parented every animated part under the chassis
  (LeftWing / RightWing.001 / Engine case L|R.1-3 all hung off
  `Dolphin_Test`, which is what `Chassis` resolved to), so each inherited
  the chassis's own (25*pitch, 25*yaw, 25*roll) turn and composed its own
  on top. On the rig the wings hang off `winghold.l|r` and the engines off
  `jetholdT|m|B.l|r` - a sibling branch of `fuse` - so that term is gone.
  The wings' own pitch input is Brake(throttle), zero unless braking, so
  they went dead on that axis: "fixed in their x axes at all times".

* The drift's Course frame was `DriftHandle`, a Transform parented under
  the vessel. A child's world rotation is stored as a local one, so the
  hull's aiming carried it between one frame's write and the next read;
  re-pointing only its forward axis left the twist. Measured: 24.6 degrees
  accumulated on parts commanded to hold still, which is what "keeping
  their up vector relative to the camera" was.

So: every per-axis response scaler and the cross-coupling knob are gone and
the wing/thruster terms are the ones this ship has always used, with the
chassis turn composed in by hand (composed, never Euler-added - 6.43
degrees apart on an ordinary stick pose). The drift frame is a quaternion
captured once at entry - no parent, nothing can perturb it - and the
appendages take no puppetry at all while drifting, only the clearance
offsets. `RotatePartFromRestInFrame` and `MovePartFromRest` therefore take
quaternions rather than Transforms; both are Dolphin-only, and the shared
`RotatePartFromRest` is untouched.

verify_vessel_rig_puppetry_frames.py grows three checks: the composed turn
matches the legacy hierarchy to one ULP, pitch reaches the wings again, and
a parented handle twists 24.6 degrees where the frozen quaternion twists
0.000. Both source files compile as shipped (Roslyn, with negative controls
proving the harness sees the edited regions).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVDmUarLeSWQa8kRBnubqw
…ere they live

Two things from the eighth flight.

THE DRIFT FRAME. Freezing the hull's orientation at drift entry is stable but
holds where the ship WAS, not where it is going - so as the pilot rolls, the
appendages stop matching the hull and the camera (measured 8.20 degrees adrift
over an ordinary aim). The frame is now the hull RE-AIMED onto Course by the
shortest arc, rebuilt from scratch every frame:

    FromToRotation(transform.forward, Course) * transform.rotation

The appendages then read as a dolphin flying straight down Course; their up
stays the hull's, which is roughly the camera's, because FromToRotation is a
pure swing about an axis perpendicular to the nose and injects no roll of its
own (verified as zero residual component ALONG the nose - comparing up-vectors
is not that test, and an earlier version of the check measured the aim's
legitimate tilt and called it roll); and being a pure function of (hull, Course)
with no state it cannot accumulate, which a parented Transform provably does.

POSITION IS RESOLVED IN A DIFFERENT FRAME FROM ROTATION, deliberately. The
clearance offsets are read in the VESSEL's frame - a clearance is measured
against the hull, and reading it in the Course frame would translate the parts
as the hull aims.

THE ENGINES. The rig's jet bones rest at z -1.90; the legacy art authored its
engine cases at z -2.047. Both models are unit-1 (UnitScaleFactor 1.0,
useFileScale 1, globalScale 1) with every scale in both prefab chains at 1, so
those are directly comparable world units and the rig sits its engines 0.15
FORWARD of the ship it replaced. `jetRestBackward` (0.15, authorable) puts them
back. It is a resting offset, not clearance - the drift gap adds on top.

verify_vessel_rig_puppetry_frames.py check 6 rewritten around the new frame
(forward exactly on Course, zero injected roll, bit-identical on rebuild) with
both rejected alternatives kept as controls; check 8 now asserts the resting
setback lands on the legacy engine z. Compiles as shipped, with a negative
control proving the harness sees the new frame line.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVDmUarLeSWQa8kRBnubqw
Reported as "the jet and wing positions look like their z value in the vessel
frame remained near zero". It is not the offsets and not the frame:

    if (InputStatus.Idle) Idle();            // ResetAnimation writes ROTATION only
    else PerformShipPuppetry(...);           // the only caller of MovePartFromRest

`VesselAnimation.Idle` relaxes rotations and nothing else, which is complete for
every other vessel because RiptideAnimation is the only animation in the fleet
that POSITIONS its parts. So the whole positional layer lived on a path an idle
vessel does not take: the engines' resting setback applied only while the stick
was off centre and froze wherever it had reached when the pilot let go.

`Idle()` is now overridden to settle positions through the same
ApplyRestingLayout() the puppetry path uses, so the two cannot describe a
different ship.

Three plausible causes were measured and ruled out first, all recorded in
VESSEL_CONSTRUCTION.md 4.6.3: an Animator stomping the write in the animation
update (there is no Animator on the prefab and no script assigns a controller);
soft skin weights smearing the mesh instead of sliding the part (every bone owns
its verts at weight 1.000 with zero sharing - wing.l 294 verts, jetT.l 538);
and the offsets being too small to see (the hull collider is 3.448 units long,
so driftWingForward 2.3 is 67% of it).

That last one also settles a scare from the same pass: the rig FBX root carries
Lcl Scaling (100,100,100), which reads as a 100x discrepancy. It is normalised
away on import - the mesh's raw extent 5.287 equals the wingspan the shipped
colliders describe, and the camera sits 20 units back - so bone positions
measured off the FBX are world units.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVDmUarLeSWQa8kRBnubqw
The engines still sat too far forward and neither the wings nor the jets moved
on a drift. The offsets were never reaching the screen in a readable size, and
the reason is a fact neither model states:

  legacy Dolphin_Test.fbx   root 'Chassis'   Lcl Scaling: NONE
  rig    dolphin_...fbx     root 'dolphin'   Lcl Scaling: (100, 100, 100)

Both declare UnitScaleFactor 1.0 and useFileScale 1. Whether Unity bakes that
100 into the bone rest poses or leaves it on the imported root decides whether
this hull is ~3.4 units long or ~340 - and the same absolute 2.3 is then either
67% of the ship or 0.67% of it. Simulating the shipped MovePartFromRest
arithmetic against the real bone chain reproduces the report exactly under the
second reading, down to the local value: wing.l goes from (0, 0.994, 0) to
(0, 0.999, -0.022) - "their z value in the vessel frame remained near zero".

Rather than pick a side offline, the offsets are now FRACTIONS of the hull's own
circumscribing radius, measured once at Initialize through
PrismOcclusionCorridor.MeasureCircumscribedRadius - the fleet's existing
world-unit hull measurement, reused rather than duplicated. Correct at either
reading, at any model scale, for any future re-export, with nothing
re-authored; and the numbers finally mean something (0.25 = a quarter of the
hull radius). The fields are not serialized on the prefab, so the new defaults
apply with no asset edit.

This also RETRACTS the previous commit's derivation of jetRestBackward = 0.15
from the legacy engine z. That rested on "both models are unit-1 so those are
comparable world units", which is exactly the false clause above.

Three causes were measured and ruled out on the way, each individually correct
and collectively misleading: nothing else on the prefab writes a transform
position (32 of 37 scripts checked, none writes localPosition; no Animator);
the skin binding is rigid, so a bone translation slides its part cleanly (every
cluster owns its verts at weight 1.000, all 12595 accounted for, and each
engine's nozzle is a CHILD of its case so it follows); and the arithmetic is
exact. Recorded in VESSEL_CONSTRUCTION.md 4.6.4.

verify_vessel_rig_puppetry_frames.py check 8 now asserts the offsets are
scale-free - the same fraction of the hull at both candidate readings (23.9%
either way), against the absolute 2.3's 81.3% vs 0.81%.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVDmUarLeSWQa8kRBnubqw
claude added 3 commits August 27, 2026 23:11
…space

"Now it was scaling things in wild ways" - my previous commit's fault, and the
useful half of it.

Re-basing the drift clearance on PrismOcclusionCorridor.MeasureCircumscribedRadius
was meant to escape the two Dolphin models' disagreement about import scale. It
did not remove that ambiguity, it IMPORTED it. For a skinned hull that helper
measures

    var space = skinned.rootBone != null ? skinned.rootBone : skinned.transform;
    Accumulate(skinned.localBounds, space, origin, ref maxSqr);

- localBounds in ROOT-BONE space, carried to world through the ROOT BONE's
transform. The root bone is exactly where the disputed Lcl Scaling (100,100,100)
lives. Multiplying a bone-space offset by a root-bone-space measurement can be
off by that factor either way, and at 100x it throws the wings and engines clear
of the ship. VESSEL_CONSTRUCTION.md 4.5 already records this trap costing the
Sparrow a ~5x oversized corridor off a 0.2 armature; I walked into it.

The basis is now the farthest positioned part's OWN rest distance from the
vessel origin, measured once at Initialize (nothing has displaced them yet, so
their position is their rest position). Whatever units those bones are in, the
offset is in the same ones by construction - no armature factor, no renderer
bounds, no unrelated child (a tail, a skimmer, a HUD canvas) can contaminate it.
Wings +0.35 reach forward on a drift, engines -0.35, -0.08 at rest.

And the offsets are CLAMPED to +-1x that reach. A clearance is a nudge, not a
launch, so the family is bounded against exactly this failure rather than
trusting the next basis to be right. The clamp tunes nothing; it refuses the
absurd.

Two rules, in VESSEL_CONSTRUCTION.md 4.6.4 and CLAUDE.md:
  * a scale-free quantity is only scale-free if the value and its BASIS are
    measured in the same space - "divide by a measurement of the ship" is not
    sufficient when that measurement comes through a different transform chain;
  * when you cannot resolve which of two readings is real, prefer a basis you
    can derive from the thing you are about to modify. It cannot disagree with
    itself.

verify_vessel_rig_puppetry_frames.py check 8 rewritten: the offsets are the same
fraction at both scale readings (35.0% either way), the mismatched-basis control
reproduces the defect (50x the part's own reach), and the clamp bounds an
authored 5.0 or -12.0. Compiles as shipped with negative controls on both the
measurement and the clamp.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVDmUarLeSWQa8kRBnubqw
…ten exactly

The import model is PINNED by measurement (a conjugation by 0.01*mirror-X;
bones keep lossyScale 100 while their world poses land on the hull - fitted
against the colliders the rig swap itself preserved, residual 3e-5), which
retires the scale ambiguity that produced both prior offset failures and
makes plain world units safe. Every number is now a measurement:

- driftWingForward 2.2: the proven bleeding-edge lunge (+2.3 from ITS
  on-screen rest; the rig rests wings 0.10 ahead of that). The previous
  reach-fraction scheme could not express it: |fraction| <= 1 on a 1.96
  basis tops out below the proven look.
- jetRestBackward 0: the "jets pushed forward" report inverted on
  measurement - the OLD RUNTIME dragged its engine pivots from authored
  z -2.047 to -1.7 every frame; the rig, which retires that drag, already
  rests its drawn nozzles 0.40 BEHIND the old screen.
- driftJetBackward 0.5: the one ask rather than parity - bleeding-edge
  never moved its engines on a drift (backward == default, same vector).

Four defects fixed with the redesign:
- the drift cage now holds the COURSE frame for POSITIONS as well as
  orientations (the old DriftHandle re-parent did both; holding only
  orientation swept the cage sideways with the aiming hull);
- the cage pose is written EXACTLY in cage coordinates with the entry pose
  ADOPTED and blended to station (the re-parent's other half: a lerp toward
  a hull-independent target trails a 110 deg/s aim by ~53 deg / ~1.7 wu of
  visible drag - "the wings and jets still appear to move as I am drifting
  and aiming");
- the rest anchor is a capture-time constant (a live read through an
  animated parent applies the chassis term twice on legacy chassis-child
  art - 27.8 deg at an ordinary stick pose; bit-identical on the rig);
- FromToRotation is guarded at the antipode (aiming fully backwards whips
  the unguarded frame at 2x the wobble rate; the previous frame is held
  inside dot < -0.999).

Also: Idle() reroutes to the drift layout while IsDrifting (touch-only
exposure; the drifting branch never evaluates Brake), the three clearance
fields are serialized into Dolphin.prefab deliberately (defaults changed
meaning twice already), and m_UpdateWhenOffscreen=1 on the rig's skinned
renderer (rest-pose culling bounds vs a 2.2 wu lunge).

Verified: offline compile (Roslyn + stubs, negative-controlled);
verify_vessel_rig_puppetry_frames.py extended to 12 checks, all green
(world-unit round trip exact at chain scale 1 and 100, cage wander 0 vs
2.5 wu for the vessel-frame control, captured anchor 1.1e-16 vs 27.8 deg
for the live-anchor control, antipode guard 0.00 vs 20 deg/step raw, lag
control 53 deg vs 0 exact). Full derivation: VESSEL_CONSTRUCTION.md 4.6.5.
NOT verified: the ship in motion - in-editor verification unavailable in
this session; this commit is the subject of the next flight.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVDmUarLeSWQa8kRBnubqw
…l findings

An adversarial four-lens review of the previous commit converged on one real
defect and three honesty gaps, all fixed:

- THE CAGE IS SLEW-LIMITED (360 deg/s, drift path only). The antiparallel
  hold cone (dot < -0.999, 2.56 deg) is an order of magnitude narrower than
  FromToRotation's ill-conditioned band (~2/sin(rho) churn amplification:
  68 deg/FRAME measured 3 deg off a full-reverse aim), leaving the hold cone
  was a one-frame snap, and the exact-write cage removed the lerp low-pass
  that used to hide both. Legitimate cage motion is bounded by the hull ROLL
  rate (~1.8 deg/frame), so a 6 deg/frame cap never engages in ordinary
  flight and turns a reverse-aim sweep into a fast continuous swing.
- The entry blend is EXPONENTIAL (Mathf.Lerp toward 1), not linear
  MoveTowards - the legacy re-parent's convergence class; a constant-velocity
  2.2 wu lunge with a hard stop read mechanical.
- _wasDrifting resets in OnEnable: a component disabled mid-drift and
  re-enabled inside a LATER drift no longer skips the entry adoption.
- CLAUDE.md's VESSEL_CONSTRUCTION row contradicted itself: it still carried
  the superseded "position and rotation can want different frames" ruling
  this branch reversed. Rewritten to the measured ruling (one Course frame
  for the whole cage).
- Verifier check 9 now runs the SHIPPED frame construction (it modelled the
  cage as a constant and mis-cited check 6): bit-still proven under
  single-axis aim, the 30-deg roll-following asserted as the designed
  up-tracking orbit (radius drift 2e-16), and check 11 gained the churn band
  (raw 20 deg/step vs shipped 6.00) and a hold-cone transit (0.00).
- Docs 4.6.5: "bit-still" scoped to pitch/yaw aim; roll-following, the slew
  limit and the blend shape recorded.

Verified: offline compile (negative-controlled), 12/12 verifier checks.
NOT verified: the ship in motion - this commit is part of the next flight.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVDmUarLeSWQa8kRBnubqw
@gradies gradies changed the title Unify vessel construction: swap the Dolphin onto its rig, and make the audits measure magnitude [needs re-flight] Unify vessel construction: swap the Dolphin onto its rig, and make the audits measure magnitude [flight 8 pending] Aug 28, 2026
…t the boosters halfway

Flight 8: "close to perfect. with only 2 minor adjustments."

1. The wings' and all six boosters' ROLL response is MIRRORED - the roll
   input is negated everywhere it reaches those parts (their own Euler term
   AND the chassis term composed into them), so their net roll deflection is
   the exact mirror of what it was. The chassis keeps the true roll, the
   aileron cross-coupling (the pitch component of the wings' z term) is
   untouched, and the drift cage still follows the hull's roll (up toward
   the camera - its own signed ask). A deliberate feel departure from legacy
   parity; the composition mechanism is unchanged.

2. The boosters' REST position moves to exactly halfway between the authored
   sculpt and the drift station: jetRestBackward 0 -> 0.25 and
   driftJetBackward 0.5 -> 0.25, so the drift STATION - called perfect - is
   byte-identical at rest+drift = 0.50. The verifier now pins the pair
   (station 0.50, rest = station/2) so retuning one field without
   re-balancing the other fails the build.

Prefab serialization follows the code defaults; Docs 4.6.5 records both as
flight-8-signed feel deltas.

Verified: offline compile (Roslyn stubs), 12/12 verifier checks incl. the
new station pin, conditional-compilation clean. NOT verified in-editor:
no Unity CLI in this session - the change is two sign flips and two
serialized floats on the flight-8-approved build.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVDmUarLeSWQa8kRBnubqw
@gradies gradies changed the title Unify vessel construction: swap the Dolphin onto its rig, and make the audits measure magnitude [flight 8 pending] Unify vessel construction: swap the Dolphin onto its rig, and make the audits measure magnitude [flight 8 passed; flight 9 confirms two tweaks] Aug 28, 2026
…'s one ask

Flight 9: "everything is perfect. just move the flying position of the
boosters back further."

jetRestBackward 0.25 -> 0.375 (half the remaining gap to the drift station),
driftJetBackward 0.25 -> 0.125 - so the drift STATION, called perfect in
both flights, stays byte-identical at rest + drift = 0.50. The visible
drift slide shrinks to 0.125 as the geometric consequence; deepening the
seat any further against the fixed station means moving the station
(driftJetBackward), not the seat - the tooltip and Docs 4.6.5 now say so.

Verifier's station pin re-signed to the shipped pair (the flight-8
"rest = station/2" clause is retired: the seat is a feel dial inside a
signed station, not a fixed ratio).

Verified: offline compile, 13/13 verifier checks, conditional-compilation
clean. NOT verified in-editor (no Unity CLI here): two serialized floats
and their matching code defaults on the flight-9-approved build.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVDmUarLeSWQa8kRBnubqw
@gradies gradies changed the title Unify vessel construction: swap the Dolphin onto its rig, and make the audits measure magnitude [flight 8 passed; flight 9 confirms two tweaks] Unify vessel construction: swap the Dolphin onto its rig, and make the audits measure magnitude [flight 9 passed; flight 10 confirms the seat] Aug 28, 2026
…D anchor, not another halving

Flight 10: "oddly this seemed to do nothing. the boosters are still right up
next to the wings unless I'm drifting."

It did nothing, and the code was never at fault. Measured from the rig's own
skin clusters (vessel frame): the nozzles sculpt at z -2.290..-1.887 while the
fuselage tail is at -2.471, so at any seat under 0.584 the boosters sit tucked
ALONGSIDE the tail. Worse, flights 9 and 10 each moved the seat 0.125 wu -
3.6% of the 3.45 hull, a handful of pixels at chase distance. Both playtests
were spent on steps below the threshold of visibility.

The cause was a constraint I imposed: an earlier pass PINNED the drift station
at 0.50 off one flight's sign-off, so every later "further back" had to come
out of the shrinking gap to that ceiling and halved itself. That is backwards -
what a drift must keep is the visible SLIDE, not an absolute station.

  jetRestBackward  0.375 -> 0.6   (clears the fuselage tail; 17.4% of hull)
  driftJetBackward 0.125 -> 0.25  (the slide restored to a visible 7.3%)
  station 0.50 -> 0.85, free to follow the seat

The verifier's station pin is retired and replaced by a VISIBILITY FLOOR:
5% of hull on the seat, on the slide, and on any step away from a value a
playtest could not see - so a sub-threshold tune fails the build rather than
costing a flight. It also asserts the measured tail-clearance anchor.

Verified: offline compile, 13/13 verifier checks, conditional-compilation clean.
NOT verified in-editor: two serialized floats; jetRestBackward is a live
inspector dial on Dolphin.prefab if the shipped value wants nudging.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVDmUarLeSWQa8kRBnubqw
@gradies gradies changed the title Unify vessel construction: swap the Dolphin onto its rig, and make the audits measure magnitude [flight 9 passed; flight 10 confirms the seat] Unify vessel construction: swap the Dolphin onto its rig, and make the audits measure magnitude [flying right; seat being dialled in] Aug 29, 2026
…ads as behind the ship

Flight 11: 'the wings don't travel far enough forward to get clearance while
drifting, and the boosters still return to a position far to forward.'

Both answered by measurement rather than increments:

- driftWingForward 2.2 -> 3.5. 2.2 was old-game parity, and the old game
  interpenetrated: the aiming hull's jaw tip sweeps a 2.835-radius sphere about
  the vessel origin, and at 2.2 the wings' nearest vertex sat at |r| 1.76 -
  inside it. 3.5 rounds up the bisected L* = 3.492, the smallest lunge putting
  every wing vertex outside that sweep x1.05 gape margin, so the jaws cannot
  reach the wings at any drift aim angle. Within the +-4 clamp.
- jetRestBackward 0.6 -> 1.0. 0.6 cleared the fuselage tail by only 0.017 and
  still read as tucked-in; 1.0 lands the nozzles' leading edge 0.42 wu behind
  the tail (29% of hull, a 0.4 step - both far above the 5%-of-hull floor).
- driftJetBackward stays 0.25 (the motion the drift shows).

Verifier: constants tracked (JET_REST 1.0, PREVIOUS_REST 0.6, lunge 3.5) and a
new geometric clearance assertion added to check 8 - the binding wing vert at
the shipped lunge must sit outside the jaw-tip sweep, so an under-clearing
retune fails the build. All 13 checks green; conditional-compilation OK.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVDmUarLeSWQa8kRBnubqw
@gradies gradies changed the title Unify vessel construction: swap the Dolphin onto its rig, and make the audits measure magnitude [flying right; seat being dialled in] Vessel construction unification — Dolphin rig swap, measured audits, vestige retirement [flying right; clearance + seat now measured] Aug 29, 2026
…e, seat it deeper

Flight 12: 'everything is perfect now (even the wings and the drifting
position of the boosters) except the home position of the boosters ... they
continue to rest near the origin of the fuselage.'

The drift position is confirmed perfect at driftJetBackwardTotal(effectively
1.25 before this commit). The rest seat (jetRestBackward) has now been called
too far forward across three flights (10, 11's ask, 12) against three
successively deeper, individually-measured seats (0.6, then 1.0) - each
survives past the 5%-of-hull visibility floor (flight 10's actual defect) but
still isn't enough, which is a different failure than flight 10's.

Two changes:
- DECOUPLE: BackwardThrusterOffset used to read jetRestBackward +
  driftJetBackward, so every rest-seat bump silently moved the drift
  clearance too - flight 11's 0.6->1.0 bump happened to land the drift total
  at 1.25, which flight 12 confirms reads as perfect. A further rest bump
  risked dragging that confirmed value along with it. driftJetBackwardTotal
  is now its own field, locked at 1.25, with no path back to jetRestBackward.
- BOLD, not incremental: jetRestBackward 1.0 -> 1.8. Clears the fuselage tail
  by 1.22 wu (vs 1.0's 0.42), nearly matching the drift's own separation
  instead of trailing it by a marginal quarter-unit.

Verifier restructured for the decoupling: JET_REST and DRIFT_TOTAL are
asserted independently (visibility floor, tail clearance), with the coupled-
field defect documented as the cause rather than re-derived. All 13 checks
green; conditional-compilation OK.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVDmUarLeSWQa8kRBnubqw
@gradies gradies changed the title Vessel construction unification — Dolphin rig swap, measured audits, vestige retirement [flying right; clearance + seat now measured] Vessel construction unification — Dolphin rig swap, measured audits, vestige retirement [drift signed off; rest seat decoupled + deepened] Aug 29, 2026
…try, halve the booster flail

Flight 13: 'I had to look at the scene view to realize the boosters were far
more back than I realized. it is just getting puppeteer[ed] in a way that made
me perceive them as much closer.'

The seat was applying all along; no seat could ever have fixed the report. The
Dolphin's chase camera is on-axis and level (followOffset 0,0,-20), so a part's
station along the hull projects to almost nothing - the surviving cues are SIZE
and OCCLUSION, and both invert: a booster seated 1.8 aft sits 3.7 wu nearer the
lens than the wings, renders ~24% larger, and draws OVER the hull wherever
silhouettes overlap. Every seat increase made the in-game read worse-or-same
while the true geometry marched backwards - the flights 10-12 loop. On top,
the puppetry flailed each case up to 100 deg at full stick (chassis 25 + the
shared exaggerated 75), sweeping six enlarged occluding silhouettes across the
wing region.

Tune the read, not the depth:
- jetRestBackward 1.8 -> 1.0: the true-geometry value (nozzle leading edge
  0.42 wu behind the tail - engines at the back of the body), judged in the
  scene view where depth is honest.
- NEW thrusterAnimationScaler = 25 for the boosters' own puppetry term
  (replaces the shared exaggerated 75 in thrusterTurn only; the wings keep
  their signed-off terms). Full-stick deflection halves 100 -> 50 deg.
- driftJetBackwardTotal stays 1.25, driftWingForward stays 3.5 (signed off).

Verifier: seat constants tracked, plus a SWING-ENVELOPE assertion - the
most-forward z any jet-cluster skin vert reaches over the full input cube
(measured about the six bone pivots) must stay behind the fuselage tail plane
at the shipped seat+amplitude (margin 0.244 wu; control: the retired 75-deg
term at the 0.6 seat crossed by 0.39). All 13 checks green; CC gate OK.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVDmUarLeSWQa8kRBnubqw
@gradies gradies changed the title Vessel construction unification — Dolphin rig swap, measured audits, vestige retirement [drift signed off; rest seat decoupled + deepened] Vessel construction unification — Dolphin rig swap, measured audits, vestige retirement [flight 13: the depth-cue inversion, fixed] Aug 31, 2026
…own turn again

"less separation between boosters and fuselage ... when turning the boosters
should stay more pinned to the fuselage puppeteering. it should still separate,
just closer to pinned than now."

Read off the code rather than assumed: Chassis resolves to the `fuse` bone - the
fuselage itself - and turns Euler(p*25, y*25, r*25), while the boosters compose
appendageChassisTurn = Euler(p*25, y*25, -r*25) BEFORE their own term. So on
pitch and yaw the boosters' chassis component tracks the fuselage exactly, and
thrusterAnimationScaler is the WHOLE of what separates them from it - one number,
with 0 meaning literally pinned.

thrusterAnimationScaler 25 -> 12. Measured peak swing of a booster off a
perfectly-pinned one, over the input cube at the farthest jet vertex (0.444 wu
from its pivot): 75 -> 0.847 wu, 25 -> 0.345, 12 -> 0.165, 0 -> 0. Relative-to-
fuselage angle on pitch/yaw falls 35.2 -> 17.0 deg: half the swing, still
legibly separating. The flight-13 envelope invariant strengthens with it - the
full-input booster envelope now clears the fuselage tail plane by 0.375 wu
(was 0.244).

On ROLL a 50-degree floor survives own=0 and this dial cannot reach it: the
appendage chassis term MIRRORS roll (the signed-off legacy-parity ask) while the
fuselage keeps true roll, so roll separation only falls 86.6 -> 65.3. Recorded as
a named constant in the verifier and in the doc so a future "still too loose when
I roll" goes to the mirror rather than to another amplitude cut that cannot
reach it.

Verifier check 8 gains a separation assertion (shipped swing strictly smaller
than what it replaced AND strictly non-zero - "still separates" is the ask) plus
the measured envelope row for own 12; all 13 checks green, negative control still
crosses the tail by 0.390. Compiled via the offline Roslyn harness with a
negative control (2 errors, restore clean); conditional-compilation gate OK.

General rule recorded: when one term is shared with the body a part is measured
against, it is not separation - separation is only what the part adds on top, so
identify which terms cancel before tuning any of them.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVDmUarLeSWQa8kRBnubqw
@gradies gradies changed the title Vessel construction unification — Dolphin rig swap, measured audits, vestige retirement [flight 13: the depth-cue inversion, fixed] Vessel construction unification — Dolphin rig swap, measured audits, vestige retirement [flight 14: the boosters pinned closer to the body] Aug 31, 2026
…wn turn to 5

"the boosters need to remain much closer to the back of the fuselage."

Third ask in the same direction, and flight 14's own measurement said the dial
could not deliver it. The boosters took appendageChassisTurn, whose roll is
MIRRORED, while the fuselage (`fuse`, driven as Chassis) keeps true roll - so on
roll the two ADDED where on pitch and yaw they cancel, and a pure roll pulled a
booster 50 degrees off the body with thrusterAnimationScaler at ZERO. No
amplitude cut could reach that.

The boosters now compose a chassis term carrying the body's TRUE roll
(thrusterChassisTurn) and keep the mirror in their OWN term only, so the chassis
component cancels on all three axes and the dial bounds the whole separation.
thrusterAnimationScaler 12 -> 5 on top of it.

Measured over the input cube: worst-case relative-to-fuselage angle 65.3 -> 8.8
deg (7.4x), peak swing off a perfectly-pinned booster 0.165 -> 0.068 wu,
flight-13 envelope margin behind the tail plane 0.375 -> 0.409 wu.

The signed-off flight-9 mirror is PRESERVED, and by sign rather than by
argument: relative roll is -5.00/+5.00 deg at full stick where it was
-62.00/+62.00 - same direction, smaller magnitude. The verifier now asserts that
sign, so a construction that cancelled the own term's mirror too would fail
rather than quietly lose the ask; it also asserts the floor is gone (own=0 now
separates by 0.0 deg on every axis). The WINGS are untouched and keep
appendageChassisTurn - nobody has reported them and flight 12 called them
perfect.

All 13 verifier checks green; Roslyn compile clean with a negative control (2
errors, restore clean); conditional-compilation gate OK.

General rule recorded: a term shared with the body cancels on the axes where it
matches it and DOUBLES on any axis where it is negated - so mirroring an
inherited term mirrors the part's motion on one axis and welds a permanent
offset onto it on another. Mirror what a part CONTRIBUTES, never what it
INHERITS.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVDmUarLeSWQa8kRBnubqw
@gradies gradies changed the title Vessel construction unification — Dolphin rig swap, measured audits, vestige retirement [flight 14: the boosters pinned closer to the body] Vessel construction unification — Dolphin rig swap, measured audits, vestige retirement [flight 15: the boosters' roll floor removed] Sep 1, 2026
Both sides improved the SAME auditor for the SAME reason from different
directions, so the resolution is a UNION rather than a pick:

  * ours measures MAGNITUDE - a labelled shape that moves less than the
    measured threshold (real shapes travel 2.46%..17.94% of their mesh
    diagonal, fake ones 0.0000%) is reported INERT rather than counted;
  * theirs handles PROCEDURAL sources (IProceduralElementMorphSource - the
    Scarab re-blends baked geometry deltas) and shapes living under a hidden
    legacy model root, which draw nothing.

A shape now counts only if it MOVES the hull AND is actually DRAWN. The two
inert reasons are disjoint and both apply; procedural coverage contributes
elements on top, so the fleet line counts shape keys and procedural morphs
together. Reporting keeps ours' per-shape travel verdict and theirs' distinct
"hidden legacy model - never drawn" line, so a reader can tell the two failure
modes apart.

Docs resolved the same way: CLAUDE.md keeps our Dolphin fleet line (theirs was
written before the swap and still called the Dolphin shape-less) and gains
their Scarab procedural paragraph; the verification checklist keeps BOTH new
entries (different vessels, no overlap); VESSEL_CONSTRUCTION.md takes their
Scarab row and keeps ours' Serpent row, whose dagger anchors a footnote below
it.

Verified: the merged auditor COMPILES against transcribed stubs with two
negative controls (a typo in the merged path, and a wrong member name on the
procedural interface - 8 and 4 errors, restore clean); all 13 puppetry
verifier checks green; conditional-compilation gate OK; flight-15 tuning
survives the merge intact (thrusterAnimationScaler 5, jetRestBackward 1,
driftJetBackwardTotal 1.25, driftWingForward 3.5).

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVDmUarLeSWQa8kRBnubqw
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants