A Scenario is an abstract C# class compiled with the game: Generate picks the galaxy through GalaxyStage (Main or
Prelude, at the scenario's seed, the background noise drawn from the same seed), Stage places the run's first
entities through ScenarioStage (Fit, Bare, Generated, Preset, Player, Place, Cargo), which collects named failures
and admits nothing. RunStart generates the galaxy, the arena under Ambient and admits everything through Zone.Admit,
all or nothing. The aetheria.scenario record, ScenarioShip, ScenarioEntity, RunStart.Check and the null-scenario
branch are cut.
New Game opens a submenu: Tutorial Galaxy and Main Galaxy, then (editor and development builds) Djinni Shakedown,
Starved Reactor, Arcs, Duel, Launcher Angles and Long Haul, each with its brief. MainMenu.Launch is the one way into a
new run; ActionGameManager.PendingScenario carries the scenario to StartGame once.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
IsTutorial duplicated Galaxy.IsPrelude; its readers read CurrentGalaxy.IsPrelude, and RunSave.Capture writes
SavedGame.IsTutorial from galaxy.IsPrelude with no isTutorial argument. TutorialPassed was never written and its only
reader was New Game's unreachable main-galaxy branch, which goes with it (S2 brings the main galaxy back as a
scenario). spawnturret's two identical generator branches collapse to one. Old player.cc files still load.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The one conflict, ShipInstance.cs's thruster emitter lookup, keeps the
branch's thruster.Item.Hardpoint.Transform and master's new
ThrusterHardpoint.WireExhaust(particles, thrusterHardpoint). Master's side
as written would have brought back the origin-cell hardpoint lookup.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The one conflict is EntityInstance's SoundBank block, which this branch had
moved onto item.Hardpoint and ship-mounts deleted: it stays deleted.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The emitter carries the invisible material, whose alpha is 0, so an exhaust
prefab left at Unity's default (Use Mesh Colors on) emitted nothing. The
shipped thruster prefabs only worked because theirs is off. ThrusterHardpoint
.WireExhaust now owns the wiring and turns it off; ShipInstance calls it. The
preview smoke wires a default particle system to each emitter on the clone and
requires every particle emitted and none transparent.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PreloadAsync rethrew on the first failing hull, so one bad package faulted
Loading and no mod ship could spawn. Each hull now loads in its own try: a
failure destroys its partial visual, is logged with the hull's name, and the
rest still preload, as Compose skips a bad package. The play smoke installs the
package beside a broken sibling that composes but cannot assemble (its map icon
moved onto a meshless muzzle node) and proves the sibling has no prototype while
the good ship spawns.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
forceRenderingOff is not serialized, so Instantiate and SaveAsPrefabAsset dropped
it and every cloned mod ship (and every FBX-built prefab) drew its collision hull.
The collider renderer now takes the template's invisible material, as the thruster
emitter does. The preview smoke checks the collider on the clone and the prefab
smoke checks it on the configured prefab.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The placement rule (HardpointAt) already finds the one hardpoint an item lies
in at equip; TryEquip threw that answer away and eighteen readers recomputed it
from the item's origin cell, which need not lie in the hardpoint (Soul's
scenarios batch-4 P30). EquippedItem.Hardpoint now carries the answer, null for
Tool gear and the hull, and every origin-cell lookup reads it instead:
FireControl.ArcFor, the eleven weapon managers, EntityInstance, ShipInstance
and ActionGameManager's articulation grouping. Entity.Hardpoints[,] stays the
per-cell map for armour and display.
TryFindSpace's hardpoint search tries every origin the fit rule can accept,
including ones up and left of the hardpoint, so a design whose shape lacks its
origin cell is found wherever ItemFits would place it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The one conflict is AetheriaStores.CatalogTypes: keep ShipAuthoring from the
ships lane and Scenario from the scenarios lane.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
forceRenderingOff is not serialized, so the prototype's clone drew the
emitter again; the preview smoke's clone check caught it. The emitter's
materials become the template's invisible material, which survives
Instantiate and which EntityInstance keeps as the renderer's own material.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Ruling thrusters-radiators-are-meshes: thruster and radiator mounts are meshes;
the thruster's is not drawn and is the surface its exhaust emits from, the
radiator's is drawn. The assembler already required a mesh for both; it now
turns rendering off on the thruster emitter, and the preview smoke checks it.
The authoring doc states the mount rules and the mesh rule.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The array's only reader was the dead SoundBank block in EntityInstance, which
looked up a hardpoint transform and did nothing with it. ShipModPlan.Equipment,
ShipModShips' assignment, the preview check, the FBX builder's HP.Equipment
case and its smoke assertion all fed that one dead reader. An HP.Equipment node
is now refused by the builder as an unknown hardpoint kind.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
StartGame's new-run branch no longer generates, unpacks or admits the
player: RunStart generates the arena and stages the run, and StartGame
enters the zone and binds the returned player (docs/scenarios-cut.md, 2.2,
deletion line). PopulateLevel's warp arrival and the spawnturret console
command admit through Zone.Admit, unpiloted. The console commands stay (Q4);
give's hull route moors a ship at the docked entity and joins a zone only
through Entity.TryUndock, which already admits.
Needs the Unity batchmode compile; Assembly-CSharp is not built headless.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
DontDestroyOnLoad throws outside play mode, so the edit-mode play smoke
failed before it reached assembly.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Validate: a hardpoint's anchor role is fixed by its type (thruster-emitter, radiator-mesh, else articulation); ShipModPlan's
duplicate role check is deleted.
- Loadouts.Commit refuses a preset naming a mod design (mod-hull:/mod-ship:), so the shipped catalog never references a mod.
- Compose quarantines each package: a bad package is excluded and named, a collision excludes every package involved; the
game boots on the rest (or the shipped catalog) and the main menu lists the exclusions.
- RequireDesigns refuses a save with a null item or lot instead of throwing NullReferenceException.
- Tests: per structural role, muzzle id ties, every slot/zone/lot in RequireDesigns, listing order, exclusion cases.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Unity generated them on import; without them the two scripts' GUIDs are
unpinned and differ per checkout.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Boot opens the derived catalog when GameData/Mods holds packages
(ShipModCatalog.ResolveCatalog), preloads each hull's GLB into an assembled
inactive prototype (ShipModShips), and LoadEntity clones it when the hull names
a Visual. ShipModPlan decides which anchor fills which ShipInstance slot; a
ShipModTemplate prefab carries the shared effects. Continue refuses a run that
names a design the catalog no longer holds (RunSave.RequireDesigns).
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A mod ship file now holds a HullData at mod-hull:<id> and the ShipAuthoring
visual package at mod-ship:<id>. HullData.Visual (key 32) is the typed ref
between them; ShipAuthoring.Hull (key 1) is retired.
- One joint validator over (hull, visual) is the only semantic check; Load
owns the file structure (one hull, one visual, deterministic keys).
- Compose copies the mod store's records verbatim, so the derived catalog
holds one hull per mod ship.
- ship_cc.py edits the hull record's slots directly; the body[HULL_SLOT]
indirection is gone.
- The package fixture is generated from ShipAuthoringTests.Fixture() (glTF
scene included) and exported when AETHERIA_SHIP_FIXTURE_DIR is set.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
- ConsoleView appended the control characters text input delivers (\b, \r),
so Backspace inserted a glyph and Enter's \r got every command refused
since d503f076 made the parser refuse instead of strip. Control characters
are no longer text; Backspace deletes like Delete.
- give reports an unknown name or a full cargo bay. A ship hull is refused
unless docked, and when docked is commissioned through
ActionGameManager.CommissionShip, the primitive TradeMenu.Buy now uses: a
bare player ship moored at the docked entity, no docking bay. Non-ship
hulls are refused.
- Record play smoke results for steps 3-6 and the docking bay finding.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Regenerate AetheriaInput.cs from the committed Aetheria.inputactions so the
wrapper carries Cycle Target Item, and bind it through Input.Player like the
other target actions. The by-name FindAction lookup was a stopgap from when
the generator could not be run outside the editor.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
After its fade a round kept integrating Velocity through the BindingLifetime wait, then a Hit (which carries
no burst point) played HitEffect at the drifted position. The wait now holds still; a hit without a point
plays the effect at the round's target position, and is skipped when the round never knew one. OnEnable resets
BindingLifetime so a pooled split child, which is never bound, does not inherit the last round's.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A burst whose every round is refused fires nothing and leaves no cooldown: the cooldown moves from
Trigger to the first round Execute does not refuse, and CanFire holds a triggered burst that has not
yet fired. ChargedWeapon.Activate asks FireControl before it starts charging, so a refused weapon pays
no charge heat or audio. InReach is deleted: Designated carries the one range test and the lock gate,
and Combat reads it. GuidedProjectile answers Resolve while alive and keeps its binding through the
shot's flight time, so a faded round still shows the simulation's detonation.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Operator ruling 2026-09-30: a no-lock round of any fuse stops at the first hull
on its aim line and detonates there (bomb fishing still bursts at max range on
a clear line). CommitBurst no longer special-cases Contact.
Soul's findings on 14f66634..7e620ff9:
- Arming (HIGH): a contact or delayed blast on a hull nearer the muzzle than the
blast radius detonated at the hull. Armed is now the one owner of the arming
rule; Solve plans its burst point through it and ApplyBlastHit pushes the hull
point through it.
- A refused beam never starts (ConstantWeapon.Activate asks Refuses), so
OnStartFiring no longer flashes visuals and audio for a beam that fires nothing.
- Step removes a shot from the queue before Apply runs damage observers, so a
throwing observer cannot make the blast repeat every tick.
- AgentFires consults Solve for a fused weapon: a refused weapon is never fired
and Combat's group pick no longer counts its damage.
- SingleAmmoBurst pays at its first unrefused round in Execute, judged with the
Range that round flies with, instead of at the trigger on the previous tick's.
- Inspect reads Designated from Solve (FireSolution.Designated) instead of
recomputing visible, in-range and locked; FireControlDiagnostic drops those
three fields and the debug HUD shows the one. Combat's group range test is
FireControl.InReach, the range half of Designated (a LockWeapon must be
activated to acquire its lock, so Combat cannot ask Designated itself).
Tests pin each rule, plus the two surviving gaps: a pushed-out targeted round's
arrival at the arming distance, and the AgentMinHitProbability boundary.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The overshoot branch of GuidedProjectile.Update (out of range, or flown past
its target) instantiated HitEffect whatever the simulation had decided. A round
the simulation never detonated could therefore explode on screen. The branch now
fades the round out; Resolve stays the one place a HitEffect is shown for a
detonation.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Unity-only, compile-checked by reading.
* F5: GuidedProjectile.Binding is one owner: a new binding disposes the one it
replaces, Kill disposes it, and OnDestroy does too, so a projectile torn down
with its scene cannot leave an observer on the zone's subjects to touch a
destroyed transform.
* F6: Resolve takes whether the simulation detonated. A miss (a contact or
delayed round that missed, a target that left) fades out with no explosion.
* F7: BurstAt resets the previous target position and target velocity, so the
retarget jump does not feed the guidance a bogus target velocity.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Operator rulings 2026-09-30.
* Combat and TurretController ask FireControl.AgentFires for each weapon. A
fused weapon fires whenever its target is designated (Designated, the one
visible / in-range / locked predicate PFire already used), whatever the arc;
an agent never fires one at nothing. A direct-hit weapon keeps the
AgentMinHitProbability threshold. The group's first weapon no longer speaks
for the group, and the turret's own range test is gone.
* FireControl.Solve is the one decision about what a round does, extracted
from Fire: Direct, Burst at a reach, or Refused. Fire, Inspect and the
weapons' refusal check all read it. Inspect gains Outcome and BurstReach and
the debug HUD prints them for fused weapons.
* A refused round costs nothing. InstantWeapon asks before its ammo and energy
spend (at the trigger and at each round), ConstantWeapon folds the refusal
into the gate its power request and Execute already share, so a refused beam
draws no power, wear, heat or visibility and stops.
* The arming distance is BlastRadius, measured from the shooter's position and
blind to hull size. HullAhead's exit, ArmingDistance and
PendingShot.ArmingDistance are deleted; HullAhead survives as HullEntry for
the contact fuse. A round fired at a valid target inside the radius is pushed
out along its line but keeps its target, so a departed target cancels it.
* Step settles a shot's queue state before publishing about it, so a throwing
presentation observer costs its own notification and cannot re-commit or
re-resolve the shot on every later tick.
Tests: FireControlPerWeaponTests (new; includes a fused-beam fixture), the
arming tests in FireControlCut124Tests rewritten to the literal distance,
the hull-inclusive probe premises deleted.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- GuidedProjectileManager passed target.transform to OnFireGuided for a
no-target fused round: a NullReferenceException inside InstantWeapon.Execute
after the shot was queued, which skipped audio, wear, heat and visibility
and unwound Zone.Update. The tuple's transform is null-safe now, and the
trackmissile console command falls back to the missile.
- Presentation follows the commit rather than predicting it. The manager binds
each guided round to Zone.ShotCommitted (retarget to the burst point the
commit names, so a Contact round bursts at its hull and a Range-clamped
burst is not overshot) and ShotResolved (burst there, then). The binding is
disposed when the projectile dies, so a pooled round does not accumulate them.
- Beams and barrel-fired projectiles stay with F5.
Unity-only; compile-checked by reading.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
EntityInstance handed effect managers the shooter's selected target whatever
FireControl decided. A fused round whose target was invalid or out of arc
flies at none, and GuidedProjectileManager dropped a launcher shot with no
target entirely, so a real burst had no missile.
- Zone.TryGetShot: a presentation reads the shot the simulation committed
(its target, its burst point) by the ShotId OnFire hands it.
- EntityInstance passes the shot's own target; a launcher round with none
flies to the shot's burst point.
Unity-only; compile-checked by reading.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Operator rulings 2026-09-30 after Soul's pass on 8c002637:
- A no-lock round flies along Entity.LookDirection restricted by the mount's
arc (FireControl.AimDirection: inside InArc's cone as is, outside it turned
to the cone's edge; no aim falls back to the mount). The simulation had no
aim clamp before; the old code used the hull-fixed mount direction.
- An invalid target (out of range, out of arc, not visible, not locked) counts
as no lock for a fused round. PFire's gate is the one decider and now also
reports `bears`; Fire reads it. The round carries no target afterwards.
- A no-lock Contact round bursts at the first hull its aim line crosses
before max range, read through Lane's own cell admission (CellsOnLine,
split out of Lane). Proximity and Delayed keep the max-range burst. Such a
round commits at Fire, since it has no target facing to read late, and
arrives when it has flown to the burst point. The seam for mining chunks is
FireControl.ContactDistance.
- A round outlives its shooter: Step no longer cancels a shot whose source
left the zone. Resolution reads the zone and the target; the shooter is an
identity a shot carries. PendingShot.FireOrigin freezes where it left from.
- ShotOutcome.Result (Miss, Hit, Burst) replaces the stored Hit bool, which
is now derived. A burst without a target is no longer reported as a miss,
and the debug HUD labels it BURST.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
FireControl.Apply now switches on the frozen shot.Fuse: a null fuse keeps
12.3's lane path (ApplyDirectHit), a proximity fuse detonates at arrival with
no roll, and a contact or delayed fuse detonates only on a committed hit
(ApplyBlastHit), locating P through Lane and the new Reach owner (moved
verbatim from Apply's own inline penetration clip; the delayed fuse point
reads the identical rule). Step has no damage branch left.
Detonate (FireControl.cs) is the one owner of blast damage: candidates by
centre-plus-half-diagonal, exact circle/cell overlap shares computed once,
every blast's shield decided live per Q12-9 = A (including the host of a
contact/delayed blast -- Commit now gates its own shield decision on
shot.Fuse == null), armour absorbed per cell, and each item's covered cells
pooled into one ItemAbsorb call. Entity.Absorb and FireControl.Splash are
deleted; Entity gains ToSchematicPoint/ToWorldPoint for the point conversion
Detonate and the delayed fuse point need. Mine.Explode now calls Detonate.
Existing Splash/Absorb-era tests across Cut4/5/6/11/123 are rewritten or
retargeted to the new public owners; behavioural coverage for the blast
model itself lands separately.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Deletes Entity.DamageSchematic (the even split over the whole hit shape)
and Entity.ApplyHit (the 0.5-step penetration march), replacing both with
Entity.Absorb (armour then item, per cell) and Entity.DamageHull, moved
verbatim from DamageSchematic's own body. FireControl.Apply now builds
2n+1 parallel lanes from the committed Bearing/Lateral (Q12-2 = A),
shares damage evenly over the lanes that meet metal, walks each lane in
order via the already-verified FireControl.Lane, absorbing cell by cell
up to the penetration depth, and sends every lane's own remainder to the
hull where it ends (Q12-3 = A). Splash is rewritten onto Absorb/DamageHull
(arithmetically identical to before) since DamageSchematic is gone.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
FireControl.Silhouette replaces HullKernel/WeightedPick/POnHull: the hull's
lateral shadow at a bearing, merged into intervals, integrated exactly with
CultMath.erf/erfinv instead of a discrete per-cell Gaussian sum. FireControl.Lane
does exact slab traversal to pick the impact cell from a lateral offset; only
its first element (the entry-nearest occupied cell) is used here, per the cut
map -- the rest is 12.3's armour-first absorption march.
Bearing timing: PendingShot.PBase is renamed PFire and now carries only the
shooter-side factor (Accuracy x PSensor) behind the gates; TravelDirection,
Spread and FireRange are frozen alongside it. The target's facing is read live
at Commit through FireControl.CommitProbability (steps 1-5 of the spec's
"Bearing timing"), not frozen at Fire, so a target that turns its armour into
an incoming shot takes the hit there. ShotOutcome gains Bearing/Lateral,
frozen at commit; Apply reads only those, never a live position or facing.
Entity.ApplyHit takes an already-schematic bearing instead of a world
direction, and the ToSchematic call 12.1 put inside it is gone.
Superseded tests: PlacementAndProbabilityShareOneKernel, EveryHitLandsOnMetal
and AimingAtTheSternHitsTheStern are replaced by
FireControlCut12Tests.PlacementAndProbabilityShareOneSilhouette,
HitsLandOnTheFacingEdge and AimingAtAnItemCentresTheScatterOnItsLane.
FireAuthorityTests.AimedHitLandsOnSelectedItem and PenetrationMarchIsPlanar are
updated for ApplyHit's new bearing parameter and Lane's entry-nearest-cell
placement (a solid hull's Tool item is never itself the entry cell; reaching
it now needs the same penetration march the new tests exercise).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
tests/mutation_tests_fire_control_cut6.sh, modelled on Cut 5's harness: a no-op
control, four mutations (6.1's shared-stream generator and dropped-ShotId seed;
6.2's double-application and unconditional-splash), byte-exact I/O with
sha256-verified restore after every mutation, and the two negative greps the
Cut 6 spec names. All five mutations behave as declared and the tree-clean
verdict is PASS.
Fixes two of Commit's and Projectile's own explanatory comments that quoted the
literal strings the negative greps check for (ItemManager.Random, Airburst),
which the first harness run caught as false failures -- reworded, no behavior
change.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
6.1 (Soul finding 6): Commit builds a local CultMath.Random from (Zone.CombatSeed,
shot.ShotId) instead of reading and writing the shared ItemManager.Random stream.
Zone exposes CombatSeed (the same expression that already seeds its own _random),
so a shot's outcome is a pure function of zone identity and shot id -- immune to
whatever else drew from the shared stream in between.
6.2 (Soul finding 5): airburst is wired into the simulation instead of back into
Projectile.cs. PendingShot gains BurstPosition/BurstRadius, frozen at Fire time
from PredictedIntercept and the weapon's authored WeaponItemData.AirburstRange
when WeaponModifiers carries Airburst. FireControl.Step resolves such a shot via
Splash instead of Apply, never both. Projectile.cs/ProjectileManager.cs drop the
dead AirburstDistance/AirburstRange fields and write-back.
Tests: FireControlCut6Tests.cs pins both rules (208 baseline + 5 new, all green).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Soul's pass over the whole campaign confirmed eleven findings. Seven were code
defects of one shape -- a rule implemented on one path and left off the path
beside it -- and this is all seven.
5.1 Tracking falls back to GameplaySettings.UnaidedTracking (new, 10) instead of
zero, and Commit drops the zero branch that turned a Tracking-less shooter
into a hard wall. Unaided fire was meant to be really, really bad; it was
334 shots and no hits against a target drifting at 5 u/s.
5.2 A shot the shield cannot absorb now breaks it. The decision is frozen into
ShotOutcome.ShieldBroken at commit and performed in Apply, so a shield that
recharges mid-flight cannot retroactively survive. Splash already did this;
the discrete roll never did, which made the whole shield-break mechanic
unreachable for every weapon in the catalog.
5.3 ConstantWeapon gates on ArcAllowsFire alongside StanceAllowsFire. A
side-mounted beam was firing forward.
5.4 The point-blank bearing case moves out of Weapon.ArcAllowsFire and into
InArc, which used to normalize the zero vector into NaN. The trigger said
fire and the probability said impossible.
5.5 A destroyed item drops out of IsRevealed's ranking, so Precision stops
concentrating hits onto a dead subsystem's footprint.
5.6 Zone subscribes Entity.Death and removes the corpse itself. That removal
lived only in Unity's loot-drop subscription, so headless nothing ever left
the zone, shots landed on corpses and no fight could end.
5.7 PendingShot drops PredictedIntercept and FlightTime, written and read
nowhere.
208 tests pass (197 plus eleven new).
Known defect, recorded rather than hidden: the committed mutation harness is
unreliable. Across two runs it failed to restore FireControl.cs and Weapon.cs,
leaving live mutations in the working tree -- including its own 5.1 mutation,
which is how a green suite briefly reported two failures. Its verdicts are
therefore evidence only where re-run from a verified-clean tree. Two anchors
(Zone.cs's ObserveAdd line, Weapon.cs's InArc return) fail to match at all, and
5.3's ConstantWeaponObeysArc survives its own mutation, so that rule is written
but not pinned. Cut 5b owns the repair; nothing here is trusted on the harness's
word, and the suite result above was taken by hand from a hash-verified tree.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Deletes the last live Physics.* hit queries and every remaining SendHit/
SendSplash call in Assets/Scripts/Gameplay/Weapons: ConstantLaser's
RaycastAll, ConstantLightning's SphereCastAll, ConstantParticleWeapon's
trigger-collider plumbing, HitscanEffect's RaycastAll, and Mine's blast
OverlapSphere. Each becomes presentation only, the same convention Cut 3
gave Projectile/GuidedProjectile/Laser/Lightning: ConstantLaser/
ConstantLightning/HitscanEffect draw to TargetTransform (or out to Range with
no target); ConstantParticleWeapon keeps only its visual emission.
ConstantWeapon.Execute (previous commit) already rolled the damage before any
of these ever draw.
Mine keeps its proximity-arming OverlapSphere (Q7: contact detection, not hit
detection) but Explode() now calls FireControl.Splash instead of walking
colliders and calling SendSplash itself.
HullCollider.Hit/Splash/SendHit/SendSplash and their event-arg classes are
deleted outright -- their last callers are gone. HullCollider is now purely a
physics surface for ship-on-ship collision impulse; no Physics.* query
decides damage anywhere in Assets/Scripts/Gameplay/Weapons except Mine's own
named exception.
Every manager (ConstantLaserManager, ConstantLightningManager,
ConstantParticleWeaponManager, HitscanManager) is updated to match: no more
Damage/Penetration/Spread/DamageType plumbing into a presentation component
that no longer decides anything with it, and HitscanManager now passes
ShotId and TargetTransform the way LaserManager already does.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Cut 3 landed 39 compile errors in GuidedProjectile: it uses ToCultMath
throughout and lost the using directive. Caught by batchmode on merge, not
by the pass that wrote it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Deletes the Unity physics hit path (HullCollider.Hit/Splash, and the
raycast/shield/SendHit blocks in Projectile, GuidedProjectile, Laser and
Lightning) and replaces it with a single owner in the same cut: FireControl
now decides whether a shot hits, Zone owns the pending-shot lifecycle, and
Entity.DamageSchematic/ApplyHit (moved verbatim from EntityInstance) apply the
result. Presentation subscribes to Zone.ShotCommitted/ShotResolved and applies
nothing.
FireControl.HitProbability is the pure targeting solution (Accuracy * pSensor
* pSpread, gated by detection/range/lock/arc). FireControl.Fire freezes the
weapon's payload and the shooter's resolved targeting stats at the instant the
trigger is pulled (Q6), computes the predicted intercept through the one
shared PredictedIntercept function (CombatState now calls the same function
instead of duplicating first_order_intercept), and queues a PendingShot.
FireControl.Step commits each shot's outcome at ArrivalTime - CommitHorizon --
deviation from the predicted intercept, measured once, live, at that instant
-- and applies it at ArrivalTime. A shot whose flight is shorter than the
horizon commits and resolves in the same tick it fires. Rolls draw from the
shooter's own ItemManager.Random (Q5, seeded and reproducible); an out-of-arc
or otherwise gated shot draws nothing.
Player fire is arc-gated through the same predicate AI and turret fire use
(Weapon.ArcAllowsFire, checked in InstantWeapon.Trigger -- the one point every
shooter's trigger passes through), closing Q2.
Adds GameplaySettings.CommitHorizon, SchematicCellSize and
AgentMinHitProbability (first guesses; the headless suite is the tuning
harness, operator play smoke is the arbiter).
FORK reported, not worked around silently: HullCollider.SendHit/SendSplash
cannot be deleted yet without breaking ConstantLaser.cs, ConstantLightning.cs,
ConstantParticleWeapon.cs, HitscanEffect.cs and Mine.cs, which still call them
from their own live Physics queries -- that's Cut 4's migration. They fire
into the void today: nothing subscribes to Hit/Splash any more for the four
weapon kinds this cut owns.
tests/Aetheria.Shared.Tests/FireAuthorityTests.cs pins the roll's seeding, the
gate-consumes-no-draw rule, resolve-on-arrival, the commit horizon and its
evasion window, short-flight degradation, the frozen-payload snapshot, aimed
hits, the item-then-hull damage order, the planar penetration march and the
shield-absorbs branch. FireControlTests.cs and InputCapacitorTests.cs are
updated for the new AI gate and OnFire(int) signature.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A new "Cycle Target Item" action (hand-authored into Aetheria.inputactions;
no Unity editor here to regenerate AetheriaInput.cs, so ActionGameManager
looks it up by name through Input.asset.FindAction instead of a typed
accessor) cycles the local player's aim point among the current target's
revealed items via TrySelectTargetItem. SchematicDisplay's enemy view now
filters to what FireControl.IsRevealed actually resolves for the viewer,
and marks whichever row is the current ResolvedTargetItem through a new
SchematicListElement.SelectedIndicator (unwired in any prefab yet -- a
no-op until an editor pass assigns one).
Presentation only; decides nothing. Unverified here -- Unity batchmode
is unavailable in this environment.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Entity.HardpointTransforms was written only by Unity (EntityInstance.cs),
so the simulation's notion of where a gun points was a readback from a
renderer that never runs headless -- Combat.cs threw KeyNotFoundException
on the first combat tick outside Unity. Deletes the field, its writer, and
the readback branch in Behavior.Direction, leaving the item-rotation path
(the same convention reaction thrusters use, R6) as the only source of
mount direction.
Adds FireControl (ServerShared), a static class owning whether a weapon
bears on a point: MountDirection (planar, from ItemRotation), ArcFor (the
per-hardpoint HardpointData.FiringArc override, falling back to the new
GameplaySettings.FiringArc default of 120), and InArc (the planar dot-cone
test, R7 -- target height never enters it, and an arc of 360+ passes
unconditionally so a turret hardpoint needs no new type, just a number).
Combat.cs and TurretController.cs now gate AI fire through InArc instead
of the old hardcoded 0.99 dot-product test against a live Unity transform.
Settings.cs drops AgentFiringMinDot, which was declared and read by
nothing.
docs/fire-control-cut.md, Cut 1.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
F4: a hit that breaks the shield usually also cooks it (TakeHit's own
AddHeat), which can knock the item thermally offline in the same tick. The
reserve's recharge and its Broken-clearing check lived inside Execute, which
Entity.Update only runs while Item.Active is true -- so a shield broken by a
hit that also shuts it down could never come back, no matter how long
RestoreDuration allows for. That logic now lives in Shield.Update
(IAlwaysUpdatedBehavior), which Entity.Update calls every tick unconditionally
(Cooldown.cs already relies on the same interface for the same reason).
F5: CanTakeHit broke the shield and drained the reserve as a side effect of
the query itself, on the premise that every caller queries it exactly once
per hit. That premise was false: RaycastAll/SphereCastAll/OverlapSphere hand
every caller under Assets/Scripts/Gameplay/Weapons both the shield collider
and the hull collider for one shot, so the same shield's CanTakeHit was
queried twice per hit. CanTakeHit is now a pure read (Broken + the reserve's
own CanSpend); the mutation moved to a new Shield.Break(), idempotent, called
by each weapon file exactly where it already decided to route a hit past the
shield instead of absorbing it.
Pinned in tests/Aetheria.Shared.Tests/ShieldReserveTests.cs:
RestorationProceedsWhileTheItemIsInactive (F4),
CanTakeHitNeverMutatesRegardlessOfHowManyTimesItIsCalled and
BreakIsIdempotentAcrossRepeatedCalls (F5). Updated every existing test that
relied on CanTakeHit's old mutating side effect to call Break() explicitly,
matching the new caller pattern.
Cannot compile-verify Assets/Scripts/Gameplay/Weapons/*.cs here (UnityEngine,
Unity batchmode unavailable in this environment) -- reviewed by hand for
brace/control-flow correctness; Self or a Unity batchmode pass should confirm.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ShipInstance's thruster and aether-drive emission were the operator's
actual complaint (2026-09-19): thrust intent times a raw durability ratio
that ignored heat and power and duplicated simulation arithmetic in
presentation. Both now multiply intent by Thruster.Condition /
AetherDrive.Condition -- no durability/heat/power math left in
presentation. Unity-side; not covered by the shared test suite. Small and
obvious for Self's batchmode run on merge to check.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The operator's play report on FieldShieldTest ("weak hits are very
janky, clicking outside the hex still makes it tremble and shatter,
pooling seems very rough") traced to two provable defects, not one
tuning constant:
- ShieldPanel.Hit had no bounds check at all. A point handed to it was
always enqueued and injected regardless of how far outside the
panel's own panelRadius it was.
- ShieldInterceptor.FindReusable decided reuse from a raw world-space
chord distance to the panel's stale spawn-time Position, using
PanelRadius*ReuseFraction (authored up to 2x) as the threshold. On a
non-uniformly scaled/curved envelope a chord across curvature is not
the panel's own in-plane extent, so a click clearly outside the
rendered tiling could still measure inside threshold and get routed
to Hit() -- which had nothing to reject it.
- Reusing a panel never refreshed its pooled DieAt, so a panel struck
again could still expire and get returned to the pool mid-fracture
("pooling seems very rough").
Fix: ShieldPanel.ContainsWorldPoint is now the one owner of "is this
point within my own radius" (panel-local in-plane distance vs.
panelRadius), used both as Hit's hard gate and as FindReusable's
candidacy test (with ReuseFraction, now capped at 1, narrowing but
never widening past the physical radius). Reuse now also extends
DieAt from the moment of the new hit.
Pinned in ShieldPanelCut3Verify.CheckHitOutsideRadiusDoesNotReachPanel:
a direct Hit() and a routed Absorb() outside a panel's own radius must
enqueue nothing and leave its state buffer untouched, and a genuinely
outside click opens its own panel. Deterministic (checks the pending
queue and no-op state, not temper drift) because breakBudgetFraction
paces fracture over many frames on purpose, so temper keeps drifting
from an earlier hit alone -- a drift-based check would have been
measuring that, not this rule (caught this against the check's own
first run).
All four ShieldPanelCut<N>Verify probes pass (batchmode, real GPU
device, no -nographics).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
R2: the shield is an interceptor. ShieldInterceptor implements IAbsorbPresenter
and converts each AbsorbEvent into a pose (intercept point projected onto the
ShieldEnvelope, normal from SurfaceNormal, size from the item's PanelRadius)
and a strike, deciding nothing else. It reuses Assets/Scripts/Prototype.cs
rather than writing a second pool.
Reuse: a second hit landing within PanelRadius*ReuseFraction of a live panel
strikes that panel (Hit only, no ResetSim) instead of spawning a new one --
this is what makes ShieldSim.compute's temper erosion observable across
multiple hits.
Pool exhaustion: when MaxLivePanels is already live, the oldest live panel
(by spawn order) is recycled in place -- reposed and re-armed for the new
intercept point -- rather than dropping the strike or growing past the
§2.3 frame budget.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ShieldManager's fallback (no ShieldEnvelope yet, e.g. every ship prefab
before the operator hand-rigs it per Q5) hard-failed to a fixed
Vector3.forward direction with a per-hit Debug.LogError. That silently
changed behaviour for every ship in the game until the operator edits
Longinus.prefab/Djinni.prefab on their own schedule -- worse than either
the deleted code or a loud one-time failure.
Extract ShieldEnvelope's local-direction primitive into two static,
no-component-required entry points -- SurfaceDirection(Transform, Vector3)
and LegacyLocalSurfacePoint(Transform, Vector3) -- so both FieldDriver's and
ShieldManager's fallbacks go through one owner instead of each re-deriving
the pre-Cut-2 formula by hand. ShieldManager's fallback now reproduces the
deleted normalize(shield.transform.InverseTransformPoint(point)) formula
exactly via the shared static, logging once (a warning, not a per-hit
error). FieldDriver's fallback is rewired onto the same static rather than
its own inline copy of the maths.
Documented, not silently assumed: FieldDriver's "envelope present" branch
(envelope.ProjectToSurface, world-space) and its fallback (the legacy local-
space formula) are only numerically identical when the carrier's transform
has zero position and identity rotation -- true of the one FieldDriver
carrier in the tree today, not true in general. This is a characteristic of
Cut 2 as scoped by the map (FieldDriver.cs:125 reads envelope.ProjectToSurface
directly, per the map's own text), not something this fix introduces or
resolves; flagged so nobody assumes the two branches are interchangeable for
a future non-origin, non-identity FieldDriver carrier.
ShieldPanelCut2Verify.CheckLegacyFallbackFormulas pins both static entry
points against an independent re-derivation of the exact pre-Cut-2 formulas,
on a carrier with a non-trivial transform (nonzero position, non-identity
rotation) so the check isn't vacuous. mutation_tests_shield_panel_cut2.py
gained two cases: SurfaceDirection collapsing back to a fixed Vector3.forward
(the exact regression fixed here), and LegacyLocalSurfacePoint collapsing to
the raw, untransformed world point -- both correctly fail the probe.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
FieldDriver derived the shield ellipsoid from its own transform.localScale;
ShieldManager derived it independently from the Shield prefab instance's
transform, doing the same normalise-and-project by hand against a different
object. Add ShieldEnvelope as the one reader of a transform's centre, radii,
surface projection, and true outward normal (derived, not authored, per R6 --
no second copy of numbers the physical MeshCollider already owns), and switch
both consumers onto it, deleting their hand-rolled projections.
Radii read the attached collider mesh's own bounds.extents per axis rather
than assuming a flat 0.5 or 1.0: Shield.prefab's Icosphere collider measures
(0.0095106, 0.01, 0.01), not a uniform round number, and the cubesphere's
collider measures exactly (1,1,1) -- confirmed by a throwaway batchmode probe
against the actual mesh assets, not guessed.
SurfaceNormal is not normalize(p): a non-uniform ellipsoid's true normal is
the gradient of the implicit surface, transformed by rotation and the INVERSE
of lossyScale (a normal is a covector). The first implementation used
TransformDirection (rotation and forward scale) and the batchmode probe's
finite-difference gradient check caught it immediately -- exactly the
plausible-looking-wrong-answer failure mode the cut map called out by name.
Both original consumers keep normalize(p)-equivalent behaviour via the new
SurfaceDirection method, since they only ever wanted a direction.
FieldDriver falls back to its pre-cut formulas (logged once) when no
ShieldEnvelope is present, since it has no guaranteed carrier yet.
ShieldManager does not: every object that carries it gets an envelope in this
cut, so a missing one logs an error and degrades to a fixed direction rather
than recomputing a second projection.
No prefab or scene file is touched here (Q5: the operator rigs prefabs and
scenes by hand); ShieldPanelCut2Verify's batchmode probe instead builds
synthetic transforms carrying Longinus's and Djinni's actual override values
plus the real Icosphere mesh, and proves ProjectToSurface lands exactly on the
ellipsoid and SurfaceNormal matches a finite-difference gradient to 1e-3.
mutation_tests_shield_panel_cut2.py mutates SurfaceNormal three ways (back to
normalize(p), back to TransformDirection, and a flattened 0.5 extents factor)
and confirms the probe goes red for each, naming the reason.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Cut 2 of docs/addressables-cut.md. The asset move (git mv
Resources/{Prefabs,Schematics,Sprites/Icons} -> Content/...) and the
UnityHelpers.cs deletion already landed in 1fdf8151, picked up by a
concurrent session sharing this working tree; this commit is the rest
of Cut 2 landing on top of that shared state, atomically:
- Bump CultLib to cultlib-unity-v1.0.60 / caching-unity-v1.4.0 (both
45c2f40) in Packages/manifest.json, packages-lock.json and
Directory.Build.props, together with the 8 ServerShared
[CultInspectorAssetPath] usages becoming [CultInspectorAssetGuid] -
the .NET and Unity sides move together or nothing compiles.
- Assets/Scripts/EngineAssets.cs replaces UnityHelpers: loads an
Addressables key (bare GUID or guid[subAsset]) synchronously,
loading the GameObject and taking the component for the four sites
that load a component. ActionBarSlot, EntityInstance, ShipInstance
and ZoneRenderer call it instead of Resources.Load/UnityHelpers; the
inline "Assets/Resources/" prefix strips are gone with them.
CultCacheDrawers' schematic preview goes through
AssetDatabase.GUIDToAssetPath first.
- Assets/Scripts/Editor/EngineAssetCheck.cs validates every stored
[CultInspectorAssetGuid] value: 32 lowercase hex, resolves to a file,
is addressable, and satisfies its member's consumer-type contract
(Texture2D for icons/logos/schematics, a GameObject with the right
component for prefabs) - table-driven, so an attributed member with
no row fails loudly instead of being skipped.
- GameData/Aetheria.cc: the scratch migration (Aetheria.Shared + the
pinned CultLib revision, a throwaway console app, never landed)
walked all 138 attributed field slots across 180 documents,
rewriting the 30 non-empty Resources paths to their .meta GUIDs -
108 stayed empty, 0 unresolved, matching the map's census exactly.
Verification: dotnet test tests/Aetheria.Shared.Tests 61/61 green
against CultLib 45c2f40; Unity 6000.3.24f1 batchmode compiles clean;
EngineAssetCheck.Run passes all 30 stored references; the map's
negative greps are clean; tests/mutation_tests.py's 5 mutations (wrong
type, non-addressable, random guid, an old path, and an untabled
attributed member) are all caught and named, the control case and the
post-revert recompile stay clean, and the catalog/source are restored
byte-identical afterward.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>