167 KiB
Fire Control: Cut Map
Date: 2026-09-19
Status: Imagination pass, cut map. Nothing here has landed. Ends are owned by
docs/three-gates-scope.md ("Fire control") and
docs/shield-presentation-contract.md ("The commit window"); this document owns
the means.
Anchors are against codex/item-provenance HEAD 8a00fc92, read-only. Nothing was
built in the main tree and Unity was not run. Claims marked (probe) were measured
by running tools/AetherDb (census, hardpoint-fit) against the live catalog
GameData/Aetheria.cc from the free worktree F:\Projects\Aetheria-stats, CultLib
pinned at the shared 45c2f40 worktree. Claims marked (read) come from reading
source at the cited anchor.
This map supersedes Cut 2 of docs/headless-playground-cut.md
(:490-810, anchored to 562cdcf2). That draft is now history for three reasons:
its anchors predate the stats-and-power campaign, which moved most of the code it
cites; it predates the commit-window ruling of 2026-09-18, which changes the shape of
a shot from an instant to a lifecycle; and it predates the per-hardpoint arc override
and 360-degree turret rulings. Its substrate probes are still good and are carried
forward below with citations. Where the two disagree, this document wins; nobody
should be reading two live designs.
Scope: fire control only. What a presentation does with a committed outcome is the
capability/presentation seam's business (docs/shield-presentation-contract.md).
Faction territory is the campaign after this. §6 names every seam and stops there.
Rulings this map is built on (do not re-litigate)
From docs/three-gates-scope.md:47-99 and docs/shield-presentation-contract.md:34-57:
- R1. Hit detection goes. Operator, 2026-09-17: "We wanted to get rid of hit detection anyway... Just roll the dice based on weapon stats, targeting system stats (new subsystem) and sensor state."
- R2. Targeting systems are a new subsystem, interior
Toolgear. - R3. A shot resolves on arrival, hit chance reduced by how far the target deviated from the predicted intercept. Manoeuvring is evasion.
- R4. The outcome commits a short fixed time before impact. Operator, 2026-09-18: "That hybrid is the way." Deviation feeds the roll up to that horizon; after it the result stands and the rest of the flight is choreography. The horizon is one authored fire-control setting. A commit is authoritative; presentations perform it and never predict.
- R5. Subsystems reveal between
TargetArmorInfoThresholdandTargetGearInfoThreshold, and both the player and the AI may aim at a revealed subsystem. - R6. Per-hardpoint firing arcs. Mount direction from the item's
ItemRotation, as reaction thrusters do; default 120 degrees fromGameplaySettings; an optional per-hardpoint override; turrets 360. - R7. The simulation is 2D. The 3D is set dressing.
- R8. Unity weapon effects and
HullColliderbecome presentation only. TheirPhysics.*queries stop deciding damage. - R9. Every weapon type in the catalog's history stays
(
docs/headless-playground-cut.mdfork E). Nothing is deleted for being unused.
From docs/stats-and-power-cut.md:996-1030, the seam the stats campaign left:
- R10. Fire control must snapshot what it rolled over. The resolver hands out a current value; it does not promise that value is still current a second later.
- R11. A starved weapon does not fire (Cut 4 of that campaign: no fraction of a shot). A degraded shot instead is a ruling against that map, not a thing to work around here. See Q6.
0. Substrate
0.1 What decides a hit today, end to end
Nothing in ServerShared does. The whole chain is Unity (read):
InstantWeapon.Execute(Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs:216-237) walks the burst and raisesOnFire(:228), a parameterlessevent Actiondeclared at:73. It carries no outcome and nothing inServerSharedsubscribes.Assets/Scripts/Gameplay/EntityInstance.cs:203-204subscribes and callsInstantWeaponEffectManager.Fire(weapon, item, source, target)(Assets/Scripts/Gameplay/Weapons/InstantWeaponEffectManager.cs:7). The constant family goes the same way at:232-235.- The manager spawns an effect and copies the weapon's resolved numbers onto it.
ProjectileManager.cs:18-25jitters the barrel forward vector byRandom.Range(±Spread/2)on all three Euler axes — that is the entire aiming model. - The effect raycasts.
Projectile.cs:58,GuidedProjectile.cs:153,Laser.cs:40,Lightning.cs:34,ConstantLaser.cs:74,ConstantLightning.cs:54,HitscanEffect.cs:31,Mine.cs:68,92, plusProjectile.cs:114for airburst. - On a hull collider it calls
HullCollider.SendHit(Assets/Scripts/Gameplay/HullCollider.cs:18-30), orSendSplash(:32-41). EntityInstance.cs:316-396subscribes to those subjects, converts a UV texture coordinate into schematic cells, expands byDamageSpread, marches a penetration ray, and calls the local functionDamageSchematic(:277-314), which is the only writer ofArmor, itemDurabilityandHull.Durabilityfrom combat.- Death:
EntityInstance.cs:405-439subscribes toHullDamage, rolls loot withUnityEngine.Random, and callsentity.Zone.Entities.Remove(entity)(:437).
Authority versus presentation in that chain. Everything from step 3 down is
authority wearing a presentation costume. The shield branch repeated in all seven
effects (Projectile.cs:60-80 and its copies) is the absorb rule, duplicated seven
times. DamageSchematic is the damage rule. EntityInstance.cs:437 is the death rule.
Genuine presentation is narrow: trail rendering, line renderers, the lightning compute
animation, hit effect prefabs, ShieldManager.ShowHit, and the destroy effect.
The 2D/3D violation. EntityInstance.cs:323 and :380 call
transform.InverseTransformDirection to get a hit direction into the ship's frame. That
is a 3D rotation standing in for the planar rotation R7 says the simulation is made of.
0.2 What already exists of targeting
Entity.Target—Assets/Scripts/ServerShared/Entity.cs:46, aReactiveProperty<Entity>. Cleared when the target leavesVisibleEnemies(:196,:222).TargetRangeis recomputed each tick at:920.Entity.EntityInfoGathered—Entity.cs:57, aReactiveDictionary<Entity, float>in [0,1], accumulated with decay bySensor.Execute(Assets/Scripts/ServerShared/Behaviors/Sensor.cs:152-184).TargetDetectionInfoThresholdgatesVisibleEnemiesatEntity.cs:232-246.TargetArmorInfoThresholdandTargetGearInfoThresholdare authored and read by no code. They are declared atAssets/Scripts/ServerShared/Settings.cs:212-213and appear nowhere else inAssets/Scripts(read). R5's reveal tiers are unwired settings.LockWeapon—Assets/Scripts/ServerShared/Behaviors/LockWeapon.cs. Integrates a 0..1_lockscaled bypow(EntityInfoGathered[target], SensorImpact)(:92), gated by aLockAnglecone offEntity.LookDirection(:88-89).CanFire(:56) requires_lock > .99fand range within[MinRange, Range]. This is the closest thing in the codebase to a to-hit probability, and it is per-weapon rather than per-ship.TurretController—Assets/Scripts/ServerShared/Behaviors/TurretController.cs.TurretControllerDatahas no fields (:15-25). It writesEntity.LookDirectionto a first-order intercept (:67-72) and fires each weapon whendot(x.Direction, Entity.LookDirection) > .99f(:81-83).Sensor— sensitivity, ping boost/energy/visibility/range/cooldown, allPerformanceStat(Sensor.cs:16-34).- Player target selection:
Assets/Scripts/Gameplay/ActionGameManager.cs:368-403(reticle, nearest, next, previous). Player fire is not arc-gated at all. - No targeting-system item, behavior or stat exists anywhere in code or catalog. A
repo-wide grep for
Targeting|FireControl|Accuracy|Aimfinds one#region Targetinglabel atActionGameManager.cs:368and nothing else; the same grep over the catalog returns zero (probe).
0.3 Where hardpoint rotation and arcs live
HardpointData—Assets/Scripts/ServerShared/ItemData.cs:538-545:Type,Position,Shape,Transform(a prefab node name),Rotation(ItemRotation),Armor. There is no arc field onHardpointData,WeaponData, or any effect. A per-hardpoint override attaches here, as catalog data already authored per hull.ItemRotationis four-way (Assets/Scripts/ServerShared/Enums.cs:7);Extensions.cs:135-166givesNone= forward,Reversed= aft,Clockwise= right,CounterClockwise= left.Agent.cs:72readsClockwiseas right andThrusteruses the same convention for mount direction, which is the precedent R6 names.Behavior.Direction(Assets/Scripts/ServerShared/Behaviors/Behaviors.cs:24-44) has two paths. The first (:30-34) readsEntity.HardpointTransforms; the second (:37-38) is the item-rotation fallback R6 wants.Entity.HardpointTransforms(Entity.cs:58-59) is written only by Unity, atEntityInstance.cs:514-517, from the firstWeaponHardpoint.FiringPointbarrel's world forward. Headless it is empty. That single dictionary is whyCombat.cs:100-102throwsKeyNotFoundExceptionon the first combat tick outside Unity, and why the simulation's own notion of where a gun points is a readback from a renderer.- Barrel swing is
ArticulationPoint, yawing and pitching towardLookAtPoint(EntityInstance.cs:400-403,:519-520). Authored limits on the Longinus energy barrels are yaw [-1, 1] — effectively fixed forward — while the Turret prefab's yaw pivot is [-720, 720] (docs/headless-playground-cut.md:533-545, prefab probe, not re-measured here). That is presentation and stays. AgentFiringMinDot(Settings.cs:218) is declared and read by nothing (read).
0.4 What the catalog ships in weapons
(probe), tools/AetherDb census and hardpoint-fit over GameData/Aetheria.cc:
51 designs, 37 products, of which 18 are weapons.
| Hardpoint kind | Designs | Sellers |
|---|---|---|
| Ballistic | 6 | Zhestokost 2, Lightsail 1, AU 1, DME 1, unsold 1 |
| Energy | 7 | NiteLife 3, Lucent 1, Adrasteia 1, Alakrita 1, unsold 1 |
| Launcher | 5 | DME 1, R&D 1, unsold 3 |
Unsold, therefore unspawnable: Autocannon, SRMM72, LRMM72, plight, pswarm.
The earlier behaviour-class breakdown (8 ProjectileManager, 3 LaserManager at
velocity 0, 5 GuidedProjectileManager, 1 LightningGunManager, and no design using
ConstantWeaponData, HitscanEffect, Mine, airburst or MIRV) is carried from
docs/headless-playground-cut.md:500-506 and was not re-measured; the census agent
could not reproduce it because census buckets by HardpointType and the one command
that prints behaviour fields was blocked. Cut 4's safety argument depends on that
claim, so Hands re-measures it before Cut 4 (§4, First).
Stats a roll can read today (Weapon.cs:22-68, all PerformanceStat unless noted):
Damage, Penetration, DamageSpread, MinRange, Range, Energy, Heat,
Visibility, Spread (an angular cone in degrees), Velocity; plus DamageCurve
(a BezierCurve, damage against normalized range), MagazineSize, ReloadTime,
AmmoType. InstantWeaponData adds Count, BurstTime, Cooldown
(InstantWeapon.cs:13-22). LockWeaponData adds LockSpeed, SensorImpact,
LockAngle, DirectionImpact, Decay (LockWeapon.cs:13-25). The item carries
WeaponRange, WeaponCaliber, WeaponType, WeaponFireTypes, WeaponModifiers
(ItemData.cs:476-492).
Note against a misreading: DamageSpread is not damage variance. It is the
footprint, in schematic cells: EntityInstance.cs:372-375 expands the hit shape
round(hit.Spread) times, where hit.Spread is the weapon's DamageSpread.
What is missing from the authored data for a roll to be meaningful:
- No accuracy stat on anything. No weapon, hull, cockpit or sensor contributes a to-hit number. This is what R2's targeting system exists to supply, and it is the whole reason the subsystem is required content rather than a nice-to-have.
- No target-size term. A roll that ignores how big the target is will feel wrong at
every range. The hull
Shapeand a cell size give an angular radius;SchematicCellSizedoes not exist and must be authored (Q4). - No tracking or angular-velocity stat, so nothing yet reads R3's deviation. The targeting system supplies it.
- Beams have nothing beam-specific.
ConstantWeaponDataadds onlyAmmoInterval(ConstantWeapon.cs:15);Spreadis inherited and unread by any beam code, andVelocityis meaningless for one. There is no dwell or wander stat. Unused today, so Cut 4 gives beams the plainest possible rule and authors nothing new. ChargedWeaponData.ChargeFiringSpreadMultiplieris a plainfloat(ChargedWeapon.cs:34), not aPerformanceStat, so unlike every other combat number it cannot vary with quality or wear. Noted, not fixed here.- Lock is binary at 0.99 (
LockWeapon.cs:56). There is no stat turning partial lock into hit chance, andGuidedWeaponData(Launcher.cs:37-55) has no lock stats at all.
0.5 Can the headless sim step a full engagement today
No, and two specific things stop it (read):
Combat.cs:100-102indexesHardpointTransforms, which only Unity writes (§0.3).MinionentersCombatStateas soon asVisibleEnemiesis non-empty, so any NPC that sees an enemy throws on the tick it sees them.- There is no owner between
InstantWeapon.OnFireandEntity.HullDamage. The onlyServerSharedwriters of durability are the repair consumable (Behaviors.cs:67,72-73).
Everything else is already there and already exercised.
tests/Aetheria.Shared.Tests/IffAndCombatTests.cs:52 (BuildWorld) opens the real
catalog, builds an ItemManager and a Zone, and :73 (NewShip) equips a gun on a
hardpoint and activates the ship — two ships, positions, look directions, sensor info,
targets and IFF, headless. :241 and :255 assert on weapon.Progress after
Activate(), because a cooldown starting is the only observable consequence of firing
that exists. That fixture is this campaign's harness; it does not need to be built.
Zone.Update (Zone.cs:126-151) steps orbits, belts, agents, then entities.
ItemManager.Random is still clock-seeded (ItemManager.cs:19), so a seeded-roll test
needs a seed argument — see Q5.
The xunit project is tests/Aetheria.Shared.Tests/Aetheria.Shared.Tests.csproj
(net10.0, xunit), referencing Aetheria.Shared/Aetheria.Shared.csproj
(netstandard2.1, noEngineReferences mirrored by
Assets/Scripts/ServerShared/Aetheria.Shared.Unity.asmdef). The mutation harnesses are
tests/mutation_tests_*.py, 17 of them, each patching a named source anchor and
asserting a named test goes red with a no-op control.
Rulings (operator and Self, 2026-09-19)
- Q4, unaided accuracy: really, really bad. Operator: "Targeting with the mark one eyeball should be really, really bad." A ship with no targeting system can hit something close, slow and unaware, and little else. That makes the targeting subsystem required equipment rather than an upgrade: every hull needs somewhere to put one, the catalog needs them at several price points, and a stripped hull is a genuine handicap. Hands picks the other two balance numbers; this one is authored to make unaided fire a last resort, and the operator tunes it in play.
- Q1, turrets: author
FiringArc: 360. No new type for what a number already says. - Q2, player fire is arc-gated like everyone else's. Manual and programmatic must not be two truths.
- Q3, death removes the ship, and loot stays as it is until the pickup cut.
- Q5, seed the rolls in this campaign. Two lines here beats waiting on an unstarted cut.
- Q6, snapshot at fire, not at commit (Self, accepting Imagination's counter-proposal to
the stats map's §8 seam). A shot carries the gun that fired it: its parameters freeze when
the trigger is pulled, and nothing about the shooter's heat, power or wear changes them
mid-flight. Evasion still decides the outcome, because deviation is measured at arrival
against the predicted intercept.
docs/stats-and-power-cut.md§8 said "snapshot at commit"; that is superseded, and the resolver's promise (a current value, not a stable one) is honoured either way. - Q7, mine proximity physics: leave it.
0b. Identity, lifecycle, authority
One row per kind this campaign introduces. No cut below is mapped with a cell empty.
| Kind | What names it | What happens to it over time | Who decides |
|---|---|---|---|
| A shot in flight | PendingShot, a struct in Zone.PendingShots (a List<PendingShot>), identified by a monotonic int ShotId from Zone. Nothing outside Zone holds a reference. |
Born in FireControl.Fire when a weapon's burst step fires, carrying a payload snapshot frozen at that instant. Ages by dt in FireControl.Step, called from Zone.Update after entities. Reaches its commit horizon, then its arrival time, then is removed the same tick it resolves. A shot whose source or target entity leaves the zone resolves as a miss and is removed. Never serialised: a zone saved mid-flight loses its shots, which is correct — a save is a scene boundary. |
Zone owns the collection and the identity. FireControl owns the transitions. Nothing else may add, remove or mutate one. |
| A roll | Nothing. It is not a thing; it is one draw from ItemManager.Random inside FireControl.Commit, and only its result is named. |
Exists for the duration of one call. Its inputs are the payload snapshot plus deviation measured at that instant; its output is written into the shot's Outcome and never recomputed. |
FireControl alone. No behaviour, agent, presentation or UI may draw for a hit. |
| A commit | ShotOutcome { int ShotId; Entity Source; Entity Target; EquippedItem Weapon; bool Hit; bool Shielded; EquippedItem Aimed; int2 Cell; float ArrivalIn; DamageType DamageType; }, published on Zone.ShotCommitted (a Subject<ShotOutcome>). |
Created once, at ArrivalTime - CommitHorizon, or at fire time when the flight is shorter than the horizon. Immutable from then on. Published once. Read by presentations and by nothing else. Dies with the shot. |
FireControl creates it and is the only writer. Presentations are the only readers. Nothing outside presentation may read a commit to change state (docs/shield-presentation-contract.md:57). |
| A targeting solution | Not a stored object. FireControl.HitProbability(weapon, source, target) is a pure function, recomputed on demand. |
Has no lifetime. AI reads it to decide whether to fire; the HUD reads it to draw a number; FireControl.Fire reads it once per shot and freezes the result into the payload. Two reads a tick apart may differ; nothing caches one. |
FireControl. The targeting-system behaviour owns only its own resolved stats; it does not compute probability. |
| Target data / knowledge of a subsystem | Entity.EntityInfoGathered[other] (existing, Entity.cs:57) is the knowledge. FireControl.IsRevealed(observer, item) is the derived predicate over it. The current aim point is Entity.TargetItem, a ReactiveProperty<EquippedItem>. |
Info accumulates and decays continuously under Sensor (Sensor.cs:152-184); ownership of that does not change. Reveal is derived on every read, never stored and never cached, so it falls back the moment info decays. TargetItem is written only by Entity.TrySelectTargetItem, nulled when Target changes, and read through a derived accessor that returns null if the item is no longer revealed or no longer belongs to the target — no loop clears it. |
Sensor owns info. FireControl owns the reveal rule and the thresholds it reads. Entity owns the selection slot; TrySelectTargetItem is its only writer. Player input, CombatState and the test harness are its three callers. |
| A hardpoint arc | HardpointData.FiringArc, a float on catalog data (ItemData.cs:538-545), zero meaning "use the default". Resolved through FireControl.ArcFor(item). |
Authored once, in the catalog. Immutable at runtime. Read per shot and per AI fire decision; never stored on the entity. Mount direction is likewise derived per read from Behavior.Direction's item-rotation path. |
The catalog authors it; FireControl resolves and applies it; GameplaySettings.FiringArc supplies the default. No prefab, transform, articulation limit or renderer may influence it. |
Cut order
- Mount direction and arc become simulation data. Deletes
HardpointTransformsand the Unity readback; adds the arc gate; unbreaks headless combat. - Targeting system and reveal. The new subsystem, the reveal rule, the selection slot, and the two dead settings wired up.
- Hit authority moves. The Physics path in the four effects the catalog actually
uses is deleted and
FireControlplus the shot lifecycle replaces it, in one cut, so damage is never applied twice. - The unused kinds join the same path. Beams, hitscan, mines, splash and airburst stop querying Physics. They keep existing (R9).
Cuts 3 and 4 cannot be swapped, and 3 cannot be split into "roll" and "delete the old path": a cut boundary between them is a cut boundary with two damage owners.
Cut 1. Mount direction and arc become simulation data
- Repo/branch:
Aetheria, branchcodex/fire-controlfromcodex/item-provenance8a00fc92. Depends on nothing. - First: update
F:\Projects\Aetheria-statsto HEAD (it sits 11 commits behind atc4f6029e); captureAetherDb census,loadout 1,danglingas the before side.
Deletes first:
Assets/Scripts/ServerShared/Entity.cs:58-59—HardpointTransforms(2).Assets/Scripts/Gameplay/EntityInstance.cs:514-517— its only writer (4).Assets/Scripts/ServerShared/Behaviors/Behaviors.cs:30-35— the readback branch inBehavior.Direction(6). The item-rotation path at:37-38becomes the whole body.Assets/Scripts/ServerShared/Agents/States/Combat.cs:100-102— theHardpointTransformsdot test (3).Assets/Scripts/ServerShared/Behaviors/TurretController.cs:81-83— the.99fdot test (3).Assets/Scripts/ServerShared/Settings.cs:218—AgentFiringMinDot, read by nothing (1).
Adds:
Assets/Scripts/ServerShared/FireControl.cs— a new static class, this cut holding only geometry:MountDirection(EquippedItem)(the planar unit vector fromBehavior.Direction),ArcFor(EquippedItem)andbool InArc(EquippedItem weapon, float3 toTarget), which testsdot(normalize(weapon.Direction.xz), normalize(toTarget.xz)) >= cos(radians(arc/2)). The test is planar (R7); target height never enters it. An arc of 360 or more passes unconditionally.HardpointData.FiringArc(ItemData.cs:545, nextKey) — full width in degrees,0meaning "useGameplaySettings.FiringArc".GameplaySettings.FiringArc = 120(Settings.cs, after:214).GameplaySettings.AgentMinHitProbabilityis not added here; Cut 1's AI gate isInArcalone, which is strictly closer to today's behaviour than a probability the roll does not yet produce.
Per-file changes (against 8a00fc92):
Combat.cs:100-102:shouldFire = FireControl.InArc(testWeapon.Item, toTarget). The intercept prediction at:84-98stays — it still aimsLookDirection, and under R3 it becomes the predicted intercept a deviation is measured against.TurretController.cs:81-83: the same call. Under the default arc a turret hull, whoseDirectionnever rotates (TurretControllerwrites onlyLookDirection), would engage only within 60 degrees of its spawn facing. R6 says turrets are 360; the turret hull's hardpoints authorFiringArc: 360. See Q1.- Catalog: author
FiringArcon theTurrethull's two Ballistic hardpoints. Follows the Cut C precedent — a Hands scratch console upserts, Unity and Studio closed.
Authority map:
- Owner:
FireControlowns whether a weapon bears on a point.HardpointDataauthors the arc;ItemRotationauthors the mount direction. - Inputs: the equipped item's hardpoint rotation,
HardpointData.FiringArc,GameplaySettings.FiringArc, the planar vector to the target. - Outputs: one bool, consumed by
CombatState,TurretController, and from Cut 3 byFireControl.Fire. - Derived state:
Behavior.Directionbecomes purely derived from item rotation. - Forbidden writers: no Unity transform,
ArticulationPoint,WeaponHardpointor prefab node may influence aim.Barrels,GetBarrelandArticulationPointsurvive as presentation and must never be read byServerShared. - Shared paths: player fire, AI fire and turret fire all go through
InArc. Player fire is arc-gated for the first time; see Q2. - Deletion line:
HardpointTransformsand its writer and readers are gone beforeFireControl.csis written.
Verification:
- builds:
Aetheria.Shared/Aetheria.Shared.csproj,tests/Aetheria.Shared.Tests,tools/AetherDb. - tests, each named with the rule it pins:
ArcFollowsMountRotationpins mount direction is the item's rotation, not the hull's facing: a fixture hull with one weapon on aNonehardpoint and one on aClockwisehardpoint; a target 90 degrees to starboard passes only for theClockwiseweapon, a target dead ahead only for theNoneweapon. Mutation: makeInArcuseEntity.Direction; swapRotate'sClockwise/CounterClockwisecases.ArcBoundaryIsHalfWidthpinsFiringArcis full width: at 120, a target 59 degrees off passes and one at 61 does not. Mutation: compare againstcos(radians(arc)).ArcIsPlanarpins R7: a target inside the arc horizontally but far above the firer still passes. Mutation: use the 3D vector.HardpointOverrideBeatsDefaultpins the per-hardpoint override wins: the same weapon on a hardpoint withFiringArc: 360passes at 170 degrees, and fails there on a hardpoint with0. Mutation: ignore the field; treat0as an arc of zero.CombatStateStepsHeadlesspins the simulation decides aim without Unity: aMinionwith a target in range steps throughZone.Updatewithout throwing. Mutation: restore theHardpointTransformsread.
- negative:
rg -n "HardpointTransforms" Assets/Scriptsempty;rg -n "ArticulationPoint|FiringPoint|WeaponHardpoint" Assets/Scripts/ServerSharedempty;rg -n "AgentFiringMinDot" Assets/Scriptsempty. (\.99fis not a safe negative grep — it occurs legitimately inLockWeapon.cs:56. Grep the two files by name instead.) - operator: in Unity, side-mounted weapons still fire at targets abeam; a turret still tracks all the way around; nothing fires through the hull.
Risk: this cut changes what the AI fires at before the roll exists, so combat feel shifts once and shifts again in Cut 3. That is the price of deleting the obsolete authority first, and it is the right price.
Cut 2. Targeting system and reveal
- Repo/branch: continues Cut 1. Depends on Cut 1 (uses
FireControl.cs), and on provenance Cut C if that has not landed, because Cut C re-upserts everyItemData. - First:
AetherDb censusandloadout 1over several seeds, as the before side.
Deletes first: nothing. This cut is entirely additive, which needs its account (CODE IS A LIABILITY): the invariant it buys is R5, which cannot be purchased by moving authority, because no authority for it exists — two authored settings are read by nothing and there is no subsystem to carry accuracy. Cut 3 is where the subtraction is.
Adds:
-
Assets/Scripts/ServerShared/Behaviors/TargetingSystem.cs—TargetingSystemData : BehaviorData(next freeUnionindex onBehaviorData; Hands reads the current list inBehaviors.csrather than trusting the number 39 quoted in the superseded draft) plus aTargetingSystem : Behaviorthat resolves and exposes:Accuracy(0..1) — the ceiling on hit probability.Resolution(detection threshold..1) — the info level at which sensor state stops limiting hits.Precision(0..1) — the chance a hit lands on the aimed item rather than a random cell.Tracking— how much deviation it forgives (consumed in Cut 3).
All four are
PerformanceStat, so lot quality, wear, heat andPowerSupplyreach them throughEquippedItem.Evaluate— that is the brownout ruling applied to accuracy, and it is why a starved targeting system rolls worse instead of switching off. Energy and heat come from the existingEnergyDrawandHeatbehaviours on the item; the subsystem adds no power machinery of its own. -
GameplaySettings.UnaidedAccuracy— what an entity with no working targeting system fires with. Such an entity usesResolution = 1andPrecision = 0: it can still select a revealed item, and its hits scatter. -
FireControl.IsRevealed(Entity observer, EquippedItem item)— derived, never cached:- Rank the target's non-hull equipment: hardpoint-mounted first, then size descending, then equipment index.
- Item
iofNis revealed wheninfo >= lerp(TargetArmorInfoThreshold, TargetGearInfoThreshold, i / max(1, N - 1)).
This is the first and only reader of
Settings.cs:212-213. -
Entity.TargetItem(ReactiveProperty<EquippedItem>, besideTargetatEntity.cs:46) andbool Entity.TrySelectTargetItem(EquippedItem), the only writer. It accepts null, or an item ofTarget.ValuethatIsRevealed. ATargetchange nulls it. The read accessor returns null when the item is no longer revealed, so decay drops the aim point without a loop. -
Player input: a select/cycle command beside
ActionGameManager.cs:368-403, callingTrySelectTargetItem. The target schematic (Assets/Scripts/UI/HUD/SchematicDisplay.cs:109-119, which today lists only weapon items) shows revealed items and marks the selection. That is presentation; it decides nothing. -
AI:
CombatStateselects the revealed targetWeaponitem with the highestRangeDamagePerSecond(range), or null. -
Catalog: two targeting-system designs, 1-cell and 2-cell, each with products from at least the manufacturers that sell the capacitor, so
IsAvailablefinds one in every galaxy.LoadoutGenerator.FillInteriorpicks one withrequired: truefor every entity withWeapons— the starting LonginusX, NPC ships and turrets. Zenith, with no weapon hardpoints, gets none.
Authority map:
- Owner:
FireControlowns the reveal rule.Entityowns the selection slot.TargetingSystemowns nothing but its own resolved stats. - Inputs:
EntityInfoGathered, the two threshold settings, the target's equipment list and item sizes. - Outputs: a bool per (observer, item); a nullable
EquippedItemper entity. - Derived state: reveal is derived on read.
TargetItem's validity is derived; only the slot is stored. - Forbidden writers: the HUD may not write
TargetItemexcept throughTrySelectTargetItem; no code may store a reveal result; no code may read the two thresholds exceptIsRevealed. - Shared paths: player selection, AI selection and the test harness use one writer.
- Deletion line: none, and that is stated rather than hidden.
Verification:
- builds: as Cut 1.
- tests:
SelectionNeedsRevealpins you cannot aim at what you have not resolved: below the item's tierTrySelectTargetItemreturns false; above it, true; a target change nulls it; info decaying below the tier makes the read null. Mutation:IsRevealedreturns true; theTargetchange skips the null; the accessor returns the stored value without re-checking.RevealOrderpins the big obvious things resolve first: hardpoint items reveal before interior items, larger before smaller. Mutation: drop the ordering.RevealSpansBothThresholdspins the tiers are the authored ones: with N items, the first reveals atTargetArmorInfoThresholdand the last atTargetGearInfoThreshold. Mutation: swap them; use the detection threshold.UnaidedFiresWorsepins the subsystem is what supplies accuracy: an entity with no targeting system resolvesUnaidedAccuracyandPrecision0; one with a destroyed or offline system resolves the same. Mutation: fall back to the system's stats when offline.StarvedTargetingRollsWorsepins the brownout ruling on accuracy: with anAccuracystat carrying aPowerSupplyterm, a half-grant halves it rather than switching the item off. Mutation: gate onPowerSupply <= 1e-4.EveryArmedLoadoutGetsATargetingSystempins the required-item rule across seeds. Mutation: droprequired: true.
- negative:
rg -n "TargetArmorInfoThreshold|TargetGearInfoThreshold" Assets/ScriptsreturnsSettings.csandFireControl.csonly. - operator:
AetherDb censusshows 2 more designs;loadout 1shows one more interior item per armed entity;danglingis 0; reopen reports 0 schema issues; CultCache Studio renders the new behaviour union (an operator check, because Studio's handling of a new union index is not something this map can promise).
Risk: LoadoutGenerator.EquipHardpoints can already fit the Tractor Beam into
LonginusX's only Sensors slot, leaving an NPC that gathers no info and therefore reveals
nothing and hits nothing (docs/headless-playground-cut.md:520-523). Check loadout
over several seeds. The fix is data or EquipHardpoints, not fire control.
Cut 3. Hit authority moves
The rebuild. The old path is deleted and the new owner added in the same cut, because any boundary between them is a boundary with two damage owners.
- Repo/branch: continues Cut 2. Depends on Cuts 1 and 2.
- First: capture a Unity play smoke of current combat feel (kill time against one NPC, roughly) as the before side, since this cut changes feel more than anything in the campaign.
Deletes first (paths and line counts against 8a00fc92):
Assets/Scripts/Gameplay/EntityInstance.cs:277-396—DamageSchematicand bothHullCollidersubscriptions (120).Assets/Scripts/Gameplay/HullCollider.cs:13-14,18-41,48-65—Hit,Splash,SendHit,SendSplashand both argument classes (~45 of 65).OnCollisionEnter(:43-46) stays; ship collision is a deferred Unity-physics surface.Assets/Scripts/Gameplay/Weapons/Projectile.cs:57-103— the raycast, shield branch andSendHit(47).:109-122(airburst) is Cut 4's.Assets/Scripts/Gameplay/Weapons/GuidedProjectile.cs:152-189— the same (38).Assets/Scripts/Gameplay/Weapons/Laser.cs:40-75— the same; theLineRendererendpoint writes at:59,70,77are presentation and are rebuilt from the outcome (36).Assets/Scripts/Gameplay/Weapons/Lightning.cs:34-72— the same; the endpoint capture at:67-71stays as a visual (39).Assets/Scripts/Gameplay/EntityInstance.cs:411-431— theUnityEngine.Randomloot roll, only if Q3 is answered (a); otherwise this cut leaves it and the seam is named in §6.
Adds:
Entity.DamageSchematic(float damage, Shape hitShape)— the rule moved verbatim fromEntityInstance.cs:277-314, now a method onEntityinServerShared, writingArmor, itemDurability,Hull.Durabilityand raising the existingArmorDamage,ItemDamageandHullDamagesubjects. Not a redesign: the same arithmetic, the same order, the same thresholds.Entity.ApplyHit(...)— the shape construction moved fromEntityInstance.cs:334-394, with two changes forced by R7: the hit cell comes from the rolledCellrather than a UV texture coordinate, and the penetration march rotates the firer-to-target vector into the target's frame with a planar rotation by-target.Directioninstead oftransform.InverseTransformDirection.FireControl.HitProbability(weapon, source, target)— pure, no draw:- Zero, with no draw consumed, when there is no target; the target is not in
VisibleEntities; range is outside[MinRange, Range]; aLockWeaponis not locked; orInArcfails. - Otherwise
p = Accuracy * pSensor * pSpread, wherepSensor = saturate(unlerp(TargetDetectionInfoThreshold, Resolution, info))andpSpread = Spread > 0 ? saturate(angularRadius / (Spread / 2)) : 1, withangularRadius = degrees(atan(0.5 * max(Shape.Width, Shape.Height) * SchematicCellSize / range)). SchematicCellSizeis the one new tuning constant (Q4).
- Zero, with no draw consumed, when there is no target; the target is not in
FireControl.Fire(weapon, item, source)— called once per burst step fromInstantWeapon.Execute(replacing the bareOnFire?.Invoke()atInstantWeapon.cs:228). It computes the predicted intercept, the flight time (range / Velocity, or zero whenVelocityis at or near zero), freezes the payload snapshot (R10) and appends aPendingShottoZone.PendingShots. The snapshot holdsDamage,Penetration,DamageSpread,DamageType,Aimed, the resolved targeting stats, and the intercept point. Nothing downstream re-evaluates a stat.FireControl.Step(Zone, dt)— called fromZone.Updateafter the entity loop (Zone.cs:150). For each pending shot: age it; atArrivalTime - CommitHorizoncompute deviation, roll, writeOutcome, publishZone.ShotCommitted; atArrivalTimeapply the outcome and remove the shot. A shot whose flight is shorter than the horizon commits at fire time, in the same call asFire— that is every velocity-0 weapon, which is three of the catalog's eighteen.GameplaySettings.CommitHorizon— R4's authored horizon, one number, in seconds.- The deviation term:
pDeviationfolds in how far the target is from the predicted intercept at commit time, forgiven by the targeting system'sTracking. This is R3's evasion, and the commit horizon is what bounds how late it counts. - The roll: one draw against
p. On a hit, a second draw againstPrecisiondecides where — onAimed's footprint cells on success, otherwise on one cell drawn uniformly over the hullShape. An active shield thatCanTakeHittakes the hit instead, and the outcome is markedShielded; that rule now exists once, inFireControl, rather than seven times in the effects. Zone.ShotCommittedandZone.ShotResolved(Subject<ShotOutcome>), the presentation feed.InstantWeapon.OnFirebecomesevent Action<int>carrying theShotId, so the Unity manager can bind its effect to the shot it belongs to.
Per-file changes:
InstantWeapon.cs:228:OnFire?.Invoke(FireControl.Fire(this, Item, Entity)). Charge multipliers already fold intoDamageandSpreadbefore this point.Combat.cs:100-102:shouldFire = FireControl.HitProbability(...) >= Settings.AgentMinHitProbability, replacing Cut 1's bareInArc(which the probability already subsumes, returning zero out of arc).AgentMinHitProbabilityis added here, not in Cut 1.TurretController.cs:81-83: the same gate and threshold.EntityInstance.cs:203-204,232-235: the manager call takes theShotIdand looks the outcome up, or subscribes toShotCommitted. Effects fly to the rolled cell's world point or to a miss offset and play the impact on arrival. They apply nothing.Zone.cs:150:FireControl.Step(this, deltaTime)after the entity loop.
Authority map:
- Owner:
FireControlowns the engage gate, hit probability, the roll, the cell, the shield branch, and when each of those happens.Entityowns the application of damage to its own schematic.Zoneowns the pending-shot collection. - Inputs: weapon stats through
EquippedItem.Evaluate; targeting-system stats;EntityInfoGathered;TargetandTargetItem; both entities' positions, velocities and planar directions; mount direction and arc;ItemManager.Random. - Outputs:
ShotOutcomeon two subjects;Entity.DamageSchematic; shieldTakeHit;Entity.IncomingHit. - Derived state:
Behavior.Direction, reveal, hit probability. Everything a presentation shows about a shot is derived from the outcome. - Forbidden writers: after this cut no
Physics.*query inAssets/Scripts/Gameplay/Weapons(except the unused four Cut 4 handles) may reach durability;HullCollidermay not raise a hit;EntityInstancemay not damage a schematic; no effect may callTakeHit; nothing outsideFireControlmay draw for a hit; nothing outside presentation may read a commit to change state. - Shared paths: player fire, AI fire, turret fire, instant, charged, guided and
locked weapons all reach damage through
FireControl.FireandFireControl.Step. There is exactly one commit primitive. - Deletion line: the six deletions above land before
FireControl.Fireexists.
Verification:
-
builds:
Aetheria.Shared,tests/Aetheria.Shared.Tests,tools/AetherDb; and Unity batchmode with the editor closed (docs/item-provenance-cut.md§6) reporting noerror CS. -
tests, each with the mutation that must kill it:
Test Pins Mutation RollsAreSeededa fight is reproducible — two zones, same seed, 50 shots, identical outcome sequences FireControldraws fromnew Random()ProbabilityFollowsInputsthe roll reads what the ruling says it reads — zero below detection and outside range; rising with info to Resolution; falling withSpread; capped atUnaidedAccuracywith no systemdrop pSensor; drop the range gate; drop the capOutOfArcConsumesNoDrawthe gate is a gate, not a penalty — the RNG sequence is unchanged by an out-of-arc trigger roll first and multiply by zero ShotResolvesOnArrivalR3 — no durability change before range / Velocity; a target removed mid-flight takes noneapply at fire time OutcomeCommitsBeforeImpactR4 — ShotCommittedfires exactlyCommitHorizonbeforeShotResolved, once per shotpublish at arrival; publish twice EvasionCountsUntilCommitAndNotAfterR4's second half — a target that jinks before the horizon changes the outcome distribution; the identical jink after the horizon does not measure deviation at arrival; measure it at fire ShortFlightCommitsAtFirethe horizon degrades gracefully — a velocity-0 weapon commits and resolves in one tick, still publishing both events in order skip the commit for short flights OutcomeIsSnapshotNotRereadR10, the seam the stats campaign named — a weapon whose Damagecollapses (power cut, heat spike) between fire and arrival still lands the damage it rolledre-evaluate Damageat arrivalAimedHitLandsOnSelectedItemR5's payoff — Precision1 andp1: only the aimed item's durability fallsthe roll ignores AimedHardpointHitDamagesItemThenHullthe damage rule survived the move verbatim — damage above armor plus item durability zeroes the item and the remainder hits the hull armor not subtracted; hull skipped PenetrationMarchIsPlanarR7 — the march rotates by the target's planar direction and gives the same cells whatever the height difference use a 3D rotation ShieldTakesHitthe absorb rule has one owner — an active shield absorbs and the schematic is untouched remove the shield branch DeadEntityStopsTakingShotsa shot cannot damage a corpse — a target destroyed before arrival takes nothing and the shot is removed resolve against a removed entity -
negative greps, each checked against legitimate names before publishing:
rg -n "Physics\." Assets/Scripts/Gameplay/Weapons/Projectile.cs Assets/Scripts/Gameplay/Weapons/GuidedProjectile.cs Assets/Scripts/Gameplay/Weapons/Laser.cs Assets/Scripts/Gameplay/Weapons/Lightning.cs Assets/Scripts/Gameplay/HullCollider.cs— empty. (A repo-widePhysics\.grep is not valid until Cut 4, and never forTractorBeam.cs, which stays.)rg -n "SendHit|SendSplash|DamageSchematic" Assets/Scripts --glob '!**/ServerShared/**'— empty.rg -n "TakeHit\(" Assets/Scripts --glob '!**/ServerShared/**'— empty. NoteCanTakeHitshares the substring; the trailing(onTakeHitdistinguishes them, and theCanprefix must be checked by eye in the first run.
-
operator play smoke: shots show rolled impacts and misses; a miss reads as a deliberate near-miss rather than a bug; damage matches the HUD; aiming at a revealed subsystem visibly concentrates damage there; kill time is in the same order as before.
Risks. UnaidedAccuracy, SchematicCellSize, CommitHorizon and the targeting stat
ranges are first guesses. The headless fixture is the tuning harness, and the operator
smoke is the arbiter. Second: Combat.cs:84-98 aims LookDirection at a first-order
intercept, and R3's deviation is measured against FireControl's own predicted
intercept; if the two predictions differ, an AI will systematically miss. They must be
one function. Hands makes FireControl own the prediction and CombatState call it.
Cut 4. The unused kinds join the same path
R9 keeps every weapon type. This cut makes the ones no design currently ships obey the same owner, so that authoring one later does not resurrect Unity hit detection.
- Repo/branch: continues Cut 3. Depends on Cut 3.
- First: re-measure the claim this cut's safety rests on — that no live design
uses
ConstantWeaponData,HitscanEffect,Mine, airburst or MIRV (§0.4, carried from a superseded map and not reproduced by this campaign's probe). A behaviour-class census needsAetherDb roles-migrateas a dry run, which the permission classifier blocked; the operator can unblock it or Hands can add a read-onlybehaviorscommand. If any of these is live, it moves into Cut 3 instead.
Deletes first:
Assets/Scripts/Gameplay/Weapons/Projectile.cs:109-122— airburstOverlapSphereandSendSplash(14).Assets/Scripts/Gameplay/Weapons/ConstantLaser.cs:74-110(~37),ConstantLightning.cs:54-70(~17),HitscanEffect.cs:31-55(~25),Mine.cs:68-75,92-117(~34),ConstantParticleWeapon.cs:72and its_hullplumbing (~8) — every remainingPhysics.*hit query andSendHit/SendSplashcall inAssets/Scripts/Gameplay/Weapons.
Adds:
ConstantWeaponrolls once perGameplaySettings.BeamResolveIntervalforDamage * interval, through the sameFireControl.Fire/Steppair with a flight time of zero, raisingOnBeamShot(int shotId). About ten lines; no design uses it yet, and it authors no new stat.- Splash — airburst and mine blast — as
FireControl.Splash, applying the existingDamageSchematicover the directional half-shape the deletedEntityInstance.cs:318-329computed, against every entity in radius. This is the one genuinely new rule in the cut, and it exists because R9 keeps the flak cannon and the mine. - Nothing else.
Mine,TractorBeamand ship collision stay Unity-physics surfaces for placement and contact, which is not hit detection; only their damage application moves.
Authority map: unchanged from Cut 3 except that the forbidden-writer list now covers
every file in Assets/Scripts/Gameplay/Weapons, and FireControl gains splash.
Verification:
- builds: as Cut 3.
- tests:
BeamRollsPerIntervalpins a beam is a sequence of rolls, not a continuous truth: over one second a beam produces1 / BeamResolveIntervaloutcomes totallingDamage. Mutation: roll per tick; roll once.SplashHitsEveryEntityInRadiuspins splash is one rule, not per-effect: two targets in radius both take damage, one outside takes none. Mutation: damage only the nearest.SplashIsDirectionalpins that the moved half-shape rule survived: cells facing the blast take it, cells facing away do not. Mutation: damage the whole shape.
- negative:
rg -n "Physics\." Assets/Scripts/Gameplay/Weaponsreturns onlyMine.cs's placement query, if Q7 keeps it — otherwise empty.TractorBeam.cs:21is outside this directory and stays.rg -n "SendHit|SendSplash" Assets/Scriptsempty.rg -n "class HullHitEventArgs|class HullSplashEventArgs" Assets/Scriptsempty. - operator: nothing to check in play, because nothing in the catalog spawns these. The check is a Studio authoring pass: author one beam product and one flak product onto a test hull and confirm they damage.
5. Subtraction ledger
Estimates, to be compared with reality in the postmortem.
| Cut | Removed | Added | Deps / targets / formats |
|---|---|---|---|
| 1 | 19 production (Entity 2, EntityInstance 4, Behaviors 6, Combat 3, TurretController 3, Settings 1) | ~60 production (FireControl.cs geometry ~45, HardpointData.FiringArc 1, GameplaySettings.FiringArc 1, call sites ~13), ~90 test |
+1 catalog field, authored on one hull. No new project, package or dependency. |
| 2 | 0 | ~140 production (TargetingSystem.cs ~55, reveal ~25, Entity.TargetItem ~25, AI selection ~10, player input ~15, LoadoutGenerator ~12), ~160 test |
+1 behaviour union index, +2 designs and their products, +1 setting. |
| 3 | ~285 production (EntityInstance 120, HullCollider 45, Projectile 47, GuidedProjectile 38, Laser 36, Lightning 39) | ~330 production (Entity.DamageSchematic/ApplyHit ~130 moved, FireControl roll and lifecycle ~140, Zone pending shots ~35, call sites ~25), ~280 test |
+2 settings. Seven duplicated copies of the shield-absorb rule collapse to one. |
| 4 | ~135 production | ~70 production, ~70 test | +1 setting. |
| Total | ~439 | ~600 production, ~600 test |
Net production about +160. The account: it buys the invariant that the simulation
resolves combat without Unity — docs/three-gates-scope.md:101-110 calls today's state
a velocity compromise — and it collapses seven copies of the absorb rule and one copy of
the damage rule into single owners. It does not buy that with a registry, an adapter,
a mode flag or a compatibility layer: the old path is deleted in the cut that replaces
it, and no cut leaves two writers of damage.
The line count is the wrong thing to stare at here. The structural delta is: one new
owner (FireControl), one new subsystem (TargetingSystem), one field on catalog data,
four settings, and minus one whole category of authority — Unity physics deciding
gameplay.
6. Build budget
- Packages and targets.
Aetheria.Shared/Aetheria.Shared.csproj(netstandard2.1),tests/Aetheria.Shared.Tests/Aetheria.Shared.Tests.csproj(net10.0, xunit),tools/AetherDb/AetherDb.csproj. Three projects, no new ones. Debug only; no release profile, no feature flags, no code generation, no new target platform. - Build host and target are the same machine: a Windows workstation producing managed assemblies for the same runtime. Nothing here is cross-platform, so no claim about a Linux artifact is made or needed.
- Where.
F:\Projects\Aetheria-stats(the free worktree; it must first be advanced fromc4f6029eto HEAD). Never the main tree — the operator plays there. NeverF:\Projects\Aetheria-tiers. CultLib via-p:CultLibRoot=<scratchpad>\cultlib-45c2f40, the shared pinned worktree, which is never removed.--artifacts-pathinto scratch so nobin/objlands in a working tree. - Footprint. Three managed projects plus their test host; on the order of a few hundred MB of artifacts, comparable to the stats campaign. No expected growth: this campaign adds no project, package reference or native component.
- Unity. Batchmode compile check with the editor closed, per
docs/item-provenance-cut.md§6, once per cut that touchesAssets/Scripts/Gameplay(Cuts 1, 3, 4). The operator's play smoke is the only Unity run. Imagination did not run Unity and did not build. - Mutation harnesses. One per cut, in the established shape
(
tests/mutation_tests_fire_control_cut1..4.py): patch a named anchor, rundotnet test tests/Aetheria.Shared.Tests --cultlib-root <pin>, assert the named test goes red, with a no-op control. Note the scar from8dddda4d: later cuts move earlier anchors, so Cut 4 re-anchors Cuts 1-3's harnesses before it lands.
7. Seams this campaign names and stops at
- Presentation of shots.
Zone.ShotCommittedandZone.ShotResolvedare the whole contract. What a projectile, panel, whip or near-miss does with a commit belongs todocs/shield-presentation-contract.mdand the panel and Lariat maps. This campaign guarantees only that the commit exists, is authoritative, arrivesCommitHorizonbefore impact, and carries what the contract says it carries: what happens, where, when it arrives, and which capability answers it — not who fired, not damage numbers. The contract's open question "where the commit window lives" is answered here:FireControl, with the horizon inGameplaySettings. - Death, removal and loot.
Entity.Deathis already composed inServerShared(Entity.cs:363-366), butZone.Entities.Removeand the loot roll live in Unity (EntityInstance.cs:411-437) and useUnityEngine.Random. Fire control makes a headless kill happen; it does not make the corpse go away. Q3. - Pickup.
ShieldManager.cs:40-51stores loot on trigger contact. Untouched. - Ship collision,
TractorBeam, mine placement. Deferred Unity-physics surfaces (docs/three-gates-scope.md:98-99). Untouched. - The navigation planner. R3 makes manoeuvring evasion, which changes what a good flight path is. The planner is a separate item in the shipping scope and this map does not anticipate it.
- Stance and IFF. Ruled and partly built (
Weapon.StanceAllowsFire,Weapon.cs:101). Fire control reads the gate and does not redesign it. - Faction territory. The campaign after this.
8. Operator questions
Most blocking first.
Q1. What makes a turret 360? R6 says turrets are 360 degrees, but a turret hull's
Direction never rotates — TurretController writes only LookDirection — so under a
120-degree default a turret would only engage within 60 degrees of its spawn facing.
A: author FiringArc: 360 on the Turret hull's hardpoints; the override R6 already
grants does the whole job and no code knows what a turret is. B: add
HardpointType.Turret and give it an implicit 360. C: give the arc to the hull
rather than the hardpoint. Recommended: A, because it buys the invariant with
authored data instead of a new type, and because a future partially-traversing turret
(a 270-degree sponson) is then already expressible. B makes the arc a property of a
category and loses that.
Q2. Is player fire arc-gated? Today it is not gated at all; the player fires whatever is equipped, wherever they point. Cut 1 makes the arc a shared rule. A: yes, gate the player exactly as the AI — a side-mounted gun simply does not fire forward, and the HUD shows which groups bear. B: gate the AI only, leaving the player's rear gun able to shoot forward. Recommended: A. Under R1 aiming is gone, so what the player does with a weapon group is the decision, and arcs are what make that decision interesting. B also splits the shared path the doctrine cares about: manual and programmatic fire would be two truths. It is a real difficulty change and should be felt in the smoke before the campaign closes.
Q3. Does this campaign move death removal and loot, or leave them in Unity? A
headless kill currently leaves the corpse in Zone.Entities, because Entities.Remove
is at EntityInstance.cs:437. A: move removal and the loot roll into Zone in
Cut 3, using ItemManager.Random — about 50 lines, and it makes a headless kill a
complete event. B: move only the removal; leave loot to the run-structure work.
C: leave both; fire control's tests assert on durability, not on removal.
Recommended: B. Removal is the half without which the simulation is lying about its
own contents, and a seeded loot roll pulls in drop velocity, bay capacity and pickup —
the run-structure campaign's material. A is more complete but widens this campaign; C
leaves ServerShared unable to say who is alive.
Q4. Three balance numbers need first values: CommitHorizon (R4's "short fixed
time"), SchematicCellSize (metres per schematic cell, which sets how much target size
matters), and UnaidedAccuracy. A: Hands picks defaults (0.3s, measured off the
LonginusX prefab, 0.35) and the operator tunes them in the smoke. B: the operator
states them now. Recommended: A for the first two and B for UnaidedAccuracy,
because it is not a tuning number — it decides whether a targeting system is a
requirement or an upgrade, which is a design ruling. If it is low, Cut 2's required-item
rule is load-bearing; if it is high, the subsystem is a nice-to-have and Cut 2 shrinks.
Q5. What seeds the rolls? ItemManager.Random is clock-seeded
(ItemManager.cs:19), so RollsAreSeeded cannot pass without a seed argument.
A: add the seed parameter here, as a two-line change. B: wait for the Run
lifecycle work that owns it (docs/headless-playground-cut.md Cut 1, unlanded).
Recommended: A. A reproducible fight is how this campaign is tuned and how a
regression is ever found again; waiting on an unstarted cut for two lines is the
compensator pattern in miniature.
Q6. When is the snapshot taken — fire, or commit? docs/stats-and-power-cut.md:1020
says "at commit time". A: one snapshot, at fire: the shot carries what left the
barrel, and only deviation is measured up to the commit. B: two snapshots — payload
at fire, targeting stats at commit — so a targeting system knocked out mid-flight
degrades the shot already in the air. Recommended: A. One rule, one freeze point, and
over a horizon measured in tenths of a second B's extra fidelity is invisible while its
extra state is permanent. B is the more simulationist answer and is worth having if the
operator wants killing the gunnery computer to spoil shots already fired.
Q7. Mines. Cut 4 moves mine damage to FireControl.Splash but leaves
Mine.cs:68's OverlapSphere for proximity triggering, which is contact detection
rather than hit detection. A: leave it, alongside TractorBeam and ship collision,
as a named deferred physics surface. B: move proximity into Zone too, so the last
Physics.* in the weapons directory goes. Recommended: A. No design ships a mine,
and B buys a clean grep rather than an invariant. Revisit when a mine is authored.
Cut 5: the rules that landed in one path and not its twin
Date: 2026-09-19. Branch codex/fire-control-5 off ca57819d.
Soul's pass over the whole campaign (2026-09-19) confirmed eleven findings. Most
of them are the same shape: a rule this map declared, implemented on one path,
and left off the path beside it. Splash breaks a shield the discrete roll does
not. InstantWeapon gates on arc and ConstantWeapon does not. Unity removes a
corpse from the zone and the simulation does not. This cut closes the twins.
Catalog authoring (Soul's findings 1 and 2) is Cut 6; the shared-RNG stream
(finding 6) and airburst (finding 5) are Cut 6 as well; the missing mutation
harnesses (finding 8) are Cut 7.
5.1 Tracking is a floor, not a cliff (Soul finding 3)
FireControl.cs:102-106 falls back to Tracking = 0 with no targeting system,
and :273-275 turns that zero into deviation < .01f ? 1f : 0f. A probe fired
334 shots at a target drifting at 5 u/s with Accuracy forced to 1 and landed
none. Q4 asked for unaided fire to be really, really bad; this is a wall.
Settings.cs, besideUnaidedAccuracy: addpublic float UnaidedTracking = 10f;with the same authoring note. Ten world units of deviation forgiveness is the first guess and the operator's knob: a target that has strayed five units from its fire-time projection halves an already-5% chance.FireControl.Trackingfalls back tosettings.UnaidedTracking, exactly asAccuracyfalls back toUnaidedAccuracy. One shape for both.Committhen deletes the zero branch outright:p *= saturate(1f - deviation / shot.Tracking). Tracking is now always positive, so the branch protects nothing.
Authority: Settings owns the unaided figures; FireControl.Tracking is the
only reader; Commit no longer carries a special case for a value that cannot occur.
5.2 A shot the shield cannot absorb breaks it (Soul finding 4)
Commit:299-302 sets Shielded only when CanTakeHit passes, and Apply:311-326
routes the rest to the hull -- neither calls Break(). Splash:365 does. The
deleted Unity path did (git show b7743789^:Assets/Scripts/Gameplay/Weapons/Projectile.cs).
So the F4/F5 shield-break mechanic is unreachable for every weapon in the catalog:
an unabsorbable 5000-damage hit leaves Broken == false and the shield absorbs the
very next shot, refilling at RefillDuration instead of the punitive RestoreDuration.
ShotOutcomegainspublic bool ShieldBroken;.Commitdecides it where it already asksCanTakeHit: shield present, active, andCanTakeHitfalse meansShieldBroken = true. The decision is frozen with the rest of the outcome (R4) -- a shield that recharges during the flight does not retroactively survive.Applyperforms it:if (shot.Outcome.ShieldBroken) shot.Target.Shield.Break();before the hull damage.CanTakeHitis still called exactly once per shot.- Presentation gains the signal for free; nothing is required to read it yet.
5.3 Continuous weapons obey their arc (Soul finding 9)
ConstantWeapon.cs:91,99 gate on StanceAllowsFire alone. A side-mounted beam
fires forward. Q2 says manual and programmatic must not be two truths; instant and
continuous currently are.
- Both gates become
StanceAllowsFire && ArcAllowsFire, matchingInstantWeapon.cs:105.
5.4 One bearing test, including at zero range (Soul finding 11)
Weapon.cs:117 allows a zero-length bearing; FireControl.InArc:47 normalizes the
zero vector into NaN and HitProbability returns 0. The player's trigger says fire
and the probability says impossible, so AI and turrets refuse to fire at a
co-located target.
InArctakes the rule: a planar bearing shorter than1e-6returns true -- point-blank cannot fail a bearing test.Weapon.ArcAllowsFiredrops its ownlengthsqspecial case and simply callsInArc. One owner, one answer.
5.5 A destroyed subsystem stops being the aim point (Soul finding 10)
A destroyed item is never removed from Equipment (Entity.cs:475 only observes
it), so IsRevealed still ranks it and ResolvedTargetItem still returns it --
Precision keeps concentrating hits onto a dead item's footprint.
FireControl.IsRevealedreturns false for an item whoseEquippableItem.Durability < .01f, and the ranking excludes destroyed items so the reveal tiers of the survivors close up rather than leaving a gap.Entity.ResolvedTargetItemtherefore drops a destroyed aim point on its next read. Nothing caches it, so no clearing pass is needed.
5.6 Death removes the ship, in the simulation (Soul finding 7)
Operator ruling Q3 says death removes the ship. Zone.Entities.Remove for death
exists only at EntityInstance.cs:324, inside Unity's loot-drop subscription. So
headless, Step's targetGone guard never fires, shots land on corpses, Splash
keeps hitting them, the AI keeps engaging them, and the tuning harness this map
calls its arbiter cannot end a fight. DeadEntityStopsTakingShots passes only
because it calls Entities.Remove by hand.
Zonesubscribes each entity'sDeathobservable when the entity joins, and removes it fromEntitieson death. The simulation owns the corpse.EntityInstance.cs:324deletes itsEntities.Removecall and keeps the loot drop and destroy effect, which are its own business. Forbidden writer: no presentation removes an entity from the zone.
5.7 Delete the two carried-and-unread fields (Soul finding 12)
PendingShot.PredictedIntercept and PendingShot.FlightTime are written at
FireControl.cs:204-205 and read nowhere. The intercept in particular is
carry-weight from Cut 3's named risk: Combat.cs:109 and Fire do share
FireControl.PredictedIntercept, but the roll judges deviation against a
straight-line projection from fire position, so the stored copy decides nothing.
- Both fields are deleted from the struct. The local
flightTimestays; it still computesArrivalTimeandCommitTime.
Verification
Each of 5.1-5.7 gets a test that fails under its own mutation, and the mutation
harness is committed as tests/mutation_tests_fire_control_cut5.sh with a no-op
control. In addition this cut pins four of the five mutations that survived 197
green tests in Soul's pass, because they are rules 5.1-5.6 touch:
FireControl.cs:365-- deleteshield.Break()fromSplash. Must die.Weapon.cs:117--ArcAllowsFire => true. Must die.InstantWeapon.cs:105-- delete theif (!ArcAllowsFire) return;player arc gate (Q2, the ruling Cut 1 deferred to Cut 3 and nothing verifies). Must die.FireControl.cs:270--elapsed = shot.FlightTimein place ofnow - shot.FireTime. This is the map's own declared mutation forEvasionCountsUntilCommitAndNotAfter, and it survives because that test's fixture gives the target zero velocity, soFireTargetVelocity * elapsedis identically zero and the mutation is a no-op inside it. Give the fixture a moving target. (After 5.7 deletesFlightTimethe mutation's spelling becomeselapsed = shot.ArrivalTime - shot.FireTime; the rule it attacks is the same.)
The fifth survivor -- freezing source.TargetItem.Value instead of
ResolvedTargetItem at FireControl.cs:193, bypassing the reveal re-check -- is
Cut 7's, alongside the missing Cut 1-3 harnesses.
Negative checks: no Entities.Remove for death outside Zone; no Tracking read
that can return zero; no PredictedIntercept or FlightTime field on PendingShot.
Cut 6: the dice stop being shared, and the flak cannon works again
Date: 2026-09-19. Branch codex/fire-control-6 off Cut 5.
Cut 6a (catalog authoring, worktree Aetheria-catalog, branch
codex/fire-control-6-catalog) runs in parallel and is specified by Soul's
findings 1 and 2 plus the Cut 2 authoring spec above. This section is Cut 6b, the
two remaining code findings.
6.1 A shot's dice belong to the shot (Soul finding 6)
Commit:266,304 draws from ItemManager.Random, a single stream shared with
ActionGameManager.cs:552,558,567,686,787 (wormhole exit velocity, loadout
generation, travel) and Combat.cs:144 (SampleDps, drawn per AI per
evaluation). Q5's reproducible fight therefore holds only inside a hermetic
fixture: in play, adding an NPC or opening a shop changes every subsequent die.
Soul's determinism probes passed for exactly that reason, which makes them a
demonstration of the fixture rather than of the rule.
The fix is a deletion, not a second stream. A stream makes the roll depend on how many draws came before it; what this campaign actually promised is that a shot's outcome is a function of the shot. So make it one.
Zoneexposespublic uint CombatSeed { get; }, set in the constructor from the same expression that already seeds_random:galaxyZone?.Name.StableHash() ?? 1337u. One zone, one stable identity — a galaxy seed reproduces it, and nothing else in the zone can perturb it.Commitstops touchingItemManager.Randomentirely. It builds its own localCultMath.Randomfrom the zone seed and the shot's own id:var random = new Random((zone.CombatSeed * 2654435761u) ^ (uint) shot.ShotId | 1u);The| 1uguards the degenerate zero seed. Every draw the commit makes — the hit roll, the precision roll, the cell pick — comes from that one local generator, and it dies with the call.- The write-back at
:304(shot.Source.ItemManager.Random = random;) is deleted with it.
Authority: the roll's randomness is a pure function of (zone identity, shot id). Forbidden writers: nothing outside Commit may seed or advance a
combat draw, and Commit may not read a shared stream. Consequence worth
stating: two shots that happen to share a ShotId across two zones roll
differently, because the zone seed differs; two runs of the same fight from the
same galaxy seed roll identically no matter what the UI did in between, which is
the promise Q5 actually made.
6.2 Airburst resolves in the simulation (Soul finding 5)
Cut 4 deleted Projectile.cs's OverlapSphere/SendSplash and added
FireControl.Splash "because R9 keeps the flak cannon and the mine". Only the
mine was wired (Mine.cs:99). Projectile.cs:16-17 still carries
AirburstDistance/AirburstRange, still written by ProjectileManager.cs:32,
read by nothing — so a flak round flies and does nothing. The stale comment at
Projectile.cs:7-8 calls airburst "Cut 4's — untouched here".
The wiring does not go back into Projectile. R8 says presentation does not
decide damage, and a projectile MonoBehaviour calling Splash would be exactly
the authority Cut 3 spent itself deleting. Airburst is a property of the weapon,
so the simulation resolves it:
PendingShotregains a burst point —public float3 BurstPosition;— frozen at fire like every other payload field, fromPredictedIntercept(which Cut 5 deletes as unread; this is its live consumer, and it comes back named for what it is rather than for what computed it).PendingShotgainspublic float BurstRadius;, frozen from the weapon's authored airburst range, and zero for a weapon without theAirburstflag (Enums.cs:101).FireControl.Firesets both when the weapon'sWeaponDatacarriesAirburst.FireControl.Step, at arrival, resolves an airburst shot by callingSplash(zone, shot.BurstPosition, shot.BurstRadius, shot.Damage, shot.DamageType)instead ofApply. An airburst round does not also roll a discrete hit: it is an area effect, which is the whole point of the flag, and applying both would be the double-application Soul was told to hunt for.Projectile.csdeletesAirburstDistanceandAirburstRangeand the stale comment;ProjectileManager.cs:32deletes the line that wrote one. The projectile's remaining job is to fly and disappear, which is all presentation should have been doing.
Open tuning question, recorded not asked: the authored airburst radius has no sibling in the catalog to argue from, because no product has ever used it. Cut 6a or a later authoring pass sets it; until then the flag is wired and unexercised, and the report must say so rather than implying a working flak cannon.
Verification
Committed harness tests/mutation_tests_fire_control_cut6.sh, no-op control
required, same byte-exact I/O rules as Cut 5.
SameFightRollsSameThroughUnrelatedDrawspins 6.1: run a fixed fight twice from one zone seed, drawing an arbitrary number of values fromItemManager.Randombetween the two runs, and the outcomes must be identical. Mutation: restore theItemManager.Randomdraw. Must die — and note that this is the test Soul's own probe could not be, because a hermetic fixture cannot see the defect.ShotIdDecidesTheDiepins that the roll is a function of the shot: two shots with the same frozen payload and different ids may differ; the same id in the same zone always gives the same result. Mutation: dropShotIdfrom the seed.AirburstSplashesAndDoesNotAlsoRollpins 6.2: an airburst shot damages everything in its radius, and its target takes area damage rather than a discrete cell hit. Mutation: callApplyas well asSplash. Must die.NonAirburstNeverSplashespins the other side. Mutation: splash unconditionally.- negative:
ItemManager.Randomappears nowhere inFireControl.cs;Airburstappears nowhere inAssets/Scripts/Gameplay.
Cut 6c: the formula gets fixed, and the single point of failure
Date: 2026-09-19. Branch off Cut 6a (e5f1318a) once Cut 5 has merged — this
touches FireControl.cs, which Cut 5 owns until then.
Cut 6a landed the catalog data that Cut 2 never shipped: 53 designs, 43 products,
loadout green on ten seeds, turret arcs migrated. Reviewing it turned up two
defects it introduced and one it inherited.
6c.1 Resolution becomes reciprocal, so the name stops lying
HitProbability computes pSensor = saturate(unlerp(TargetDetectionInfoThreshold, Resolution(source), info)).
With the live TargetDetectionInfoThreshold of .1, a higher Resolution
means more gathered info is needed before sensor state stops limiting hits.
Resolution is a cost. The field's own comment says so accurately -- "the info
level at which sensor state stops limiting hits" -- and the word "Resolution"
says the opposite, because in every other context a higher resolution is a
better instrument.
Cut 6a authored the 1-cell Targeting Computer at .3 and the 2-cell
Fire Control Array at .75. Against a target scanned to info .3, that gives
the cheap design pSensor = 1.0 and the 150000c design pSensor = 0.31. The
premium item is strictly worse until the player has scanned hard, which is not a
tradeoff anyone authored -- it is the formula misleading an author who had read
it and said so in the same paragraph.
Operator ruling, 2026-09-19: "When the formula is unintuitive, we don't make the
handle more opaque for correctness, we fix the formula. Plenty of core gameplay
formulas are reciprocal just for this reason." An earlier draft of this cut
proposed renaming the field to SensorDemand; that is superseded. The name
stays Resolution, higher stays better, and the formula takes the reciprocal:
HitProbabilityderives the ceiling rather than reading it directly:var demandCeiling = detection + (1f - detection) / max(Resolution(source), 1e-3f);thenpSensor = saturate(unlerp(detection, demandCeiling, info)).- Resolution
1therefore means "needs complete information before sensor data stops penalising this system" -- which is precisely what the unaided fallback of1fshould mean. That fallback stays as it is and is now the floor by construction instead of by coincidence. The guard against a zero or negative authored Resolution is themax, and no authored value should ever reach it. - Re-author both designs on the new scale: 1-cell
2(sensor-limited until info reaches.55), 2-cell4(until.325). Both comfortably better than unaided, the premium one better than the cheap one, and the numbers now read the way a designer reaching for the knob expects. TargetingSystemData.Resolution's comment gains one sentence naming the direction and the reciprocal, so the next author does not have to derive it.
6c.2 One seller is a die roll against ship generation
Cut 6a sold both targeting designs from NiteLife Energy alone, satisfying the
Cut 2 spec's letter ("at least the manufacturers that sell the capacitor") while
missing what that clause was for. Galaxy.cs:109 selects
factions.OrderBy(random).Take(settings.MegaCount) — a random subset of the
twelve factions — plus the quest faction and the authored neutrals. A galaxy that
does not draw NiteLife has no targeting product, IsAvailable
(LoadoutGenerator.cs:154) filters every candidate out, and
FillInterior's required: true throws InvalidLoadoutException again. Cut 6a's
ten green seeds do not disprove this; they sample the draw.
Two changes, because content coverage and structural fragility are different problems and only one of them is fixed by authoring:
- The generator degrades instead of throwing. A
required: trueinterior item with no available product leaves the entity without one rather than failing generation outright. This is only safe because Cut 5.1 gave unaided fire a floor: such a ship now fires badly instead of not at all. The generator logs the gap so it surfaces as content debt rather than silence, andAetherDb loadoutreports it per seed. - Thematic sellers, which also buy coverage. NiteLife Energy is the roster's power-generation faction; targeting belongs to Finch Cybernetics (passive sensors) and Lucent Media (active sensors), both of which the roster names for exactly this. Each design gains products from both. That is three sellers per design, and it puts the item in the hands of the factions whose brand explains why they make it.
6c.3 Recorded, not fixed
Large Drive's new product declares no roles because the design declares none.
That is honest, and it is also a design with no role axes in a game where roles
are how products differentiate. It belongs to the content pass, not here.
Verification
- A test pins 6c.1's direction: two entities, identical but for
Resolution, at the same info level -- the higher Resolution has the higherHitProbability, at every info level between detection and full. Mutation: drop the reciprocal and read Resolution as the ceiling directly (the shipped behaviour). Must die. This is the assertion whose absence let a premium item ship strictly worse than a cheap one. - A test pins 6c.2: a galaxy containing no seller of any targeting design still
generates every hull, and those entities resolve
UnaidedAccuracy. Mutation: restore the throw. Must die. AetherDb loadoutacross at least 50 seeds, plus a run with the faction set forced to exclude all three sellers. Report the gap count, not just "no throw".- Census unchanged at 53 designs; products rise by 4 (two more sellers per design).
Cut 7: the harnesses that were prose
Date: 2026-09-19. Branch codex/fire-control-7 off Cut 6.
git log --all -- tests/mutation_tests_fire_control* returns exactly one commit,
034e8d3e, adding cut4 alone. §6 of this map declares cut1..4. So the Cut 3
mutation table above (seeding, the commit horizon, the frozen snapshot, the
shield branch, the penetration march, the roll itself) is prose: it names the
mutations that should die and nothing has ever run them.
That is not a paperwork gap. Soul wrote five mutations against rules this map
claims to pin and all five survived 197 green tests, including
InstantWeapon.cs:105, which is operator ruling Q2 — the one thing Cut 1
explicitly deferred to Cut 3. Cut 5 pins four of them because it touches their
rules. This cut pins the fifth and builds the harnesses that should have existed
since Cut 1, so that every verdict this campaign has recorded becomes reproducible
rather than remembered.
7.1 The fifth survivor: the reveal re-check
FireControl.cs:193 freezes Aimed = source.ResolvedTargetItem. Mutating it to
source.TargetItem.Value bypasses the reveal re-check — you could aim at a
subsystem you have lost resolution on — and 197 tests stayed green.
- A test pins that an aim point whose reveal has decayed below its tier is not
frozen into the shot: the shot still fires, at a random cell, with no
Aimed. Mutation: read the rawTargetItem.Value. Must die. - Note the interaction with Cut 5.5: a destroyed aimed item must fall out by the
same path, through
IsRevealed, not through a second check. If pinning this needs two tests, the rule has two owners and that is itself the finding.
7.2 Harnesses for Cuts 1, 2 and 3
Three committed scripts — tests/mutation_tests_fire_control_cut1.sh, cut2.sh,
cut3.sh — each with a no-op control, byte-exact I/O, anchors matching exactly
once, and a reverse-write restore. Each runs the mutation table its cut already
declares in this document; do not invent a new table, and do not quietly
substitute an easier mutation for one that will not die.
Every survivor is a finding to report, never a test to weaken. The expected
cause, seen twice in this campaign already, is a fixture too weak to distinguish
the mutant: EvasionCountsUntilCommitAndNotAfter survived its own declared
mutation because its target had zero velocity, so the sabotage was arithmetically
a no-op. When a mutation survives, first ask whether the fixture differs from the
rule's actual precondition, fix the fixture, and say so in the report.
Where a declared mutation genuinely cannot be reached by the current suite, write it up as not yet reached, never as unreachable, and name what reaching it would take. Both times that call was made elsewhere in this project it was wrong, and a fifteen-line probe killed the mutant.
7.3 What the harnesses are allowed to assume
Cut 6a authors the first targeting-system designs into the catalog. A harness that
depends on catalog content is a harness that breaks when content changes, so
these suites build their own fixtures through the same synthetic-upsert path
TargetingSystemTests.cs:368-403 already uses. That file is also a cautionary
example: it passes by upserting a synthetic design into a throwaway cache, which
pins the generator and says nothing about whether the shipped catalog can
generate a ship. Both are worth having; only one of them was, and the map claimed
the other.
So: one test in this cut asserts against the live catalog — that
AetheriaStores.Open over GameData/Aetheria.cc yields a targeting system the
player's own hull can be generated with. That is the assertion whose absence let
Cut 2 ship code without data.
Verification
- All four harnesses (cut1-3 plus the existing cut4) run clean: every declared mutation dies, every no-op control leaves the suite green.
- The report carries the full table — cut, mutation, died/survived, and for any survivor the fixture defect behind it.
dotnet testgreen, with the count.
Cut 6d: where a hit lands is a dart throw, not a coin flip
Date: 2026-09-19. Operator direction, 2026-09-19.
The defect
FireControl.Commit places a hit like this:
if (aimedCells != null && aimedCells.Length > 0 && random.NextFloat() < shot.Precision)
cell = aimedCells[random.NextInt(aimedCells.Length)];
else
cell = coords[random.NextInt(coords.Length)];
Precision is a coin flip between exactly on the aimed item and uniformly
anywhere on the hull. Aim at a stern thruster with Precision .35 and the 65%
of hits that miss it are spread evenly over the bow, the cockpit and everything
else — no nearer the thruster than if the player had never aimed at all. There is
no spatial term anywhere in it.
This is the same shape as 5.1's Tracking cliff: a quantity that should be a gradient implemented as a binary, so the failure case is maximally wrong rather than slightly wrong.
Operator, 2026-09-19: "I would presume that aiming for an item in the stern would still tend hit in that general area even if it misses the item. Like throwing a dart at the grid."
The rule
One Gaussian kernel over the target's hull schematic, in cell units:
- Aim point. The centroid of the aimed item's cells, or the hull's
CenterOfMasswhen nothing is aimed at. One path — the uniform-random branch is deleted, not demoted. - Sigma. Derived from the frozen
shot.Precision, higher Precision giving a tighter group. Authored so that the unaided floor (UnaidedPrecision, joiningUnaidedAccuracyandUnaidedTrackinginGameplaySettings) is a sigma wide enough to read as spraying at the silhouette. - Weights.
w(cell) = exp(-d² / 2σ²)for each occupied hull cell,dthe distance from the aim point.
The tradeoff, and why it cannot be a second miss stage
Operator, 2026-09-19: "It would also mean that aiming can be a tradeoff; if you're not precise enough, you're better off aiming center mass than risking misses on a thin limb."
That tradeoff only exists if a stray dart can miss the ship. An earlier draft of this section clamped a stray to the nearest occupied cell, which destroys it: aiming at a wingtip would cost nothing and merely redistribute damage inward.
But resolving the stray as a second miss stage would break R3 — one roll
decides damage — and make HitProbability a lie, since a shot could pass the
roll and then fizzle on placement. So the off-hull mass is priced into the single
roll instead:
pOnHull = Σ w(cell) / (2πσ²)
the share of the kernel's total mass that lands on metal.
HitProbabilitygainspOnHullas a factor. Aiming at a thin limb with a loose group drops the number on the HUD, before the trigger — the tradeoff is legible at decision time rather than discovered afterwards.Commitdraws the cell as a weighted pick over the samew(cell). A hit always lands on metal, because the chance of not landing on metal was already charged at the roll.
One kernel, computed by one function, used for both the probability and the placement. Cut 3's named risk was two functions answering "where will this shot go" and eventually disagreeing; this does not reintroduce it.
pOnHull depends only on the aim point, the hull shape and the frozen
Precision, so it folds into PBase at fire time and the freeze discipline
(R10, Q6) is unchanged.
Consequences, named rather than discovered
- Every hit in the game moves, not only aimed ones: unaimed fire becomes "aim at the centre of mass with the worst sigma", so big central sections soak more fire than extremities. That is the intended change, and it is a balance change.
- A fat hull is inherently easier to hit than a needle, for free.
- The two targeting products' authored
Precisionvalues (.05-.2and.35-.55) become meaningless as written, because they are probabilities and this makes Precision a grouping tightness. They are re-authored in the same pass as Cut 6c's reciprocalResolution. - Double-charging risk.
HitProbabilityalready carriespSpread, which penalises a small target against a wide weapon spread.pOnHullalso penalises small targets. Check whether the two charge the same thing twice before authoring numbers; if they do, one of them owns it and the other goes. - AI willingness shifts.
Combat.csandTurretController.csgate onAgentMinHitProbabilityagainst this number, so NPCs will hold fire more than they used to the momentpOnHulllands. That is a tuning pass, not a bug, but it will show up in the first smoke and should not be mistaken for broken AI.
Verification
AimingAtTheSternHitsTheStern: with a mid-range Precision, hits aimed at a stern item land in the stern half far more often than the bow half. Mutation: restore the uniform-random branch. Must die. This is the assertion the shipped coin flip would fail.ThinLimbCostsHitChance:HitProbabilityagainst an aim point on a single-cell extremity is strictly lower than against the centre of mass, at the same Precision — and the gap widens as Precision falls. Mutation: droppOnHullfrom the probability. Must die.PlacementAndProbabilityShareOneKernel: the empirical on-hull rate over many seeded draws matches thepOnHullthe probability reported, within tolerance. Mutation: perturb sigma in one of the two call sites only. Must die — this is the test that would catch the two functions drifting apart.EveryHitLandsOnMetal: no committed hit ever resolves to an unoccupied cell, across seeds and aim points including extremities.
7.4 Amendment (2026-09-19): the live-catalog test has a second job
7.3 above asks for one test that opens the real GameData/Aetheria.cc through
AetheriaStores.Open, because Cut 2 shipped a required: true rule with no data
to satisfy it and every green suite in the campaign missed it.
Merging Cuts 6b and 6c gave that test a second and larger job. 6b added
WeaponItemData.AirburstRange as a non-nullable float at a new MessagePack
key. Every weapon record in the shipped catalog was serialized before that key
existed, so the reader was handed nil for the absent slot, a float cannot
take nil, and AetheriaStores.Open threw outright — the game could not read its
own catalog. Fixed at c896489b by making the field float?.
Neither cut could have caught it. 6b verified with unit tests over synthetic fixtures and never opened the shipped catalog; 6c opened the catalog constantly but on a branch without the field. The defect existed only in the merge, and only when something read real data.
So the live-catalog test is not a content check that happens to touch a file. It is the campaign's only assertion that the shipped data is readable by the shipped code at all, and it is the one test whose absence let a branch that bricks the catalog pass every gate. Write it so it fails loudly on a deserialization throw, not only on a missing design:
ShippedCatalogOpensAndGeneratesAnArmedHull: open the real catalog read-only throughAetheriaStores.Open, enumerate everyEquippableItemDataso every document type is actually deserialized (a lazy read that never touchesWeaponItemDatawould have passed on the broken merge), and generate a loadout forLonginusX. Any exception is a failure.- Mutation: none needed for the throw path — the shipped catalog either deserializes or it does not. For the generation half, the Cut 2 mutation applies: drop the targeting designs' products and the test must go red.
The standing rule this cut records, because it will recur every time a field is added: a new non-nullable value-type field on a persisted CultCache document cannot read records written before it existed. Nullable, or a migration that rewrites every affected record — and the migration cannot run while the read still throws, so tolerant deserialization comes first either way.
Cut 8: a removed entity is a dead entity, and the anchors the diagnostics moved
Date: 2026-09-22. Branch codex/fire-control-8 off 72c0109c.
Operator play smoke, 2026-09-20: heat spike on entry with the ship unable to
move, a nearby ship likewise glowing hot and motionless, shots that registered no
hits, and finally a NullReferenceException out of LockWeapon reaching
Entity.EntityInfoGathered as the other ship vanished. Diagnosis (Codex, relayed
by the operator): the other ship's behaviors updated after the entity had been
cleaned out. Not reproducible afterwards, which makes it a hidden bug rather than
a fixed one -- the diagnostics commit 72c0109c changed timing and allocation,
not the cause.
8.1 Removal and deactivation are one transition
Entity.Deactivate (Entity.cs:445) disposes every subscription, clears
EntityInfoGathered, VisibleEntities, VisibleEnemies and VisibleFriendlies,
and sets _active = false. Zone.TryDock (Entity.cs:943) has always paired the
two: Zone.Entities.Remove(ship); ship.Deactivate();. Removal from the zone means
the entity stops living there.
Cut 5.6 added the death path and did only half of it. Zone.cs:78:
Entities.ObserveAdd().Subscribe(add => add.Value.Death.Subscribe(_ => Entities.Remove(add.Value)));
The entity leaves the collection and stays active, holding live subscriptions
and stale references, while every surviving entity's ObserveRemove handler
(Entity.cs:200-213) nulls its Target and drops it from EntityInfoGathered,
EntityHostility and the visibility sets. Half the world has forgotten it and it
does not know it is dead.
- The death subscription becomes
Entities.Remove(e); e.Deactivate();, matchingTryDock's existing pairing exactly. One transition, one owner. - Ordering risk to verify, not assume:
EntityInstancealso subscribes toEntity.Death(its loot drop and destroy effect,EntityInstance.cs:300-325). Subscription order decides whether loot drops before or afterDeactivate.Deactivatedoes not touchEquipmentorCargoBays, so loot should survive, but this must be confirmed in play rather than reasoned about -- if loot stops dropping, that is this cut's fault and the deactivate moves after it.
8.2 A deactivated entity does not update
Zone.Update (Zone.cs:172) iterates a snapshot:
foreach (var entity in Entities.ToArray()) entity.Update(deltaTime);
so an entity removed part-way through a frame still receives its Update in that
same frame, after the rest of the world has dropped its references. Entity.Update
(Entity.cs:1031) never consults _active. Every behaviour then runs against
torn-down state, and LockWeapon.cs:98 reaches Entity.EntityInfoGathered[Entity.Target.Value]
through a raw indexer.
Entity.Updatereturns immediately when!_active.
This is the fix, and the raw indexer is deliberately not the fix. Guarding
LockWeapon with TryGetValue would silence this one call site and leave every
other behaviour running on a corpse -- the compensator the doctrine warns about.
Liveness is the entity's own property and the entity is the one place to enforce
it. IsActive already exists at Entity.cs:116; nothing was asking it.
8.3 The other symptoms are not explained by this, and are not closed
The heat spike, the motionless ships and the shots that registered no hits are
unexplained. One coherent story fits all three -- an item's heat driving the
targeting system offline makes FireControl.Accuracy fall back to
UnaidedAccuracy (.05), which reads exactly like "I can fire but nothing
lands," and an overheated thruster reads as "I cannot move" -- but that is a
hypothesis, not a finding, and nothing here has reproduced it.
What can be said from the source: ActionGameManager.cs:1281 passes
Time.deltaTime into Zone.Update unclamped, and Zone.cs:160 divides by it
(orbit.Value.Velocity = (Position - PreviousPosition) / deltaTime). Unity caps
Time.deltaTime at Time.maximumDeltaTime, so a large spike is bounded, but a
zero or near-zero first frame is not bounded from below and that division is
unguarded. Worth a probe before it is worth a fix.
This cut does not chase it. It records the symptom, fixes the defect that is proven, and leaves the rest open with the evidence that exists -- an unreproducible heat spike is not something to patch speculatively.
8.4 Re-anchor the four harnesses the diagnostics refactor stranded
72c0109c routed HitProbability through a new FireControl.Inspect, replacing
five early returns with flags and one gate. That is the campaign's own doctrine
applied correctly -- the operator's comment says it outright, that keeping the
calculation here stops the debug HUD growing a second, subtly different fire
control model -- and it rewrote the exact lines four harnesses anchor into.
Seven anchors now fail: cut3 (2), cut5 (1, its no-op control), cut6c
(2, including its control), cut6d (2). Every one reports tree-clean: PASS, so
nothing leaked, but a harness whose control cannot run is defending nothing.
- Re-anchor all seven onto the current spelling in
Inspect. The rules they attack are unchanged; only the text moved. - A control that cannot find its anchor must stay a hard failure. Do not make anchors fuzzy to survive refactors -- loudly stale is the property that makes these harnesses trustworthy, and it is what surfaced this within one run.
Verification
DeadEntityDoesNotUpdate: an entity killed mid-frame receives no furtherUpdate, and aLockWeaponon a ship whose target dies the same frame does not throw. Mutation: drop the_activeguard. Must die.DeathDeactivates: a dead entity reportsIsActive == falseand holds no live subscriptions. Mutation: remove theDeactivate()call. Must die.LootStillDropsOnDeathif it can be reached headlessly; otherwise an explicit operator check, named as such rather than assumed.- All seven harnesses clean with tree-clean PASS, controls included.
- 222 tests plus the new ones; catalog reads; Unity batchmode exit 0.
Cut 9: the die is not a die
Date: 2026-09-22. Branch codex/fire-control-9 off dcb8c448.
Soul pass over ca57819d..dcb8c448 (2026-09-22). Ten findings; this cut takes the
three that block play. The rest are Cut 10.
9.1 The hit roll has a range of 0.125 (Soul C1). Blocking.
FireControl.cs:356:
var random = new Random((zone.CombatSeed * 2654435761u) ^ (uint) shot.ShotId | 1u);
...
var hit = p > 0f && random.NextFloat() < p;
CultMath.Random is xorshift32 and NextFloat() returns the top 24 bits of the
first round (Random.cs:22-42). One round does not carry a low-bit seed
difference into the high bits. ShotId varies only in the low bits, so the top
three bits of the first draw are fixed by the zone seed alone.
Measured, 2000 shot ids per zone: every zone's roll spans 0.1249. Reproduced
independently with a different hash function — the span is structural, not a
property of StableHash. The consequence is that a shot is a step function of
p whose threshold is fixed when the zone is created: below the band it never
hits, above it always does. An unaided shooter hit 0 of 18,926 shots against the
shipped catalog. Roughly one zone in eight cannot land a fully aided shot at
p = .74.
This is the operator's "I can target and fire with no hits registering at all," and it is stable per zone, which is why it came and went.
It is also this map's own prescription (Cut 6b, §6.1), written by Self, shipped through three cuts, and never once measured.
The rule: the roll must be uniform on [0,1). Not merely deterministic — determinism is what a constant already gives, and is exactly why three determinism tests passed over it.
- Mix the seed before the first draw, with a standard 32-bit finalizer
(
fmix32/splitmix32: xor-shift, multiply, xor-shift, multiply, xor-shift). The per-shot seed derivation is unchanged; only its diffusion into the first output changes. Determinism, the(zone, shot id)purity and the freeze discipline all survive untouched. - This is a caller's obligation, not a CultMath defect. Seeding xorshift with
a structured value and drawing once is the documented way to get a correlated
first output. Record a follow-up to give
CultMath.Randoma mixed-seed entry point so no other GameCult consumer re-derives this the hard way — that is a CultLib cut with its own release, not this one.
Verification, and the test whose absence caused this:
TheDieIsUniform: fire many shots across several zone seeds at an intermediatep(say .3 and .6) and assert the observed hit fraction is nearp, and that the observed rolls span substantially more than 0.2. Mutation: remove the finalizer. Must die.- No test anywhere in this campaign measures a distribution. Every behavioural
fixture uses
accuracy: 1oraccuracy: 0, which is why a die stuck on one face satisfied all of them. That gap is the finding, not the line of code.
9.2 pOnHull collapses to zero above Precision ~4 (Soul C2). Blocking.
FireControl.cs:497-516. Sigma = 1/Precision, and once sigma falls below about
half a cell every exp(-d²/2σ²) underflows against an aim point that is not
exactly on a cell centre — which is every shipped hull, since no centre of
mass is integral (LonginusX 2.5/6.697, Zenith 5.5/5.5, Turret 3.5/3.5). Measured
pOnHull on LonginusX: .516 at Precision .3, 1.000 at 1.19, .112 at 5,
.000 at 10 and beyond.
So a better targeting system makes you miss, and past Precision ≈ 5 the weapon cannot hit anything. Authored content sits at .5-1.3, just under the cliff, with a margin of about 4x — and the operator is about to tune this number.
- Clamp sigma at a floor of about half a cell, or normalize by the discrete sum
rather than the continuous 2πσ². Either makes
pOnHullmonotonic in Precision over the whole domain instead of only below the cliff. WeightedPick(:535-545) carries a comment claiming it "can never fall through." It can, whenevertotalWeight == 0, returning a fixed corner cell. It is unreachable today only becausepOnHull == 0forcesPBase == 0first. Fix the cause and delete the false comment.- Cut 6d's map section claims to name its consequences; it does not name this. Add it.
9.3 The LockWeapon crash is still reachable (Soul C3). High.
Cut 8 fixed a real liveness defect and did not fix the operator's crash.
LockWeapon.cs:98 only runs on an active entity, so 8.2's !_active guard
cannot be what prevents it — tests/mutation_tests_fire_control_cut8.sh:17-19
says as much outright, and I merged it anyway.
The live precondition is Target.Value set but absent from EntityInfoGathered
on an active entity, and docking still reaches it: Deactivate (Entity.cs:445)
disposes the Entities.ObserveRemove subscription that is the only thing nulling
a stale Target, and nulls nothing itself; Activate (Entity.cs:167) reseeds
EntityInfoGathered and likewise leaves Target alone. Dock, let the target die
or leave, undock, and the first frame throws.
DeactivatenullsTarget(andTargetItem), orActivatereconciles it against the reseededEntityInfoGathered. One of the two owns it; say which.- Still not a
TryGetValueinLockWeapon. The invariant is that an active entity'sTargetis always a live entity it has info on. - Pin it with the dock/die/undock sequence, not with a unit test on the guard.
Verification
- The three tests above, each dying under its own mutation, in a committed
tests/mutation_tests_fire_control_cut9.shwith a no-op control, byte-exact I/O, sha256-verified restore and an explicit tree-clean verdict. - All nine harnesses sequentially, one at a time, all clean.
- 226 tests plus the new ones; catalog reads;
dangling0. - After 9.1 lands, re-measure the fight: Soul's probe, run with a corrected die, gave 60.8% hit rate and a 4.3 s median time to kill for an aided shooter, 2.6% unaided. Confirm the shipped build now reproduces that, because those are the numbers the operator will tune against.
Recorded for Cut 10, not fixed here
Soul C4 (a committed hit on a target that dies mid-flight publishes as a hit),
C5 (Inspect is numerically exact but costs 9.87 µs and 448 B per call on paths
that used to bail — the operator's own comment on the hot path looking like a
diagnostic one has an empirical answer now), C6 (RollsAreSeeded is obsolete —
Cut 7 retired one such test and missed this one), C7 (FireControlCut6cTests
authors Precision = 0, making six of seven checks vacuous), C8 (Precision = 1000 fixtures make several kernel-shape mutants unkillable, and pOnHull's
deletion is caught by exactly one test), C9 (EveryHitLandsOnMetal runs one
configuration, not the range it claims), plus the harness-audit items: the
tree-clean verdict restores before it measures, a red result cannot distinguish a
killed mutant from a compile failure, and the no-op control covers only one file
per harness.
Cut 11: mutation testing moves to Stryker, and the survivors it found
Date: 2026-09-22. Operator, on the hand-written anchor harnesses: "What is the point of those mutation anchors? Tests should cover how the code behaves, not how it is shaped." And on Stryker: "sounds good; we don't need to run with every mutation active, right? > to >= on float thresholds is indeed petty."
Why
The Eureka principle (2026-09-15) is sound and stays: every rule needs a test that
fails when the rule breaks. The mechanism that accreted on it over the following
week -- literal-text anchors, perl substitution, per-cut bash and Python harnesses
-- coupled mutation testing to code shape. It stranded anchors on every refactor
(72c0109c, Cut 9, Cut 10), and at Cut 10 an anchor kept matching exactly once
while silently re-targeting onto Inspect, mutating code the tests no longer ran.
Soul's harness audit also found the tree-clean verdict restoring before it
measured, and a compile failure counting as a kill.
A Stryker spike against FireControl.cs alone: 276 mutants, 7m18s, 208 killed,
67 survived. It killed every mutation the hand-written harnesses defended, never
edits a source file, and has nothing to re-anchor.
11.1 Retire the ServerShared harnesses
Delete the 23 hand-rolled harnesses (5,816 lines) whose targets are all under
Assets/Scripts/ServerShared -- every mutation_tests_fire_control_*, every
mutation_tests_stats_power_*, mutation_tests_shield_reserve.py,
mutation_tests_condition_ratio_cut8.py and mutation_tests.py. Stryker's scope
covers all of their targets; the behavioural tests they measured stay.
Kept, recorded, not yet answered: mutation_tests_addressables_cut.py and the
three mutation_tests_shield_panel_cut*.py. They target Unity Editor probes
(EngineAssetCheck.cs, ShieldPanelCut*Verify.cs) that Unity compiles outside
Aetheria.Shared, so Stryker cannot reach them. They carry the same anchor
fragility; the right answer for GPU and editor code is probably that the batchmode
probes are themselves the behavioural tests, but that is a separate decision.
11.2 Scope, pinned
tests/Aetheria.Shared.Tests/stryker-config.json scopes mutation to
Assets/Scripts/ServerShared/** and excludes plugins. Per cut, run against the
cut's own diff:
dotnet tool restore
cd tests/Aetheria.Shared.Tests
CULTLIB_ROOT=<CultCache worktree> CULTMATH_ROOT=<CultMath worktree> dotnet stryker --since:<base commit>
Each root must sit at its pinned revision in Directory.Build.props (see the Cut 12.0
status note).
Float-threshold boundary flips are known-equivalent. Stryker files >→>=
under the same Equality mutator as ==→!=, and the latter catches real bugs, so
the mutator stays on. A survivor that only moves a float comparison across its
boundary is triaged as equivalent and never chased. For the same reason the raw
mutation score is not a gate; the survivor list is the signal.
11.3 Degenerate fixtures
Most of the 67 survivors trace to three constants shared across the fire-control fixtures, each of which makes a family of mutants arithmetically identical to the original -- the same defect class as the zero-velocity target and the durability coincidence, found here as a pattern instead of one at a time:
- Shooter at
float3.zero.target - sourceequalstarget + source, so four separate position mutations survive (FireControl.csHitProbability, Inspect, Fire, Apply). - Fire time zero.
now - FireTimeequalsnow + FireTime, so the elapsed-time mutation survives -- which meansEvasionCountsUntilCommitAndNotAfterstill does not defend the rule Cut 5 gave it a moving target to defend. - Spread zero everywhere.
PSpreadreturns 1 on every fixture, so it is effectively untested, and*versus/against it is indistinguishable.
Move shooters off the origin, fire at nonzero times, and give some fixtures real spread. Fix the fixture, never weaken the assertion.
11.4 Behavioural tests the survivors point at
- Splash handedness. Flipping the sign in
var right = float2(forward.y, -forward.x)passes every test. The operator's "damage on the wrong side of the ship" was a legacy Unity bug; this line has read the same since482c47bf, so the rewrite never shipped it, but nothing defended against it either. Self said on 2026-09-19 that Cut 6b would add a test pinning that a port blast damages port cells; it never landed and nothing checked. Pin it. - The shield loses energy when it absorbs. Deleting
Shield.TakeHitfrom the discrete path survives, because tests assert the hull was untouched and never that the shield paid for it. - Splash's shield-absorb branch has no coverage at all.
- Zones roll differently.
CombatSeed *→/makes the seed zone-independent and survives;TheDieIsUniformchecks uniformity within a zone only. Tighten it enough that theMixSeedweakenings that survive it (>>→<<,^=→|=) die. - A shot whose target left the zone resolves as a miss (Soul C4). Today only the uncommitted branch rewrites the outcome; a committed hit on a target that died mid-flight is published as a hit. Fix the code and pin it.
Verification
Stryker over FireControl.cs before and after: every non-equivalent survivor
above killed, the remainder triaged by name. 231 tests plus the new ones; Unity
batchmode clean.
Status (2026-09-22)
Landed on codex/fire-control-10: 8ef3b34c (Stryker tool), b305debd (map,
config), 724ec5e7 (11.1), a6543af7 (11.4 and the C4 fix), 1b187db4 (11.3),
b0fd3cd0 (second round, below). 244 tests.
Stryker over FireControl.cs, 278 mutants per run:
| Run | Killed | Survived | Score |
|---|---|---|---|
| Spike, before Cut 11 | 208 | 67 | 74.6% |
| After 11.4 | 219 | 58 | 78.6% |
| After 11.3 fixtures | 225 | 51 | 81.1% |
| After the second round | 225 | 43 | 83.3% |
The second round came from reading the 51. Four were real undefended behaviour and one was a duplicated formula:
InspectrecomputedPBaseas its own product of the factors, so the gates and the product had two owners, and a*→/mutant of the HUD's copy survived the agreement test (every fixture had spread 0, soPSpreadwas 1).PBaseis nowHitProbability's own answer; the test becameTheHudShowsTheFactorsTheSimulationMultiplies, over a spread cone that does not fill the silhouette.Fire's redundanttarget != null ?guard aroundHitProbabilitywent with it.- A beam shot's
damageOverridecould be dropped:BeamRollsPerIntervalcomputed the expected damage from its own roll count, a tautology, now deleted.ADamageOverrideIsWhatTheShotCarries. Trackingcould ignore the fitted targeting system: no fixture's target ever moved off its predicted line.AFittedTargetingSystemForgivesAJink.- A shot orphaned before commit could skip
ShotCommitted, or publish its miss as shielded.ShotAtTargetThatLeavesBeforeCommitCommitsAsAMiss. ArrivalIn(the HUD countdown) was unchecked; asserted in the C4 test.
The 43 that remain, triaged:
- Boundary flips, equivalent (23): L52, 54, 56, 75, 135 (two), 157 (two), 193 (two), 223, 273, 410, 418 (two), 465 (two), 503, 518, 524, 586, 605, 608.
MixSeedshift and xor weakenings, measured equivalent (4): L382, 384, 386 (two). 11.4 asked for them to die; measured, the die stays uniform and zone-distinct under each, so there is no behaviour to pin. They change how the mixer is built, not whether the die is fair.- Unreachable through the live callers (8): L240 and L241
(
DeviationProbability's null-target branch; the engine guards it, only the HUD can pass null), L283 (three; airburst has no authored radius anywhere, recorded since Cut 6b), L320 (empty-list early return, an optimisation), L410||(a nonzeroPBaseimplies a target), L158 and L610 (no coverage: an unlocked lock weapon reaching this line, and the last-cell fallback of the weighted pick). - Recorded gaps, not yet reached (8): L135 ternary (two; no test that the AI in
Combat.csleads a moving target), L435 (three; the aimed flag on an outcome), L464 and L465 negate (theApplyhit direction through theFireControlpath), L585 and L587 (the aimed item's centroid as aim point). These are "not yet reached," not "unreachable."
Cut 12: shots arrive edge-on
Date: 2026-09-22. Imagination pass, revised the same day after the second round of rulings
below. Anchors are against origin/codex/item-provenance cd846916 (Cuts 10 and 11 merged).
Claims marked (probe) were measured in a scratch copy of the test project
(scratchpad/cut12probe, the Cut 11 fixture plus probe methods) or by a lane-model script
(scratchpad/exposure.py), in both cases against a copy of GameData/Aetheria.cc. Claims
marked (read) come from source at the cited anchor.
The defect
The impact cell is chosen as if every shot drops in from overhead.
FireControl.HullKernel (FireControl.cs:558-578) throws a 2D Gaussian at the top-down
schematic, centred on the aim point. POnHull (:233-234), WeightedPick (:601-611) and
ResolveAimPoint (:583-589) all read that kernel, and none of them takes the shooter's
bearing. (probe) Probe3_BearingBlind: pOnHull is 0.8748 at target facings (0,1),
(1,0) and (.6,.8) alike.
Bearing is only used after the cell has been chosen: Entity.ApplyHit (Entity.cs:381-409)
walks penetration from that cell, and Splash (FireControl.cs:515-527) picks the half of
the hull that faces the blast. So armour facing does nothing. A wedge whose bow carries all
the plate gets hit in its soft stern from dead ahead. The game is planar (R7), so a shot
travels in the plane and should arrive edge-on.
The damage rule makes it worse. Entity.DamageSchematic (Entity.cs:336-374) splits the
damage evenly over every cell of the hit shape. (probe) Probe1: 30 damage with
penetration 3 into column 1 of the 5x3 fixture, with 10 armour on the front cell only, puts
10 on each of the three path cells. The plate absorbs its 10, the item behind it still takes
10, and the stern cell passes 10 to the hull. The item receives exactly what it would have
with no plate in front of it.
Rulings (operator, 2026-09-22)
First round:
- Model adopted: throw the dart at the silhouette the shooter sees. Scatter is 1D and perpendicular to the bearing. Project the hull's occupied cells onto the lateral axis and throw a 1D Gaussian at that projection, with the same sigma rule, centred on the aim point's projection. pOnHull is the Gaussian's mass on that shadow. Depth comes from geometry: walk the ray in from outside along the bearing, and the first occupied cell is the impact cell. Penetration continues along the same ray.
- Operator: "No, you're right, this is fine. Armor absorbs first. Shields remain
omnidirectional. Map it."
- Armor absorbs first. Damage runs down the ray in order. At each cell the armour absorbs first, then the item, and only the remainder travels on. The even split over the hit shape goes, for the penetration path and the DamageSpread expansion alike.
- Shields remain omnidirectional. The shield stays one whole-ship reserve with no facings.
- Rejected: any fraction of shots arriving top-down to preserve cockpit sniping. The
operator accepted that cockpit shots become positional: the cockpit must be exposed from
your bearing, or you must flank, or penetration must reach it with what the armour did not
absorb. Cockpit destruction is still instant death (
Entity.cs:494).
Second round:
-
Q12-1 = B: the bearing that places a hit is taken at Commit. Operator: "Of course we must use the target's facing at arrival, tanking a hit where you have armor is basic maneuvering." After Self explained the commit horizon: "at commit." The target's facing therefore stays live until Commit, as deviation already does. See Bearing timing.
-
Q12-2 = A: spread is width. It becomes 2n+1 parallel lanes, and each lane is absorbed from its own facing cell. Operator: "Spread is width makes sense for shotguns, but what about explosive penetrators? Burrowing in before splashing radially would be a satisfying way to damage vulnerable internals." That remark became sub-cut 12.4.
-
Q12-3 = A (direct hits): a lane's remainder goes into the hull wherever the lane ends.
-
Q12-4 = A: delete
ShotOutcome.Aimed. -
Penetration retune is a follow-up. Operator: "Retuning penetration is a follow up." See F12-2. Until it lands, interior cells are reached only by blasts.
-
The detonation primitive (12.4): a blast is an area, not a bundle of rays. The first draft of this section cast rays; the operator cut that. Operator, 2026-09-22: "sorry, why are we doing blast rays instead of areas? I'd say distribute the damage over the affected area, but that only works for areas". So a blast at a schematic point P with radius r covers the cells within r of P, and damage is distributed over the disc's area: a cell takes the share of the disc that lies inside it. The share of the disc that falls outside the hull's occupied cells is lost. Each covered cell absorbs independently, in the existing order (armour, then item, then hull). Nothing travels, nothing is occluded, and no remainder is routed anywhere.
A shot has a direction; a blast does not. That sentence is the rule behind the split: direct hits keep the lane model of 12.2 and 12.3, and blasts are areas. Where P sits:
- proximity fuse: the intercept point outside the hull (today's airburst);
- contact fuse: the impact cell;
- delayed fuse or penetrator: the end of the penetration walk.
All three are weapons that carry a blast radius. A weapon without one is not a blast at any penetration: it keeps 12.3's lane (see Where 12.3 and 12.4 meet).
12.4 replaces
FireControl.Splashand its half-hull footprint, which absorbs F12-1. -
WeaponModifiers are labels, not behaviour. Operator: "The AP modifier was merely there as a label. Like the Incendiary modifier which doesn't actually determine that the weapon does Thermal damage, but exists to mark weapons that do. Modifiers show up in schematics as a shorthand for the weapon type." Detonation behaviour comes from weapon data fields only.
-
Not ruled: a HUD showing which target items are exposed from the current bearing (Q12-5).
What the probes found
- Penetration does nothing in the shipped catalog. (probe) The largest authored
Penetration is
0.25(Autocannon, SRMM72, LRMM72).ApplyHitonly walks whenpenetration > .5f(Entity.cs:392), andWeaponData.Penetrationis inspector-ranged0..1(Behaviors/Weapon.cs:28). - LonginusX's cockpit cannot be reached edge-on until penetration reaches about two
cells. (probe)
exposure.pywalked lanes 0.01 cells apart at every whole degree of bearing over the real 6x17 schematic. The ControlModule (cockpit) and the AetherDrive are never the first cell in any lane. They are still never reached at penetration 0.25 or 1.0, and are first reached at 2.0. The Turret hull's ControlModule is enclosed too. Until F12-2 lands, the only way to kill a LonginusX through its cockpit is a blast delivered inside the hull (12.4). - DamageSpread is live content. (probe) GT 3K authors 1-3.25, plight 1-3.3, and
pswarm and scorched void policy 0.9-1.5.
Shape.Expandis an 8-neighbour ring (ItemData.cs:164-174), so spread 3 produces a 7x7 footprint today. - No shipped weapon carries a blast, or the Airburst label. (probe)
Probe4: everyWeaponItemData.AirburstRangeis null, and noWeaponModifiersincludes Airburst. The labels in use are RapidFire, Cluster and Incendiary. All three Thermal weapons carry Incendiary, and no non-Thermal weapon does. No weapon carries ArmorPenetrating, including the three with penetration 0.25. So the only live consequence of demoting the Airburst label is the code path atFireControl.cs:282. - The handedness history.
right = float2(forward.y, -forward.x)has not changed sinceb7743789/482c47bf(git log -S). No sign error ever shipped. What survived was the mutant-forward.x→forward.x, which is invisible when |fy| > |fx|, and Cut 11 pinned it. The formula is duplicated atEntity.cs:394-396andFireControl.cs:516-520, and 12.1 collapses the two copies. - The code has two cell conventions. The kernel,
Shape.CenterOfMass(ItemData.cs:121-123) andResolveAimPointput cell i at the integer point i.ApplyHitinstead starts fromcell + .5and truncates (Entity.cs:398-404), which treats cell i as the square [i, i+1). (probe) On bearing (.8,-.6) from (1,2), the 0.5-step march went (1,2)→(2,1) and skipped (2,2), a cell the ray actually crosses. - Nothing reads
ShotOutcome.Aimed.rg "\.Aimed\b"finds onlyFireControl.cs:292(PendingShot, which stays),:432,:435and:481, plusFireControlCut7Tests.cs:190(which also reads PendingShot). Applyreads live positions at arrival (FireControl.cs:463-465), which is an R4 leak and Stryker's L464/L465. 12.2 removes it.- No simulation code places the schematic in the world. (read)
SchematicCellSize(Settings.cs:255, default 2) is read only byPSpread, and ApplyHit and Splash convert directions only. A blast needs world↔schematic points, so 12.4 names that rule.HullCollideris a physics surface only (Gameplay/HullCollider.cs:4-8). - Renaming slot 29 is compatible drift, not a break. (probe + read) The catalog
embeds each type's schema descriptor, including
{"slot":29,"name":"AirburstRange", "type":"System.Nullable<System.Single>"}, and the wire format is positional (Probe5_Wire:[…,12.5,50,20], with no field names on the wire). On a content-hash mismatch,CultCache.ResolveSchema(CultCache.cs:590-670) runsCompareSchemaShapes(:672-744), which compares slot type, reference, cardinality, target schema, name semantics and index alias, not the member name. So renaming slot 29 opens asCompatibleDriftwith warnings. A new nullable slot opens with adefaulted_missing_slotwarning. The warnings persist until the catalog is next written with the new descriptor. Readers of the field:FireControl.cs:284andFireControlCut6Tests.cs:81-101, 249, 268, 279.tools/AetherDbhas none. TheAirburstRange:lines in nine weapon prefabs (for exampleFlak Cannon.prefab:65) are orphaned YAML from a deleted MonoBehaviour field: no C# field of that name exists outsideItemData.cs:498.
Frame, geometry and the bearing (the rules every sub-cut reads)
-
Schematic frame, one owner.
Entity.ToSchematic(float2 worldPlanar) → float2maps world to schematic with x = starboard and y = bow:forward = normalize(Direction),right = float2(forward.y, -forward.x), returningfloat2(dot(v, right), dot(v, forward)). This is the transform ApplyHit and Splash already use, with the same sign. The Cut 11 fixture comment and the LonginusX schematic (nose at y 11-16, reactor and radiators at y 0-3) agree with it. 12.4 adds the point formsToSchematicPoint(float2 worldPlanar) = ToSchematic(worldPlanar - Position.xz) / SchematicCellSize + Shape.CenterOfMassand its inverseToWorldPoint, so the entity's position is the schematic's centre of mass. Operator confirmed 2026-09-22, from the schematic images: "bow is indeed +y on the schematic images." Still on the operator's check list after 12.4 lands, because it is the half that only a running blast can show: that blasts land where the model shows them, which is what confirms the centre-of-mass anchor. -
A cell is the unit square centred on its integer coordinate. This matches
CenterOfMass, the aim point and the old kernel. The+ .5convention dies with ApplyHit. -
Travel direction.
FireControl.TravelDirection(Weapon, Entity source, Entity target) → float2is the planar world direction ofPredictedIntercept(weapon, source, target) - source.Position. That intercept is Cut 3's one function, and for a beam it reduces to the target position. Below a length of 1e-6 it falls back to world +z, which is today'sApplyfallback (:463). It belongs to the shot: Fire freezes it intoPendingShot. -
Bearing.
bearing = normalize(target.ToSchematic(travelDirection)). This is the only place a world direction meets a target's facing. -
Lateral axis
ℓ = float2(-b.y, b.x). The sign is fixed once, here. -
Shadow. Each occupied cell c projects to
[dot(c,ℓ) - h, dot(c,ℓ) + h), withh = (|ℓ.x| + |ℓ.y|) / 2. The shadow is the sorted and merged union of those intervals; merging matters because off-axis intervals overlap. Squares are used rather than points because a line at lateral offset s crosses square c exactly when s falls in c's interval. "s lies in the shadow" is therefore the same event as "this lane has metal", so pOnHull is exactly that event's probability, and placement drawn from the shadow always lands on metal. -
Silhouette(Entity target, HullData hull, EquippedItem aimed, float2 bearing, float precision)replacesHullKerneland is the one function. It returns the merged intervals,a = dot(aimPoint, ℓ), sigma,Span = hi_max - lo_min, andPOnHull = Σ_k Φ((hi_k - a)/σ) - Φ((lo_k - a)/σ), whereΦ(z) = ½(1 + erf(z/√2)).aimPointcomes fromResolveAimPointunchanged; only its lateral projection matters.Sigmakeeps its rule (FireControl.cs:549-550). The 9.2 rationale in its comment is gone, because an exact 1D integral does not undershoot, so rewrite it: the floor is now a design minimum on group tightness (F12-3). The function allocates only on the ungated path: one interval array of length N (≤128 on shipped hulls) plus a sort. -
PSpread keeps its formula, but takes values instead of a
Weapon:PSpread(float spread, float span, float range, float cellSize). The half-extent becomes.5f * span * cellSizeinstead ofmax(Width, Height)(:224). PSpread and pOnHull read one silhouette, so they cannot disagree about the target's size. -
Lateral draw. One
NextFloatu, taken after the hit roll (the drawWeightedPickused to take). Walk the cumulative interval mass tou · POnHullto select interval k, then invert inside it withs = a + σ√2 · erfinv(2p - 1). Clamp into [lo_k, hi_k) to absorb rounding. The clamp is a guard, not an invariant. -
Lane walk
Lane(HullData hull, float2 b, float s, buffer)collects the occupied cells whose interval contains s, each with slab entry and exit parameterstalong b, wheret = dot(point, b). Cells are ordered by entry, with ties broken bydot(c, b)and then cell index. It is exact slab traversal and replaces the 0.5-step sampling. The result goes into a caller-supplied pooled buffer (ArrayPool<LaneCell>.Shared; netstandard2.1 hasSystem.Buffers). A direct hit starts from t → -∞ (outside), so its first element is the impact cell. It is the walker's only caller: blasts are areas and do not traverse.Metal is continuous while
next.entry ≤ current.exit + 1e-4, and the first gap ends the walk. That keeps today's rule atEntity.cs:401. -
Penetration along a direct-hit lane. A cell is reached when
entry - impact.entry < penetration, in cells. The impact cell is always reached. The> .5fthreshold (Entity.cs:392) is deleted.
Bearing timing (Q12-1 = B). A shot is split into what the shooter decided and what the target is doing:
-
Frozen at Fire: everything the shooter decided (R10).
PendingShot.PBaseis renamedPFirebecause its meaning changes: it isAccuracy × PSensorbehind the same gates, and it is 0 when any gate fails. Also frozen:TravelDirection,Precision,Aimed, the weapon'sSpread, andFireRange(the range at Fire, which PSpread reads). -
Live until Commit: everything the target is doing. That was already its position (deviation); now it is also its facing.
Commitcomputes, in this order:pDeviation, as today.bearing = normalize(shot.Target.ToSchematic(shot.TravelDirection)), from the target's facing at the commit tick.sil = Silhouette(target, hull, shot.Aimed, bearing, shot.Precision).p = PFire × pDeviation × PSpread(shot.Spread, sil.Span, shot.FireRange, cellSize) × sil.POnHull.- The one roll against p. On a hit, the lateral draw from
sil, and the impact cell fromLane.
Steps 2-5 are one function,
FireControl.CommitProbability(PendingShot, float now, out Silhouette). The debug HUD calls it, which retires the inline estimate atActionGameManager.cs:1317-1318(shot.PBase * pDeviation, which would otherwise be a second, wrong copy of the model). -
The outcome freezes the committed geometry (R4).
ShotOutcomegainsfloat2 Bearing(schematic frame, as committed) andfloat Lateral, and keepsCell.ApplywalksLane(hull, outcome.Bearing, outcome.Lateral). After Commit, turning the target changes nothing, and Apply reads no position or facing. -
HitProbability and Inspect report
PFire × PSpread × pOnHullat the current travel direction and facing. That is a forecast, in the same way the HUD already treats deviation.Inspect.PBase == HitProbabilitystill holds, because both are the forecast. The hot path still gates first (:150-159) and builds the silhouette only after the gates.
What this does to the earlier rules:
- R3 (one roll, and a hit always lands on metal) still holds. The factor the roll is
charged with, pOnHull at the commit bearing, comes from the same
silthat placement draws from, in the same call. Placement is exactly consistent with the pOnHull applied, and it always lands on metal. What changes is that HitProbability's number is no longer the exact roll price when the target turns during flight. It is the price only if the target holds its facing, just as it already is only if the target holds its course. - Cut 5's deviation factor keeps its ordering and meaning. It is still measured at the
commit tick against the fire-time projection, and it is still multiplied into p before
the single roll. The two silhouette factors join it in the same product. The Cut 7 guard
(
p > 0f && shot.Target != null,:410) extends to cover them: whenPFire × pDeviationis 0, Commit builds no silhouette. Multiplication order has no numerical effect. The guard exists only to skip work. - 6d's premise ("pOnHull depends only on the aim point, the hull shape and the frozen Precision, so it folds into PBase at fire time") is retired. pOnHull now depends on the target's facing, which is live target state like position, so it is priced at Commit. R10 is untouched: every shooter stat is still frozen at Fire, and the target's facing was never a shooter stat.
- Cut 10 still holds: gated HitProbability allocates nothing. Commit is not on the hot path.
Sub-cut order
| Sub-cut | Repo | Nature | Depends on |
|---|---|---|---|
| 12.0 | CultLib | CultMath gains erf and erfinv (a gap filled in its owner) |
none |
| 12.1 | Aetheria | subtraction and frame owner; no behaviour change | none |
| 12.2 | Aetheria | where a hit lands: silhouette at Commit, Apply stops reading positions | 12.0, 12.1 |
| 12.3 | Aetheria | how a direct hit travels: exact lane walk, sequential absorption, spread lanes | 12.2 |
| 12.4 | Aetheria | the detonation primitive (areas), fuse data, the Airburst label demoted; Splash deleted | 12.3 |
The five are kept apart so that Soul falsifies each question on its own: where a shot
lands, what a direct hit does, and what a blast does. In 12.2, ApplyHit survives for one
more cut with a changed signature: it takes the schematic bearing and loses its own
rotation, while its march and its even split are left untouched until 12.3.
Cut 12.0. CultMath gains erf and erfinv
- Repo/branch:
F:\Projects\CultLib, off currentmain. - Adds:
math.erf(float)andmath.erfinv(float)inpackages/cultmath/src/CultMath/math.cs, as standard single-precision approximations (for example Abramowitz-Stegun 7.1.26 or a minimax fit; Giles 2010 for erfinv), with tests inpackages/cultmath/tests/CultMath.Tests. Neither is an HLSL intrinsic, so there is no shader-parity obligation. - Release: run
packages/cultmath/scripts/build-unity-package.ps1, bumpunity/org.gamecult.cultmath/package.jsonfrom 0.2.3 to 0.2.4, and tagcultmath-unity-v0.2.4. Aetheria then bumpsPackages/manifest.json:56and the headlessCultLibRootpin. - Verification:
ErfMatchesReferenceValues: erf at 0, ±0.5, ±1, ±2 and ±3 matches tabulated values within 2e-6; erf is odd; the limits are ±1.ErfinvInvertsErf: over y ∈ [-0.999, 0.999].- The CultLib mutation run, with every survivor triaged by name.
Status: the code closes, the release does not. Landed on CultLib
hands/cultmath-erf: 87b95ae, b688cb2 (erf, erfinv, tests), then the fix batch
fc9ce97, 4cdc02b, 6e8362b, cc71f17. 102 tests.
Two verification lines above were stale when written and are corrected here. CultLib had
no mutation tooling at all, so there was no "CultLib mutation run"; Stryker.NET was adopted
in its own cut (hands/adopt-stryker) and its CultMath baseline is 2243 mutants, 350
survived, triaged in that cut's report. The headless pin needed more than a bump. Directory.Build.props:5 pins one CultLib revision
for every package, and that revision (45c2f40) is exactly Unity's caching-unity-v1.4.0 /
cultlib-unity-v1.0.60; moving it to the release would have put six CultNet commits of newer
CultCache, including a breaking registration change, under the headless tests while Unity
stayed on 1.4.0. So the headless build now pins per package, mirroring the manifest:
CultMathRoot/CultMathRevision at the 0.2.4 tag's merge (6d5e2096), CultLibRoot/
CultLibRevision still at 45c2f40 for CultCache. A package's pin moves only when its
manifest tag moves. (Self first repeated Soul's claim that there was no pin, from a read of
lines 3-4; line 5 is the pin, and the build refused the first attempt to use the release.) The
Unity manifest lives at packages/cultmath/unity/org.gamecult.cultmath/package.json, not
at the repo root.
Soul's first pass found the defect this cut would otherwise have shipped: erfinv(±1)
returned the wrong-signed infinity, so a sample drawn for the far edge of an interval
was clamped onto the near edge. Reachable in ordinary play — erf saturates to exactly 1
at 5.543σ, so any interval edge past that yields p = 1. Fixed with two guards; outside
the domain is NaN, now documented and pinned. Soul also measured the reported accuracy
figures 1.5× optimistic (true worst error: erf 6.621e-7, erfinv 5.066e-7, round trip
6.109e-7, all inside the 2e-6 bar), found erfinv had no accuracy test at all (the round
trip was blind by 300-400×, proven with a coefficient mutant), and found
ErfinvIsMonotonicallyIncreasing asserting a property neither function has (9662 backward
steps in erfinv at float granularity; the fixture's 0.001 step hid every one). The second
pass re-derived all 19 new reference values by a different method and closed the cut.
Cost, for 12.2 to answer rather than assume: erf is 16.3 ns per call and
allocation-free, but a silhouette costs one erf per interval edge — ~4.2 µs per
evaluation on a 128-interval hull, 1.06 µs on 64, and worse under IL2CPP. The AI evaluates
HitProbability constantly. 12.2 must measure the new placement against the old kernel per
gated-in evaluation and report both, not assume 1D beats 2D.
Released 2026-09-24: CultLib 20e6da5 (release) and 8c86bc9 (the changelog meta), merged to
main at 6d5e2096, tagged cultmath-unity-v0.2.4. Release Soul pass: the DLL rebuilt byte-
identical from source (sha256 2470cd48…), public surface +2 (erf, erfinv), shipped
erfinv(±1) correctly signed; its one finding, CHANGELOG.md without a Unity .meta, was fixed
before tagging. Aetheria repinned in 2a68b4c0 (manifest and Unity-written lock, hash
6d5e2096); batchmode resolve and compile clean, no missing-meta warning. Follow-up for CultLib:
build-unity-package.ps1:89-90 checks metas only for code and binary files, so it could not see
the missing .md meta.
Was owed (kept for the record): Unity
consumes a precompiled CultMath.dll pinned at cultmath-unity-v0.2.3
(Packages/manifest.json:56), so no Unity-side fire-control code can call erf until the
package is rebuilt, bumped to 0.2.4, tagged and repinned. The headless tests see the
sources directly, which is why nothing failed. 12.2 must not land in Unity before that.
Recorded, not done: erf/erfinv are scalar-only while every other scalar in
math.cs ships float2/3/4 overloads. The consumer is scalar; vector overloads are a
separate decision.
Cut 12.1. One frame owner, and the dead outcome flag
- Repo/branch: Aetheria, off the Cut 11 merge. No behaviour change.
- Deletes first:
- The two inline frame copies:
FireControl.cs:516-520becomestarget.ToSchematic(toTarget), normalized, keeping the zero-length fallback. The whole of Splash dies in 12.4, but the frame owner lands first.Entity.cs:394-396becomesToSchematic(hitDirection).
ShotOutcome.Aimedand its plumbing (Q12-4 = A):- the field,
FireControl.cs:702; - the
aimedparameter ofMakeOutcome(:470,:481); Commit'saimedlocal and flag (:420,:435);- the
AimedCellshalf ofResolveAimPoint's tuple (:583-589), which now returnsfloat2.
- the field,
- The two inline frame copies:
- Adds:
Entity.ToSchematic(float2). - Verification:
-
Builds: headless
Aetheria.Shared, plus a Unity batchmode compile by Self. -
All 244 tests pass unchanged.
SplashDamagesTheSideTheBlastCameFromandPenetrationMarchIsPlanarpin the frame through its new owner. -
Negative greps:
rg -n "forward\.y, -forward\.x" Assets/Scriptsreturns exactly one hit, inEntity.cs.rg -n "Outcome\.Aimed|outcome\.Aimed|o\.Aimed" Assets/Scripts testsreturns nothing.
Both were checked against the current tree: the first finds today's two copies, and the second finds nothing.
-
Status (2026-09-24): landed at b851b0d1 (+21/-18 across Entity.cs and
FireControl.cs). 244 tests pass, both greps as specified (the one frame copy is
Entity.cs:383, inside ToSchematic), Unity batchmode compile clean, and nothing outside
FireControl.cs read ShotOutcome.Aimed (the HUD holds outcomes but never touched it).
Stryker on the changed lines: the frame's sign mutant dies; the only survivors near the diff
are ResolveAimPoint's pre-existing centroid arithmetic (aimedCells.Length > 0 → >= 0,
/ → *), which 12.2's AimingAtAnItemCentresTheScatterOnItsLane is specified to close.
--since does not work in this repo (Stryker's own diff comes back empty), so runs use
--mutate on the touched files and filter the report to changed lines. No separate Soul pass:
a no-behaviour cut this size folds into 12.2's.
Cut 12.2. Where a hit lands
- Repo/branch: Aetheria, on top of 12.1, with the 12.0 pin in place.
- Deletes first:
HullKerneland its comment block (FireControl.cs:531-578).WeightedPick(:591-611).POnHull(:229-234).- Apply's live direction (
:463-465). - The bounding half-extent (
:224). - The 9.2 SigmaFloor rationale (
:542-548). Rewrite it as described above. - The ActionGameManager inline estimate (
:1317-1318).
- Keeps:
Sigma,SigmaFloor,ResolveAimPoint,CellsOf, the gate order (:150-159) andInspect.PBase = HitProbability(...)(:204). - Adds:
TravelDirection,Silhouette,CommitProbability, the lateral draw,Lane(only its first element is used in 12.2), and these fields:PendingShot:TravelDirection,SpreadandFireRange;PBaseis renamed toPFire.ShotOutcome:BearingandLateral.
- Per-file changes:
FireControl.cs:161-167: after the gates,PFire(...) × PSpread(...) × Silhouette(...).POnHull. The bearing comes fromTravelDirectionand the target's current facing. Nothing new runs before:159.:197-202: Inspect uses the same functions. It gains nothing new beyonddiagnostic.PFireif the HUD wants it (optional; presentation).:263,:286-308: Fire freezesPFire,TravelDirection,Spread = weapon.SpreadandFireRange.:409-435: Commit takes steps 1-5 of Bearing timing throughCommitProbability.:463-467: Apply passesshot.Outcome.Bearing(schematic frame) toApplyHit. For this one cut, ApplyHit's parameter becomes a schematic bearing and theToSchematiccall 12.1 put inside it is removed.ActionGameManager.cs:1317-1318: the pending line printsFireControl.CommitProbability(shot, now, out _)instead ofshot.PBase * pDeviation.Combat.cs:117andTurretController.cs:84need no change. They read HitProbability, which is now a facing-aware forecast. Expect the AI to prefer broadside shots on long hulls, and targets that turn to lose fewer hits. That is tuning, not a defect.
- Authority map:
- Owner:
FireControl.Silhouetteowns scatter.FireControl.CommitProbabilityowns the commit-time roll price and placement source.FireControl.TravelDirectionowns the shot's world direction.Entity.ToSchematicowns the frame.
- Inputs:
- Frozen:
PFire,TravelDirection,Precision,Aimed,Spread,FireRange. - Live at Commit: target position (deviation) and target facing (bearing).
- Frozen:
- Outputs: p for the one roll, then
Bearing,LateralandCellin the immutable outcome. - Derived state:
- HitProbability and Inspect are a forecast of the commit price and decide nothing.
ShotOutcome.Cellis display-only, for the HUD (ActionGameManager.cs:1323) and the tests.
- Forbidden writers:
- Nothing else computes a sigma, a projection or a bearing.
- Nothing after Commit reads a target's facing or anyone's position for placement or damage direction.
- The HUD computes no probability of its own.
- Shared paths: InstantWeapon (
InstantWeapon.cs:248), beam rolls (ConstantWeapon.cs:181, where zero flight time makes the fire tick the commit tick), the AI, turrets and the HUD. - Deletion line:
HullKernel,WeightedPick,POnHulland the HUD estimate are gone beforeSilhouettehas a caller.
- Owner:
- Verification. Fixtures follow 11.3: the shooter is off the origin, fire times are
nonzero, facings include ones where |fx| > |fy|, and weapon spread is nonzero wherever
PSpread is asserted.
BroadsideIsEasierThanHeadOn: on a 2x12 hull, pOnHull and PSpread head-on are strictly below broadside, at facings (0,1) and (1,0). It kills the bearing being ignored and the oldmax(W,H).TurningArmourIntoTheShotTakesItOnTheArmour(the ruling's own case):- Setup: a slow shot (flight time well above
CommitHorizon) at the soft stern of a wedge with bow-only plate. The target turns its bow to face the shot'sTravelDirectionbefore commit. - Pass: every hit's
Cellis on the bow row. - Kills: freezing the bearing at Fire; reading the facing at Fire.
- Setup: a slow shot (flight time well above
TurningAfterCommitChangesNothing(R4, and closes L464/L465):- Setup: through
Fire. After commit and before arrival, the target turns 90° and the shooter moves. - Pass: the damaged cells equal those implied by the committed
Bearing/Lateral. - Kills: Apply reading live positions or facing.
- Setup: through
PlacementAndProbabilityShareOneSilhouettereplaces 6d'sPlacementAndProbabilityShareOneKernel(FireControlCut6dTests.cs:318).- Setup: a static target at a diagonal bearing, so the intervals overlap and the forecast equals the commit price.
- Pass:
CommitProbabilityequals a pOnHull that the test computes independently, by numerically integrating the Gaussian over a union of intervals the test builds itself. The empiricalLateraldistribution matches the per-interval shares, and the empirical hit rate matches p. - Kills: sigma or bearing perturbed at one site; the union merge skipped; ℓ swapped with b.
TheHudEstimateIsTheCommitPrice: for an in-flight shot, the value the HUD prints and the p Commit rolls against are the same number at the commit tick. It kills a second formula.HitsLandOnTheFacingEdgesupersedesEveryHitLandsOnMetal(:357): on the concave fixture, each hit'sCellis occupied, and nothing occupied precedes it in its own lane. The test recomputes the lane fromBearingandLateral.AimingAtAnItemCentresTheScatterOnItsLanecloses L585/L587 and replacesAimingAtTheSternHitsTheStern(:213), whose fixture fires from the target's stern (FireControlCut6dTests.cs:163-164) and would pass for the wrong reason.- From broadside, hits aimed at the stern land mostly in the stern half, and hits aimed at the bow land mostly in the bow half.
- From dead astern, hits aimed at the bow land on the stern edge.
TheSigmaFloorHoldsAtHalfACell: pOnHull at Precision 1000 equals pOnHull at Precision 2 on a one-cell lane. The exact integral no longer collapses, so this test, not the cliff test, is now what kills "delete the floor". Keep the cliff test and rewrite its mutation comment.ShieldIsOmnidirectional: an active shield absorbs the same shot identically from bow, stern and beam.- Kept green:
GatedOutHitProbabilityAllocatesNothing.TheHudShowsTheFactorsTheSimulationMultiplies, re-read as forecast factors.PSpreadIsTheSilhouettesShareOfTheSpreadCone, which fires from astern at a 5-wide hull. There the span is 5, equal to the oldmax(5,3), so its numbers stand.ThinLimbCostsHitChance, with its comment rewritten in 1D terms.- Every test that reads
shot.PBase, updated toPFirewith its expectation unchanged.
- Stryker:
--mutate "**/FireControl.cs" --since:<12.1 head>. Survivors are triaged by name, and float boundary flips count as equivalent (11.2).
- Operator:
- A smoke against a LonginusX-class AI from the bow and then the beam. The HUD
hullfactor should read higher from the beam. - Turn your armoured face into a slow missile and watch where it lands.
- A smoke against a LonginusX-class AI from the bow and then the beam. The HUD
Cut 12.3. How a direct hit travels
-
Repo/branch: Aetheria, on top of 12.2.
-
Deletes first:
Entity.DamageSchematic(Entity.cs:333-374) andEntity.ApplyHit(Entity.cs:376-409). Going with them:- the 0.5-step march;
- the
+ .5cell convention; - the
> .5fthreshold; - the Expand spread footprint.
-
Adds:
Entity.Absorb(int2 cell, float damage) → float, moved verbatim fromEntity.cs:348-366. Armour absorbs up to its value, then the item whend > .1f, and the remainder is returned. It emitsArmorDamage((cell, incoming))andItemDamage((item, incoming))with today's meaning, and only for incoming > 0.Entity.DamageHull(float), moved fromEntity.cs:369-373(> .1f, oneHullDamageevent).
-
Per-file changes:
FireControl.Apply(:449-468), with the shield branches unchanged:IncomingHit.OnNext(source)moves here fromEntity.cs:383.- The lanes are
Lateral + kfork ∈ [-n, n], wheren = (int) floor(DamageSpread + .5f)(today's rounding,Entity.cs:389), spaced one cell apart along ℓ (Q12-2 = A). - Damage splits evenly over the lanes that meet metal. The centre lane always does.
- Each lane walks from its own facing cell, clipped to the penetration depth, with
rem = target.Absorb(cell, rem)at each cell. - Each lane's final remainder goes to the hull, where the lane ends: penetration
exhausted, a gap, or the far side (Q12-3 = A). The remainders are summed and passed to
DamageHullonce. - A weapon with a contact or delayed fuse does not take this path. It detonates instead (12.4), never both.
Where 12.3 and 12.4 meet. Which model a shot uses is decided by the blast radius, not by its penetration:
- No blast radius — a dumb AP round. Steps 1-5 are the whole model, whatever the penetration. The shot travels its lane and is absorbed cell by cell in order, with the remainder carrying on until it is spent or the lane ends (Q12-3). Damage in a line. More penetration spreads the same damage over more cells; it never concentrates it at depth. Q12-8 says nothing about this case.
- With a blast radius — an explosive penetrator. The shot reaches its fuse depth without spending damage on the way, and then detonates as an area (12.4). Q12-8 governs only this case, because the payload is the explosion, not the dart.
The two roles that follow are the design intent: the AP round is the pinpoint component sniper, a thin line of damage through whatever it crosses, and the explosive penetrator is the interior wrecker, a disc across a whole section.
Splash is untouched in 12.3 and deleted in 12.4. Until then it calls
Absorbper footprint cell and passes the summed remainder toDamageHull, which is arithmetically identical to today. -
Authority map:
- Owner:
Entity.Absorbowns what one cell does with incoming damage.FireControl.Applyowns the order and the share per lane. - Inputs: the committed
Bearing,LateralandCell, plus the frozenPenetration,DamageSpreadandDamage. - Outputs:
Armor[], itemDurabilityandHull.Durability, plus the eventsIncomingHit,ArmorDamage,ItemDamageandHullDamage. Their consumers:ActionGameManager.cs:1070,EntityInstance.cs:292,InventoryPanel.cs:470-482, andEntity.cs:488-494(ItemDestroyed, HullArmorDepleted, Death). - Forbidden writers: no weapon hit writes armour or item durability except through
Absorb.Behaviors.CauseDamage(Behaviors/Behaviors.cs:62-73) is self-damage and out of scope. - Deletion line: both Entity methods are deleted before
Absorbgets a second caller.
- Owner:
-
Verification:
DamageIsAbsorbedInOrderAlongTheRayreplacesHardpointHitDamagesItemThenHullandPenetrationMarchIsPlanar(FireAuthorityTests.cs:481-526, which call the deletedApplyHit).- Setup: through
Fireat facing (1,0). The lane runs through a 10-armour front cell, then a marker, then a soft cell, with damage 30 and penetration 3. - Pass: armour −10, marker −20, soft cell untouched, hull −0. Today it would be 10/10/10 (probe).
- Kills: an even split; items before armour; no carry-forward.
- Setup: through
ArmourFacesTheShot:- Setup: a wedge with bow-only plate, penetration 0, many shots. Facings (0,1), (.8,.6) and one where |fx| > |fy|.
- Pass: from ahead only bow cells lose armour, and from astern the reverse.
BuriedCockpitNeedsPenetration: a cockpit enclosed by one ring of cells.- At penetration 0 it is never damaged from four bearings.
- At a penetration that clears the ring, it takes exactly
damage/lanesminus what the ring absorbed. - Enough damage fires
DeathwithCockpitDestroyed.
SpreadWidensTheImpactAcrossLanes: spread 1 at penetration 0 puts damage/3 on each of three facing-edge cells and nothing behind them.ARayThroughACornerCrossesTheCornerCell: the diagonal case fromProbe1reaches (2,2).LaneRemainderReachesTheHull: a ray that exits a thin hull delivers its remainder toHull.Durability(Q12-3 = A).- Kept green: every Splash test, and
AirburstSplashes…(Cut 6). - Negative:
rg -n "DamageSchematic|ApplyHit\(" Assets/Scripts testsreturns nothing. Today it matchesEntity.cs,FireControl.cs,FireAuthorityTests.cs, and two comment lines atGameplay/EntityInstance.cs:280-281. Rewrite those comments andFireControl.cs:492. - Stryker:
--since:<12.2 head>overFireControl.csandEntity.cs.
-
Operator: a smoke with a GT 3K or pswarm launcher into a LonginusX bow, then from a flank. The schematic display should pulse the facing edge only.
Cut 12.4. The detonation primitive
- Repo/branch: Aetheria, on top of 12.3. It lands as two commits:
- (a) the data fields and the label demotion. This is behaviour-neutral on shipped content, because no weapon carries the label or a radius (probe).
- (b)
Detonate, with Splash deleted.
- Deletes first:
- The label read
isAirburst = … WeaponModifiers.HasFlag(WeaponModifiers.Airburst)(FireControl.cs:281-284), in (a). AfterwardsWeaponModifiershas no simulation reader.rg -n "WeaponModifiers\." Assets/Scripts/ServerSharedreturns nothing (today it returns exactlyFireControl.cs:282). FireControl.Splash(:488-529) and its half-hull footprint, in (b). This absorbs F12-1.PendingShot.BurstRadius(:648-652) becomesBlastRadius, andBurstPositionstays.
- The label read
- Data (smallest field set, both on
WeaponItemData,ItemData.cs:476-499):- Slot 29,
float? AirburstRange, is renamedfloat? BlastRadius(JSON"blastRadius"), in world units, meaning the same thing it meant for airburst. Same slot, same type: CultCache resolves it as compatible drift (see the probes). - New slot 30:
WeaponFuse? Fuse(JSON"fuse"), withenum WeaponFuse { Contact, Proximity, Delayed }inEnums.cs. It is nullable per the 7.4 rule. Null means no blast, which is what every shipped record reads as. - A shot detonates iff
Fuse != null && BlastRadius > 0. Fire freezes both (PendingShot.Fuse,BlastRadius). A fuse without a radius (or a radius without a fuse) is inert, and nothing reports it: Q12-6 ruled that the catalog is not policed. - The catalog needs one write through
tools/AetherDbor Studio to clear the drift warnings. That write is content, with no data change, and is recorded as part of the retune follow-up. - Readers to update:
FireControl.cs:284andFireControlCut6Tests.cs:81-101, 249, 268, 279, which authorWeaponModifiers.Airburst. Those fixtures move toFuse = Proximity. Whether those fixtures keep the label is free: Q12-6 ruled that nothing checks labels against behaviour.
- Slot 29,
- Adds:
FireControl.Detonate(Zone zone, float2 worldPoint, float radius, float damage, DamageType type), the one owner of blast damage, plusEntity.ToSchematicPointandToWorldPoint(see the frame rules).-
Where P is:
- Proximity: Step calls it at arrival with the frozen
BurstPosition(PredictedIntercept,:283). There is no roll, as today (AirburstSplashesEvenOnA GuaranteedMissstands). - Contact: on a committed, unshielded hit, P is the entry point of the impact cell on the committed lane.
- Delayed: on a committed, unshielded hit, P is the end of the penetration walk on that lane (penetration exhausted or first gap). The walk only locates P; nothing is absorbed along it (Q12-8), and that is true only because this weapon carries a blast. A weapon with no blast radius never reaches this path and spends its damage down the lane instead (12.3).
Both contact and delayed are converted with
target.ToWorldPoint, so every entity in the radius (the host included) is treated alike. A shielded contact or delayed hit is absorbed whole, as a direct hit is, and does not detonate.Mine.cs:99callsDetonate. - Proximity: Step calls it at arrival with the frozen
-
Area, per entity:
- Candidates are the entities with
|Position.xz - P| ≤ radius + boundingRadius, whereboundingRadius = ½·√(W²+H²)·cellSize. - For each candidate,
Ps = ToSchematicPoint(P)andrCells = radius / SchematicCellSize. - For every cell of the candidate's schematic inside the bounding box
[Ps ± (rCells + .5)], computeoverlap(c), the exact area of the disc of radiusrCellscentred onPsintersected with that cell's unit square. - An occupied cell takes
damage × overlap(c) / (π · rCells²)throughAbsorb(c, …), and the remainder of that share goes toDamageHull. An unoccupied cell's share is lost, and so is every part of the disc outside the schematic. - Entities are independent. Two ships caught by one blast each cover their own cells, and neither shadows the other.
- Candidates are the entities with
-
Why exact overlap and not sampling. The overlap is the standard circle-rectangle intersection, evaluated per cell from the antiderivative
½(x√(r²-x²) + r²·asin(x/r))over the cell's clipped x-span, combined across the rectangle's edges. It is closed form, allocation-free, and exactly conservative: the overlaps over the bounding box sum toπ·rCells², so shares sum to 1 and the only loss is geometric. Supersampling would be cheaper to write and would break that: a blast smaller than one cell, or one straddling a cell boundary, would deliver the wrong total, and the conservation test below could only be written with a tolerance wide enough to hide real errors. -
Shields: an entity with an active shield is charged the summed share of the disc that its occupied cells cover, under the same
CanTakeHit/Break/TakeHitrules as today.- If the shield absorbs, no cell of that entity takes anything.
- If not, it breaks and the cells take their shares.
Today every shield in radius pays the full damage (
:505-513). Charging the covered share makes the shield pay only for what reaches the ship, which is consistent with "omnidirectional": the reserve has no facings, but it is not charged for energy that went past the hull. -
Cost:
candidates × (2·rCells + 2)²overlap evaluations, about 3 × 27² ≈ 2k for a 25-unit blast at the default cell size, once per detonation and never per tick. No allocation: the loop writes nothing but the per-cell absorption. -
Accepted loss. Interior armour no longer shadows the cells behind it, because nothing is occluded within an area. A bulkhead protects its own cell and no other. The ray model bought that shadowing, and the operator cut the ray model. Falloff toward the rim is F12-7, not this cut.
-
HitProbability and Inspect are unchanged:
- A contact or delayed blast still needs the shot to hit, so the same forecast applies.
- A proximity blast has no roll and delivers whatever geometry gives. The forecast still reports the direct-hit number for it. That is pre-existing, and AI gating on it is recorded as F12-5.
-
- Authority map:
- Owner:
FireControl.Detonateowns blast damage and the disc's coverage.Entity.Absorbowns per-cell absorption, shared with 12.3.Laneowns traversal and is a direct-hit function only; a blast calls it just once, for the delayed fuse's point, on the walk 12.3 already performs. - Inputs:
- world P, radius, damage and type;
- each entity's position, facing, hull, armour and occupancy at the detonation tick;
- the fuse and radius, frozen from weapon data at Fire.
- Outputs: the same mutations and events as 12.3.
- Derived state:
WeaponModifiersis display-only:SchematicListElement.cs:54icons,GameSettings.cs:67, andGameplaySettingsEditor.cs:44. - Forbidden writers:
- No label decides behaviour.
- Nothing but
Detonateapplies area damage. - Mine's Unity
BlastRangeand arming query remain presentation-side inputs (R8 residue, Cut 4 Q7) and decide no damage themselves.
- Shared paths: the airburst resolution in
Step(:356), Mine (Mine.cs:99), and contact and delayed hits fromApply. - Deletion line:
Splashand the label read are deleted beforeDetonateis called.
- Owner:
- Verification:
TheDiscIsConservedOverTheGrid: over a solid hull large enough to contain the disc, the total damage delivered equalsdamagewithin float tolerance, at several radii including one smaller than a cell and one centred on a cell corner. It kills a sampling approximation, a wrong normalizer, and a bounding box that clips the disc.EachCellTakesItsShareOfTheDisc: on a solid hull, the damage on a named cell equalsdamage × overlap / (π r²), with the overlap computed independently by the test (fine numeric integration). It kills an even split over the covered cells, which is what today's Splash does.ExternalBlastWastesMostOfItsEnergy: a proximity blast beside a hull delivers total armour + item + hull damage well belowdamage. The expected share is the disc's overlap with the schematic, computed by the test.InternalBlastDeliversIt: the same blast deep inside the hull delivers nearly all ofdamage, and damages interior items an external blast of the same radius cannot reach.PenetratorBurrowsBeforeItBursts: a delayed-fuse hit with penetration 2 into the enclosed-cockpit fixture damages the cockpit, while a contact-fuse hit with the same stats does not. It also pins Q12-8's scope, in two halves:- With a blast radius, the armour the burrow passes through absorbs nothing, so the cockpit's share is the full area share of its own cell.
- With the radius removed and everything else held, the same shot is a dumb AP round: it damages the cells along its lane in order, and the cockpit gets only what the armour and items in front of it left (12.3). It kills a build that routes every penetrating shot through the fuse path.
ABlastDamagesTheCellsNearestIt: a blast to port damages port cells and leaves starboard cells untouched, at facings including |fx| > |fy|. It is the successor ofSplashDamagesTheSideTheBlastCameFromandSplashIsDirectional, and it is what pinsToSchematicPoint's handedness.ShieldPaysForWhatReachesIt: a shield in radius is charged the covered share, not the whole blast.SplashShieldAbsorptionDrainsTheReserveandSplashBreaksUnabsorbedShieldare rewritten to the covered share, with the expected amounts recomputed rather than loosened.LabelsDoNotDecideBehaviour: a weapon labelledAirburstwith no fuse resolves as a direct hit, and a weapon withFuse = Proximityand no label detonates.- Kept green:
AirburstSplashesEvenOnAGuaranteedMissand its no-double-application twin, re-fixtured toFuse. - Negative:
rg -n "Splash\(|HasFlag\(WeaponModifiers" Assets/Scripts testsreturns nothing. Today it matchesFireControl.cs,Mine.csandFireControlCut4/5/6/11Tests.cs. - Stryker:
--since:<12.3 head>.
- Operator: author one proximity weapon and one delayed-fuse weapon in a scratch
catalog. Watch an airburst beside a LonginusX and a penetrator into its nose, and check
that the damage lands where the model shows the blast. That also verifies
ToSchematicPoint's centre-of-mass anchor.
0b. Identity, lifecycle, authority
Two persisted changes, both on aetheria.weaponitemdata v1 and both in 12.4:
| Field | Slot | Change | Lifecycle | Reader risk |
|---|---|---|---|---|
BlastRadius (was AirburstRange) |
29 | rename, same float? type |
authored catalog | compatible drift: CompareSchemaShapes ignores member names (CultCache.cs:672-744); drift warnings until the next catalog write |
Fuse |
30 | new, WeaponFuse? |
authored catalog | nullable, so older records read as null ("no blast"), per the 7.4 rule |
Runtime-only and never serialised (0b table, FireControl.cs:626-629, :686-689):
PendingShot:PFire,TravelDirection,Spread,FireRange,Fuse,BlastRadius.ShotOutcome:Bearing,Lateral.
Nothing in saves changes shape.
Operator questions
Ruled 2026-09-22 (the words are recorded under Rulings):
-
Q12-1: B, the bearing is taken at Commit.
-
Q12-2: A, spread is width.
-
Q12-3: A, the lane remainder goes into the hull where the lane ends (direct hits).
-
Q12-4: A,
ShotOutcome.Aimedis deleted. -
Q12-6: no. Operator: "12-6, nope". No catalog test ties a label to behaviour.
WeaponModifiersstays authored metadata that nothing checks and nothing reads in the simulation, and a weapon whose label disagrees with its data is authoring's business. -
Q12-7: dissolved, not answered. It asked where a blast ray's remainder goes. With blasts as areas there are no rays and no remainder to route, so the question has no subject. The ray model's shadowing goes with it (see Accepted loss in 12.4).
-
Q12-8: A, and it is scoped to weapons that carry a blast. Nothing is absorbed along an explosive penetrator's burrow: it reaches its fuse depth without spending damage, and the explosion is the payload. Penetration is a reach decision there — how deep the fuse point can sit — not a damage budget.
The first wording ("the burrow is travel, not damage") read as if it covered every penetrating shot. It does not. Operator, 2026-09-22: "Q12-8 is odd. Like, penetration is still a thing even if it's not a warhead. For a dumb AP round, you'd expect it to do damage in a line, not dump all its damage at max depth, that would make AP rounds even better at component sniping than AP explosives". Agreed, and the map never meant otherwise: a weapon with no blast radius takes 12.3's lane, absorbed cell by cell in order with the remainder carrying on, and Q12-8 does not reach it. See Where 12.3 and 12.4 meet.
So "armour absorbs first" holds everywhere it was ruled: down the lane for a direct hit, and per covered cell for a blast. Against an explosive penetrator, armour protects by the reach gate and by absorbing its own cell's share of the disc. The consequence to watch when F12-2 tunes penetration is that an explosive penetrator with reach pays nothing for the plate it crosses, while an AP round pays for every plate it passes.
Open:
- Q12-5 (not ruled). Should the HUD show which target items are exposed from the current
bearing?
- Recommended: a follow-up after 12.3. It would be a presentation read of
SilhouetteandLane, the way Inspect is: for each revealed item, is it first in some lane of the current shadow. It belongs on the target-item cycling UI.
- Recommended: a follow-up after 12.3. It would be a presentation read of
Follow-ups recorded, not in this cut
- F12-2. Penetration retune (operator: "Retuning penetration is a follow up.").
- Shipped values are ≤ 0.25 against an inspector range of 0..1, and enclosed cockpits need about 2 cells.
- The same pass decides which weapons get fuses and blast radii (none have one today), and writes the catalog once to clear the slot-29/30 drift warnings.
- Until it lands, interior cells are reached only by blasts.
- F12-3. SigmaFloor's purpose changed. It was a numerical guard and is now a design minimum on group tightness. Revisit it when Precision is next tuned.
- F12-4. Double charging. 6d's note that pSpread and pOnHull both price target size still stands. Both now read one Span and shadow, so a later merge is mechanical.
- F12-5. AI and proximity blasts.
Combat.cs:117gates a proximity weapon on a direct-hit forecast that does not decide its damage. This is pre-existing. - F12-6. Mine's blast inputs. The prefab
BlastRangeand the arming OverlapSphere (Mine.cs:69-78,:99) are presentation-side (Cut 4 Q7). Mine has no catalog weapon record carryingFuse/BlastRadius. It can move when it ships (R9). - F12-7. Blast falloff toward the rim. Every cell takes its area share flat, so a cell at the rim is hurt as hard per unit of area as the one under the fuse. A radial falloff is a tuning decision for the same pass that authors radii, and it changes only the weight inside the integral, not the owner.
- F12-8. Blasts do not shadow. Nothing inside an area is occluded, so a bulkhead protects only its own cell, and two ships caught by one blast do not shade each other. This is the accepted cost of areas over rays. Reopen it only if play shows interiors are too soft.
- Behaviors.CauseDamage (
Behaviors/Behaviors.cs:62-73) writes durability andHullDamagewithoutAbsorb. It is self-damage, not a hit, and is left alone. - Orphaned
Airburst*YAML in nine weapon prefabs. It is harmless, and Unity drops it on the next save of each prefab.
Subtraction ledger (estimate)
| Sub-cut | Removed | Added | Notes |
|---|---|---|---|
| 12.0 | 0 | ~40 src + ~30 test (CultLib) | release cultmath-unity-v0.2.4, manifest bump |
| 12.1 | ~15 (two frame copies, Aimed plumbing) | ~6 (ToSchematic) |
no behaviour change |
| 12.2 | ~65 (HullKernel, WeightedPick, POnHull, live direction, bounding extent, HUD estimate, stale comments) | ~95 (TravelDirection, Silhouette, CommitProbability, lateral draw, Lane, 5 PendingShot/Outcome fields) | 6d kernel tests rewritten (~−150/+220) |
| 12.3 | ~80 (DamageSchematic, ApplyHit) | ~45 (Absorb, DamageHull, lane orchestration) | 2 FireAuthority tests replaced; ~6 new |
| 12.4 | ~45 (Splash, its half-hull footprint, the label read, BurstRadius plumbing) | ~60 (Detonate, circle-square overlap, point transforms, WeaponFuse, slot 30) |
Splash tests rewritten; ~7 new. Areas need no ray loop, so this is smaller than the ray draft |
| Net Aetheria src | ~205 | ~205 | one persisted slot added (nullable), one renamed; no targets, dependencies or formats; CultMath gains 2 functions |
The net is roughly flat: the positive part buys the bearing-aware scatter, sequential absorption, and a blast model that replaces a half-hull approximation. The rest is subtraction.
Section history: Cut 6d's "The rule" (the 2D kernel) and its premise that pOnHull folds
into PBase at Fire, 9.2's SigmaFloor rationale, and Cut 4's Splash rule (FireControl.cs:488-497)
describe models this cut replaces. Self should mark them as history in the status header
as 12.2 and 12.4 land.