Skip to content

Add Scarab and Urchin vessels with ecosystem and UI support - #799

Open
FenrysUnchained wants to merge 795 commits into
claude/untested-backlog-qa-workflow-7a0nb9from
claude/qa-backlog-7mvlsr
Open

Add Scarab and Urchin vessels with ecosystem and UI support#799
FenrysUnchained wants to merge 795 commits into
claude/untested-backlog-qa-workflow-7a0nb9from
claude/qa-backlog-7mvlsr

Conversation

@FenrysUnchained

Copy link
Copy Markdown
Contributor

Summary

Introduces two new playable vessels (Scarab and Urchin) with full integration into the game ecosystem, UI systems, and level configurations. Includes new environmental assets, shader enhancements, and comprehensive cell/spawn profile updates.

Key Changes

New Vessels

  • Scarab prefab (Assets/_Prefabs/Spacevessels/Scarab.prefab): New vessel with HUD container, camera settings, and elemental ability map
  • Urchin support: Elemental ability map and camera settings for Urchin vessel
  • Updated existing vessels (Rhino, Sparrow, Serpent, Manta, Dolphin, Falcon, Shrike, Termite) with minor adjustments and new child transforms

Graphics & Shaders

  • New shader assets: ChargeCrystal.shader, EchoSightHalo.shader, ForcefieldCrackleCapsule.shader, PrismOcclusionDitherPreview.shader
  • New HLSL modules: ChargeCrystal.hlsl, PrismDestructionSight.hlsl (Dolphin's Echo Sight GPU implementation)
  • Enhanced shader graphs: Updated ExplodingBlockGraph.shadergraph and BlockGraph.shadergraph with expanded functionality
  • New materials: Charge crystal, echo sight halo, Rhino sword tracer, and Rhino blade crackle materials
  • Updated materials: Block materials with shader graph refinements

Ecosystem & Environment

  • New flora/fauna prefabs: Quasicrystal flora variants, Gyroid and Quasicrystal branch spindles, Urchin spike projectiles (standard, energized, super-energized)
  • New environment assets: AstroLeagueBall, AOEScarabCavitation projectile, CrystalCharge/CrystalMass updates
  • New cell configurations: Boneyard cell (4 variants with spawn profiles and scavenger configs), Lattice cell (with Gyroid/Quasicrystal/SchwarzP flora variants), Rampage cell (4 variants with flora configs)
  • Updated cell configs: Astro League, Blob, Hesperides, Nucleus Rush cells with new fauna/flora entries
  • Spawnable prefabs: 4 Boneyard and 4 Wildlife Cage variants for level design

UI & HUD

  • New HUD variant: ScarabHUDVariant.prefab for Scarab vessel
  • Updated HUD prefabs: Dolphin, Manta, Rhino, Serpent, Sparrow, Squirrel HUD variants
  • Updated UI panels: MiniGameHUD, OptionsMenuContent, Pause Menu, VesselHUD with Scarab support

Configuration & Data

  • New config assets: CrystalCaptureConfig, PrivacyConsentConfig, PrismSuperShieldJiggleConfig, ScarabNucleusFieldConfig, DisplayNameValidationConfig, SelfTrailContactConfig
  • Updated configs: EndConditionOverrides, ElementalCrystalSet, PostHogConfig, DefaultNetworkPrefabs
  • Elemental ability maps: New Scarab and Urchin maps; updated Dolphin and Sparrow maps

Models & Animations

  • New models: SparrowModel4.fbx, Sparrow Missile.fbx with metadata
  • Updated animations: SparrowAnimatorController.controller with new blend tree states

Infrastructure

  • CI/CD workflows: New bleeding-edge-guard.yml workflow; updated unity-ci.yml, sync-build-branches.yml, tag-internal-build.yml, build-branch-ci.yml
  • Code analyzers: Added Microsoft.Unity.Analyzers plugin
  • EditorConfig: New root .editorconfig for formatting standards
  • Documentation: Updated skill guides (asset-surgery, ecology, vessel, ship

https://claude.ai/code/session_01PsUBppM6FtPcfyiZSM9Eun

claude and others added 30 commits August 19, 2026 23:57
…rvable

Reading the shipped expression turned up a real composition bug. The drift was
added BEFORE the rotation, and the rotation is about the face centroid - so
`rel` was the ~12 world units the shard had already travelled rather than the
face's own ~1 unit of extent. The face swung on a wide arc instead of spinning
in place, and at the angles this reaches (up to ~3 rad) the shard ended up
travelling back toward the blow rather than away from it.

Tumble now runs first, drift second. The tumble is also conditional (a
degenerate normal, or an impulse straight down the face normal, has no axis to
turn about) while the drift is not, so neither case returns early any more - a
face struck dead-on is pushed, it just does not spin.

The verifier's expectation for that case was transcribed from the code rather
than from the intent, so it asserted the bug. Corrected: it now states the
composition (spin in place, then translate) and independently checks that a
dead-on strike still drifts.

Also adds CSLogChannel.PrismShieldShatter and one guarded line per disengage:
whether the batched overlay was accepted at all, and the impulse before and
after the shield's speed clamp. "The shatter looks unchanged" has two very
different causes - no overlay, or an overlay carrying no impulse, since zero
velocity is the identity by design - and nothing on screen tells them apart.
Off by default; FrogletTools > Toolbox > Logging turns it on.

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

feat(scarab): pay a struck switch with a scarab-wing dais
…pawned N times

The right thumb stick is REPLICATED, and ScarabJukeController never checked ownership.

InputStatus.RightNormalizedJoystickPosition is backed by a NetworkVariable with read
permission Everyone (InputStatus.n_rNorm), on the InputStatus NetworkBehaviour that
lives on Player.prefab - which carries a NetworkObject and is registered in
DefaultNetworkPrefabs.asset, so IsSpawned is true in every networked session and the
getter returns the owner's stick on EVERY machine.

ScarabJukeController.Update had no ownership test, and nothing above the stick read
stops a replica: _jukeArmed re-arms everywhere (jukeCooldownSeconds 0), InputStatus is
non-null on replicas, and AutoPilotEnabled is false on every peer for a HUMAN pilot
(Player.StartPlayer only calls ToggleAIPilot for IsInitializedAsAI, and returns early
on network clients). So the whole fire path ran once per peer:

  - N cavitation plates per dash - each peer spawned its own AOECylindricalExplosion
    and shredded its own local prism set;
  - the Astro League ball took TWO kicks - the server's own replica-blast called
    AstroLeagueBall.ApplyBlastServer directly while the owner's blast arrived again
    through RequestBlastBall_ServerRpc (the double-credit shape BENDS.md records for a
    replayed blast);
  - replicas wrote ModifyVelocity for a vessel they do not own, fighting the
    owner-authoritative NetworkTransform;
  - and NotifyJukeFired_ServerRpc looked redundant, because the server was already
    firing - which is the tell that nobody expected the replicated stick.

SCARAB.md section 3.4 already specified the correct shape ("owner poll -> execute
locally -> Juke_ServerRpc -> Juke_ClientRpc -> non-owner peers play the visual"); only
the ServerRpc half had been wired, and without the owner gate the "owner poll" was
every peer polling. This lands the rest of it:

1. The fire path is gated on `_status.Player is not { IsLocalPilot: true }`.
   IsLocalPilot, NOT IsOwner: it is the same predicate InputController.Update uses to
   decide who may CONSUME the stick, and the only one that also holds on the legacy
   non-networked single-player spawn path where IsSpawned is false and IsOwner reports
   false for a human. A response to local input belongs behind the same gate as the
   input itself.
2. The 360 degree spin is now SENT rather than left to fall out of duplicated
   simulation: NotifyJukeFired_ServerRpc carries the roll sign and fans out
   BroadcastJukeRoll_ClientRpc (the host, owning the server copy, broadcasts directly
   instead of round-tripping). Non-owners get RollRoutine with a NULL transformer, so a
   replica writes only the visual child's local rotation - never the root bank, never
   BlockRotationOverride, both of which belong to the owner.

This also makes SCARAB.md's "a client's cavitation explosion exists only on that
client" true; it described an intent the code did not have.

Also noted in AOECylindricalExplosion: it is spawned directly, not through
ExplosionHelper.CreateExplosion, which special-cases the cone.

Verified: Roslyn type-check of ScarabJukeController against a stub harness transcribed
from the real VesselTransformer/IVesselStatus/IPlayer/NetworkBehaviour signatures;
structural compile of every file the branch touches; the Netcode RPC suffix convention;
and the ownership matrix walked by hand for host-owned, client-owned and observer
peers (owner rolls locally and skips the ClientRpc; every other peer rolls cosmetically;
the server's strike window still opens via the ServerRpc).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WPQZ9ActM4dCrqL9GUW8UV
…zero its zero state

Two defects found by reading the project's own settings and the blast's own exit paths.

1. THE CONTACT HOLD WAS SIZED AGAINST A TIMESTEP THIS PROJECT DOES NOT USE.
   contactHoldSeconds 0.05 was chosen against Unity's default 0.02 fixed timestep;
   ProjectSettings/TimeManager.asset runs 0.04 (25 Hz), so it guaranteed only 1.25
   physics steps at the plate's full extent - and one step is the difference between
   "the punch reliably catches a ball at its rim" and "usually". The hold is now floored
   at 2 x Time.fixedDeltaTime, so the guarantee is expressed in the unit it actually
   depends on and survives someone retuning the timestep. At 0.04 that is 0.08s / 2.0
   steps; the sweep itself is 5.25 steps.

2. AT DEPTH 0 THE TRIGGER WAS A 90x90 INVISIBLE PANE, AND A BLAST CAN FREEZE THERE.
   ShapeTriggerBox(0) produced 2R x 2R x epsilon - full width, no thickness - because
   only the axial term was driven by depth. The base spherical blast reaches the correct
   zero state for free (it zeroes transform.localScale in Initialize, which zeroes its
   collider with it); this one drives the collider directly on a scale-1 root and has to
   say so. It matters because AOEExplosion's cancellation contract deliberately leaves a
   cancelled explosion ALIVE at its current size so a visible blast does not pop out of
   the world - and the pre-visual early-out runs before the sweep has advanced at all, so
   an early cancel would have parked an invisible 90x90 vessel/ball hitbox in the arena
   for the rest of the match. Depth <= 0 is now a nothing-box on every axis, and that
   same pre-visual exit disables the trigger outright (nothing has rendered yet, so there
   is no continuity to preserve - only an inert husk holding a live hitbox).

The geometry proof now asserts both: the zero state is a nothing-box, and the contact
window is at least two physics steps at the shipped timestep.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WPQZ9ActM4dCrqL9GUW8UV
…t from typed-in numbers

Tools/Build/verify_scarab_cavitation_plate.py, in the shape this repo already uses for
generated constants (verify_gyroid_octagon_tables.py and friends) and for the same
reason: a geometry argument validated once in a session, against numbers typed into a
script, is not evidence about what ships. Between the argument and the asset there is a
TRANSCRIPTION, and that step is invisible to both the argument and code review.

It parses Scarab.prefab, AOEScarabCavitation.prefab and ProjectSettings/TimeManager.asset,
derives the plate the game will actually build, and asserts:

  - the hull collider is centred on the vessel origin (or "radius" means something else)
  - the Burst slabs tile [0, L] exactly at 10..240 fps - no gap, no reach past the tip
  - the drawn cylinder IS the damaged volume, frame for frame
  - the trigger box circumscribes and never under-reaches
  - depth 0 is a nothing-box on every axis (a cancelled blast can freeze there)
  - the contact hold covers >= 2 physics steps at the REAL fixed timestep, and is floored
    off fixedDeltaTime rather than being a bare wall-clock number
  - proportionalDebris is on and restitution x Inertia == 1, so debris leaves at the
    blast's own velocity
  - the plate child's authored rotation maps the built-in Cylinder's +Y onto the sweep
    axis, and the mesh really is the Cylinder (10206), not the Sphere it was cloned from

Proven to FAIL, not just to pass - four negative controls, each reverted byte-identically
afterwards: reverting the plate mesh to a Sphere, retuning Inertia off the parity value,
and un-flooring the contact hold all fail correctly.

The fourth control taught the tool something. Asserting "an inscribed sphere would
under-reach" failed on a plate authored at L >= 2R - a LEGAL aspect where a sphere would
be adequate and the box is still correct, just no longer necessary. That is a validator
enforcing a window it measured at one aspect, which is how a gate starts lying, so the
sphere comparison is now printed as info and the assertion is the one that actually
matters: the trigger never under-reaches.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WPQZ9ActM4dCrqL9GUW8UV
An eight-dimension audit of the right-stick dash -> cylindrical blast chain. Two of its
dimensions independently re-derived the replicated-stick defect already fixed in 5848011
(from the same evidence: InputStatus.n_rNorm, readPerm Everyone). These are the rest.

1. A STRAIGHT UP/DOWN JUKE AIMED THE BLAST AT WORLD +Z. The blast's rotation is
   Quaternion.LookRotation(dir, ship.up), and a vertical juke makes dir EXACTLY ship.up -
   the keyboard's right stick is digital, so it hands over exactly (0,1), and
   ProjectOnPlane leaves it untouched while flying straight. LookRotation cannot resolve a
   basis from parallel vectors and returns identity. That was harmless when this line was
   written, because the blast was a SPHERE; the plate reads its sweep axis straight off
   that rotation, so the punch swept and shoved mass along world +Z while the player
   watched their ship dash upward. The existing sqrMagnitude guard cannot catch it either -
   identity yields a perfectly unit forward. Now picks an up-hint that cannot be parallel;
   the cylinder is rotationally symmetric about its axis, so the hint only has to be usable
   (unlike the cone's gape axis, which IS gameplay).

2. THE CRYSTAL SWEEP REACHED 43 UNITS BEHIND THE PILOT. Prisms come off the exact Burst
   slab, but crystals came off the swept cylinder's BOUNDING SPHERE - and a sphere cannot
   bound a squat cylinder tightly: at 45 wide by 54 long it reaches 42.9 u behind the hull
   on the first frame. A crystal conversion is not a soft outcome, it SPENDS the crystal and
   forges a ball, so the blast built balls out of mass it visibly missed while its own prism
   half agreed it had touched nothing there. The sphere is now only the broadphase;
   SweepCrystals takes an optional SweptCylinder narrowphase running the same predicate
   AOECylinderSweepQueryJob runs on prisms. Verified against the reported case: a crystal 30u
   astern is inside the sphere and outside the cylinder; one genuinely inside the plate
   still resolves.

3. THE BLAST HAD NO TURN-END KILL SWITCH. AOEExplosion.gameData is [Inject] and is the only
   thing that wires OnMiniGameTurnEnd -> CancelExplosion and OnResetForReplay ->
   PerformResetCleanup, but ScarabCavitationBlast spawned the blast with a bare Instantiate
   and never injected - so this was the one AOE in the game that kept sweeping after a turn
   ended and survived a replay reset. Silent, because the null-guard that swallows it is
   written exactly as a good null-guard is (CLAUDE.md's anti-pattern on relying on [Inject]
   at a non-injecting spawn site). Now injected off the vessel impactor's container, before
   Initialize, the way ExplosionHelper does it.

4. PROSE THAT QUOTED DERIVED NUMBERS WENT STALE. The 5x/3x resize (3988d56) moved the
   geometry but not the rationale written by 91faccc, so SCARAB.md and the load-bearing
   comment in AOECylindricalExplosion.Initialize still asserted an 18 u reach, a 0.070 s
   duration and a 5.6 u dash displacement against the shipped 54 / 0.210 / 16.8. Re-anchored,
   and the general lesson recorded next to the rule that caused it: when a blast's reach
   becomes DERIVED, prose quoting the derived numbers goes stale the moment it is retuned -
   state the relationship and let the verifier print the arithmetic.

5. TWO UNCONDITIONAL LOGS PER DASH. CLAUDE.md requires finished-system bring-up telemetry to
   move to a CSLogChannel rather than shout forever. New CSLogChannel.ScarabDash, off by
   default, guarded by IsVerbose so the interpolation is not paid either.

Also corrected two stale "BoxCollider" descriptions of the Scarab hull left over from the
sphere swap (ScarabHullBuilder's class doc, SCARAB.md section 3.0).

Reported but deliberately NOT changed, because each is a design call rather than a defect:
DualMouseInputStrategy never writes the right stick (so the dash is unfirable there);
holding the stick at the perimeter re-fires the dash every 0.5 s (the blast's own cooldown
still paces the punch); the hull sphere over-covers the body vertically by ~2.9x, which is
inherent to fitting one sphere to an elongated hull; and the Scarab's skimmer strictly
contains its hull, so the hull's two vessel-crystal effects can never fire.

Verified: Roslyn type-checks of both changed files against stub harnesses transcribed from
the real signatures, structural compile of every changed file, the plate verifier, and a
numeric proof of the narrowphase against the exact scenario the audit reported.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WPQZ9ActM4dCrqL9GUW8UV
…nert half-fix of my own

The audit's critic re-read the branch at HEAD and checked eight areas none of the eight
audit dimensions covered. Four were real.

1. MY OWN DI FIX (1bafccc) WAS INERT, and its comment documented behaviour that does not
   occur. Injecting at the spawn site only makes the subscription POSSIBLE: Instantiate runs
   Awake+OnEnable before any call site can inject, so gameData is null there, and the retry
   lives in AOEExplosion's PRIVATE SubscribeToGameEvents - called from the base Initialize,
   which AOECylindricalExplosion replaces wholesale and could not reach. So the blast was
   injected and still never hooked OnMiniGameTurnEnd / OnResetForReplay. The method is now
   protected, with a doc comment saying why a subclass needs it, and the override calls it.
   Both halves are required; neither works alone.

   AOEConicExplosion has the identical gap and is deliberately NOT touched - that is the
   Dolphin's play-tested blast in Bends and Rampage, and it deserves its own change and its
   own playtest. Recorded in SCARAB.md so it is not lost.

2. THE ABILITY MAP CONTRADICTED THE REQUIREMENT. Scarab.asset declared the Cavitation Blast
   with Input 11 = OnlyRightStickAction, which on gamepad is raised from the right TRIGGER
   (GamepadInputStrategy builds both stick events off rightTrigger.ReadValue() > deadzone,
   despite their names) - so the four-icon row would have taught the blast as a trigger
   ability the moment that row is authored. The enum has no member for "right thumbstick at
   the perimeter" (the juke polls the axis directly and raises nothing), so it is now 0, the
   same "no input-event binding" value the Space entry already uses for the ball mint.

3. THE SAME ASSET STILL DESCRIBED A SPHERE to the player - "every right-stick dash throws a
   compact spherical blast" - which is the exact geometry this branch replaced. The three
   doc-drift findings all named SCARAB.md and code comments; the shipped text a player reads
   was missed by every one of them. Rewritten to the swept plate, and to name the control.

4. THE DASH WAS UNFIRABLE ON DUAL MOUSE. DualMouseInputStrategy set only the EASED stick
   pair and never the normalized one, so RightNormalizedJoystickPosition was permanently
   (0,0) on that strategy - the Scarab's dash and blast could not fire, the Sparrow's gun aim
   never tracked, and the thumb-perimeter UI never moved. Its virtual stick is already
   ClampMagnitude'd to 1, so publishing it is the same contract the other three strategies
   ship; nothing is newly computed, it was simply never handed over.

Also: the plate verifier now STATES the one thing it cannot prove - the hull collider's world
lossyScale lives in SparrowModel1.fbx's binary, so R=45 assumes a unit-scaled FBX root. The
blast sizes itself correctly either way and every assertion is about relationships that hold
at any scale, but whether 4.5 is the intended WORLD radius needs one look in the editor, and
the checklist now says exactly where to look. And four latent traps the critic surfaced are
recorded in SCARAB.md: the trigger-named stick events, blast<->ball working only because the
ball is layer 0 (Explosions does not collide with Crystals), the shared 16-element crystal
overlap buffer, and the debuff effect asset shared with the Dolphin's cone.

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

Missed by the previous commit, which staged Assets and Tools by path and left Docs
behind — exactly the half-landed shape the tooling contract warns about, caught by the
stop hook rather than by me.

The plate verifier prints a caveat it cannot resolve: the hull collider's world scale
lives in SparrowModel1.fbx's binary, so "r 4.5 -> a 45-unit plate" assumes the FBX
instance root is unit-scaled. This is the matching in-editor step — select the hull
GameObject, read its world scale — so the caveat has somewhere to be discharged instead
of being a note nobody acts on.

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

Three changes, all Scarab-side.

1. The skimmer forges from OMNI crystals only. An elemental crystal is the
   platform's element economy - it is how every vessel levels Charge/Mass/
   Space/Time - so spending one on a ball meant a Scarab could never level an
   element it flew past. ScarabBallForgeBySkimmerCrystalEffectSO now returns on
   anything that is not an OmniCrystalImpactor, handing the crystal back to the
   HULL, whose four elemental branches collect it normally. TeamCrystalImpactor
   derives from Omni and is deliberately included. The BLAST forge was already
   omni-only by construction (ExplosionImpactor.SweepCrystals only picks up
   OmniCrystalImpactor), so this makes the two paths agree.

2. The Scarab's hull carries no omni-crystal effects
   (ScarabImpactorDataContainer.vesselCrystalEffects is now empty). The skimmer
   sphere strictly contains the hull, so whatever the skimmer converts the hull
   never sees; an omni effect there could only fire on a crystal the forge had
   already refused, and there is no such case. The four elemental branches keep
   their effects, because those are exactly the crystals the forge hands back.

3. A CELL holds at most 4 loose balls. When a further one enters, every loose
   ball in that cell detonates regardless of domain, the arriving one included -
   one networked event on every peer (TickCellMembershipServer ->
   DetonateAllLooseInCellServer -> CellOverload_ClientRpc), using the same
   per-ball DetonateWithRadiusServer the nucleus overload uses so the two read as
   one event. Embedded/hidden balls do not count: a ball studded in the nucleus
   is not in play, and knocking it loose is exactly the event the rule is about.

   This replaces Scarab Scramble's per-DOMAIN cap enforced at FORGE time
   (ScarabBallForge.ForgeGate, ballsPerPlayer x roster - both retired). A rule
   enforced at one producer can only ever see that producer: a ball enters play
   two ways, forged from a crystal and knocked loose out of the nucleus, so the
   forge-time gate was blind to half of them. It was also arbitrary - it fired at
   2 balls for a solo pilot while the same court held 6. The limit lives on the
   ball prefab because it is a platform ball rule, not a mode policy, so it
   travels into freestyle and the menu with nothing to install.

   Two ordering hazards handled: the announcement is sent BEFORE the detonation
   loop (a forged ball is despawned by its own detonation, so the RPC would be
   sent from an object that no longer exists), and ServerFixedUpdate bails when
   the tick detonated this ball rather than continuing to write NetworkVariables
   on it. The rule is gated on IsSpawned && IsServer, since a no-network local
   session can neither detonate nor send the RPC.

Docs: SCARAB.md 4.1/4.6, SCARABSCRAMBLE.md, UNITY_VERIFICATION_CHECKLIST.md
(incl. an MPPM step for the synced overload and a check that both entry routes
trigger it), GameToastSituation comment.

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

Session retrospective from the Echo Sight replication + AI orbit-break work.

Technique 4.5d — offline simulation of a CONTROL LOOP: extract the pure math
into a UnityEngine-free static class so the tested path is the shipped path,
integrate a bounded-turn-rate plant, score over a randomized ensemble rather
than a scenario, and measure candidate fixes against each other before
shipping one. Two of this session's plausible fixes were rejected on those
numbers, which is what forced the search to continue to the real cause.

Traps:
- a comparison mixing a squared quantity with a linear one is invisible to
  review and to every static check; its symptom is behaviour nobody
  attributes to arithmetic
- a guard that belongs to a heuristic FALLBACK must not be inherited by an
  explicit provider (the named case is the one the guard rejects)
- an AI's engagement range is authored independently of its weapon's reach
- IsLocalPilot and IsOwner coincide for every human and diverge for every AI

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014uhgLqwZSu9Zini2pXcs68
…prisms-cost-ldgl3n

Every pilot sees every Echo Sight, and AI stop orbiting their objectives
The Dolphin's `DefaultMinimumSpeed` was authored 10, so a released throttle
left the vessel crawling at 10 u/s instead of stopping. Set it to 0 and
re-derive the speed ladder in DOLPHIN_ENERGY_ECONOMY.md (cruise 78 -> 68,
boost 357 -> 347).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DzR5V5ZcTBwbXs1eY89Hj3
…b-squirrel-colliders-4po5km

# Conflicts:
#	Assets/_Scripts/Controller/Vessel/R_VesselActions/SCARAB.md
#	Assets/_Scripts/Utility/CSDebug.cs
- UNITY_VERIFICATION_CHECKLIST: new newest-first entry with the in-editor steps
  (throttle-off is now a real stop; drift-at-rest and AI are the risk cases), and
  a superseded marker on the earlier retune's knob table.
- SPEED_TUNNEL fleet table: the Dolphin row still carried its pre-retune 60/210;
  now 68/347 with the squared boost multiplier noted.
- ElementalAbilitySystem/BACKLOG item 20: retune figures re-derived off floor 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DzR5V5ZcTBwbXs1eY89Hj3
…eed-59q8ay

fix(dolphin): minimum speed is zero
… steers it

Reported twice as "looks the same as always", and the reason is a design error
rather than a plumbing one: the rotation was made PROPORTIONAL to the breaking
impulse. A shatter with no impulse - or with one that never reaches the GPU -
therefore had no rotation at all and rendered as the pre-change fly-out, pixel
for pixel. That is indistinguishable on screen from "the feature is not wired",
which is exactly how it read.

The reporter's own description isolated it: the faces DO fly along their normals
and DO respect the shatter duration, so Normal, Direction, ShatterOffset,
StartTime and Duration all demonstrably reach the shader. Only the new float3
_ShieldMorphVelocity does not. (Ruled out along the way: the C# value is not
zero - SpawnablePrism.prefab serialises the shield component with an EMPTY field
block, yet the shipped 0.6s duration and 3-unit fly-out are exactly the C#
initialisers, which proves Unity applies initialisers for absent fields and so
shatterDriftSpeedCap is live at 20.)

So the headline motion no longer stands on the least-proven input in the chain:

  * PRISM_SHIELD_SHATTER_TUMBLE (rad/s) is ALWAYS ON and depends on nothing but
    the clock and the mesh's own face normal - both proven to arrive.
  * PRISM_SHIELD_SHATTER_SPIN (rad per world unit travelled) rides on top, so a
    hard hit still spins harder than a soft one.
  * The axis is cross(v, n) - the explosion's own axis - when there is an impulse,
    and the face's in-plane Duff-basis tangent when there is not. In-plane
    matters: the face TIPS AWAY from where it was pointing instead of spinning
    about its own normal like a plate on a stick, and since the object-space
    normal is the face id on these hard-edged meshes, the eight faces never
    tumble in lockstep.

General rule now recorded in 4.8.1: an effect's headline motion must not be gated
on the newest, least-proven input. Put the new input on the refinement.

Also: shatter durations 0.6 -> 1.0s (octahedron) and 0.7 -> 1.1s (stellation),
in the initialisers AND in the two prefabs that serialise a value, since "they
don't last long" was the other half of the report - a fly-out reads in a third of
a second, a rotation needs long enough to be seen turning.

The verifier's headline case is inverted to match: it now asserts that a shatter
with NO impulse still tumbles, and that the impulse-less tumble is pure rotation
about the centroid (no drift). Twelve checks, all passing on the shipped HLSL.

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

The merge with bleeding-edge surfaced doc drift my own earlier commits left
behind, plus one enum collision with a parallel branch.

- CSLogChannel.ScarabDash moved 1 << 4 -> 1 << 5. The switch-logging branch
  claimed 1 << 4 first and merged, and two branches adding "the next bit" merge
  cleanly into an enum with duplicate members. The mask is a session-only static
  (no EditorPrefs), so renumbering costs nothing.

- UNITY_VERIFICATION_CHECKLIST: step 3b still described the cavitation blast as
  "a small SPHERICAL explosion ~45u ahead (diameter 90)" and the tuning line
  still carried blastScale/forwardOffset/Inertia 1.8 — all replaced by the swept
  plate on this branch and never re-written. Step 4 still described the retired
  HULL forge (a ball appearing ahead of the nose carrying your speed); the
  skimmer converts at rest, so the check is now the opposite one, and step 8's
  "dash-into-crystal parity" is retired for the same reason.

- CLAUDE.md: the ScarabScramble paragraph described the per-domain ForgeGate cap
  that this branch replaced with the per-CELL ball limit, and the AOE-impulse
  paragraph named only the Dolphin cone as proportionalDebris-on. Both updated,
  including why the Scarab's product is deliberately 1.0 (debris speed IS the
  sweep speed) and what a third AOE shape has to carry.

- ScarabBallForge.ForgeGate's docstring claimed Scramble installs it. Nothing
  installs it now; the hook is kept for a mode that wants to gate FORGING, with
  a note not to reach for it to bound a population again. Recorded in
  SCARABSCRAMBLE.md's follow-ups alongside the poll-vs-event note.

Session retrospective (skills):
- asset-surgery: CS2001 on a path you can cat is a QUOTING bug — this repo's
  space-bearing directories shred a shell-expanded file list; use a response
  file unconditionally. Plus three networking ordering traps: announce a batch
  removal before performing it when the announcer is in the batch (a ClientRpc
  needs a live NetworkObject); a per-object tick that can destroy `this` must
  return a bool so its caller bails; a server-only rule must be gated on
  IsSpawned && IsServer, since the local no-network path runs the same tick.
- ship: grep AssetDatabase.CreateAsset, never a bare CreateAsset —
  [CreateAssetMenu] matches the short form and reports gameplay SOs as tools.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WPQZ9ActM4dCrqL9GUW8UV
…lliders-4po5km

feat(scarab): sphere hull, +50% skimmer, swept-plate dash blast, omni-only forge, per-cell ball overload
explosion's duration

Two corrections from playtest, both of them the base explosion material's own
method that the shatter was not following.

THE PIVOT. The rotation was applied to (MorphedPosition - FaceCentroid), which
pivots the face around its BAKED centroid - a point it has already flown up to
ShatterOffset units away from along its normal. The face therefore orbited that
point instead of spinning about its centre of mass, which reads as "not really
rotating". The shatter now splits the face the way the explosion material does:

    faceCenter = FaceCentroid + offset*Normal      // where the centre ends up
    rel        = faceScale * (Position - centroid) // the face, centred on ITSELF
    p          = faceCenter + R(rel) + drift

Translate the face centre to the origin, rotate, translate back. The split also
makes the drift immune to the tumble for free - it is added to the CENTRE, never
rotated with the geometry.

THE DURATION. The shatter ran 0.6-1.1s against the prism explosion's 7.5s and
looked truncated beside it. Both tiers now default shatterDuration to
PrismExplosion.DefaultDuration rather than a number of their own, so the two can
never drift again: a shield coming apart and a prism coming apart are the same
event class. Authored into the two prefabs that serialise a value; the main
SpawnablePrism.prefab serialises none and picks up the initialiser.

The tumble is a RATE (rad/second, like the explosion's own), so a longer shatter
spins further - PRISM_SHIELD_SHATTER_TUMBLE re-scaled 4.0 -> 1.2 rad/s for the
new life. Noted in the docs: the shatter's removal mechanism is the face
CONTRACTING to a point rather than the explosion's opacity fade, so the
contraction is now correspondingly slow and is the knob to watch if it ever
reads sluggish.

The verifier is rebuilt around the same decomposition and grows the regression
test for exactly the reported symptom: the rotation must preserve the distance
to the face's own centre, and must NOT preserve the distance to the baked
centroid. Mutation-tested - restoring the old pivot fails both. Thirteen checks,
all passing on the shipped HLSL.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HECJksYQTkpbPwpFWzTiBp
Every future tool (human- or AI-written) now writes against one normative
checklist: placement/naming, mandatory [FrogletTool] metadata with calibrated
importance, palette-only colours with the banner accent bound to the category
colour, the standard window anatomy, config-in-ScriptableObject, the
reader/writer declaration, console discipline, and the paperwork (tool + file
index rows) that ships with a keeper tool.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CSU6uKMdHPVw7QG9vqwZFh
…etector)

Always-on editor crash watchdog. A BelowNormal background thread drains a
concurrent queue of errors/exceptions (captured via
Application.logMessageReceivedThreaded, so capture never depends on a
responsive main thread) into an append-only journal under Logs/CrashDetector/,
flushing after every drain, and heartbeats a session sentinel carrying the
editor state plus a main-thread liveness stamp.

A clean exit marks the sentinel clean; a domain reload re-adopts the session by
pid. When the next launch finds a sentinel that is neither clean nor owned by a
live Unity process, the previous session ended abnormally - Unity crash, PC
fault, force-kill, hang-then-kill - and a Crash-*.log report is written from
the stale sentinel, the captured error journal, and the tail of Unity's own
Editor-prev.log, with a hang-vs-abrupt-death verdict derived from the liveness
stamp. Reports are pruned to a cap; an error storm is rate-limited and the
journal size-capped so the detector can never fill a disk.

Reader tool per Docs/TOOLING.md: writes only machine-local files
(Logs/, UserSettings/), never assets - no ledger, no ship panel. Settings are a
ScriptableSingleton under UserSettings/; the window (banner, status pills,
verdict card, report rows, settings) draws entirely through
FrogletEditorPalette per the new authoring contract.

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

New FrogletTools lane (FrogletToolCategory.Diagnostics, indigo accent,
"Diagnostics & Health") per the TOOLING.md curation protocol - enum, palette
map, LabelFor, registry keywords and the section table move together. The
crash detector refiles under it: CrashDetector/ becomes Diagnostics/ and the
window becomes DiagnosticsWindow (git renames, GUIDs stable), now tabbed
Crash Detector + Bug Ledger with a board card for each.

The Bug Ledger is the team's live bug list inside the editor. Every distinct
error/exception/assert signature auto-files one issue into BugLedger/issues/
at the project root - one small JSON file per issue, committable and
merge-friendly: ids derive from the normalized signature (digit/hex runs
collapsed; machine-local path prefixes cut back to Assets/ in both mono-style
" in " and unity-style "(at " frames), so the same bug lands in the SAME file
on every machine and reviewers only ever see the issues a branch touched.
Capture rides Application.logMessageReceivedThreaded into a background worker
so the log path never blocks on file IO, and one occurrence write per issue
per editor session keeps the committed files quiet.

A fix is not believed until the game proves it: Mark Fixed -> VALIDATING, and
the issue closes (file deleted) only after its clean-session quota - play
runs for play-mode bugs, full editor sessions for edit-mode ones, each with a
minimum length so an accidental play press is not evidence, and a bounded
queue drain before the session verdict so an in-flight error cannot be
credited as clean. A recurrence while validating reopens the issue as a
regression, loudly. Per issue: pause validation, ignore (parks it and
suppresses re-filing), resolve now, delete; custom bugs file by hand, and a
crash report files into the ledger with one button.

Serialization is hand-rolled both ways (worker-thread-safe, deterministic
one-field-per-line layout); the parser tolerates unknown keys, missing keys
and garbage files. Round-trip, parser tolerance and cross-machine signature
determinism are proven by compiling and EXECUTING the shipped code offline -
which caught the unity-style frame format keeping its absolute path, fixed
before ship.

Reader tool per Docs/TOOLING.md: no Assets/ writes, no ship panel; settings
are a ScriptableSingleton under UserSettings/, and the committable store's
contract lives in BugLedger/README.md.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CSU6uKMdHPVw7QG9vqwZFh
The shatter removed its faces by scaling each one to a point, which reads as the
shield DEFLATING. Debris does not shrink - the exploding prism keeps its faces at
full size and PrismErosionFade sweeps one hard, jagged, UV0-anchored front across
each of them. The shield now does the same, on BlockGraph, where a shielded
prism's own material actually lives.

  * faceScale stays 1 for a shatter (the bloom still scales out from the centroid)
  * PrismShieldMorph gained an Opacity output - 1 unless shattering, then a LINEAR
    1 - p, because the erosion's thresholds are CDF-fitted against a linear alpha
    ramp rather than the eased t. Appended as slot 10, so the migration renumbers
    no existing edge.
  * the shield meshes grew UV0 (Octahedron/StellatedOctahedronMeshGenerator
    .ErosionUVChannel). The erosion anchors to a MESH attribute by design and these
    meshes carried only UV1. Each face maps to the same isoceles triangle in the
    unit square; identical UVs do not make faces peel alike because the wipe's
    direction and jag are hashed PER FACE from the centroid. Safe to add only
    because BlockGraph reads UV0 nowhere else - the tool asserts that before it
    splices.

BlockGraph MULTIPLIES rather than replaces: _Alpha x Survival -> BaseAlpha. A prism
that is not shattering has Opacity 1, PrismErosionFade returns Survival 1 outright
at BaseOpacity >= 1, and Alpha x 1 is Alpha - bit-for-bit the old chain, INCLUDING
the cloak family's authored near-zero alpha. Feeding the erosion the material's
alpha directly would have put a wipe on every cloaked prism.

wire_prism_shield_erosion.py owns this splice and deliberately does not share a
tool with wire_prism_explosion_erosion.py: same HLSL function, two graphs, two
drivers, disjoint asset sets, so neither can regress the other. It ships with the
acyclicity and property-node-type gates whose absence turned this branch magenta
earlier.

Also confirms, by assertion rather than by reading: the tumble axis IS
normalize(cross(Velocity, Normal)) when an impulse is present. The verifier states
the full expected position, so it fails if the cross product is dropped,
mis-ordered or left un-normalised. Seventeen checks, all passing on the shipped
HLSL, plus a new edit-mode gate that every face's UV0 is a real non-degenerate
frame (an empty channel would pop each face instead of eroding it).

Known gap, stated: a prism that is BOTH transparent and shielded renders its shards
on ExplodingBlockGraph, where the erosion is driven by the explosion clock - which
is unstamped for a shard, so TransparentPrismMaterial's resting _Opacity of 0 makes
those shards invisible. Predates this pass, unchanged by it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HECJksYQTkpbPwpFWzTiBp
…rity, doc links

Four upgrades to the Diagnostics family plus its documentation and a doc-link
system for the whole tool board.

Resolved archive: closing an issue now stamps it (state, resolvedUtc,
resolution) into BugLedger/resolved/ - pruned to a cap, toggleable - instead of
discarding it outright; git history keeps everything either way, the archive
makes "what got fixed lately" browsable.

Shared signature core: the fingerprint pipeline moved to
CosmicShore.Utility.BugSignature - runtime-safe on purpose, so the planned
in-game reporter can hash device-side errors with the SAME code and write the
same ids into UGS player data. Pinned by BugSignatureTests (edit mode),
including the cross-OS frame case the first implementation failed.

Tool findings: BugLedger.ReportFromTool / ReportToolFindings let auditors file
failures as tracked issues, deduped by (tool, normalized title) so re-runs
refresh rather than duplicate - and the TOOL is the validator: a full clean
re-run auto-resolves its validating findings (quota 1; a deterministic re-run
is stronger evidence than any number of play sessions). Tool issues carry
scope "Tool" and are excluded from session-based validation by construction.
VesselSkimmerAudit is the reference integration (findings offered via one
dialog, clean runs credit silently); the crash detector's File Bug button now
dedupes by report filename and files at blocker severity.

Severity: blocker/major/minor on every issue - serialized (missing key reads
as major, so pre-severity files stay valid), sorted within each state,
click-to-cycle pill on rows, popup on the new-bug form.

Doc links: [FrogletTool] grew DocPath (repo-relative path + optional #anchor).
Documented cards on the Froglet Master Tool board carry a DOCS chip and an
"Open documentation" context entry; FrogletDocLinks builds the GitHub URL from
the checkout's own origin remote on bleeding-edge and falls back to the local
file. DocPath authored on the board itself, Pending Tool Changes, Game Mode
Prefab Kit, Validate Clock Wiring, the skimmer audit, and both Diagnostics
cards; the Diagnostics window gained a Docs button. The authoring contract now
requires DocPath on documented tools.

Docs/DIAGNOSTICS.md is the proper documentation of both tools - mechanisms,
store contract, signature identity, the full lifecycle with validation
evidence rules, honest limits, the tool-author API, and a 5-minute in-editor
verification script - indexed from CLAUDE.md and TOOLING.md;
BugLedger/README.md updated to match.

Verified offline: full-reference Roslyn type-check of the diagnostics family
clean; 34 executed assertions over the shipped code (JSON round-trip incl. the
new fields, parser tolerance, cross-machine error ids in both stack-frame
formats, severity model, tool-id stability, remote-URL parsing);
conditional-compilation gate, meta parity and ambiguity sweeps clean.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CSU6uKMdHPVw7QG9vqwZFh
Both fixes target work that runs inside the window the editor spends in
"Run managed callbacks", where the main thread is blocked and Windows
marks the editor Not Responding.

PlayModeSOProtector: the play-mode SO snapshot held ~11 MB of asset text
across ~790 SessionState string keys for the whole play session, read
every file back on exit to compare contents, and called ImportAsset with
ForceUpdate one asset at a time (each triggering its own synchronous
refresh). It now snapshots to Library/PlayModeSOSnapshot, gates restore
on write time + length so an untouched asset costs one stat call instead
of a full read-back, and batches the reimports inside
StartAssetEditing/StopAssetEditing. A small SessionState bool keeps a
crash-orphaned snapshot from being replayed into a later editor session.
Assets created or deleted during play are still left alone.

SceneBootstrapper: the OnValidate-noise auto-save ran
EditorSceneManager.SaveScene on the active scene after every domain
reload and every play-mode exit whenever Cinemachine/URP/NetworkManager
OnValidate dirtied it -- Menu_Main is 4.1 MB. It is now opt-in and off
by default, toggled from FrogletTools > Scene Setup > Testing
Multiplayer. With it off the scene reads as dirty and saving is the
developer's call.

Docs: PERFORMANCE_OPTIMIZATION.md Task 10 records what the phase is, the
five ranked contributors, the Editor.log "Domain Reload Profiling:"
block as the measurement that ends the guessing, and the three deferred
items with the reason each needs its own pass.

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

Resolves Task 10's two actionable deferred items (the third, FMOD, stays
capture-gated).

Enter Play Mode Options is ON (m_EnterPlayModeOptionsEnabled: 1, both
Disable bits kept), so a Play press no longer pays the domain reload that
was the largest cost in the 'Run managed callbacks' stall. The audit that
gated it classified every mutable static and static event in the 259
runtime files that carry one, and ships 52 new
[RuntimeInitializeOnLoadMethod(SubsystemRegistration)] resets for the
needs-reset bucket: OnApplicationQuit latches that fire on editor play
exit (PrismEffectsManager._isQuitting killed half the VFX system from the
second Play on), Time-stamp comparisons across the restarting clock
(haptics rate limits, combat-hit latches, per-impactor explosion
cooldowns), begin/end bracket counters whose finally a play exit skips
(PrismTrailBuilder's arena gate, Prism bulk transport), benchmark
overrides with no restore path, disk-cache Initialized latches, and the
dormant PlayFab session statics. Two structural cases: a non-generic
reset host for NetworkClientCache<T> (the attribute never fires on an
open generic), and an instance teardown for TournamentController, whose
SceneManager.sceneLoaded subscription outlives its pure-C# singleton.

Obvious.Soap gets the project's second [Cosmic Shore patch]:
ScriptableEventBase had no play-mode lifecycle at all, so an event's
private _onRaised delegate survived every Play press and the
un-unsubscribed constructor lambdas in AnalyticsServiceFacade (14),
ApplicationStateMachine (3) and TournamentController (1) would have
stacked one dead handler set per session. Events, lists and dictionaries
now clear their C# delegates at both play-mode boundaries,
ScriptableVariable also clears _onValueChanged, and all four families fix
a reimport double-subscribe. Reflex 14.1.0, UniTask, Netcode 2.5.0, FMOD
and DOTween were verified compatible from pinned source.

The editor asmdef split is closed with data, not shipped: 79 of 168
editor files are tests that cannot move, only 26 of the remaining 89
(~17% of editor LOC) are free of gameplay-type references, and the split
cannot touch the reload cost - only sub-second compile time.

Docs: Task 10 records the resolution, the recurring leak shapes for
review calibration, and the protocol that keeps the flip safe (second-
Play misbehavior means a missed static; fix with a reset, not by turning
the option off).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SNzk7VVDVWNsM6G9SvNoBw
claude and others added 29 commits August 25, 2026 23:46
…ce sets

THE MISSING BAND. The gauge track is drawn after the ability plate and, at
gaugeCellFraction 1, is exactly the same trapezoid - so at full width it painted
over the plate's slant band and that card silently lost its edge. It only
happens on a card that actually BINDS a meter, which is why it showed up on the
Squirrel's Charge slot alone (and would have on Sparrow Time and Scarab Space).
Everything laid on a plate now insets by PlateEdgeReach = slantEdgeThickness +
slantEdgeAntialias, so the band FRAMES the meter, which is the better read
anyway. The general shape is worth remembering: a decoration that exactly covers
the thing beneath it is invisible until something else needs what is underneath.

TIGHTER ROW. cardPitch 137.7 -> 116, authored as plateWidth + 2x cellGap so the
space BETWEEN totems is exactly twice the space WITHIN one - the same
relationship as inter-word against inter-letter spacing, and what keeps four
cards reading as four objects rather than one strip. Margins 65.1 -> 40 right
and 53 -> 44 bottom; the chip's own bottom margin goes 21px -> 14px.

CONTROL CHIPS. chipGap 8 -> 6, matching cellGap: the chip is a third element in
the same vertical stack, so it should clear the ability plate by the same
distance the element plate does.

And the reason they moved when the device set changed: Unity does not lay out
inactive hierarchies, so placing a hidden set's glyphs measured a stale or zero
parent rect and baked a wrong anchor into them - pad -> keyboard -> pad brought
the pad glyphs back somewhere else. TryApplyAbilityPlacement now skips anything
that is not activeInHierarchy, and ApplySet re-arms placement every time a set
is shown, so each set is placed from live rects on the frame it appears. That
also makes the placement self-healing against anything that moves the row later.

Auditor and tests gained the three relationships this pass introduced: cards
must sit further apart than a totem's own plates, chipGap must equal cellGap,
and the gauge must be inset rather than flush with the plate outline.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YZnzo7ipUR6TSQ9FjMf7bt
The missile crosses ~360 u of arena over three seconds and a hit is worth 50
points in Dog Fight, so everyone in the arena has a reason to see it coming and
know whose it is. It gets a TAIL — the long streak tuned for legibility at range
(Docs/VESSEL_TAIL_AND_JETS.md) — not a jet (a short plume on an engine node,
tuned for the pilot flying that engine) and not a trail (conserved prism mass;
this carries no collider, no state, and killing it destroys nothing).

A tail is defined by its PURPOSE, not its owner. The contract said "vessel"
because vessels were the only things that had one; this is the first non-vessel.
A jet stays vessel-only: it is a readout of an engine, and a missile is one.

The shared asset, not a copy. SkyBurstProjectile.prefab NESTS
Components/VesselTail.prefab, so a retune of the tail's look reaches the missile
with no second edit. On the instance, widthScale and the authored local position
are both inert — the code owns them, per flight, because the round's size is not
a constant.

Both missile-specific numbers are MEASURED, off the geometry the hit sphere is
already fitted to, so the tail and the hit volume cannot disagree about how big
the round is:

  mount  the model's measured rear face, scaled by growth (Projectile.TailMount)
         — the exact mirror of the nose fit
  width  0.4 x the round's own body diameter (Projectile.TailWidth) — 3.05 u at
         resting Mass. Deriving it is not optional: the round swells 14x-38x with
         MASS and a TrailRenderer's width is world-space, so a fixed ribbon would
         be a thread behind a 63 u missile. 0.4 is the ratio the one hull the
         fleet actually tuned a tail against already flies at (widthScale 2.5 on
         a ~6.4 u hull), so the missile's tail reads as the same KIND of streak.
  colour the firing pilot's live domain, re-read per flight, never snapshotted —
         the same reason ApplySpikeAppearance is

Two things a pooled round needs and a vessel does not:

  * ReclaimTail() clears the ribbon at launch. A TrailRenderer records
    world-space points, so without it every reissue draws one straight line from
    wherever the last missile detonated to this one's bay.
  * ReleaseTailToFade() cuts the tail loose at retirement — the round is switched
    off 0.025 s after it detonates, which would blink several hundred units of
    live ribbon out in one frame. The blast covers the head of it; the rest is
    nowhere near the explosion. Continuity of existence applies to a tail exactly
    as it applies to a prism. It comes home when the fade ends or when the round
    is re-fired, whichever is first: a 20-deep pool cycles faster than a 4 s
    ribbon.

TailGradient (new) is now the ONE composition of a tail's colour gradient, shared
by VesselTailAndJets and Projectile — two transcriptions of one gradient drift
the first time either is retuned, and a tail that reads differently depending on
what wears it has stopped being one signal.

VesselTail.prefab loses six dead disabled particle systems (~690 KB -> ~4 KB).
They were free on a vessel and would not have been on a pooled projectile: the
skyburst pool is 20 deep per Sparrow, so a four-Sparrow match would have carried
~480 disabled ParticleSystems nothing could ever play. Proven safe rather than
assumed — every reference into that prefab anywhere in the project resolves to
one of the four surviving objects.

Also excludes the tail from CacheTransformRole: a TrailRenderer is a Renderer,
and counting it would tell a bare hit sphere (the turret's carried collider) that
it draws a model, silently switching its growth off the moment it got a tail.

TailMount/TailWidth are pure statics with five new cases in
SparrowRoundGrowthTests (rear-face fit, sign, width curve, the 0.4, monotonicity
in Mass).

Not editor-verified — /verify-unity could not run in this session. Roslyn parsed
every changed .cs clean, the shipped numbers re-derive to the prefab's collider
bytes exactly, both edited prefabs pass a YAML integrity pass, and
check_conditional_compilation.py is green. The look is unearned; the in-editor
steps are Prompt 17 in Docs/UNITY_VERIFICATION_CHECKLIST.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y7R8eSqTufRumLLyxLyabo
The glyph "moving" on a device switch was never a drift. It was a SIZE
mismatch, and my previous fix addressed a cause that was not the cause.

Measured off Squirrel.prefab: the pad icon strips are 269x11 px with glyphs
spanning 50x50, while the PC text strip is 366x22 with glyphs at 106x22.
Placement centred every hint on its card's 24px chip socket, so the two sets
got DIFFERENT clearances - the pad glyph overhung the card by 7px while the
keyboard one sat 7px clear. Going pad -> keyboard -> pad looked like the label
had moved; it never moved, the two sets were never the same size.

AdoptIntoSocket now reparents each hint INTO its card's ControlChip, stretched
to fill it, with preserveAspect on the image. The lockup owns the chip's size as
well as its position, exactly as it owns the ability icon's, and a hint supplies
only artwork - so every device set renders at one size with one clearance, 6px
below the card. Being a child also makes the position structurally undriftable:
no rect fractions, no retry, no dependence on when a set was last laid out.

Its other half is ApplyAdoptedVisibility. An adopted hint has left its icon-set
root, so activating that root can no longer reach it and the switcher has to
toggle the hints themselves; without it every device's glyphs would show at
once. PlaceOnAbilityIcon survives as the legacy path for a HUD with no lockup,
and its doc comment - which asserted the previous, wrong diagnosis as fact - is
corrected rather than left in the tree.

The general trap, recorded in CLAUDE.md and ABILITY_LOCKUP.md: two things that
look interchangeable at one size are only interchangeable at that size, and
anything centred on a fixed socket inherits the content's height as a variable.

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

REVERT THE ADOPTION. Re-homing the control hints into the chip socket made
their position undriftable, but it took them out from under the icon-set roots
InputDeviceIconSetSwitcher activates - so switching device sets stopped working
and the keyboard set could no longer be reached. The rule it cost: a component
that shows and hides things by toggling a parent OWNS that parenting, and
anything that re-homes its children has to take over the showing and hiding too.
Not worth it here, so the hints stay under their roots.

The actual defect was never the parenting, it was the SIZE, and that part
stands: PlaceOnAbilityIcon now takes a size alongside its target. With one
supplied it collapses the anchors to a point and states the size outright, plus
preserveAspect on the image, so every device set renders identically; with none
it keeps the old span-preserving path for a HUD with no lockup. The size comes
from the chip socket's own sizeDelta - point-anchored, so no layout pass -
which is why SUPPLYING it is safe where READING the hint's rect is not: these
glyphs are stretch rects with sizeDelta zero and read as zero before layout.

RETIRE THE OLD UI. RetireLegacyChrome works per ability HOST, so it reaches
nothing on a HUD with no hosts - leaving exactly the vessels whose row is all
LOCKED cards (Rhino, Manta, Serpent) drawing their old boost meters and buttons
next to the new row, where the older one looked like the real UI. A HUD that
binds no icons now has its root-level content retired too, under two guards:
only children that actually DRAW (a subtree with no Graphic is logic, not UI,
and switching it off would stop behaviour rather than hide a picture), and the
device-hint roots are EXEMPT - they are direct children of the HUD root, so a
positional sweep would take every control hint with them, on the one
un-populated vessel that has any. The auditor lists what each HUD will lose,
since it happens at runtime and the prefab still shows it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YZnzo7ipUR6TSQ9FjMf7bt
bleeding-edge superseded Docs/UNITY_VERIFICATION_CHECKLIST.md while this branch
was in flight: new unverified work now goes in the PR body's Verification status
section, which /qa-backlog scans into Docs/QA/QA_BACKLOG.md. So the Prompt 17
entry this branch added is removed and its content moves to the PR body, and
SPARROW_SKYBURST_BAY.md's verification section points at the current loop for the
tail while keeping the existing checklist pointer for the bay + growth pass, whose
entries are still live there.

(The tail commit's own message cites 'Prompt 17 in UNITY_VERIFICATION_CHECKLIST.md';
that pointer is superseded by this commit and by the PR body.)

Also keeps CacheTransformRole's docstring in step with its body — it now excludes
two renderers, the charge shell and the tail, because neither draws the round.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y7R8eSqTufRumLLyxLyabo
It is the same marker, on the same shared prefab, that the skyburst missile now
wears - so 'A vessel's TAIL' is the exact doc rot this branch created, and a
reader of the component would conclude tails are vessel-only.

Also corrects a pre-existing contradiction in the same block: it claimed a jet is
'seen by that pilot only, by default', which the shipped contract explicitly
retired (VESSEL_TAIL_AND_JETS.md 2 - a per-viewer switch was added once and
removed, because a rival's plumes are how you read their thrust up close).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y7R8eSqTufRumLLyxLyabo
Three additions, all from work this session did for the first time:

- ADDING a nested prefab instance. The skill covered reading them thoroughly and
  writing them not at all. Three documents plus a list edit, and the third -- a
  stripped stub PER COMPONENT you need to reference from the host -- is the one
  that fails silently: a serialized field just reads None.
- Deleting objects from a SHARED prefab, with the project-wide proof that makes
  it safe, including the regex detail that matters (Unity wraps the guid onto the
  next line at ~80 columns, so a single-line grep misses about half the real
  references). Plus the reason the deletion became worth doing at all: a
  'costs nothing' claim about a dead asset is scoped to its current consumers,
  and pooling a new one voids it.
- The TrailRenderer-on-a-pooled-object trap, in three parts: the world-space
  reuse streak, the vanish-on-return, and the fact that a TrailRenderer is a
  Renderer so any GetComponentsInChildren<Renderer>() semantic test changes
  answer the day somebody adds one.

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

Sparrow: give the skyburst missile a TAIL
The Sparrow's control glyphs were stranded where the old ability row used to
be. The cause is structural, not positional: SparrowHUDVariant, DolphinHUDVariant
and ScarabHUDVariant each carry an XBOX_Icon_Root and a PS_Icon_Root with NO
InputDeviceIconSetSwitcher to drive them - they are standalone prefabs rather
than variants of VesselHUDPrefab, which is where the switcher lives. Those
glyphs are never lit, never switched for the player's actual device, and never
placed, so moving the row left them behind.

RetireLegacyChrome only ever reached what sat around an ability icon, so it
could not touch them - nor the orphaned boost rings, nor an Ammo Count nothing
writes to. The lockup now also retires root-level content that no component on
the HUD still REFERENCES. By then everything the lockup owns has moved into the
row, so a root-level child that still draws is either actively driven or dead,
and which one is a reference question - asked by reflecting over the HUD root's
components plus the icon-set switcher, rather than guessed from names or hand
-listed per vessel.

Three guards. Only children that DRAW, because a subtree with no Graphic is
logic and switching it off would stop behaviour rather than hide a picture.
Reference sources limited to the HUD ROOT, because a component sitting inside a
leftover branch would reference its own children and spare the branch it belongs
to. And a reference from `highlights` does not count: that is the legacy press
glow the card superseded, and the Rhino's entry points inside its old boost
container - a reference from something the lockup retired is not evidence that
anything still uses it.

Measured from the assets: Sparrow and Scarab lose Boost Button/display, Ammo
Count, the emptied ActionIconHolder and both glyph roots; the Dolphin loses a
stray Image and both glyph roots; the Rhino loses BoostContainer. The Serpent
and Squirrel keep their glyph roots, because their switcher references them.

The real fix for those three HUDs is authoring a switcher so their hints work
like the Squirrel's rather than being retired; until then they show no control
hints, which is honest - static pad glyphs shown to a keyboard player were
misinformation before they were also misplaced. Recorded as the open follow-up.

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

TWO SYMPTOMS, ONE GAP: the lockup owned the row but not the control hints, and
not the frame the row is anchored to.

CHIPS. The lockup now DRAWS each card's control chip from a fleet-wide
ControlGlyphSetSO, so a vessel authors no glyphs at all. Each card derives its
own artwork - ability -> its map entry's InputEvents ->
InputHintBindingMap.BindingFor (a new inverse of the existing table) -> physical
control -> sprite for pad, label for keyboard. Nothing is guessed and nothing is
per-vessel, which makes a wrong label structurally impossible.

That replaces per-vessel authored glyph roots, which is what stranded the
Sparrow: SparrowHUDVariant, DolphinHUDVariant and ScarabHUDVariant each carried
an XBOX_Icon_Root and a PS_Icon_Root with no switcher to drive them, so their
glyphs were never lit, never device-matched and never placed. Reconstructing
hints from that art was not an option - the Sparrow's pad sets do not even
correspond between families (Xbox A/B/R1/R2 against PS circle/triangle/L2/
square), so honouring them would have produced labels that were confidently
wrong. Pad families therefore share one sprite, which is what the only working
hint set in the project (the Squirrel's L1/R1) already did.

Blank stays honest: a passive ability has no button, and Button1/2/3 have no
keyboard equivalent in InputHintBindingMap, so those chips draw nothing rather
than borrowing another device's picture. Device DETECTION stays in
InputDeviceIconSetSwitcher, which VesselHUDController now ENSURES the same way
it ensures the lockup, and pushes to the view on every set change.

THE FRAME. The row anchors to (1,0) of the HUD root with the style's margins,
which assumes that root is the screen. Measured off the prefabs, it is not:
Rhino/Scarab/Sparrow stretch their root to the full canvas while Dolphin and
VesselHUDPrefab - and therefore Squirrel, Serpent and Manta - point-anchor
theirs at the CENTRE at 100x100. So (1,0) resolved to the screen's corner on one
group and to a point 50px from the canvas centre on the other: the margins meant
two different things and the vessels visibly disagreed about where the row sits.
NormaliseHudRoot stretches the root and flattens any per-vessel HUD scale, which
is safe precisely because the sweep has already moved everything the lockup owns
into the row and retired the rest. A container's rect is part of the contract
for everything anchored inside it - standardising the contents while leaving the
frame per-vessel standardises nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YZnzo7ipUR6TSQ9FjMf7bt
…last competing glyphs

The card already DREW its control chip, but InputDeviceIconSetSwitcher was still
toggling three authored glyph roots on the VesselHUDPrefab variants (Squirrel,
Serpent, Manta) - a second glyph display beside the chip. It survived the lockup's
retire sweep only because the sweep consulted the switcher's own references, and
ApplySet re-activated the roots on every device change, so switching them off could
never have held.

The switcher gives up the display entirely (627 -> 138 lines). Deleted: the three
icon-set root fields, the per-set HintVisual list, the ability-placement pass,
SetHintActive, DriveHintVisuals and BindHintsToAbilities. Kept: Current, IsKeyboard,
OnSetChanged - which device is the player holding, and nothing else. With no
reference left to spare them, those branches now retire like any other leftover.

A reference from something the lockup superseded is not evidence anything still uses
it - including a reference held by the superseded component itself.

Three bugs the authored display had masked:

- OnSetChanged was declared, subscribed and NEVER RAISED, so a chip could not follow
  a device change at all. ApplySet now raises it. An event nobody raises looks
  identical to an event nobody needs.
- KeyboardSet() fell back to Xbox whenever keyboardTextRoot was null, and no vessel
  wires one - so IsKeyboard was never true and the LSHIFT/RSHIFT labels could never
  appear anywhere. Together with the above, this is why pad -> keyboard -> pad could
  not reach the keyboard. A fallback that protected an authored display keeps firing
  after the display stops being authored.
- padGlyphHeld/heldColor were authored (L1 Active / R1 Active) and read by nothing.
  The chip takes the held art off the card's existing press path, so the card lights
  the ability and the chip lights the button - one press. A one-shot PlayPressFlash
  is explicitly not a hold.

VesselAbilityRowAuditor section 5 retargeted: it read the switcher's setVisuals by
name, which now resolves to nothing, and would have reported "no control hint labels
it" for every pressable ability on every vessel. It now checks what can still leave a
card blank - an ability whose control has no pad sprite or keyboard label in
Resources/ControlGlyphSet.

VesselHUDPrefab.prefab drops the four dead switcher keys.

Both stub harnesses compile, including a generated probe that compiles the auditor's
new check verbatim (negative-controlled). check_conditional_compilation.py passes
1756 files. Not editor-verified - Docs/UNITY_VERIFICATION_CHECKLIST.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YZnzo7ipUR6TSQ9FjMf7bt
…ty-icon-design-system-whstrh

# Conflicts:
#	CLAUDE.md
#	Docs/UNITY_VERIFICATION_CHECKLIST.md
…UD asset

This doc claimed the retire sweep clears the Rhino's BoostContainer. It does not.
The claim was read off RhinoHUDVariant.prefab, where RhinoVesselHUDView wires only
`highlights` and BoostContainer therefore looks unreferenced. Rhino.prefab's instance
overrides that view with six live readouts (lineIcon, debuffIcon, crystalIcon,
debuffTimerText, skimmerSizeIcon, slowedCountText) and adds three root-level branches
the HUD asset does not contain at all (LaserTargeting, Crystal, ForceField).

Four of the Rhino's six root branches are genuinely driven - verified, the view writes
every one of those fields - so the sweep spares them exactly as its third guard
promises. The cluster is anchored at (0.93..0.98, 0.04..0.13), the bottom-right corner
the lockup row now occupies, which is why it reads as the old UI coming back.

A HUD prefab asset does not tell you what a vessel wires: the vessel's prefab instance
can add GameObjects the asset has never heard of and populate fields the asset leaves
empty. Same family as the Scarab name-override misread - a confident claim about assets
derived from the wrong file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YZnzo7ipUR6TSQ9FjMf7bt
Reported as "the Rhino is showing its old UI again". It was not a regression: the
Rhino's cluster is genuinely driven - RhinoVesselHUDView writes lineIcon, debuffIcon,
crystalIcon, debuffTimerText, skimmerSizeIcon and slowedCountText - and the retire
sweep was sparing it exactly as its third guard promises. It reads as the old UI
because it is anchored at (0.93..0.98, 0.04..0.13), the bottom-right corner the lockup
row now occupies.

The reference guard asks "is this driven?" as a proxy for "is this still wanted?". On a
vessel that has adopted the row that proxy is right; on one whose four cards all render
LOCKED it answers the wrong question, and on the Rhino the honest answer to both is yes.

RetireLegacyHudContent now splits on one fact - does this vessel bind any ability icon?
Any icon bound (Squirrel, Sparrow, Dolphin, Scarab): guard unchanged. No icon bound
(Rhino, Manta, Serpent, all 0/4): every drawing root-level child retires, because there
is no designed row for anything at root to belong to.

Accepted cost, by design call: the Rhino stops showing its debuff timer, slowed count,
skimmer-size ring, laser and crystal indicators until they are re-homed. The view still
writes to them, so a branch is switched off rather than unwired, and the rule reverses
itself the day that vessel binds its first icon.

Harness compiles; check_conditional_compilation.py passes 1771 files. Not editor-verified
- Docs/UNITY_VERIFICATION_CHECKLIST.md carries the steps, including the regression to
watch for (the four adopted vessels must be unaffected).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YZnzo7ipUR6TSQ9FjMf7bt
Deep-pass findings, all evidence-backed.

1. DolphinHUDVariant was the ONLY prefab carrying an authored AbilityLockupView
   component; the other seven ensure it at runtime. Nothing referenced it
   (abilityLockups is unwired, and ResolveAbilityLockups GetComponents it anyway),
   so it is removed: nobody authors what everybody ensures. EnsureAbilityLockup is
   idempotent - it resolves an existing component first and Build early-outs on
   _built - so behaviour is unchanged either way.

2. The docs claimed "pad families share one sprite - the only working hint set in
   the project already did". That is FALSE, and measuring it is cheap: at the merge
   base every HUD that authored glyph art authored FAMILY-SPECIFIC art (Sparrow and
   Scarab each carried PS L2/o/square/triangle against Xbox A/B/R1/R2), and
   SquirrelHUDVariant authored none of its own. Sharing one sprite is a deliberate
   regression, not a continuation. Two things make it acceptable and both are narrow:
   the shoulder pair CANNOT be authored per-family today (the project ships PS/L1 and
   XBOX/R1 and has no XBOX/L1 or PS/R1 - the asset's mixed sourcing is the only art
   that exists), and the face buttons are used by one vessel. Stated cost: a
   PlayStation player sees Xbox A/B letters on the Sparrow. Follow-up recorded.

3. The clear-slate rule's cost is now tabulated per vessel rather than described for
   the Rhino alone. Manta and Serpent also lose four inherited buttons - Boost
   Button/display, Exhause Barrage, ShootBullets, ShootMissiles - each carrying a
   live ResourceDisplay. Those are safe to switch off for a checked reason, not an
   assumed one: their Button components have zero onClick wiring, and both vessels'
   ability-map entries are open design slots with Input = 0, so nothing routes a
   press through them. Had that not held, clearing them would have deleted the
   on-screen ability control on every touch device.

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

Two prefab-reading traps the branch paid for (an asset does not tell you what its
instance wires; a multi-document regex spans documents and returns a plausible wrong
answer) and one harness trap (an explicit <Compile Include> list silently ignores a
file you add, so 'Build succeeded' can mean nothing was checked - negative-control it).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YZnzo7ipUR6TSQ9FjMf7bt
…n-system-whstrh

The ability lockup (TOTEM) — one fleet-wide system for ability icons + element indicators
…e boost ring

The Squirrel's Time slot (Boost Ring / SquirrelTubeActionSO) was still wearing
BloomIcon-PLACEHOLDER. Replace it with the thing the ability actually makes: the
ring seen endwise, cut perpendicular to the tube axis.

The icon is GENERATED from the authored asset rather than drawn, so it cannot
drift from the ability. Tools/Build/author_squirrel_boost_ring_icon.py reads
segments/radius/prismScale out of SquirrelTubeAction.asset and reproduces
BoostRingBuilder.LayRing's own placement maths; --check fails until the icon is
regenerated after a retune (one authored number per displayed quantity).

At the shipped 8 / 8 / 4 that is eight 4x4 cube faces on a radius-8 circle. Each
prism is posed with local +y along the radial, so the squares carry their own 45
degree step and the ring alternates four square-reading prisms with four
diamond-reading ones - not styling, that is what gets built. The generator
asserts the prisms do not overlap (gap 0.897 world units; at segments 12 on this
radius they interpenetrate, which it refuses outright) and emits pure white with
alpha only, since the HUD tints ability icons at runtime.

The placeholder sprite is shared with AOEExplosionPlaceHolder.prefab, so it is
left untouched - only the Squirrel HUD's Time icon is repointed (one line). The
icon RectTransform's legacy 90 degree Z rotation is a no-op for this figure and
is deliberately left alone, so nothing in the row moves.

Also corrects the adjacent stale "Cooldown HUD" paragraph, which still described
the bespoke radial reload that SquirrelVesselHUDView retired in favour of the
ability lockup's fleet-wide cooldown veil.

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

The new cross-section icon did not render: Squirrel.prefab's nested HUD instance
carried a prefab-instance override setting m_Sprite on fileID 8778855275387912087
(the Time ability icon) to GyroIcon-PLACEHOLDER, and an instance override always
beats the prefab asset. SquirrelHUDVariant's own sprite had therefore been dead
for as long as that override existed.

It was the only m_Sprite override on the instance - the other three ability icons
resolve from the variant - which reads as a stray edit made on the vessel instance
rather than a per-vessel pattern. Delete it rather than repoint it, so the HUD
variant is the single source of truth for all four icons.

Swept the rest of the fleet: no other vessel prefab overrides an ability icon
sprite (Serpent's remaining m_Sprite override is a control glyph).

Records the trap in SQUIRREL_TUBE.md, including why it survived a first pass: the
modification entry wraps `target:` across two lines, so a one-line regex over
m_Modifications reports zero overrides and reads as a clean instance.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DuQKibovn6ini452mGE26C
Review-pass findings from the ship protocol:

- author_squirrel_boost_ring_icon.py --check wrote a scratch PNG INSIDE Assets/
  before comparing, which churns Unity's asset database and strands a stray file
  if the run dies before cleanup. Split encoding from writing (build_png/write_png)
  so --check compares in memory and never touches disk. Output is byte-identical.

- Verified the doc's claim that the icon RectTransform's 90 degree Z rotation is a
  no-op for this figure: 0 of 21,904 pixels differ under the rotation, worst alpha
  delta 0/255. Exact, not approximate.

Knowledge capture:

- CLAUDE.md: record on the Squirrel fleet row that the Time icon was authored in
  the HUD variant AND overridden on the vessel prefab instance, so the variant's
  value was dead and editing it changed nothing.

- asset-surgery skill: extend "a PREFAB ASSET does not tell you what its INSTANCE
  wires" with the third failure mode (an instance OVERRIDING a field the asset does
  author - worse, because the asset shows a plausible value that is never used) and
  the raw-line parse recipe, since m_Modifications entries wrap across lines and a
  one-line regex reports zero overrides on a 166-override instance.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DuQKibovn6ini452mGE26C
…-prisms-7n2ly3

feat(squirrel): Time ability icon is a true cross-section of the boost ring
Adds the data layer the in-game encyclopedia reads, and the FrogletTools
window that authors it from the project's own assets.

- CodexSO (Assets/Resources/Codex.asset) — one catalog the runtime UI loads
  with CodexSO.Load(), no per-scene wiring. An entry is a PAGE: one per
  crystal element family (5) and one per lifeform species (22), with the
  four elements / five heart levels folded in as variants. 27 pages over 88
  lifeform configs.
- CodexHarvester — scans the project and MERGES under a field-ownership
  contract, so Scan & Merge is safe to run at any time: harvested facts and
  wiring are re-derived, authored prose/ordering/discovery/pose are never
  touched, and an entry whose source asset vanished is flagged as an orphan
  rather than deleted. Species group by PREFAB, not asset name.
- CodexImageBaker — renders a hero PNG per entry off the prefab ASSET
  (nothing is instantiated, so no gameplay component Awakes). Alpha is
  recovered from two opaque renders rather than trusted from the render
  target, which is pipeline-dependent; a shader that needs a running frame
  renders empty here, so coverage is measured and a lit silhouette is used
  instead of writing a blank PNG.
- CodexWindow — add / edit / delete / reorder any entry, detach a harvested
  fact to keep an edit, re-pose and re-bake an image, and ship the asset
  output through FrogletToolShipPanel.

A crystal's impactor class (elemental / omni / team) is deliberately not
surfaced: it decides who may collect one, which the palette already says
in-world, and is mechanics rather than encyclopedia content.

Discovery is a hook only — every entry ships unlocked; UnlockedByDefault and
DiscoveryKey exist so progression needs no schema change later.

Verification status: C# verified syntax-only (Roslyn parse, zero CS1xxx;
remaining errors are the expected missing Unity assemblies). NOT opened in
the Unity Editor — no Editor is available in this session, so /verify-unity
could not run. The tool has not been executed and Codex.asset does not exist
yet; it is created on first open.

Docs: Docs/CODEX.md, indexed in CLAUDE.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0152jjmZN9HXCq1dwQ2SYZLv
A flora prefab carries exactly ONE prism — the seed — because a plant is a
growth RULE, not a model, so the first pass photographed a single box for
all 16 species. The lava lamp's Lifeform bench already solved this
(FloraIconBuilder); the baker now reaches the same answer through the same
two calls rather than a second copy of it:

- Flora.TryPreviewGrowth runs the species' rule in the abstract, and
  CellMiniatureBuilder.BuildFromLays turns the poses into one mesh, painted
  with a lit material in the domain's colour.
- PhyllotacticFlora.TryPreviewGrowth added — the 8 Hesperides forms had no
  preview at all, so they were anonymous spheres on the bench too. It mirrors
  SeedTips / DecideStep / DecideWhorl with three substitutions and only three:
  a caller-seeded System.Random (the contract forbids touching
  UnityEngine.Random), a local claim list instead of PrismSpatialIndex, and a
  node that becomes a tip immediately because there are no frames. Prism
  SHAPES are not re-derived — StemPrismScale/LeafPrismScale are the live ones,
  so a preview cannot drift from the plant on taper, cross-section or jitter.
  Jitter() gained one shared implementation that swaps its randomness source.
- Fauna are harvested normally: unlike flora they ARE authored in place (a
  shark's wings, belly and danger rods sit at real offsets). Non-body branches
  (trail/vfx/pip/explosion/particle) are now filtered, matching the bench.
- A COLONY's body is its MEMBERS: the worm colony root carries no mesh and no
  nested instance, which is why it failed with "no visible meshes". The baker
  now lays a chain of its head/body/tail prefabs at the colony's own authored
  spacing and taper, found by serialized-property name so a rename drops the
  icon instead of breaking the build.

Also: Validate & Push is removed. Validate stays as a report-only toolbar
button; every file the tool writes is still recorded on the tool ledger, so
FrogletTools > Build > Pending Tool Changes lists them and they are pushed by
hand.

The four "Wildlife Cell N Fauna Config Data has no prefab" warnings are
correct and need no code change — those assets are empty stubs (FaunaPrefab
unset, InitialSpawnCount 0, SpawnProbability 0) in the Wildlife Blitz cell
configs. Recorded in Docs/CODEX.md as a known limitation.

Verification status: C# verified syntax-only (Roslyn parse, zero CS1xxx;
remaining errors are the expected missing Unity assemblies). NOT opened in the
Unity Editor — no Editor is available in this session. The flora preview and
the colony chain have not been rendered.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0152jjmZN9HXCq1dwQ2SYZLv
Three things, all from playtest feedback on the first bake.

Flora are painted NEUTRAL, not Jade. Colour means DOMAIN in this project —
who owns it — and an encyclopedia page is nobody's. The lava lamp paints its
bench icons in the player's domain for the opposite reason: there, you are
about to release one.

Images fill the frame. Framing was solved from bounds.extents.magnitude —
the radius of the sphere the box fits inside, which for anything non-spherical
is far larger than the box, so every icon sat small in a wide margin (worst
for the long thin subjects this codex is mostly made of: a plant, a worm). It
is now solved from the bounds' eight corners, so Padding 1 really does mean
edge to edge, and the default drops 1.25 -> 1.05. A per-entry "Reset pose"
button picks the new default up on entries authored before it.

The window follows the house style. It was plain IMGUI; it now uses
FrogletEditorPalette the way the Froglet Master Tool does:
- Coloured section bars (tinted ground + accent stripe + accent label + count
  pill), collapsible, per kingdom and per detail section.
- List rows as cards with hover and selection states, an accent stripe, a
  larger thumbnail and an at-a-glance state pill (ORPHAN / NO IMAGE / LOCKED).
- The four primary actions as ColorButtons in their own strip instead of
  toolbar text, colour-coded by what they do.
- A status card with a DONE/ISSUES pill and a dismiss button, and a footer
  reporting entry counts and how many are illustrated.
- Search, kingdom filter, expand/collapse all and Select Asset in the toolbar.
- Preview box 128 -> 168px.

Verification status: C# verified syntax-only (Roslyn parse, zero CS1xxx) and
every FrogletEditorPalette member used was cross-checked against the palette.
NOT opened in the Unity Editor — no Editor is available in this session, so
the layout has not been seen.

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

The QA window's publish step takes a different path on a protected branch
(bleeding-edge): rather than checking anything out, it pushes the commit
straight to a qa/results-<session> ref. That push carried -u.

With a REFSPEC, -u sets the upstream of the SOURCE ref -- which is the
tester's current branch, not the ref being created. Reproduced in a sandbox:

    git push -u origin HEAD:refs/heads/qa/results-demo
    branch 'bleeding-edge' set up to track 'origin/qa/results-demo'

So a tester who pressed Submit on bleeding-edge had their local bleeding-edge
silently re-pointed at a QA results branch, and their next Pull in GitHub
Desktop would have pulled that branch into the build they were testing. The
tool would have reported success while doing it.

Dropping -u fixes it and still publishes the ref (verified in the same
sandbox: branch created, upstream left on origin/bleeding-edge). Nothing local
tracks the QA ref and the tester never checks it out, so it needs no upstream
at all.

This path had never run: all testing so far was on a working branch, which
takes the ordinary `push -u origin <branch>` path where -u is correct. It
became the DEFAULT path the moment this work merged to bleeding-edge, since a
tester now starts there.

Known and unchanged: the commit is still made on the local protected branch
before being pushed to the QA ref, so the tester's local bleeding-edge sits
one commit ahead of origin afterwards. Harmless (the data is on the QA branch,
and it resolves when that merges) but untidy; building the commit with
commit-tree so the local branch never moves is the follow-up.

C# real-compiled (Roslyn + stubs, exit 0); 65 python selftest checks green;
conditional-compilation gate clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PsUBppM6FtPcfyiZSM9Eun
…ster owns

Follow-up to the -u fix. Publishing from a protected branch still made a LOCAL
commit before pushing it to the QA ref, which left the tester's bleeding-edge
one commit ahead of origin forever -- with a Push button in GitHub Desktop that
either errors or, if the branch is not protected server-side, lands a QA results
commit straight on bleeding-edge.

No local commit is made at all now. The commit object is assembled with plumbing
and pushed directly:

    read-tree HEAD  ->  add PATHS  ->  write-tree  ->  commit-tree  ->  push

against a THROWAWAY index (GIT_INDEX_FILE), which is also what keeps it scoped --
whatever else the tester has dirty or staged is invisible to it. HEAD, the branch,
the real index and the working tree are all untouched.

refs/qa-published/<stem> records what was last published for this session and
parents the next publish, so a second submit FAST-FORWARDS the QA branch instead
of being rejected as unrelated histories. It also keeps that commit object
reachable against gc, and sits outside refs/heads so it never appears as a branch.

Prototyped against real git before any C# was written, and the prototype checks
the things that actually bite: HEAD unmoved, upstream still origin/bleeding-edge,
branch not ahead, an unrelated dirty file NOT swept into the pushed tree, and a
second submit fast-forwarding cleanly.

FrogletGit gains RunInWithEnv/RunWithEnv (env vars are the only way to reach
GIT_INDEX_FILE -- it has no command-line equivalent). Pure refactor: RunIn now
delegates with a null env, so every existing caller is byte-identical in
behaviour. Both FrogletGit.cs and QASessionWindow.cs were really compiled
(Roslyn, exit 0) -- the shared-infra file against the REAL
System.Diagnostics.Process ref assembly, so the process plumbing is type-checked
rather than stubbed.

65 python selftest checks green; conditional-compilation gate clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PsUBppM6FtPcfyiZSM9Eun
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.

5 participants