From 263ac421fbda8489af3de43d6ccefd1857f235fe Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 13 Sep 2026 14:02:49 +0200 Subject: [PATCH 01/49] REC-095: delete confirmed-dead narrative test fixtures and zero-byte scratch file Deletes only the two candidate groups that survived independent re-verification and had zero collision with the live codex/aetheria-state-rebuild branch: - GameData/Narrative/HeroOrZero.json, GameData/Narrative/Locations/TestA.json, GameData/Narrative/Quests/TestQuest.json: StoryProcessor.cs only ever calls DirectoryInfo.EnumerateFiles("*.ink"/"*ink") against these directories; no .cs, .csproj Content/None glob, or other file in the tree references these basenames. Confirmed via rg across the whole tree and voidbot search_sources (no hits either way). - Vault/Aetheria/scratch.md: zero-byte Obsidian vault scratch file, no references anywhere in the tree, no voidbot search_sources hits. Kill-attempts that came up empty (recorded for the record, not evidence of absence beyond what's stated): - rg for basenames HeroOrZero.json/TestA.json/TestQuest.json/scratch.md across the whole tree excluding Library/Temp/obj/bin/.git: no hits. - Economy.Server.csproj and Economy.Shared.csproj inspected for wildcard Content/None includes that might implicitly sweep in the JSON files: none found (Economy.Shared only globs ServerShared/**/*.cs as Compile). - mcp__voidbot__search_sources queried for both candidate groups: no consumer hits, only unrelated fuzzy matches. NOT deleted here despite being in REC-095's candidate list (see coordinator report for full detail): StrategyGameManager.csbak, CultClient.cs, CultCache.cs backing-store classes, Economy.Server/appsettings.json, and Economy.Shared/bin+obj all collide with commits already on origin/codex/aetheria-state-rebuild (which has independently already deleted or heavily rewritten those exact paths) and are therefore BLOCKED per the no-touch-live-branch-paths rule, not executed from this worktree. REC-095. Co-Authored-By: Claude Sonnet 5 --- GameData/Narrative/HeroOrZero.json | 1 - GameData/Narrative/Locations/TestA.json | 1 - GameData/Narrative/Quests/TestQuest.json | 1 - Vault/Aetheria/scratch.md | 0 4 files changed, 3 deletions(-) delete mode 100644 GameData/Narrative/HeroOrZero.json delete mode 100644 GameData/Narrative/Locations/TestA.json delete mode 100644 GameData/Narrative/Quests/TestQuest.json delete mode 100644 Vault/Aetheria/scratch.md diff --git a/GameData/Narrative/HeroOrZero.json b/GameData/Narrative/HeroOrZero.json deleted file mode 100644 index e63bd875c..000000000 --- a/GameData/Narrative/HeroOrZero.json +++ /dev/null @@ -1 +0,0 @@ -{"inkVersion":20,"root":[["^As you wander the docking area, swaying a little as you readjust to the inertial compensation aboard the station, a shout stops you in your tracks, and a quick about face reveals a frantic young man wearing what appears to be a dazzling, star-splattered cape.","\n","^“Oh, there you are,” he says, grabbing your hand and pulling you into the shadow of a rather dilapidated looking ship. “Thank credits I’ve found you. I’ve been looking all over for you.”","\n",["ev",{"^->":"0.4.$r1"},{"temp=":"$r"},"str",{"->":".^.s"},[{"#n":"$r1"}],"/str","/ev",{"*":"0.c-0","flg":18},{"s":["^You blink. “Uh…have we met? And can you let go of me? You’re kind of hurting my arm.”",{"->":"$r","var":true},null]}],{"c-0":["ev",{"^->":"0.c-0.$r2"},"/ev",{"temp=":"$r"},{"->":"0.4.s"},[{"#n":"$r2"}],"\n","^The man seems surprised to hear this.","\n","^“Oh, come on, old buddy,” he says. “It’s Bertie! From Potoo The gala? I was bartending for extra credits, and we were just chatting, and then everything was all, ‘boom! Bang!’ and everyone was running around all ‘oh, no, we’re going to die!”’ and you were all ‘just part of the human condition, am I right?’ and just straight up did a death drop into the carnivorous sand dunes after that thing with the spines! Bam! Just like that. And we all thought you were going to get eaten but you totally didn’t and I said, I said, ‘that was amazing,’ and you said ‘I know!’ And then you ordered a Calpraxian moonshine popper with ‘extra moons, please’ and a fondant umbrella and drank the whole thing? The clip got like, ten thousand hits. I’m your biggest fan!”","\n","^You…don’t remember any of that. But you do really love Calpraxian moonshine poppers, and doubly so when they’re doubled. These two things are probably related. “Oh,” you say. “Thanks, I think.”","\n","^“Oh, this is the best!” Bertie says. “I’ve been looking all over the galaxy for you, and here you are! Come on, come on, I want to show you something.”","\n","^“Show me what?” you say, not entirely sure where this is headed.","\n","^“This,” he says, whipping out a phaser and firing a stun beam directly between your eyes.","\n",{"->":"0.g-0"},{"#f":5}],"g-0":[{"->":"wakeywakeyeggsandfakey"},["ev",{"^->":"0.g-0.1.$r1"},{"temp=":"$r"},"str",{"->":".^.s"},[{"#n":"$r1"}],"/str","/ev",{"*":".^.^.c-1","flg":18},{"s":["^\"Really? Why?\"",{"->":"$r","var":true},null]}],["ev",{"^->":"0.g-0.2.$r1"},{"temp=":"$r"},"str",{"->":".^.s"},[{"#n":"$r1"}],"/str","/ev",{"*":".^.^.c-2","flg":18},{"s":["^\"Oh, hey Dave! Nice ship.\" ",{"->":"$r","var":true},null]}],"ev","str","^-kick him in the shins and run-","/str","/ev",{"*":".^.c-3","flg":20},{"c-1":["ev",{"^->":"0.g-0.c-1.$r2"},"/ev",{"temp=":"$r"},{"->":".^.^.1.s"},[{"#n":"$r2"}],"\n","^“Why?” the man says. “Why? I can’t believe you’re asking me that. And me your biggest fan, too.”","\n","^“Really?” you say. “You grab me out of nowhere, say you’ve been looking all over for me, start dragging me to your junky, rat-trap ship, and are somehow surprised when I want to know your motives?”","\n",[[{"->":"ship"},{"->":"0.g-1"},{"#f":5,"#n":"g-0"}],null],{"#f":5}],"c-2":["ev",{"^->":"0.g-0.c-2.$r2"},"/ev",{"temp=":"$r"},{"->":".^.^.2.s"},[{"#n":"$r2"}],"\n","^He stops.","\n","^“Dave?” he says, looking deeply offended. “Who’s Dave? Dave is a debtor’s name. I’m Humblebert Maxibillion Goiterwold. The Twenty-Second. I’d think you of all people would know that.”","\n","^“Not good enough to be Humblebrag Monstermillion Goiterwold the first, eh?” you say. “Well, we’ve all been there.”","\n","^“Humblebert Maxibillion Goiterwold,” he insists, cape swirling around him and catching the light with an incredible sense of narrative drama.","\n","^You’ve never killed a man for his clothes before, but you’re definitely starting to consider it.","\n","^“And you, ,” he continues, oblivious to your thoughts, “Have been chosen. By me! Out of all the best fighters in the galaxy—oh, yes, I’ve been watching you, . The sand dunes on Potoo 6—the thing with the jelly—the battle on the Moons of Alcorion! Amazing! I need your heroic skills. You see,” he says, gesturing at the half falling apart ship behind him, “my manor has been infested with Iltorian venom beetles. Oh, horrible things; twice the size of a man and three times as deadly! I need you to exterminate them.”","\n",[["ev",{"^->":"0.g-0.c-2.19.0.$r1"},{"temp=":"$r"},"str",{"->":".^.s"},[{"#n":"$r1"}],"/str","/ev",{"*":".^.^.c-0","flg":18},{"s":["^“Fighting gigantic killer insects with my pal Dave? It’s just like old times! Consider me hired.” ",{"->":"$r","var":true},null]}],["ev",{"^->":"0.g-0.c-2.19.1.$r1"},{"temp=":"$r"},"str",{"->":".^.s"},[{"#n":"$r1"}],"/str","/ev",{"*":".^.^.c-1","flg":18},{"s":["^“Uh…what manor? That’s a spaceship.” ",{"->":"$r","var":true},null]}],["ev",{"^->":"0.g-0.c-2.19.2.$r1"},{"temp=":"$r"},"str",{"->":".^.s"},[{"#n":"$r1"}],"/str","/ev",{"*":".^.^.c-2","flg":18},{"s":["^“Well, this is obviously a trap. Bye!” ",{"->":"$r","var":true},null]}],{"c-0":["ev",{"^->":"0.g-0.c-2.19.c-0.$r2"},"/ev",{"temp=":"$r"},{"->":".^.^.0.s"},[{"#n":"$r2"}],"\n","^“Humblebert,” the young man says, and with a swirl of his cape motions you up the steps to his ‘manor’. You, long since ready for an adventure, follow. As the door slides shut behind you with a loud screech and a faint scent of scorched rubber, you turn to look for Humblebert, only to find him gone and your vision slowly going black. As you slip into unconsciousness, you swear you can hear someone whisper, slowly, incredulously, “Who the debt is Dave?”","\n",{"->":"wakeywakeyeggsandfakey"},{"->":"0.g-1"},{"#f":5}],"c-1":["ev",{"^->":"0.g-0.c-2.19.c-1.$r2"},"/ev",{"temp=":"$r"},{"->":".^.^.1.s"},[{"#n":"$r2"}],"\n",{"->":"ship"},{"->":"0.g-1"},{"#f":5}],"c-2":["ev",{"^->":"0.g-0.c-2.19.c-2.$r2"},"/ev",{"temp=":"$r"},{"->":".^.^.2.s"},[{"#n":"$r2"}],"\n",{"->":"Exitone_thepeaceout"},{"->":"0.g-1"},{"#f":5}]}],{"#f":5}],"c-3":["^ ",{"->":"Exitone_thepeaceout"},"\n",{"->":"0.g-1"},{"#f":5}],"#f":5}],"g-1":["done",{"#f":5}]}],"done",{"ship":["^“It’s not a ship,” he snaps, turning slightly red with outrage. You seriously begin to debate the merits of just knocking him out and running for it. “It’s a battleground! An arena. A castle of unspeakable horrors, to be conquered by only the bravest of heroes! It is your destiny.”","\n","^You look at the grav-dynamic, be-thrustered, battered but still very obviously ship-like space-faring transport behind him.","\n","^“No,” you say. “I’m pretty sure it’s a ship.”","\n","^You turn to leave the madman to his destiny, and then, with a sudden, sharp crackle of electricity along the labyrinth of your nerves, darkness closes in.","\n",[[{"->":"wakeywakeyeggsandfakey"},{"#f":5,"#n":"g-0"}],null],{"#f":1}],"wakeywakeyeggsandfakey":[["^You awaken, gear missing and a faint prickle between your eyes, to a cascade of brilliant light, which eventually clears to reveal the ominously decorated interior of a ship, and the staticky blare of a catchy jingle played over monitors that are not built for quite that level of *peppy*.","\n","^This is nothing, of course, to the voice which follows it. It’s the voice of a being who has somehow attained their designated 8 hours of restful sleep, and followed it up with a heaping bowl of sunbeams and three different kinds of protein. It is, you recognize resentfully, the voice of someone who proudly identifies as a morning person.","\n","^You make a mental note to strangle them should the opportunity ever arise.","\n","^“Gooooood morning, Lucent Employees and Copyrighted Genotype Creations!” the voice cheers. “It’s 5am studio time, and you know what that means! Oh yes. It’s time for the number one reality show hit of the season, the reason you all wake up bright eyed and bushy tailed these workday mornings--and not just for my dashing good looks, either! It’s that wonderful time of day we all gather by the X-CAff bot to watch one randomly chosen citizen, plucked out of their daily life by yours truly, strive to prove they have the mettle to survive the Gauntlet…and become our new Hero! I’m your host, Humblebert Maxibillion, and this is…HERO or ZERO!”","\n","^Whoops and cheers suddenly surround you. Whether real or manufactured, you can’t tell, and you don’t know which would be more comforting.","\n","^You take a closer look around. For a supposed number one hit, the scenic design is a bit…lackluster. The broad outlines are obviously there—the idea of an old Terran castle traced in stonework and lanternlight—but you can clearly see through the facade the sleek metallic gleam of a spacefaring world.","\n","^You don’t have much time to consider, because the voice is speaking again, loud and tinny:","\n","^“Today’s would-be hero is a rough and tumble contract pilot pulled straight outta X station! (under license agreement with Finch Cybernetics, of course!) [he/she/it select pilot’s gender]’s just awoken here in our ancient Castle Victory, hand hewn from the stone by bold warriors in the halcyon days of yore--”","\n",["ev",{"^->":"wakeywakeyeggsandfakey.0.16.$r1"},{"temp=":"$r"},"str",{"->":".^.s"},[{"#n":"$r1"}],"/str","/ev",{"*":".^.^.c-0","flg":18},{"s":["^He takes a breath, and you take the opportunity to cut in. “Hey! Hey! I think there’s been a mistake. I didn’t sign up for this.\" ",{"->":"$r","var":true},null]}],["ev",{"^->":"wakeywakeyeggsandfakey.0.17.$r1"},{"temp=":"$r"},"str",{"->":".^.s"},[{"#n":"$r1"}],"/str","/ev",{"*":".^.^.c-1","flg":18},{"s":["^”Oh, for—it’s not a castle; it’s a spaceship! Look—I can see the wing struts—” ",{"->":"$r","var":true},null]}],["ev",{"^->":"wakeywakeyeggsandfakey.0.18.$r1"},{"temp=":"$r"},"str",{"->":".^.s"},[{"#n":"$r1"}],"/str","/ev",{"*":".^.^.c-2","flg":18},{"s":["^The curiousity is too much. You burst into the continuous torrent of his speech with an excited, “Oh, this sounds fun! Is there a prize?” ",{"->":"$r","var":true},null]}],"ev","str","^-Search for an exit-","/str","/ev",{"*":".^.c-3","flg":20},"ev","str","^-Do nothing-","/str","/ev",{"*":".^.c-4","flg":20},{"c-0":["ev",{"^->":"wakeywakeyeggsandfakey.0.c-0.$r2"},"/ev",{"temp=":"$r"},{"->":".^.^.16.s"},[{"#n":"$r2"}],{"->":".^.^.deathandorglory"},"\n",{"->":".^.^.deathandorglory"},{"#f":5}],"c-1":["ev",{"^->":"wakeywakeyeggsandfakey.0.c-1.$r2"},"/ev",{"temp=":"$r"},{"->":".^.^.17.s"},[{"#n":"$r2"}],"\n","^The voice—Humblebert Maxabillion?—sounds annoyed as he continues over you. “The, ah, ancient and very real Castle Victory, home of…the GAUNTLET!”","\n","^“I don’t need any more armour, thanks,” you say, feeling around for the escape hatch you know from extensive perusal of the fine print of all your ship purchases that Lucent Industries safety regulations requires to be no more than nine metres away at all given times. The ‘stonework’ gives slightly beneath your fingers. “Besides, I’m pretty sure this is just papier mache—”","\n","^“Papier mache is an ancient and respected art form,” Humblebert snaps, before gathering himself and continuing cheerily in his role of host.","\n","^At your feet, the ‘stones’ begin to glow, illuminating a path into the bowels of the ship. \"I want to state that at no point did I sign up for, agree to, or otherwise consent to any of this,\" you say, because there are limits and, more importantly, liabilities.","\n",{"->":".^.^.deathandorglory"},{"->":".^.^.deathandorglory"},{"#f":5}],"c-2":["ev",{"^->":"wakeywakeyeggsandfakey.0.c-2.$r2"},"/ev",{"temp=":"$r"},{"->":".^.^.18.s"},[{"#n":"$r2"}],"\n","^“Oh, the prize is greater than you could ever imagine,” Humblebert says. “And it looks like our contestant is raring to fight for it! So without further ado…”","\n","^The stones beneath your feet kindle with some soft white light.","\n","^“March forth, you fount of potential,” Humblebert says. “Glory awaits.”","\n","^You march. ",{"->":"TheGauntlet"},"\n",{"->":".^.^.deathandorglory"},{"#f":5}],"c-3":["^ ","\n","^Despite your best efforts, no exit is to be found. The only way, it seems, is forward. As if to underscore this inevitability, the stones beneath your feet begin to glow, leading you on to whatever strangeness awaits. ",{"->":"TheGauntlet"},"\n",{"->":".^.^.deathandorglory"},{"#f":5}],"c-4":["^ ",{"->":"TheGauntlet"},"\n",{"->":".^.^.deathandorglory"},{"#f":5}],"deathandorglory":["^“Oh, nobody signs up,” your host—Humblebert?—says. “This is Hero or Zero! A challenge of daring—of thinking on your feet—of courage in the face of unexpected adversity! There’s no glory in preparation.”","\n","^“This is kidnapping and I’m leaving,” you say.","\n","^Humblebert Maxabillion laughs. It is…not a nice laugh. “Oh, no, my friend. No one leaves. Not til they’ve either passed through the trials—or perished. You can opt for immediate death, of course, but it won’t make you popular with the viewers.”","\n","^“Yeah,” you say. “That’s incredibly illegal.”","\n","^“Not in Castle Victory!” Humblebert trills. The stones at your feet begin to glow softly, forming a path leading into the darkness ahead. His voice becomes stentorian. “Now, contestant. Choose. Death or glory.”","\n","^Slowly, familiarly, and then gaining frantic speed in a contagious mob, the studio audience picks up the chant.","\n","^Death or glory.","\n","^Death or glory.","\n","^Death or glory.","\n","^Death or glory. Death or glory. Deathorglory.Deathorglory.deathorglorydeathorglorydeathorglorydeathorglordeathor","\n","^“Well?” Humblebert says.","\n",["ev",{"^->":"wakeywakeyeggsandfakey.0.deathandorglory.22.$r1"},{"temp=":"$r"},"str",{"->":".^.s"},[{"#n":"$r1"}],"/str","/ev",{"*":".^.^.c-5","flg":18},{"s":["^Uh, glory, I guess. ",{"->":"$r","var":true},null]}],["ev",{"^->":"wakeywakeyeggsandfakey.0.deathandorglory.23.$r1"},{"temp=":"$r"},"str",{"->":".^.s"},[{"#n":"$r1"}],"/str","/ev",{"*":".^.^.c-6","flg":18},{"s":["^GLORY! RAH! ",{"->":"$r","var":true},null]}],["ev",{"^->":"wakeywakeyeggsandfakey.0.deathandorglory.24.$r1"},{"temp=":"$r"},"str",{"->":".^.s"},[{"#n":"$r1"}],"/str","/ev",{"*":".^.^.c-7","flg":18},{"s":["^Death. I pick death. ",{"->":"$r","var":true},null]}],{"c-5":["ev",{"^->":"wakeywakeyeggsandfakey.0.deathandorglory.c-5.$r2"},"/ev",{"temp=":"$r"},{"->":".^.^.22.s"},[{"#n":"$r2"}],{"->":"TheGauntlet"},"\n",{"#f":5}],"c-6":["ev",{"^->":"wakeywakeyeggsandfakey.0.deathandorglory.c-6.$r2"},"/ev",{"temp=":"$r"},{"->":".^.^.23.s"},[{"#n":"$r2"}],{"->":"TheGauntlet"},"\n",{"#f":5}],"c-7":["ev",{"^->":"wakeywakeyeggsandfakey.0.deathandorglory.c-7.$r2"},"/ev",{"temp=":"$r"},{"->":".^.^.24.s"},[{"#n":"$r2"}],"\n","^The crowd howls.","\n","^“Uh, really?” Humblebert says. “No one’s ever picked death before.”","\n","^“What can I say, I’m a trendsetter,” you say. “I’d be obliged if you could deliver my remains to my employers; they’re going to want to know who to sue for property damage.”","\n","^The crowd screams.","\n","^Humblebert’s voice drops from manic announcer to annoyed show-biz veteran, dropping off air and wrangling the talent. “Look, Captain Fatalism, this is a reality show. We don’t actually…I don’t come into work to kill people, okay? It’s all smoke and mirrors, right? And we have ratings to think about! So if you don’t mind just trying the first trial and then taking a dive there, if you really feel like you have to, it’d make my life a lot easier. And yours too. You don’t want to upset the channel execs. You really don’t.”","\n",[["ev",{"^->":"wakeywakeyeggsandfakey.0.deathandorglory.c-7.17.0.$r1"},{"temp=":"$r"},"str",{"->":".^.s"},[{"#n":"$r1"}],"/str","/ev",{"*":".^.^.c-0","flg":18},{"s":["^You smile. \"They'll live. And so will I. I'm leaving.\" ",{"->":"$r","var":true},null]}],["ev",{"^->":"wakeywakeyeggsandfakey.0.deathandorglory.c-7.17.1.$r1"},{"temp=":"$r"},"str",{"->":".^.s"},[{"#n":"$r1"}],"/str","/ev",{"*":".^.^.c-1","flg":18},{"s":["^You sigh. \"Well, alright. But if you’re going to go around kidnapping people you may as well really commit to the whole ‘death or glory’ thing. Make the stakes matter. Otherwise your audience won’t care what happens.\"",{"->":"$r","var":true},null]}],{"c-0":["ev",{"^->":"wakeywakeyeggsandfakey.0.deathandorglory.c-7.17.c-0.$r2"},"/ev",{"temp=":"$r"},{"->":".^.^.0.s"},[{"#n":"$r2"}],{"->":"Exitone_thepeaceout"},"\n",{"#f":5}],"c-1":["ev",{"^->":"wakeywakeyeggsandfakey.0.deathandorglory.c-7.17.c-1.$r2"},"/ev",{"temp=":"$r"},{"->":".^.^.1.s"},[{"#n":"$r2"}],"\n","^“Great, great. Wonderful!” Humblebert says. “Now if you could just say “Glory” on camera, that’d be…that’d be perfect.","\n","^“Uh, glory?” you say.","\n","^Humblebert sighs. “Good enough.” ",{"->":"TheGauntlet"},"\n",{"#f":5}]}],{"#f":5}],"#f":5}]}],{"#f":1}],"TheGauntlet":[["^Humblebert Maxibillion’s voice echoes sonorously as you follow the gently glowing path. “Our hero-to-be must face the Gauntlet, and its three onerous challenges: first, the Trial of Wit! Quick thought and cleverness will lead to victory—no brainless barbarians will be *our* heroes.","\n","^“Secondly, they will face the Trial of Daring! Courage and nerves of synthetic sapphire are required for this task—only the bravest survive.”","\n","^“And third, the most perilous of all, and the perennial favourite of our loyal fans—the Trial of Heroically-Avoiding-Being-Agonizingly-Disintegrated-While-Dodging-Plasma-Bombs! A very necessary quality for anyone who endeavors to attain the coveted title of Official Lucent Hero (tm).”","\n","^“And, needless to say, it’s hero or zero, so any failure on the part of our adventurer means—that’s right! THE OUBLIETTE!”","\n","^Excited cheers ring through the speakers.","\n","^“Oubliette,” you whisper to yourself. It’s fun to say. You doubt it’ll be fun to experience.","\n","^“So, contestant,” Maxibillion roars. “Are you ready to brave the Gauntlet and prove yourself…our HERO?”","\n",["ev",{"^->":"TheGauntlet.0.14.$r1"},{"temp=":"$r"},"str",{"->":".^.s"},[{"#n":"$r1"}],"/str","/ev",{"*":".^.^.c-0","flg":18},{"s":["^\"Bring it on!\" ",{"->":"$r","var":true},null]}],["ev",{"^->":"TheGauntlet.0.15.$r1"},{"temp=":"$r"},"str",{"->":".^.s"},[{"#n":"$r1"}],"/str","/ev",{"*":".^.^.c-1","flg":18},{"s":["^\"Fine. I guess. If I have to.\" ",{"->":"$r","var":true},null]}],["ev",{"^->":"TheGauntlet.0.16.$r1"},{"temp=":"$r"},"str",{"->":".^.s"},[{"#n":"$r1"}],"/str","/ev",{"*":".^.^.c-2","flg":18},{"s":["^\"...Don't I get a weapon or anything?\"",{"->":"$r","var":true},null]}],{"c-0":["ev",{"^->":"TheGauntlet.0.c-0.$r2"},"/ev",{"temp=":"$r"},{"->":".^.^.14.s"},[{"#n":"$r2"}],{"->":"Trial_of_Wit"},"\n",{"->":".^.^.g-0"},{"#f":5}],"c-1":["ev",{"^->":"TheGauntlet.0.c-1.$r2"},"/ev",{"temp=":"$r"},{"->":".^.^.15.s"},[{"#n":"$r2"}],{"->":"Trial_of_Wit"},"\n",{"->":".^.^.g-0"},{"#f":5}],"c-2":["ev",{"^->":"TheGauntlet.0.c-2.$r2"},"/ev",{"temp=":"$r"},{"->":".^.^.16.s"},[{"#n":"$r2"}],"\n",{"->":".^.^.g-0"},{"#f":5}],"g-0":["^“Nope!” Humblebert says. “A true hero requires no weapon but their heart.”","\n","^“I’d rather be a live coward with a mini railgun, thanks,” you say, but it’s clear that you don’t really have the choice.","\n",{"->":"Trial_of_Wit"},{"#f":5}]}],{"#f":1}],"Trial_of_Wit":[["^The path opens out into a large room, which would be empty if it weren’t mostly taken up by the enormous cybernetic beast blocking the clearly marked exit forward. Two giant paws, folded with incongruous daintiness below a human face, one clearly printed from an artist’s collection, flex, catching the light like knife blades.","\n","^“Welcome,” she says, voice low and purring, “to the Trial of Wit. I am the Snynx, Mistress of Riddles.”","\n","^“Don’t you mean the sphynx?” you say, before you can stop yourself.","\n","^The beast sighs loudly. “No. That name is a Miss Terri’s copyright. I’m off brand. Stop asking.”","\n","^You nod understandingly.","\n","^“I am the arbiter of the Trial of Wit,” the Snynx continues, long, rapier like claws tapping on the stone. “And you, my nosy hero to be, must answer me these riddles three, if you another day would see.”","\n","^“That’s not a riddle, is it?” you ask.","\n","^“No,” she says. “But this is:","\n","^I eat and eat,","\n","^But never sated,","\n","^A hunger here","\n","^From death created,","\n","^I have no mind,","\n","^I cannot feel,","\n","^Still, spaghetti is","\n","^my favourite meal.","\n","^What am I?”","\n","^“Ooh, that’s a tough one!” Humblebert crows. “Let’s hope it doesn’t make spaghetti out of them. It’s done in plenty in the past-a!”","\n","^An audible groan can be heard from the studio audience.","\n","^You shake your head, scrunch up your eyes, and blurt out an answer.","\n",["ev",{"^->":"Trial_of_Wit.0.40.$r1"},{"temp=":"$r"},"str",{"->":".^.s"},[{"#n":"$r1"}],"/str","/ev",{"*":".^.^.c-0","flg":18},{"s":["^\"Uh, um…a puppy? No! Wait…\" ",{"->":"$r","var":true},null]}],["ev",{"^->":"Trial_of_Wit.0.41.$r1"},{"temp=":"$r"},"str",{"->":".^.s"},[{"#n":"$r1"}],"/str","/ev",{"*":".^.^.c-1","flg":18},{"s":["^\"A black hole.\" ",{"->":"$r","var":true},null]}],["ev",{"^->":"Trial_of_Wit.0.42.$r1"},{"temp=":"$r"},"str",{"->":".^.s"},[{"#n":"$r1"}],"/str","/ev",{"*":".^.^.c-2","flg":18},{"s":["^\"A fungus.\" ",{"->":"$r","var":true},null]}],["ev",{"^->":"Trial_of_Wit.0.43.$r1"},{"temp=":"$r"},"str",{"->":".^.s"},[{"#n":"$r1"}],"/str","/ev",{"*":".^.^.c-3","flg":18},{"s":["^\"Oh, that's easy. It's me!\" ",{"->":"$r","var":true},null]}],["ev",{"^->":"Trial_of_Wit.0.44.$r1"},{"temp=":"$r"},"str",{"->":".^.s"},[{"#n":"$r1"}],"/str","/ev",{"*":".^.^.c-4","flg":18},{"s":["^\"A virus!\" ",{"->":"$r","var":true},null]}],["ev",{"^->":"Trial_of_Wit.0.45.$r1"},{"temp=":"$r"},"str",{"->":".^.s"},[{"#n":"$r1"}],"/str","/ev",{"*":".^.^.c-5","flg":18},{"s":["^\"I don't know that!\" ",{"->":"$r","var":true},null]}],{"c-0":["ev",{"^->":"Trial_of_Wit.0.c-0.$r2"},"/ev",{"temp=":"$r"},{"->":".^.^.40.s"},[{"#n":"$r2"}],{"->":"ExitThree_oublietteroulette"},"\n",{"->":".^.^.g-0"},{"#f":5}],"c-1":["ev",{"^->":"Trial_of_Wit.0.c-1.$r2"},"/ev",{"temp=":"$r"},{"->":".^.^.41.s"},[{"#n":"$r2"}],"\n",{"->":".^.^.g-0"},{"#f":5}],"c-2":["ev",{"^->":"Trial_of_Wit.0.c-2.$r2"},"/ev",{"temp=":"$r"},{"->":".^.^.42.s"},[{"#n":"$r2"}],{"->":"ExitThree_oublietteroulette"},"\n",{"->":".^.^.g-0"},{"#f":5}],"c-3":["ev",{"^->":"Trial_of_Wit.0.c-3.$r2"},"/ev",{"temp=":"$r"},{"->":".^.^.43.s"},[{"#n":"$r2"}],{"->":"ExitThree_oublietteroulette"},"\n",{"->":".^.^.g-0"},{"#f":5}],"c-4":["ev",{"^->":"Trial_of_Wit.0.c-4.$r2"},"/ev",{"temp=":"$r"},{"->":".^.^.44.s"},[{"#n":"$r2"}],{"->":"ExitThree_oublietteroulette"},"\n",{"->":".^.^.g-0"},{"#f":5}],"c-5":["ev",{"^->":"Trial_of_Wit.0.c-5.$r2"},"/ev",{"temp=":"$r"},{"->":".^.^.45.s"},[{"#n":"$r2"}],{"->":"ExitThree_oublietteroulette"},"\n",{"->":".^.^.g-0"},{"#f":5}],"g-0":["^The snynx smiles.","\n","^“You are…correct.”","\n","^“And that’s one riddle down, two to go. Will our intrepid contestant survive this test of wits? Tune in after the break and find out! I’m your host, Humblebert Maximillion, and this is…HERO or ZERO!”","\n","^“So…can I go get an xcaff or something, or…” you begin.","\n","^Laughter rings out of over the speakers. “No break for you, contestant! No, it’s life or death in Castle Victory! And you’ve got two and a half more trials to complete.”","\n","ev",{"CNT?":"wakeywakeyeggsandfakey.0.deathandorglory.c-7"},"!","/ev",{"*":".^.c-6","flg":9},"ev",{"CNT?":"wakeywakeyeggsandfakey.0.deathandorglory.c-7"},"/ev",{"*":".^.c-7","flg":9},{"c-6":[{"->":".^.^.^.^.Riddletwo"},"\n",{"->":".^.^.^.bit"},{"#f":5}],"c-7":[{"->":".^.^.^.bit"},"\n",{"->":".^.^.^.bit"},{"#f":5}],"#f":5}],"bit":["^You grin. \"Oh, come on. If you’re not going to kill me you can at least get me a drink.\"","\n","^\"Fine,\" Humblebert hisses. \"But only if you stop being a pain in my--\"","\n",{"->":".^.^.^.Riddletwo"},{"#f":5}]}],{"Riddletwo":[["^“The second riddle,” the snynx says imperturbably, “is as follows:","\n","^An atom? NAh.","\n","^It’s not my all,","\n","^Though crystalline,","\n","^And white, and small,","\n","^But sight’s not the sense,","\n","^Of who I am,","\n","^My taste—unmatched,","\n","^My match—a CLam!","\n","^What am I?”","\n",["ev",{"^->":"Trial_of_Wit.Riddletwo.0.20.$r1"},{"temp=":"$r"},"str",{"->":".^.s"},[{"#n":"$r1"}],"/str","/ev",{"*":".^.^.c-0","flg":18},{"s":["^\"Now that has to be cocaine.\" ",{"->":"$r","var":true},null]}],["ev",{"^->":"Trial_of_Wit.Riddletwo.0.21.$r1"},{"temp=":"$r"},"str",{"->":".^.s"},[{"#n":"$r1"}],"/str","/ev",{"*":".^.^.c-1","flg":18},{"s":["^\"Bacon!\" ",{"->":"$r","var":true},null]}],["ev",{"^->":"Trial_of_Wit.Riddletwo.0.22.$r1"},{"temp=":"$r"},"str",{"->":".^.s"},[{"#n":"$r1"}],"/str","/ev",{"*":".^.^.c-2","flg":18},{"s":["^\"Salt?\" ",{"->":"$r","var":true},null]}],["ev",{"^->":"Trial_of_Wit.Riddletwo.0.23.$r1"},{"temp=":"$r"},"str",{"->":".^.s"},[{"#n":"$r1"}],"/str","/ev",{"*":".^.^.c-3","flg":18},{"s":["^\"Star dust.\" ",{"->":"$r","var":true},null]}],["ev",{"^->":"Trial_of_Wit.Riddletwo.0.24.$r1"},{"temp=":"$r"},"str",{"->":".^.s"},[{"#n":"$r1"}],"/str","/ev",{"*":".^.^.c-4","flg":18},{"s":["^\"Elbruxian sticking powder?\" ",{"->":"$r","var":true},null]}],["ev",{"^->":"Trial_of_Wit.Riddletwo.0.25.$r1"},{"temp=":"$r"},"str",{"->":".^.s"},[{"#n":"$r1"}],"/str","/ev",{"*":".^.^.c-5","flg":18},{"s":["^\"...can I take a mulligan?\"",{"->":"$r","var":true},null]}],{"c-0":["ev",{"^->":"Trial_of_Wit.Riddletwo.0.c-0.$r2"},"/ev",{"temp=":"$r"},{"->":".^.^.20.s"},[{"#n":"$r2"}],{"->":"ExitThree_oublietteroulette"},"\n",{"->":".^.^.g-0"},{"#f":5}],"c-1":["ev",{"^->":"Trial_of_Wit.Riddletwo.0.c-1.$r2"},"/ev",{"temp=":"$r"},{"->":".^.^.21.s"},[{"#n":"$r2"}],{"->":"ExitThree_oublietteroulette"},"\n",{"->":".^.^.g-0"},{"#f":5}],"c-2":["ev",{"^->":"Trial_of_Wit.Riddletwo.0.c-2.$r2"},"/ev",{"temp=":"$r"},{"->":".^.^.22.s"},[{"#n":"$r2"}],"\n",{"->":".^.^.g-0"},{"#f":5}],"c-3":["ev",{"^->":"Trial_of_Wit.Riddletwo.0.c-3.$r2"},"/ev",{"temp=":"$r"},{"->":".^.^.23.s"},[{"#n":"$r2"}],{"->":"ExitThree_oublietteroulette"},"\n",{"->":".^.^.g-0"},{"#f":5}],"c-4":["ev",{"^->":"Trial_of_Wit.Riddletwo.0.c-4.$r2"},"/ev",{"temp=":"$r"},{"->":".^.^.24.s"},[{"#n":"$r2"}],{"->":"ExitThree_oublietteroulette"},"\n",{"->":".^.^.g-0"},{"#f":5}],"c-5":["ev",{"^->":"Trial_of_Wit.Riddletwo.0.c-5.$r2"},"/ev",{"temp=":"$r"},{"->":".^.^.25.s"},[{"#n":"$r2"}],{"->":"ExitThree_oublietteroulette"},"\n",{"->":".^.^.g-0"},{"#f":5}],"g-0":["^“Amazing! Our contestant has beat the first odds kindly provided by Diceroll Statistics Inc., proving their cleverness two for nil against the devious snynx! Will their wit be enough to see them through to the next challenge, or will our brave pilot perish in ignominy? Tune in after these words from our sponsors to find out!”","\n","ev",{"CNT?":"wakeywakeyeggsandfakey.0.deathandorglory.c-7"},"!","/ev",{"*":".^.c-6","flg":9},"ev",{"CNT?":"wakeywakeyeggsandfakey.0.deathandorglory.c-7"},"/ev",{"*":".^.c-7","flg":9},{"c-6":[{"->":".^.^.^.^.^.RiddleThree"},"\n",{"->":".^.^.^.bitsy"},{"#f":5}],"c-7":[{"->":".^.^.^.bitsy"},"\n",{"->":".^.^.^.bitsy"},{"#f":5}],"#f":5}],"bitsy":["^“Honestly, I’m a little offended that my potential death is being televised and you’re not even showing it live.”","\n","^“Don't be ridiculous. Real death is never cinematic enough for TV. That’s why we have editors.” ",{"->":".^.^.^.^.RiddleThree"},"\n",{"#f":5}]}],{"#f":1}],"RiddleThree":[[["^“So, who exactly are these sponsors?” you ask, and are, unsurprisingly, ignored.","\n","^“The third riddle is as follows,” the snynx says.","\n","^“Without a doubt, you’ve challenged me,","\n","^You’ve got a wit, or two, or three,","\n","^You think you’re smart—you’ve got it made,","\n","^Your thoughts are quick; your mind’s a blade,","\n","^You think this test will be a rout—","\n","^But answer this:","\n","^Which way is out?”","\n","^And as the snynx speaks, the room spins disorientingly, rolls and whirls, walls and floor and ceiling switching place like crossed roulette wheels, the clearly marked exit now decidely unclear.","\n","^“Oh, they look puzzled now! What’ll it be, contestant? Only one route leads forward. The rest are just a sudden sharp drop down five stories, to the very end of the story.”","\n","^You sit down, on the cold stone (ish) floor, and come up with a plan.","\n","ev","str","^-guess at random-","/str","/ev",{"*":".^.c-0","flg":20},"ev","str","^-take one last sip before splashing your XCAFF on the walls to see where it seeps through the gaps around the door-","/str",{"CNT?":"wakeywakeyeggsandfakey.0.deathandorglory.c-7"},"/ev",{"*":".^.c-1","flg":21},"ev","str","^-redefine the idea of ‘inside’ as being everything contained beyond the walls. You are now outside. The way ‘out’ is now...here.-","/str","/ev",{"*":".^.c-2","flg":20},"ev","str","^-Give up-","/str","/ev",{"*":".^.c-3","flg":20},{"c-0":["^ ","\n",["ev","visit",3,"seq","/ev","ev","du",0,"==","/ev",{"->":".^.s0","c":true},"ev","du",1,"==","/ev",{"->":".^.s1","c":true},"ev","du",2,"==","/ev",{"->":".^.s2","c":true},"nop",{"s0":["pop","^ ",{"->":"ExitThree_oublietteroulette"},{"->":".^.^.23"},null],"s1":["pop",{"->":"Trial_of_Wit.forth"},{"->":".^.^.23"},null],"s2":["pop",{"->":"ExitThree_oublietteroulette"},{"->":".^.^.23"},null],"#f":5}],"\n",{"#f":5}],"c-1":["^ ",{"->":".^.^.^.^.^.forth"},"\n",{"#f":5}],"c-2":["^ ",{"->":".^.^.^.^.^.forth"},"\n",{"#f":5}],"c-3":["^ ",{"->":"ExitThree_oublietteroulette"},"\n",{"#f":5}],"#f":5,"#n":"g-0"}],null],{"#f":1}],"forth":[["^To your eternal shock, the snynx listens to you answer…and nods.","\n","^“Congratulations,” she says, and removes two of her claws. She offers them to you. “You have chosen…wisely. Good luck, hero.”","\n","^The audience cheers.","\n","^“You’ve passed the Trial of Wit. You may continue.”","\n","^The door opens. You continue, snynx claws in hand.","\n","^“Incredible! Look at that! Our hero to be continues to defy all bets on their grizzly demise. I’ve got to say, I didn’t see that one coming. We’ve got a crafty one on our hands, eh? Alright. But cunning will only get you so far. Now, contestant, gird your loins and line your girdles, and get ready to face…the Trial of Daring!”","\n","^You enter a new chamber, a long, high hall lit only by the gently swaying illumination of very atmospheric biolanterns. You step forward gingerly, only to jump back as your foot meets nothing but empty air. Something whistles by in the space it had just been.","\n","^In the shifting dimness, you can just make out the dark lines of rope hanging above the pit.","\n","^“Daring. Courage. Bravery. Nerve,” Humblebert says, voice hushed and almost reverent. “The very essence of a hero. Now, contestant, your task lies before you. Cross the Chasm. Brave the void. If you dare.”","\n","^Someone in the studio audience begins a low chant, and it catches like brushfire in a parched forest.","\n","^Jump. Jump. Jump. Jump.","\n","^Jump.","\n","ev","str","^Attempt to cross using the available system of ropes and handholds","/str","/ev",{"*":".^.c-0","flg":20},"ev","str","^if variable geckogirl is true, as yet undefined:","/str","/ev",{"*":".^.c-1","flg":20},"ev","str","^Attempt to cross using the snynx claws as climbing tools.","/str","/ev",{"*":".^.c-2","flg":20},["ev",{"^->":"Trial_of_Wit.forth.0.42.$r1"},{"temp=":"$r"},"str",{"->":".^.s"},[{"#n":"$r1"}],"/str","/ev",{"*":".^.^.c-3","flg":18},{"s":["^\"Brave the void,\" you whisper, square your shoulders, and leap straight off the edge into the darkness below. ",{"->":"$r","var":true},null]}],{"c-0":["\n",{"->":".^.^.g-0"},{"#f":5}],"c-1":["^ You look down at your fingers, splayed and not quite human, remembering high walls and falling and genomes in beautiful sequence and the kind of hope that kills just as fast as it saves, and smile. ",{"->":".^.^.c-3.8.geckotime"},"\n",{"->":".^.^.g-0"},{"#f":5}],"c-2":["\n",{"->":".^.^.g-0"},{"#f":5}],"c-3":["ev",{"^->":"Trial_of_Wit.forth.0.c-3.$r2"},"/ev",{"temp=":"$r"},{"->":".^.^.42.s"},[{"#n":"$r2"}],{"->":"ExitFour_bouncehousestageleft"},"\n",[["^“I love you, gecko hands,” you whisper, as you crawl easily along the wall to the gasps and cheers of the studio audience.","\n","^“And our contestant has…lizard genes! Apparently!” Humblebert chirps. “Oh, that’s a twist I didn’t see coming. I guess you could say it’s…the anolest trick in the book! Well, I don’t suppose they’re going to have any problem with this challenge. But there’s still one more trial to go—and the most dangerous of all. Like they say in the pleasure houses of Anthrax 9—the best is yet to come!”","\n",{"->":".^.^.^.^.g-0"},{"#f":5,"#n":"geckotime"}],null],{"#f":5}],"g-0":["^It’s a struggle, but it works. Sweaty, scratched, and exhausted, you survive the Trial of Daring and make it to the other side.","\n","^“Oohh, we nearly lost ‘em a couple of times there, but look at that! They made it. What a heartstopping scene that was. But, of course, the show’s not over yet! One trial remains.”","\n","^Tentatively, you follow the glowing path as it leads you once more forward, into another large room, likewise decked out like the main hall of a castle.","\n","^Humblebert’s voice rings out once more.","\n","^“So far our would be hero has proved their wit, and their courage—but now it’s time for them to prove…their resistance to superheated plasma!”","\n","^An earthshattering scream of excitement erupts from the speakers. You look around for an exit, but it’s too late, the doors have sealed behind you and there’s no way forward.","\n","ev",{"CNT?":"wakeywakeyeggsandfakey.0.deathandorglory.c-7"},"!","/ev",{"*":".^.c-4","flg":9},"ev",{"CNT?":"wakeywakeyeggsandfakey.0.deathandorglory.c-7"},"/ev",{"*":".^.c-5","flg":9},{"c-4":[{"->":".^.^.^.bob.kill"},"\n",{"->":".^.^.^.bob"},{"#f":5}],"c-5":[{"->":".^.^.^.bob"},"\n",{"->":".^.^.^.bob"},{"#f":5}],"#f":5}],"bob":["^\"I thought you said you weren’t in the business of killing people!”","\n","^“I mean, we’re not in the business of killing people before they’ve provided full entertainment value,” Humblebert says. “I thought that was implied? Now don’t be a baby and a take a blast of superheated plasma to the face like true hero of the people. And don’t forget to smile!”","\n",{"->":".^.kill"},["^Slots in the walls open up, revealing the death machines within, and suddenly you’re running and dodging for your life.","\n","ev","str","^Keep dodging. Surely they can’t keep firing forever?","/str","/ev",{"*":".^.c-6","flg":20},"ev","str","^Find shelter","/str","/ev",{"*":".^.c-7","flg":20},["ev",{"^->":"Trial_of_Wit.forth.0.bob.kill.14.$r1"},{"temp=":"$r"},"str",{"->":".^.s"},[{"#n":"$r1"}],"/str",{"CNT?":".^.^.^.^.c-3.8.geckotime"},"/ev",{"*":".^.^.c-8","flg":19},{"s":["^Forward...is on fire. Left is also on fire. What was once right--extremely on fire. But up...up is safe. And you know you can make it up there, you mutant wonder, you. ",{"->":"$r","var":true},null]}],["ev",{"^->":"Trial_of_Wit.forth.0.bob.kill.15.$r1"},{"temp=":"$r"},"str",{"->":".^.s"},[{"#n":"$r1"}],"/str",{"CNT?":"ship"},"/ev",{"*":".^.^.c-9","flg":19},{"s":["^This, as you quite firmly told Humblebarf-Maximurderous-Soon-To-Be-Maximurdered on your abduction, is not a castle. It's a debting spaceship. It's designed to protect your frail meatball body. You grin, and activate the personnel shields. ",{"->":"$r","var":true},null]}],{"c-6":["^ ",{"->":"Exit_DeathMKI"},"\n",{"#f":5}],"c-7":["^ ",{"->":"Exit_DeathMKII"},"\n",{"#f":5}],"c-8":["ev",{"^->":"Trial_of_Wit.forth.0.bob.kill.c-8.$r2"},"/ev",{"temp=":"$r"},{"->":".^.^.14.s"},[{"#n":"$r2"}],"\n","^“I love you, gecko hands,” you whisper, as you hang by your mutant grip cells from the high ceilings, safely away from any stray plasma blasts. Below you, everything burns. ",{"->":"ExitFive_Victory"},"\n",{"#f":5}],"c-9":["ev",{"^->":"Trial_of_Wit.forth.0.bob.kill.c-9.$r2"},"/ev",{"temp=":"$r"},{"->":".^.^.15.s"},[{"#n":"$r2"}],{"->":"ExitFive_Victory"},"\n",{"#f":5}],"#f":5,"#n":"kill"}],{"#f":5}]}],{"#f":1}],"#f":1}],"Exitone_thepeaceout":["^By dint of your swift feet, the complete lack of pursuit, and a loud warning cry of ‘stranger danger!’, you escape the odd young man, and go on with your adventurer’s life.","\n","^The only lasting impact of the encounter is the occasional passing thought on a sleepless night: “I wonder where the weirdo got that amazing cape?”","\n","^Just another little mystery for the filing cabinet. ","done","\n",{"#f":1}],"ExitTwo_givemedeathorgivemedeath":["^Humblebert pauses. “Well, I can’t really—oh, forget it. Just leave! You’re terrible on camera anyway. I’ve got better prospects three spreadsheets deep!”","\n","^A door opens to your left, leading back out into the busy spaceport you were abducted from. As you duck through it and head down the ladder, you can hear Humblebert yelling after you, “And don’t think I’m not showing this tape to the execs, you, you…aggressively independent person! I will! And they’re not going to be happy…”","\n","^You don’t think much of it. You have *real* trials to face, out there in the galactic maelstrom. And you really should be getting to them. ","done","\n",{"#f":1}],"ExitThree_oublietteroulette":["^“You are…incorrect,” the snynx says.","\n","^“INCORRECT,” Humblebert half-screams. The speakers pop and crackle. “You know what that means, folks! It’s time for…THE OUBLIETTE!”","\n","^And with that, the floor opens beneath you and all of a sudden you’re falling, all your life saving gadgets long gone and years of your past flashing past before your eyes, heart pounding to break your ribs and lungs gasping for the last air you’ll ever get before—","\n","^Everything stops. You stare, amazed to find yourself here, now, and wonderfully, gloriously alive. Even the scruffy, dark clothed man standing over you looks beautiful to your miraculously saved eyes.","\n","^“The snynx stumped you, eh?” he says, scratching his chin. “Well, it’s alright. She gets most of you. I told HB that he should make the riddles easier, you know, for the show, but who listens to me? I’m just a techie.” He looks at you. “Don’t look so scared, kid. The big bad Oubliette is just an airlock with a holofilter to make it feel like you’re falling fifty feet instead of two. [laughs] Got you good, didn’t it? Bit rough on the ol’ equilibrium, but the screaming always goes over well with the fans. I mean, for all they know, you’re bleeding out on a pit of spikes right now. Bloodthirsty, the public is. Absolutely bloodthirsty. But there it is. Good try. Oh, and if you want your compensation, you need to go visit the Unexpected Employee Liason office on Durant 3. Fill out forms 2a, 6x, and they’ll need a blood sample or three.”","\n","^“Thanks?” you manage, and scramble up and out into the spaceport before anyone else can decide you need to be part of their show. ","done","\n",{"#f":1}],"ExitFour_bouncehousestageleft":["^After falling for what seems like lightyears, you hit the ground with a light thump and nothing bruised but your pride.","\n","^“That was incredibly stupid,” a grizzled man in a dusty black shirt says, helping you to your feet. “I’ve never seen anything that stupid before. Really, I’m actually kind of impressed. Anyway, you lost! Too bad, very sad, you can claim your compensation at the Reality Conscription Offices on Datrik 6. Have a fantastic day!”","\n","^You wander off, slightly dazed, into the bustling spaceport from whence you began, not entirely sure what just happened and determined not to think too much about it.","\n","^You really, really, really need a drink. ","done","\n",{"#f":1}],"ExitFive_Victory":["^“Brilliant! Marvelous! Wonderful! Let’s have a hand for our new HERO, [Pilot’s name],” Humblebert Maxibillion’s voice announces, and then the man himself appears at last, dropping from above like an enormous bejeweled bat.","\n","^He beams, the expression so dazzling you can’t help but blink, and whips off his cloak. It flutters there, in a nonexistent breeze, catching the light like diamonds.","\n","^“Here,” he says. “I present to you, the Cape of Heroism, and the Official Irrevocable Title of Genuine Lucent Hero (tm). Use it wisely. And congratulations.”","\n","^As you tie the cape around your shoulders, the sound of cheering reaches you from worlds away, the people of Lucent’s empire sharing their adulation.","\n","^“Congratulations,” Humblebert says once more, shaking your hand. “And now…we send our hero back to [pronoun] life, secure in the knowledge that if we are ever in need…our tried and tested saviour will be ready.”","\n","^You open your mouth to speak, only to find yourself standing back in the spaceport from whence you began, your own trusty ship beside you and no Humblebert Maxibillion or dressed up castle ship in sight.","\n","^You turn, and as you do, your new cape catches the light like a swatch full of stars.","\n","^Hero, you think. Yeah. Yeah, I am. obtainspacecape ","done","\n",{"#f":1}],"Exit_DeathMKI":["^As it turns out, they can keep firing forever. Or, at least, much longer than you can keep running.","\n","^The last thing you remember is a strange sense of peace as your nerves burn away and then the world becomes just...nothing.","\n","^You wake up three days later in a Finch Medical regen-pod, all limbs and nerves attached and really wishing you didn’t remember how you ended up there.","\n","^You make a note in your journal. If the opportunity ever arises, Humblebert Maxibillion is toast. ","done","\n",{"#f":1}],"Exit_DeathMKII":["^Shockingly enough, reality game show sets don’t involve a lot of hiding places that are resistant to plasma arcing around at 28000K. You are positively incandescent to discover this. Luckily, the surprise doesn’t last long.","\n","^You wake up three days later in a Finch Medical regen-pod, all limbs and nerves attached and really wishing you didn’t remember how you ended up there.","\n","^You make a note in your journal. If the opportunity ever arises, Humblebert Maxibillion is toast. ","done","\n",{"#f":1}],"#f":1}],"listDefs":{}} \ No newline at end of file diff --git a/GameData/Narrative/Locations/TestA.json b/GameData/Narrative/Locations/TestA.json deleted file mode 100644 index dd822d57f..000000000 --- a/GameData/Narrative/Locations/TestA.json +++ /dev/null @@ -1 +0,0 @@ -{"inkVersion":20,"root":[[{"#":"constraint: DistanceFrom start = 0"},{"#":"type: Station"},{"#":"security: Open"},{"#":"faction: Miss Terri"},{"#":"name: Test Location 1"},{"#":"nameZone: Start Zone"},{"->":"start"},["done",{"#f":5,"#n":"g-0"}],null],"done",{"start":[["^This is the start of the location story.","\n","ev","str","^Go to location A1","/str","/ev",{"*":".^.c-0","flg":4},["ev",{"^->":"start.0.8.$r1"},{"temp=":"$r"},"str",{"->":".^.s"},[{"#n":"$r1"}],"/str","/ev",{"*":".^.^.c-1","flg":18},{"s":["^This will end the story.",{"->":"$r","var":true},null]}],{"c-0":["^ ",{"->":"A1"},"\n",{"->":".^.^.g-0"},{"#f":5}],"c-1":["ev",{"^->":"start.0.c-1.$r2"},"/ev",{"temp=":"$r"},{"->":".^.^.8.s"},[{"#n":"$r2"}],"\n",{"->":".^.^.g-0"},{"#f":5}],"g-0":["^They lived happily ever after.","\n","end",{"#f":5}]}],{"#f":1}],"A1":[["^This content is in location A1","\n","ev","str","^Return to start","/str","/ev",{"*":".^.c-0","flg":4},["ev",{"^->":"A1.0.8.$r1"},{"temp=":"$r"},"str",{"->":".^.s"},[{"#n":"$r1"}],"/str","/ev",{"*":"A1.0.c-1","flg":2},{"s":["^Choice in location A1 ",{"->":"$r","var":true},null]}],{"c-0":["^ ",{"->":"start"},"\n",{"#f":5}],"c-1":["ev",{"^->":"A1.0.c-1.$r2"},"/ev",{"temp=":"$r"},{"->":"A1.0.8.s"},[{"#n":"$r2"}],"end","\n",{"#f":5}]}],{"#f":1}],"#f":1}],"listDefs":{}} \ No newline at end of file diff --git a/GameData/Narrative/Quests/TestQuest.json b/GameData/Narrative/Quests/TestQuest.json deleted file mode 100644 index 41095e5f9..000000000 --- a/GameData/Narrative/Quests/TestQuest.json +++ /dev/null @@ -1 +0,0 @@ -{"inkVersion":20,"root":[[["done",{"#f":5,"#n":"g-0"}],null],"done",{"A1":[[{"#":"location: TestA"},{"#":"required"},"ev","str","^This choice is being injected from TestQuest","/str","/ev",{"*":".^.c-0","flg":4},{"c-0":["^ ",{"->":"A1A"},"\n",{"#f":5}]}],{"#f":1}],"A1A":[["^This is a branch thread being injected by TestQuest","\n","ev","str","^Exit the injected thread","/str","/ev",{"*":".^.c-0","flg":20},{"c-0":["^ ","end","\n",{"#f":5}]}],{"#f":1}],"#f":1}],"listDefs":{}} \ No newline at end of file diff --git a/Vault/Aetheria/scratch.md b/Vault/Aetheria/scratch.md deleted file mode 100644 index e69de29bb..000000000 From 6b222564ee42c4a497542b923def4371c76259b3 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 01:55:51 +0200 Subject: [PATCH 02/49] Add the operator checklist for merging fire-control-12 to master One file with the checks only the operator can do before the merge, folded into a single play session, with a map pointer on each line. It also lists what was already done, what is not part of this merge, the open questions, and the three uncommitted scene and input files to decide on. Co-Authored-By: Claude Opus 5.5 --- docs/merge-to-master-checklist.md | 113 ++++++++++++++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 docs/merge-to-master-checklist.md diff --git a/docs/merge-to-master-checklist.md b/docs/merge-to-master-checklist.md new file mode 100644 index 000000000..0848b4b2d --- /dev/null +++ b/docs/merge-to-master-checklist.md @@ -0,0 +1,113 @@ +# Aetheria merge checklist: `codex/fire-control-12` into `master` + +Compiled 2026-09-29 by Eyes from the maps on the branch. Pointers are `doc:line` in `docs/` at the branch tip. "Unrecorded" means the maps do not say the check was done. + +**What merging does.** `origin/master..codex/fire-control-12` is 257 commits. It carries the item-provenance work after 2026-09-17, stats-and-power (resolver, power bus, input capacitors, priority tiers, power-supply term and brownout, nominal requests, condition ratio, shield reserve, authored roles), fire-control Cuts 1-12.4(b), locomotion Cut 1 (restored hulls), the shield-panel interceptor, and the Addressables cuts. Self performs the git merge after you sign off. Nothing here is for the merge itself; it is what only you can check first. + +**Correction to the brief.** `codex/cultcache-cutover`, `codex/aetheria-state-rebuild` and `codex/item-provenance` are all ancestors of the branch tip, and the first two are already on `origin/master`. Master's merge commit `9b85211f` (2026-09-17) took cultcache-cutover and item-provenance at their 09-17 tips, after your play smoke passed (`cultcache-migration-cut.md:148`). Master has two commits the branch lacks (`9b85211f`, `263ac421`, which deletes dead narrative fixtures). `git merge-tree` of the two showed no conflicts. No `aetheria-state-rebuild` doc exists. + +## 1. Before you start + +- Open branch `codex/fire-control-12` at its tip, `4b594e116c4063ef5ee1dcb937bab0288008e63e` (2026-09-26, in sync with `origin/codex/fire-control-12`). +- Unity `6000.3.24f1` (`ProjectSettings/ProjectVersion.txt`). +- Batchmode: `tasklist` on 2026-09-29 showed Unity Hub and the licensing client but no `Unity.exe`, so none was running. Batchmode refuses an open editor and the maps say to close it and not kill anything (`item-provenance-cut.md:485,519`). Recheck when you start. +- The Unity manifest pins `cultmath-unity-v0.2.4` (`Packages/manifest.json`); the tag exists in CultLib. Unity needs network access to resolve it on first open (`fire-control-cut.md:2205-2212`). +- **Delete or move `GameData/run.cc` and `GameData/player.cc` before the first launch.** Both are git-ignored and stale (2026-09-22, 91,580 and 1,964 bytes). The maps' fresh-launch checks assume neither exists (`cultcache-migration-cut.md:2745`), and an old run store throws on the first lot lookup (`item-provenance-cut.md:595-596`). The persisted shape has changed since (power tier slot, fuse slot 32). + +**Your uncommitted work.** The merge happens in a separate worktree and does not touch it. `git status` lists 22 modified tracked files and 3 untracked. `git diff --stat` shows content changes in only 9; I infer the other 13 (materials, render textures) differ by line endings, from the LF/CRLF warnings. +- Content changes: `Asset Sources/Adrasteia1.blend`, `Car Paint White.mat`, `Steel.mat`, `Map Gravity Lines.mat`, `Minimap Gravity Lines.mat`, `LiberationSans SDF - Fallback.asset`, `Assets/Scenes/ARPG.unity` (+374), `Assets/Scenes/FieldShieldTest.unity` (+263), `Assets/Scripts/AetheriaInput.cs` (+40). +- Line-ending-only (inferred): the other Map/Sector `.mat` files, 8 `.renderTexture` files, `Stardust.mat`. +- Untracked: `Asset Sources/Adrasteia1_ContourRoute.blend`, `Ships.blend`, `adra1.png`. +- **Decide about three that belong to merged campaigns (my reading of the diffs):** + - `AetheriaInput.cs` (+40) adds the `Cycle Target Item` action (key J). The branch already has that action in `Assets/Resources/Aetheria.inputactions:72,326` and reads it in `ActionGameManager.cs:412`, but not in the generated wrapper. It is the wrapper catching up to committed data. Recommend including it. + - `FieldShieldTest.unity` (+263) adds `ShieldInterceptor`, `ShieldEnvelope`, a `Panel` with `Prototype` and `ShieldPanel`. This is the shield-panel Cut 4 rig you clicked together (`shield-panel-cut.md:840-846`). It is not on the branch. Without it the interceptor code merges with no rig. + - `ARPG.unity` (+374) adds "Debug Info Panel" and "Debug Info Text". `ActionGameManager.DebugInfoText` (`ActionGameManager.cs:151,1287,1333`) is the fire-control debug HUD and it returns early when null. Without the scene change the HUD "hull" factor in step 10 is invisible. + +## 2. One play session, in order + +### 2a. Fixtures to author first + +1. **Scratch catalog for 12.4.** Copy the catalog; do not edit `GameData/Aetheria.cc`. Author one weapon with `Fuse = Proximity` and a `BlastRadius`, and one with `Fuse = Delayed`, a `BlastRadius` and a penetration of about 2 cells (`fire-control-cut.md:3094,2656-2658`). Shipped penetration is at most 0.25 and enclosed cockpits need about 2 cells, so a penetrator authored from shipped values cannot reach the interior (`:3224-3225`). Fields are `aetheria.weaponitemdata` slot 29 (`BlastRadius`) and slot 32 (`Fuse`). Unrecorded: how to point the game at the scratch catalog, and which tool you author with (the map says only "Studio or `AetherDb`", `:2665`). +2. **Studio open, no game running.** You need it at 2c and 2d. Batchmode-based checks are the agent's, not yours. + +### 2b. Fresh launch (no `run.cc`, no `player.cc`) + +3. **Launch to main menu.** Expect no Addressables errors, and the Groups window shows `Assets/Content` under `Default Local Group` (`addressables-cut.md:301`). `player.cc` appears and Continue is disabled (`cultcache-migration-cut.md:2746`). Proves: Addressables Cut 1, CultCache cutover. The 09-17 smoke covered the last two on the old tree; this is a cheap recheck on the new schema. +4. **New Game (tutorial).** Expect the galaxy generates and the start ship is LonginusX (`settings-globals-cut.md:498`, an expectation, not a recorded check for this stack). Expect a Zenith station with a docking bay in the entrance zone every time (`locomotion-cut.md:375`). Proves: locomotion Cut 1 station rule. +5. **Addressables, "Use Asset Database".** Ships, turret and Zenith spawn; thruster and aether-drive particles show; fire an instant weapon (Autocannon) and a charged one (ChargeBlast SG); action bar icons show (`addressables-cut.md:362-366`). Proves: Addressables Cut 2. + +### 2c. Flight, power and economy + +6. **Provenance in play.** Starting-ship items show tier colour; the properties panel shows Manufacturer, product name and flavour for branded gear. Dock and buy: the brand still shows in cargo. `give Lamp` (any design name): no Manufacturer row. Kill an NPC and pick up loot: brand shown (`item-provenance-cut.md:528-539`). Proves: item-provenance Cut C. +7. **Fly a restored hull (Longinus or Djinni).** Thrusters move and turn it (`locomotion-cut.md:501`). Watch the speed cap: `VelocityLimit` is gated by the hull's `Active`, and a hull taken offline with thrusters firing loses the cap (`:377`). It did not happen in 300 s on shipped settings; report if you see runaway speed. Unrecorded: how to spawn a restored hull in play (the map names no path). +8. **Weak reactor (O2).** Fly with a reactor too small for the loadout. Expect visible, legible starvation rather than a cooked reactor (`stats-and-power-cut.md:705-706`). Proves: power bus (map Cut 3). +9. **Brownout and refill (O6, O4).** Brownout reads as degradation, not breakage (`:774`, `:990`). A weapon refilling under brownout stutters instead of firing full rate and cooking the ship (`:729-730`). Proves: power-supply term, brownout, input capacitors. +10. **Memory across zones (O1).** Play across several zones with kills, open the Profiler or Task Manager, and confirm memory does not climb (`:672-673`, `:980-982`). Proves: stat resolver, the entity-leak fix (map Cut 2). Combine with steps 11 and 12. + +### 2d. Combat (against a LonginusX-class AI), then save and Studio + +11. **Fire-control Cut 1 arcs.** Side-mounted weapons still fire at targets abeam; a turret tracks all the way round; nothing fires through the hull (`fire-control-cut.md:397-398`). +12. **Fire-control Cut 3 and shots.** Shots show rolled impacts and misses; a miss reads as a near-miss, not a bug; damage matches the HUD; aiming at a revealed subsystem concentrates damage; kill time is in the same order as before (`:659-661`). Reveal and selection work, kills drop loot, pickup stores it, the tractor beam still pulls (`headless-playground-cut.md:789-794`). No crash or frozen hot ship when the target vanishes (the 2026-09-20 failure, `fire-control-cut.md:1464-1470`). Recheck after the fixes: unrecorded. +13. **12.2, bow then beam.** The HUD `hull` factor reads higher from the beam. Turn your armoured face into a slow missile and watch where it lands (`:2380-2383`). +14. **12.3, launcher into a bow, then a flank.** Use a GT 3K or pswarm launcher. The schematic display pulses the facing edge only (`:2519-2520`). +15. **12.4, scratch catalog.** Watch a proximity airburst beside a LonginusX and a delayed penetrator into its nose. Damage must land where the model shows the blast; this confirms the centre-of-mass anchor. Bow is already confirmed +y (`:3095-3097`, `:2028-2031`). Proves: 12.4(b) `Detonate`. +16. **Wormhole, save, Continue.** Take a wormhole, quit, Continue: same items, same tier and brand (`item-provenance-cut.md:536-537`). +17. **Die.** Continue is disabled and `run.cc` holds no ledger (`:540-541`). +18. **Shield panel, `FieldShieldTest`.** Follow the numbered look in `shield-panel-cut.md:683-700`. Nothing visible on load. Click the nose: a patch grows and the field ripple runs too. Click the flank: the panel lies flat, not tilted. Click the same spot twice: it strikes the same panel. Spam far-apart clicks: at most 12 panels, oldest replaced. Let them fade: none left. Then the Cut 5 payoff: strong hits dice the patch, and repeated hits on one spot break through on a later hit (`:737-739`). Proves: shield-panel Cuts 3, 4, 5. +19. **Studio click-through.** + - Open `Aetheria.cc`. The fire-control behaviour union renders (`fire-control-cut.md:503-506`). + - `LonginusX` schematic still underlays the shape drawer (`addressables-cut.md:370-371`). + - The restored hull records open (`locomotion-cut.md:501`; the map gives no checklist beyond "the Studio click-through"). + - The scratch catalog's new `Fuse` and `BlastRadius` fields edit (from 2a; no map line). + - Open `GameData/run.cc` after step 16. `aetheria.provenanceledger` renders `Lots` with `Attributed` origins and per-role lists. If Studio cannot draw an int-keyed dictionary of unions, that is a Studio defect to raise, not a schema change (`item-provenance-cut.md:541-544`). +20. **Addressables, "Use Existing Build".** Run *Build > New Build > Default Build Script*, then repeat step 5. This proves the player catalog carries GUID keys. Then *Analyze*, "Check Resources to Addressable Duplicate Dependencies", and record the result; 5 known leftovers are expected (`addressables-cut.md:367-370`). + +### 2e. Desk review (not play) + +21. **Role content (O7).** Review `docs/stats-power-cut7-roles.md`. Rows marked "generic, needs review" are arbitrary-seed, not brand identity, and no design yet has a second product with a raised role (`:156`, `:194-202`; `stats-and-power-cut.md:797-799`, `:991`). + +## 3. Feel calls during the session + +Only what the maps name. +- **Balance numbers.** `CommitHorizon` (first value 0.3 s), `SchematicCellSize` (0.35) and the targeting stat ranges are first guesses; the smoke is the arbiter (`fire-control-cut.md:664-665`, `:855-859`). `UnaidedAccuracy` is ruled "really, really bad" (`:253-259`); judge whether it reads as a last resort. +- **Arc rule (Q2).** Arcs apply to the player as well as the AI. The map says it is a real difficulty change to be felt in the smoke (`:836-841`). +- **AI holds fire more** once `pOnHull` prices shots. The map calls that tuning, not broken AI (`:1396-1400`). +- **Penetration retune (F12-2)** is a ruled follow-up, "Retuning penetration is a follow up" (`:3223-3229`). The map does not tie it to a smoke. The only link is 2a step 1: shipped penetrators cannot reach interiors, so step 15 needs the scratch value. +- **Locomotion Q6, yaw weight (w = 3 recommended).** The map says settle it in the Cut 1 play check (`locomotion-cut.md:372-373`, `:903-904`), but the weight belongs to Cut 3's solver, which has not landed. There is nothing to feel today. Ambiguity, not a merge gate. + +## 4. Already done: do not redo + +- Play smoke for cultcache-cutover plus item-provenance, 2026-09-17: new game, combat, loot, save and Continue with brand and quality intact, death clears the run, stance toggle, `FieldShieldTest` (`cultcache-migration-cut.md:148-153`). It ran on the old tree, so steps 3, 6, 16 and 17 are rechecks, not repeats of a gap. +- Studio click-through for the CultCache port, 2026-09-17: AmmoType refs repointed at `515859cf`, `dangling` at 0, schematic underlay restored and checked (`cultcache-migration-cut.md:161-165`). +- Bow is +y on the schematic, confirmed 2026-09-22 from the schematic images (`fire-control-cut.md:2028-2029`). The blast-anchor half is still open (step 15). +- Shield-panel play evidence, 2026-09-18, rigged scene: a panel spawns, is reused and spiderwebs; it shatters only near the rim (`shield-panel-cut.md:119-124`). Partial: the Cut 5 fixes landed after, so step 18 is still open. +- A play smoke on 2026-09-20 found the frozen-ship, no-hits and `LockWeapon` crash (`fire-control-cut.md:1464-1470`). That was a failure report, not a pass. Cuts 8 and 9 answered it and no recheck is recorded. +- Stats-and-power Cuts 0-1 landed with Soul verification and your re-author ruling (`stats-and-power-cut.md:79-99` onward). No operator check remains from them. + +## 5. Not part of this merge + +- Stats-and-power tier control in the schematic UI (O5) and the deletion of `IOrderedBehavior`/`SortPosition` (O3): no `PowerTier` under `Assets/Scripts/UI` at the tip, and `SortPosition`/`IOrderedBehavior` still exist in `Reactor.cs`, `Entity.cs` (my grep). `stats-and-power-cut.md:752-753`, `:985-989`. +- Stats-and-power UI reconciliation (O8: properties panel, trade menu, schematic HUD, Studio over a stat with terms): the map's Cut 8 has not landed; the branch's "Cut 8" commits are the condition ratio (`:819`, `:991-992`). +- Settings-into-globals: only Cut 0 (delete 5 dead assets, `ea8b420e`) landed. Its play smoke and Studio click-through wait for Cut 1 (`settings-globals-cut.md:498-511`). The map header still says nothing landed. +- Build delivery: the two commits are the map and a ruling (`build-delivery-cut.md:5`, operator checks `:252,324,360,404`). +- Locomotion Cuts 2-5, including Q6 and Q4 (`locomotion-cut.md:360,905`). +- `content-batch-one.md` is a spec that writes no catalog data (`:5-8`). +- Fire-control follow-ups F12-2 to F12-8 (`fire-control-cut.md:3223-3250`). + +## 6. Open questions still yours + +- **Q12-5 (not ruled):** should the HUD show which target items are exposed from the current bearing? Recommended as a follow-up after 12.3, on the target-item cycling UI (`fire-control-cut.md:1959`, `:3215-3218`). +- **Locomotion Q4:** heading versus aim; blocks Cut 5 only (`locomotion-cut.md:905-913`, `:372`). +- **Locomotion Q6:** the yaw weight; open, does not block (`:372-373`). +- **Defaults Self took in 12.4, which you may overrule** (`fire-control-cut.md:3208-3213`): a blast ignores `DamageSpread` and P sits on the centre lane; `IncomingHit` fires for the host of a contact or delayed hit only; the delayed fuse point clamps to the last reached cell's exit; `Detonate` runs a flat armour, pool, hull pass. +- **Stale headers, not open questions:** the fire-control 12.3 "pending ruling" at `:2581` was ruled at `:2588`; Q12-9 is ruled A (`:3171`); the shield-panel Q1-Q7 block (`shield-panel-cut.md:61`, `:800-`) was not updated after Cuts 1-5 landed, Q5 being ruled C (`:839-846`). + +## 7. What happens after you sign off + +- **Self merges `codex/fire-control-12` to `master` with `--no-ff`**, in a separate worktree. That single merge carries item-provenance, the stats-and-power work, fire control and locomotion Cut 1. The cultcache-cutover and item-provenance merge into master already happened on 2026-09-17 (`9b85211f`, `cultcache-migration-cut.md:148`), so the maps' "merge the two together" instruction (`item-provenance-cut.md:82`) is spent. The tree merges clean today. +- Any of section 1's three uncommitted files you want included need to be committed to the branch first, or they stay out. +- **Soul gates outstanding:** + - Fire-control 12.4 Soul passes: running tonight. The 12.4 section carries no "closed" status yet (`fire-control-cut.md:2602`). 12.0-12.3 are closed (`:2168-2210`, `:2253`, `:2385`, `:2522`). + - CultMath bounded least squares is locomotion Cut 2, not in this stack (`locomotion-cut.md:508`, `:360`). It gates nothing here. + - Unrecorded: a Unity batchmode compile at the tip `4b594e11`. 12.2 and 12.3 record clean compiles at their own heads. +- **Soul status at merge time: Self fills in.** From 8b4bf01eacd6f0e83c51d5a0adea60c190b7087e Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 03:38:14 +0200 Subject: [PATCH 03/49] Fire control 12.4 fix batch: tangent disc, candidate cull, lethal blast F1: RectDiskOverlap counted a whole square when a cell edge sat exactly r from the disc centre (`h < yhi`, `-h > ylo`). The comparisons are inclusive now, so the chord bounds the piece. A proximity burst on an integer centre of mass delivered 127.32 of 100 at BlastRadius 1 and 100.80 at 3. F2: delete Detonate's distance prefilter and the radius <= 0 guard. The prefilter measured from the centre of mass with the bounding box's half-diagonal, which is not a bound on how far a hull's cells reach; it culled real overlap on an L-shaped hull and on the shipped Longinus corners. RectDiskOverlap is the single decider. An untouched entity is still skipped (its covered list is never allocated). F3: Detonate iterates a snapshot of zone.Entities, as Zone.Update does. A lethal blast removed the entity from the live collection and the next MoveNext threw, leaving the shot pending so it detonated again the next tick. Tests: tangent discs through Fire and Detonate, an L-shaped hull, grazing blasts at the farthest corner of Longinus and LonginusX (expected values from a numeric overlap), and a lethal blast that must resolve once. Co-Authored-By: Claude Sonnet 5 --- Assets/Scripts/ServerShared/FireControl.cs | 35 ++-- .../FireControlCut124Tests.cs | 177 +++++++++++++++++- 2 files changed, 190 insertions(+), 22 deletions(-) diff --git a/Assets/Scripts/ServerShared/FireControl.cs b/Assets/Scripts/ServerShared/FireControl.cs index c76ffcc19..8e8cc53c4 100644 --- a/Assets/Scripts/ServerShared/FireControl.cs +++ b/Assets/Scripts/ServerShared/FireControl.cs @@ -1073,23 +1073,20 @@ public static class FireControl // DamageHull call. public static void Detonate(Zone zone, float2 worldPlanar, float radius, float damage, DamageType damageType) { - // F3 (Soul, Cut 12.3 fold-in): Detonate cannot be reached with a nonpositive radius. Fire only ever - // freezes a Fuse when BlastRadius > 0 (12.4(a)), so this guard matters only for a direct caller (a - // test, or Mine.Explode with a zero BlastRange). - if (radius <= 0f) return; - - foreach (var entity in zone.Entities) + // No distance prefilter: RectDiskOverlap is the one decider of whether the disc touches a hull. A + // prefilter measured from Position (the centre of mass) with the bounding box's half-diagonal is not a + // bound on how far a hull's cells reach from it -- it culled real overlap on an L-shaped hull and on the + // shipped Longinus corners. A nonpositive radius needs no guard either: the overlap of a disc with no + // area is zero for every cell, so nothing is covered and nothing is delivered. + // + // The pass runs over a snapshot: a lethal blast fires an entity's Death, which removes it from + // Zone.Entities, and the live enumeration threw on the next MoveNext (the same idiom Zone.Update uses). + foreach (var entity in zone.Entities.ToArray()) { var cellSize = entity.ItemManager.GameplaySettings.SchematicCellSize; var hullData = entity.ItemManager.GetData(entity.Hull) as HullData; var shape = hullData.Shape; - // Candidates: |Position.xz - P| <= radius + half the hull's own diagonal, in world units -- catches - // a long hull whose end lies inside the blast even though its centre does not. - var toEntity = length(entity.Position.xz - worldPlanar); - var halfDiagonal = .5f * length(float2(shape.Width, shape.Height)) * cellSize; - if (toEntity > radius + halfDiagonal) continue; - var centre = entity.ToSchematicPoint(worldPlanar); var rCells = radius / cellSize; var normaliser = PI * rCells * rCells; @@ -1097,17 +1094,18 @@ public static class FireControl // Shares, computed once, in hull Coordinates order -- the same order armour absorbs in below and // an item's pool deposits in. var coords = shape.Coordinates; - var covered = new List<(int2 Cell, float Share)>(); + List<(int2 Cell, float Share)> covered = null; var totalShare = 0f; for (var i = 0; i < coords.Length; i++) { var overlap = CircleSquareOverlap(centre, rCells, coords[i]); if (overlap <= 0f) continue; var share = damage * overlap / normaliser; - covered.Add((coords[i], share)); + (covered ??= new List<(int2 Cell, float Share)>()).Add((coords[i], share)); totalShare += share; } - if (covered.Count == 0) continue; + // An entity the disc does not touch has nothing to decide: no shield is consulted, nothing is dealt. + if (covered == null) continue; var shield = entity.Shield; var shieldActive = shield != null && shield.Item.Active.Value; @@ -1209,8 +1207,11 @@ public static class FireControl var mid = .5f * (a + b); var h = sqrt(max(0f, r * r - mid * mid)); - var upperIsDisc = h < yhi; - var lowerIsDisc = -h > ylo; + // Inclusive: a rectangle edge exactly tangent to the disc (h == yhi at a piece whose midpoint is 0) + // is the disc's own boundary, so the chord is the true bound there. A strict test sent that piece to + // the whole-rectangle branch and counted the square in full -- a boundary flip that creates damage. + var upperIsDisc = h <= yhi; + var lowerIsDisc = -h >= ylo; var upper = upperIsDisc ? h : yhi; var lower = lowerIsDisc ? -h : ylo; if (upper <= lower) continue; diff --git a/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs index 8460947c2..78001d914 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs @@ -90,12 +90,13 @@ public sealed class FireControlCut124Tests : IDisposable WeaponModifiers modifiers = WeaponModifiers.None, bool equipShield = false, float shieldCapacity = 1000f, bool shieldActive = true, int2? cockpitCell = null, float cockpitDurability = 1000f, - (string Name, Shape Shape, int2 Cell, float Durability)[] custom = null) + (string Name, Shape Shape, int2 Cell, float Durability)[] custom = null, + float accuracy = 1f, int2? gunCell = null) { var hullData = new HullData { Name = "Hull", HullType = HullType.Ship, Shape = hullShape, Durability = 1000000, Mass = 1000, Armor = 0, - Hardpoints = { new HardpointData { Type = HardpointType.Sensors, Position = new int2(0, 0), Shape = new Shape() } } + Hardpoints = { new HardpointData { Type = HardpointType.Sensors, Position = gunCell ?? new int2(0, 0), Shape = new Shape() } } }; var shooterHullData = new HullData { @@ -125,7 +126,7 @@ public sealed class FireControlCut124Tests : IDisposable { Name = "Targeting", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 1, MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, - Behaviors = { new TargetingSystemData { Accuracy = Constant(1f), Resolution = Constant(1000f), Precision = Constant(1000f), Tracking = Constant(1000000f) } } + Behaviors = { new TargetingSystemData { Accuracy = Constant(accuracy), Resolution = Constant(1000f), Precision = Constant(1000f), Tracking = Constant(1000000f) } } }); cache.Upsert(new GearData { @@ -191,7 +192,7 @@ public sealed class FireControlCut124Tests : IDisposable var target = new Ship(items, zone, new EquippableItem { Data = hullRef, Durability = 1000000, Lot = lot++ }, new EntitySettings()); var targetGun = MakeWeapon(lot++); - Assert.True(target.TryEquip(targetGun, new int2(0, 0))); + Assert.True(target.TryEquip(targetGun, gunCell ?? new int2(0, 0))); EquippedItem cockpit = null; if (cockpitCell != null) @@ -369,6 +370,171 @@ public sealed class FireControlCut124Tests : IDisposable Assert.True(e.Target.Hull.Durability < before, "the near end of a long hull must take damage even though its centre is far outside the radius"); } + // Per-cell armour large enough that ArmorAbsorb never depletes it: every ArmorDamage event then reports its + // cell's raw share, whether or not an item sits on the cell. + private static void ArmourEverything(Engagement e) + { + foreach (var c in e.HullData.Shape.Coordinates) { e.Target.Armor[c.x, c.y] = 1e6f; e.Target.MaxArmor[c.x, c.y] = 1e6f; } + } + + // The independent expectation: `damage` shared over a disc of radius rCells centred at schematic (cx,cy) in + // proportion to numeric overlap with each occupied cell. + private static double ExpectedDelivered(Shape shape, double cx, double cy, double rCells, double damage) + { + var overlap = 0.0; + foreach (var c in shape.Coordinates) overlap += DiscAreaInCell(cx, cy, rCells, c); + return damage * overlap / (Math.PI * rCells * rCells); + } + + // ==== 12.4 fix batch F1: a disc tangent to a cell edge must not count the whole square ==== + + // A cell edge exactly r from the centre, with the piece's midpoint at 0, used to fall through + // `h < yhi` / `-h > ylo` into the "whole rectangle" branch, so the square counted in full. BlastRadius 1 + // world unit is half a cell (edges at +-.5 from an integer centre of mass), 3 is one and a half (edges at + // +-.5 and +-1.5): both are tangent to cell edges. Measured through Fire before the fix: 127.32 and 100.80 + // delivered of 100. + [Theory] + [InlineData(1f)] + [InlineData(3f)] + public void ATangentDiscDeliversExactlyItsDamageThroughFire(float blastRadius) + { + var e = Build(TestSettings(), SolidShape(5, 5), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: blastRadius, damage: 100f); + ArmourEverything(e); + var total = 0f; + using (e.Target.ArmorDamage.Subscribe(x => total += x.damage)) + { + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + } + Assert.Equal(100f, total, 2); + } + + [Theory] + [InlineData(1f)] + [InlineData(3f)] + public void ATangentDiscDeliversExactlyItsDamageThroughDetonate(float radius) + { + var e = Build(TestSettings(), SolidShape(5, 5)); + ArmourEverything(e); + var total = 0f; + using (e.Target.ArmorDamage.Subscribe(x => total += x.damage)) + FireControl.Detonate(e.Zone, e.Target.Position.xz, radius, 100f, DamageType.Kinetic); + Assert.Equal(100f, total, 2); + } + + // ==== 12.4 fix batch F2: the exact overlap is the only decider of "the disc touches the hull" ==== + + // An L-shaped hull whose centre of mass sits far from the far end of its long arm. The blast lands on that + // arm's end, 14 world units from the centre of mass -- beyond radius + half the bounding box's diagonal + // measured from it, which the old candidate cull used, so the cull threw away a blast that overlaps a + // cell. Expected value: a numeric overlap of the cells, not the code under test. + [Fact] + public void ABlastAtTheFarEndOfAnLShapedHullLands() + { + var s = new Shape(10, 4); + foreach (var c in s.AllCoordinates) s[c] = c.x < 4 || c.y == 0; + var e = Build(TestSettings(), s); + ArmourEverything(e); + var com = e.HullData.Shape.CenterOfMass; + var p = float2(9.9f, 0f); + var expected = ExpectedDelivered(e.HullData.Shape, p.x, p.y, 1.0, 100.0); + Assert.True(expected > 10.0, $"fixture: the blast must overlap the hull, expected {expected}"); + + var total = 0f; + using (e.Target.ArmorDamage.Subscribe(x => total += x.damage)) + FireControl.Detonate(e.Zone, e.Target.Position.xz + (p - com) * 2f, 2f, 100f, DamageType.Kinetic); + + Assert.Equal(expected, total, 1); + } + + // The same rule on the shipped hulls: a blast just grazing the corner farthest from the centre of mass. The + // corners of the shipped Longinus and LonginusX reach past the old cull's box-half-diagonal bound. + [Theory] + [InlineData("Longinus")] + [InlineData("LonginusX")] + public void AGrazingBlastAtAShippedHullsFarthestCornerLands(string hullName) + { + Shape shape; + var cache = OpenReadOnlyRealCatalog(Path.Combine(FindRepoRoot(), "GameData", "Aetheria.cc"), out _); + try { shape = cache.GetByName(hullName).Shape; } + finally { cache.Dispose(); } + + var com = shape.CenterOfMass; + var far = float2.zero; + var best = -1f; + foreach (var c in shape.Coordinates) + foreach (var (dx, dy) in new[] { (-.5f, -.5f), (.5f, -.5f), (-.5f, .5f), (.5f, .5f) }) + { + var corner = float2(c.x + dx, c.y + dy); + var d = length(corner - com); + if (d > best) { best = d; far = corner; } + } + // One cell of radius, centred .9 cell outward of the corner: the disc reaches the corner by .1 cell. + var centre = far + normalize(far - com) * .9f; + + var e = Build(TestSettings(), shape, gunCell: shape.Coordinates[0]); + ArmourEverything(e); + var expected = ExpectedDelivered(shape, centre.x, centre.y, 1.0, 100.0); + Assert.True(expected > .05, $"fixture: the blast must graze the hull, expected {expected}"); + + var total = 0f; + using (e.Target.ArmorDamage.Subscribe(x => total += x.damage)) + FireControl.Detonate(e.Zone, e.Target.Position.xz + (centre - com) * 2f, 2f, 100f, DamageType.Kinetic); + + Assert.Equal(expected, total, 1); + } + + // ==== 12.4 fix batch F3: a lethal blast must not break the entity enumeration ==== + + private Ship AddShip(Engagement e, float3 position, float hullDurability, int lot) + { + var hullRef = e.Items.ItemData.RefOf(e.Items.ItemData.GetByName("Hull")); + var ship = new Ship(e.Items, e.Zone, new EquippableItem { Data = hullRef, Durability = hullDurability, Lot = lot }, new EntitySettings()); + var marker = new EquippableItem + { + Data = e.Items.ItemData.RefOf(e.Items.ItemData.GetByName("Marker")), Durability = 1000000, Lot = lot + 1 + }; + Assert.True(ship.TryEquip(marker, new int2(1, 1))); + e.Zone.Entities.Add(ship); + ship.Activate(); + ship.Position = position; + return ship; + } + + // A blast that kills an entity mid-pass: death removes it from Zone.Entities, and the next MoveNext of the + // live enumeration threw. The shot was never removed, so it detonated again the next tick (the probe saw + // 2.69 then 5.38 on the target). Pass: nothing throws, the shot resolves and leaves exactly once, and the + // target is damaged exactly once. + [Fact] + public void ALethalBlastResolvesOnceAndBreaksNothing() + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: 30f, damage: 100f); + var near = AddShip(e, e.Target.Position + float3(8, 0, 0), 1f, 300); + var far = AddShip(e, e.Shooter.Position + float3(-5000, 0, 0), 1f, 302); + + var resolved = 0; + var targetDamage = 0f; + using var r = e.Zone.ShotResolved.Subscribe(_ => resolved++); + using var a = e.Target.ArmorDamage.Subscribe(x => targetDamage += x.damage); + using var h = e.Target.HullDamage.Subscribe(x => targetDamage += x); + using var i = e.Target.ItemDamage.Subscribe(x => targetDamage += x.damage); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + + Assert.Empty(e.Zone.PendingShots); + Assert.Equal(1, resolved); + Assert.DoesNotContain(near, e.Zone.Entities); // fixture: the blast really was lethal to a ship mid-pass + Assert.Contains(far, e.Zone.Entities); + Assert.True(targetDamage > 0f, "fixture: the target must be inside the blast"); + var afterFirstTick = targetDamage; + + e.Zone.Update(.01f); + + Assert.Equal(1, resolved); + Assert.Equal(afterFirstTick, targetDamage, 4); + } + // ==== Absorption order ==== // ABlastIsAbsorbedThroughArmourFirstPerCell (replaces AbsorbSpendsArmourBeforeTheItemOnTheSameCell): a disc @@ -894,7 +1060,8 @@ public sealed class FireControlCut124Tests : IDisposable Assert.True(e.Target.Hull.Durability <= before); } - // Soul F3: Detonate cannot be reached with a nonpositive radius. + // Detonate has no radius guard: a nonpositive radius covers no cell (a disc with no area overlaps nothing), + // so it delivers nothing and touches no shield. [Theory] [InlineData(0f)] [InlineData(-5f)] From 76e98598b1dc75a089a1f17d52c1c961b010be40 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 03:48:29 +0200 Subject: [PATCH 04/49] Fire control 12.4 fix batch F4: tests that could not fail now can Each rule below was protected only by a test a mutant passed. New or strengthened tests, with the mutant each one kills: - AContactOrDelayedBlastOnAMissDoesNothing: ApplyBlastHit's miss guard. - ToWorldPointIsTheInverseOfToSchematicPointAtANonAxisFacing and AContactBlastLandsOnTheCellItHitAtANonAxisFacing: ToWorldPoint's right axis, asserted per cell at facing (2,1); TurningAfterCommit now compares per cell. - ProximityBurstPositionIsThePredictedInterceptForAMovingTarget: a moving target and an intercept solved in the test, not Durability <= before. - AnUntouchedEntitysShieldIsNotConsulted: Detonate's skip of untouched entities. - TheFusePointStaysOnMetalAcrossAConcaveGap: the delayed point clamps at the first gap (the lane's walk ends there). - ShieldPaysForWhatReachesIt asserts the reserve dropped by the covered share; AShieldRaisedBetweenCommitAndArrivalAbsorbsTheBlast and AShieldDroppedBetweenCommitAndArrivalDoesNotAbsorbTheBlast replace the outcome-flags-only check with the shield state flipped between commit and arrival. Co-Authored-By: Claude Sonnet 5 --- .../FireControlCut124Tests.cs | 273 ++++++++++++++++-- 1 file changed, 242 insertions(+), 31 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs index 78001d914..d976f1a1c 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs @@ -760,11 +760,40 @@ public sealed class FireControlCut124Tests : IDisposable var cellSize = e.Items.GameplaySettings.SchematicCellSize; var worldCentre = e.Target.Position.xz + float2(-2.5f * cellSize, 0); + Assert.True(e.Target.Shield.CanTakeHit(DamageType.Kinetic, 70f)); // fixture: the reserve is exactly full + Assert.False(e.Target.Shield.CanTakeHit(DamageType.Kinetic, 70.5f)); + // damage 100, half retained (50) -- 50 <= capacity 70, so a correct implementation absorbs it whole and // never breaks; a "charge the full 100" mutant would find 100 > 70 and break the shield instead. + var cellEvents = 0; + using var a = e.Target.ArmorDamage.Subscribe(_ => cellEvents++); FireControl.Detonate(e.Zone, worldCentre, .5f * cellSize, 100f, DamageType.Kinetic); Assert.False(e.Target.Shield.Broken); + Assert.Equal(0, cellEvents); // the shield took it all, nothing reached a cell + // The reserve dropped by the covered share and by nothing else: 70 - 50 = 20 left. It is read through the + // public query as a threshold probe, since no recharge runs between Detonate and here. + Assert.True(e.Target.Shield.CanTakeHit(DamageType.Kinetic, 19.9f)); + Assert.False(e.Target.Shield.CanTakeHit(DamageType.Kinetic, 20.1f)); + } + + // An entity the disc does not touch has nothing to decide: it is skipped whole, its active shield is not + // consulted and nothing is dealt. The blast lands 20 cells away from a shielded hull. + [Fact] + public void AnUntouchedEntitysShieldIsNotConsulted() + { + var e = Build(TestSettings(), SolidShape(5, 4), equipShield: true, shieldCapacity: 70f); + for (var i = 0; i < 30; i++) e.Zone.Update(.1f); + Assert.True(e.Target.Shield.CanTakeHit(DamageType.Kinetic, 70f)); + var events = 0; + using var a = e.Target.ArmorDamage.Subscribe(_ => events++); + using var h = e.Target.HullDamage.Subscribe(_ => events++); + + FireControl.Detonate(e.Zone, e.Target.Position.xz + float2(40f, 0f), 2f, 1000f, DamageType.Kinetic); + + Assert.Equal(0, events); + Assert.False(e.Target.Shield.Broken); + Assert.True(e.Target.Shield.CanTakeHit(DamageType.Kinetic, 70f)); } // ABlastShotsShieldIsDecidedAtDetonation (Q12-9 = A): Commit decides no shield for a shot that carries a @@ -792,6 +821,72 @@ public sealed class FireControlCut124Tests : IDisposable Assert.False(outcome.ShieldBroken); } + // The other half of Q12-9 = A: the shield's state at arrival, not at commit, decides. A contact shot in + // 2 s of flight commits 1.5 s in; the shield is flipped between commit and arrival, and the blast (radius + // 15 cells, so the whole 20-cell hull lies inside the disc and the covered share is exactly + // damage * 20 / (pi * 15^2)) must follow the state it finds. The reserve is read through the public + // CanTakeHit as a threshold probe: nothing recharges between Detonate and the read. + [Fact] + public void AShieldRaisedBetweenCommitAndArrivalAbsorbsTheBlast() + { + const float capacity = 1000f; + var share = 50f * 20f / (PI * 15f * 15f); + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 50, targetRange: 100, + fuse: WeaponFuse.Contact, blastRadius: 30f, damage: 50f, + equipShield: true, shieldCapacity: capacity, shieldActive: false); + ArmourEverything(e); + var cellDamage = 0f; + using var a = e.Target.ArmorDamage.Subscribe(x => cellDamage += x.damage); + using var h = e.Target.HullDamage.Subscribe(x => cellDamage += x); + ShotOutcome committed = null; + using var c = e.Zone.ShotCommitted.Subscribe(o => committed = o); + + var shotId = FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + while (!e.Zone.PendingShots.Any(s => s.ShotId == shotId && s.Committed)) e.Zone.Update(.05f); + Assert.True(committed.Hit); + Assert.False(committed.Shielded); + Assert.False(committed.ShieldBroken); + + e.Target.Shield.Item.Enabled.Value = true; // up before arrival, down at commit + for (var i = 0; i < 4; i++) e.Zone.Update(.05f); + Assert.True(e.Zone.PendingShots.Any(s => s.ShotId == shotId), "fixture: the shot must still be in flight"); + Assert.True(e.Target.Shield.CanTakeHit(DamageType.Kinetic, capacity - .1f), "fixture: the reserve must be full"); + while (e.Zone.PendingShots.Any(s => s.ShotId == shotId)) e.Zone.Update(.05f); + + Assert.Equal(0f, cellDamage); // the shield decided at arrival, so no cell took anything + Assert.False(e.Target.Shield.Broken); + Assert.True(e.Target.Shield.CanTakeHit(DamageType.Kinetic, capacity - share - .05f)); + Assert.False(e.Target.Shield.CanTakeHit(DamageType.Kinetic, capacity - share + .05f)); + } + + // And the reverse: up at commit, down at arrival -- the blast finds no shield and lands on the cells, and + // the reserve is not charged. + [Fact] + public void AShieldDroppedBetweenCommitAndArrivalDoesNotAbsorbTheBlast() + { + const float capacity = 1000f; + var share = 50f * 20f / (PI * 15f * 15f); + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 50, targetRange: 100, + fuse: WeaponFuse.Contact, blastRadius: 30f, damage: 50f, + equipShield: true, shieldCapacity: capacity); + ArmourEverything(e); + for (var i = 0; i < 30; i++) e.Zone.Update(.1f); + var armour = 0f; + using var a = e.Target.ArmorDamage.Subscribe(x => armour += x.damage); + ShotOutcome committed = null; + using var c = e.Zone.ShotCommitted.Subscribe(o => committed = o); + + var shotId = FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + while (!e.Zone.PendingShots.Any(s => s.ShotId == shotId && s.Committed)) e.Zone.Update(.05f); + Assert.False(committed.Shielded); + + e.Target.Shield.Item.Enabled.Value = false; + while (e.Zone.PendingShots.Any(s => s.ShotId == shotId)) e.Zone.Update(.05f); + + Assert.Equal(share, armour, 2); // the whole covered share landed on the cells + Assert.True(e.Target.Shield.CanTakeHit(DamageType.Kinetic, capacity - .1f)); // and the reserve was not charged + } + // ==== Fuses (through Fire, SchematicCellSize 2) ==== // PenetratorBurrowsBeforeItBursts, which pins Q12-8 in both halves. A 3x3 solid hull's only interior cell @@ -880,22 +975,135 @@ public sealed class FireControlCut124Tests : IDisposable Assert.Equal(expected, delivered, 0); } + // TheFusePointStaysOnMetal, the concave half: a 1x5 column with cell (0,2) missing. A lane's walk ends at + // its first gap, so the reached metal is cells (0,0) and (0,1) whatever the penetration, and the fuse point + // must clamp to the last reached cell's exit (schematic y 1.5, the metal's edge) rather than sit at + // entry + penetration inside the gap (y 2.4 at penetration 2.9) or on the far segment the walk never + // reached (y 2.7 at 3.2). Entries run from -.5, so at penetration 1.5 the point is y 1.0, inside cell (0,1), + // with the disc wholly on metal. + [Theory] + [InlineData(1.5f, 1.0)] + [InlineData(2.9f, 1.5)] + [InlineData(3.2f, 1.5)] + public void TheFusePointStaysOnMetalAcrossAConcaveGap(float penetration, double pointY) + { + var shape = SolidShape(1, 5); + shape[new int2(0, 2)] = false; + var e = Build(TestSettings(), shape, penetration: penetration, fuse: WeaponFuse.Delayed, blastRadius: .3f, damage: 100f); + var comX = e.HullData.Shape.CenterOfMass.x; + var rCells = .3 / e.Items.GameplaySettings.SchematicCellSize; + var expected = ExpectedDelivered(e.HullData.Shape, comX, pointY, rCells, 100.0); + + var before = e.Target.Hull.Durability; + ShotOutcome outcome = null; + for (var attempt = 0; attempt < 60 && (outcome == null || !outcome.Hit); attempt++) + { + using var s = e.Zone.ShotResolved.Subscribe(o => outcome = o); + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + } + Assert.NotNull(outcome); + Assert.True(outcome.Hit); + Assert.Equal(expected, before - e.Target.Hull.Durability, 0); + } + + // A contact or delayed blast needs a committed hit, exactly like a direct hit's own gate: on a miss nothing + // happens -- no blast, no IncomingHit. Accuracy 0 pins the roll to a miss. Kills: ApplyBlastHit without its + // `Outcome.Hit` guard, which would detonate at a point derived from a miss's empty geometry. + [Theory] + [InlineData(WeaponFuse.Contact)] + [InlineData(WeaponFuse.Delayed)] + public void AContactOrDelayedBlastOnAMissDoesNothing(WeaponFuse fuse) + { + var e = Build(TestSettings(), SolidShape(5, 4), fuse: fuse, blastRadius: 30f, penetration: 1f, damage: 500f, accuracy: 0f); + ArmourEverything(e); + var before = e.Target.Hull.Durability; + var events = 0; + using var a = e.Target.ArmorDamage.Subscribe(_ => events++); + using var h = e.Target.HullDamage.Subscribe(_ => events++); + using var i = e.Target.ItemDamage.Subscribe(_ => events++); + using var hit = e.Target.IncomingHit.Subscribe(_ => events++); + ShotOutcome outcome = null; + using var r = e.Zone.ShotResolved.Subscribe(o => outcome = o); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + + Assert.NotNull(outcome); + Assert.False(outcome.Hit); // fixture: a guaranteed miss + Assert.Empty(e.Zone.PendingShots); + Assert.Equal(0, events); + Assert.Equal(before, e.Target.Hull.Durability); + } + + // ToWorldPoint and ToSchematicPoint are inverses, and ToWorldPoint puts one cell to starboard of the centre + // of mass two world units along the starboard axis (right = (forward.y, -forward.x), spelled out here). At + // facing (0,1) a flipped right axis is still masked by a centre-lane point; (2,1) is not. + [Fact] + public void ToWorldPointIsTheInverseOfToSchematicPointAtANonAxisFacing() + { + var facing = normalize(float2(2, 1)); + var e = Build(TestSettings(), SolidShape(5, 4), targetFacing: facing); + var t = e.Target; + var com = e.HullData.Shape.CenterOfMass; + var starboard = float2(facing.y, -facing.x); + + var world = t.Position.xz + float2(7f, -3f); + var back = t.ToWorldPoint(t.ToSchematicPoint(world)); + Assert.Equal(world.x, back.x, 3); + Assert.Equal(world.y, back.y, 3); + + var oneToStarboard = t.ToWorldPoint(com + float2(1, 0)); + Assert.Equal(t.Position.x + 2f * starboard.x, oneToStarboard.x, 3); + Assert.Equal(t.Position.z + 2f * starboard.y, oneToStarboard.y, 3); + var oneToBow = t.ToWorldPoint(com + float2(0, 1)); + Assert.Equal(t.Position.x + 2f * facing.x, oneToBow.x, 3); + Assert.Equal(t.Position.z + 2f * facing.y, oneToBow.y, 3); + } + + // A contact blast on a hull at facing (2,1): the shot enters over the starboard side, off the hull's own + // centre line, so the blast point's lateral offset from the centre of mass is not zero and a flipped right + // axis in ToWorldPoint would mirror it onto the far side. Placement is asserted per cell: the impact cell + // takes a share, and no cell farther than one from it takes anything (radius .15 cells). Kills: a flip of + // ToWorldPoint's right axis, which the host's round trip would otherwise hide from every total. + [Fact] + public void AContactBlastLandsOnTheCellItHitAtANonAxisFacing() + { + var e = Build(TestSettings(), SolidShape(5, 4), targetFacing: float2(2, 1), fuse: WeaponFuse.Contact, blastRadius: .3f, damage: 100f); + ArmourEverything(e); + var deposits = new Dictionary(); + using var a = e.Target.ArmorDamage.Subscribe(x => deposits[x.pos] = (deposits.TryGetValue(x.pos, out var d) ? d : 0f) + x.damage); + ShotOutcome outcome = null; + for (var attempt = 0; attempt < 60 && (outcome == null || !outcome.Hit); attempt++) + { + using var s = e.Zone.ShotResolved.Subscribe(o => outcome = o); + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + } + Assert.NotNull(outcome); + Assert.True(outcome.Hit); + + Assert.True(deposits.ContainsKey(outcome.Cell), $"the impact cell {outcome.Cell} took nothing; damaged: {string.Join(",", deposits.Keys)}"); + foreach (var cell in deposits.Keys) + Assert.True(Math.Abs(cell.x - outcome.Cell.x) <= 1 && Math.Abs(cell.y - outcome.Cell.y) <= 1, + $"cell {cell} took damage far from the impact cell {outcome.Cell}"); + } + // TurningAfterCommitDoesNotMoveTheBlastOnItsHost: a contact-fuse shot. After commit and before arrival, the // host turns and moves. Pass: the host's per-cell damage equals that of the same shot with no turn. Kills: // taking P from the live bearing, or from Outcome.Cell's centre in place of the lane point. [Fact] public void TurningAfterCommitDoesNotMoveTheBlastOnItsHost() { - float TotalArmorDamage(Action afterCommit) + Dictionary ArmorDamageByCell(Action afterCommit) { var e = Build(TestSettings(commitHorizon: .5f), SolidShape(5, 4), velocity: 50, targetRange: 100, fuse: WeaponFuse.Contact, blastRadius: 3f, damage: 100f); foreach (var c in e.HullData.Shape.Coordinates) { e.Target.Armor[c.x, c.y] = 1000f; e.Target.MaxArmor[c.x, c.y] = 1000f; } - var total = 0f; - using var a = e.Target.ArmorDamage.Subscribe(x => total += x.damage); - using var h = e.Target.HullDamage.Subscribe(x => total += x); - using var i = e.Target.ItemDamage.Subscribe(x => total += x.damage); + // Per cell, not a total: a blast that lands on the wrong cells deals the same total. + var perCell = new Dictionary(); + using var a = e.Target.ArmorDamage.Subscribe(x => perCell[x.pos] = (perCell.TryGetValue(x.pos, out var d) ? d : 0f) + x.damage); var shotId = FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); // Step to just past commit (flight time 2s, commit horizon 0.5s -> commits at t ~= 1.5s). @@ -907,18 +1115,19 @@ public sealed class FireControlCut124Tests : IDisposable while (e.Zone.PendingShots.Any(s => s.ShotId == shotId)) e.Zone.Update(.05f); - return total; + return perCell; } - var noTurn = TotalArmorDamage(null); - var turned = TotalArmorDamage(e => + var noTurn = ArmorDamageByCell(null); + var turned = ArmorDamageByCell(e => { e.Target.Direction = normalize(float2(1, 0)); e.Target.Position += float3(50, 0, -30); }); - Assert.True(noTurn > 0f); - Assert.Equal(noTurn, turned, 0); + Assert.NotEmpty(noTurn); + Assert.Equal(noTurn.Keys.OrderBy(k => k.x).ThenBy(k => k.y), turned.Keys.OrderBy(k => k.x).ThenBy(k => k.y)); + foreach (var cell in noTurn.Keys) Assert.Equal(noTurn[cell], turned[cell], 2); } // LabelsDoNotDecideBehaviour: a weapon labelled Airburst with no fuse resolves as a direct hit, and a @@ -1031,33 +1240,35 @@ public sealed class FireControlCut124Tests : IDisposable } } - // Soul F2: no test pinned BurstPosition for a moving target. A proximity blast against a moving target - // detonates at the predicted intercept PredictedIntercept froze at Fire, not at the target's own arrival - // position. Kills: BurstPosition read from the target's live position instead of the frozen intercept. + // Fire freezes a proximity shot's burst point at the predicted intercept of a moving target -- where a shot + // of the weapon's own velocity, fired now, meets the target -- not at the target's position now. The + // expectation is solved here from first principles: |d + v t| = s t for the flight time t, planar, the + // shooter's own motion never fed in. Target 100 ahead, drifting (30,0) against a 50 unit/s shot: t = 2.5 s, + // so the burst sits 75 units to the side of where the target is at Fire. + // Kills: BurstPosition frozen at the target's current position (PredictedIntercept never consulted). [Fact] public void ProximityBurstPositionIsThePredictedInterceptForAMovingTarget() { - var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, targetRange: 200, + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 50, targetRange: 100, fuse: WeaponFuse.Proximity, blastRadius: 3f, damage: 50f); - // Give the target a lateral velocity right after Fire so it does not sit at its fire-time position by - // arrival, but the weapon's own Velocity (0, an instant hit) still froze the intercept as exactly the - // fire-time target position (PredictedIntercept's own fallback below the .01f velocity floor). + var v = float2(30f, 0f); + e.Target.Velocity = v; + + var s = e.Shooter.Position.xz; + var d = e.Target.Position.xz - s; + var a = dot(v, v) - 50f * 50f; + var b = 2f * dot(d, v); + var c = dot(d, d); + var t = (-b - sqrt(b * b - 4f * a * c)) / (2f * a); // a < 0: the positive root + Assert.Equal(2.5f, t, 3); + var expected = e.Target.Position.xz + v * t; + var shotId = FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); - var shot = e.Zone.PendingShots.Single(s => s.ShotId == shotId); - var frozenBurst = shot.BurstPosition; - Assert.Equal(e.Target.Position.x, frozenBurst.x, 1); - Assert.Equal(e.Target.Position.z, frozenBurst.z, 1); + var shot = e.Zone.PendingShots.Single(x => x.ShotId == shotId); - e.Target.Velocity = float2(30, 0); - var before = e.Target.Hull.Durability; - e.Zone.Update(1f); // move the target well away from the frozen burst position, then resolve - - // The blast still lands where PredictedIntercept froze it (the fire-time position), not where the - // target ended up -- so it still damages the target only because the target has not yet moved far - // enough at THIS shot's own (velocity-0, instant) arrival, which happens before the move above can - // matter: Zone.Update(1f) advances both the shot and the target in the same step, but the shot resolves - // using the frozen BurstPosition regardless of where the target is by then. - Assert.True(e.Target.Hull.Durability <= before); + Assert.Equal(expected.x, shot.BurstPosition.x, 2); + Assert.Equal(expected.y, shot.BurstPosition.z, 2); + Assert.True(length(shot.BurstPosition.xz - e.Target.Position.xz) > 70f, "the burst must lead the target, not sit on it"); } // Detonate has no radius guard: a nonpositive radius covers no cell (a disc with no area overlaps nothing), From fd792013582cb5a1f270479d5880ff6534ea81b1 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 04:17:43 +0200 Subject: [PATCH 05/49] Fire control 12.4 fix batch F5: measure the steady state, not the first window LaneAndSilhouetteAllocateNothingGivenAPooledBuffer failed in 1 of 12 full-suite runs (1232 bytes over 1000 Lane calls) and in 0 of 100 runs of the test alone or its class. The allocation is a one-off, not a per-call cost: under suite load the runtime's background tier-up is delayed by other tests' JIT activity, so first-use allocation lands inside the single measured window. The rule stays: Lane and buffered Silhouette allocate nothing. The test now measures up to 20 rounds of 1000 calls and requires the smallest round to be zero. A per-call allocation appears in every round and still fails it. Co-Authored-By: Claude Sonnet 5 --- .../FireControlCut12Tests.cs | 31 +++++++++++++------ 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/FireControlCut12Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut12Tests.cs index 964c2bf2e..30cf46356 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut12Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut12Tests.cs @@ -1352,22 +1352,35 @@ public sealed class FireControlCut12Tests : IDisposable for (var i = 0; i < 100; i++) FireControl.Lane(hull, bearing, sil.A, laneBuffer); // JIT warm-up for (var i = 0; i < 100; i++) FireControl.Silhouette(e.Target, hull, null, bearing, .5f, intervalBuffer); - var before = GC.GetAllocatedBytesForCurrentThread(); - for (var i = 0; i < 1000; i++) FireControl.Lane(hull, bearing, sil.A, laneBuffer); - var laneAllocated = GC.GetAllocatedBytesForCurrentThread() - before; - Assert.True(laneAllocated == 0, $"1000 Lane calls allocated {laneAllocated} bytes; \"never allocates\" is not true"); + // Steady state, not first-use: under a whole-suite run the runtime's background tier-up is delayed by the + // other tests' JIT activity, so a one-off allocation (measured: 1232 bytes over 1000 calls, in 1 of 12 + // full-suite runs and 0 of 100 isolated runs) can land inside a single window. That is a cost paid once. + // A per-call allocation, the regression this test guards, lands in EVERY round, so the smallest round + // over a bounded number of rounds is the steady-state allocation. + long SmallestRoundAllocation(Action round) + { + var smallest = long.MaxValue; + for (var r = 0; r < 20 && smallest != 0; r++) + { + var start = GC.GetAllocatedBytesForCurrentThread(); + round(); + smallest = Math.Min(smallest, GC.GetAllocatedBytesForCurrentThread() - start); + } + return smallest; + } - before = GC.GetAllocatedBytesForCurrentThread(); - for (var i = 0; i < 1000; i++) FireControl.Silhouette(e.Target, hull, null, bearing, .5f, intervalBuffer); - var silhouetteAllocated = GC.GetAllocatedBytesForCurrentThread() - before; - Assert.True(silhouetteAllocated == 0, $"1000 buffered Silhouette calls allocated {silhouetteAllocated} bytes"); + var laneAllocated = SmallestRoundAllocation(() => { for (var i = 0; i < 1000; i++) FireControl.Lane(hull, bearing, sil.A, laneBuffer); }); + Assert.True(laneAllocated == 0, $"1000 Lane calls allocated {laneAllocated} bytes in every round; \"never allocates\" is not true"); + + var silhouetteAllocated = SmallestRoundAllocation(() => { for (var i = 0; i < 1000; i++) FireControl.Silhouette(e.Target, hull, null, bearing, .5f, intervalBuffer); }); + Assert.True(silhouetteAllocated == 0, $"1000 buffered Silhouette calls allocated {silhouetteAllocated} bytes in every round"); // Recorded, not asserted zero: the full gated-in HitProbability call still allocates, from Entity's own // collection enumeration (GetBehavior over ReactiveCollection), not from anything this fix batch // touches. A loose upper bound catches a real regression (e.g. a reintroduced comparer wrapper) without // pretending Entity's own cost is Lane's or Silhouette's to fix here. for (var i = 0; i < 100; i++) FireControl.HitProbability(e.Weapon, e.Shooter, e.Target); - before = GC.GetAllocatedBytesForCurrentThread(); + var before = GC.GetAllocatedBytesForCurrentThread(); var sum = 0f; for (var i = 0; i < 1000; i++) sum += FireControl.HitProbability(e.Weapon, e.Shooter, e.Target); var hitProbabilityAllocated = GC.GetAllocatedBytesForCurrentThread() - before; From 4629bc6799c5fc7da8e9be47b0ec5129ed595bcd Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 07:36:04 +0200 Subject: [PATCH 06/49] Fire control 12.4 closing batch: Death fires once; pin the blast loop's multi-kill and untouched-entity cases Entity.Death, the owner of the stream, now ends after its first cause. A blast that destroyed a cockpit fired Death, which removed and deactivated the entity, and the blast's own DamageHull then raised a second Death to every subscriber but Zone's idempotent handler. Every damage path gets the guard because none of them raise Death themselves. New pins: one blast killing three ships (a break after the first kill survives the old suite), a shieldless ship outside the disc receiving nothing, a cockpit-kill blast and a direct hull kill each raising Death exactly once. Co-Authored-By: Claude Sonnet 5 --- Assets/Scripts/ServerShared/Entity.cs | 5 +- .../FireControlCut124Tests.cs | 94 +++++++++++++++++++ 2 files changed, 98 insertions(+), 1 deletion(-) diff --git a/Assets/Scripts/ServerShared/Entity.cs b/Assets/Scripts/ServerShared/Entity.cs index bccc990a3..6ad751e01 100644 --- a/Assets/Scripts/ServerShared/Entity.cs +++ b/Assets/Scripts/ServerShared/Entity.cs @@ -516,7 +516,10 @@ public abstract class Entity Death = HullDamage.Where(_ => Hull.Durability < .01f).Select(_ => CauseOfDeath.HullDestroyed) .Merge(HeatstrokeDeath.Select(_ => CauseOfDeath.Heatstroke)) .Merge(HypothermiaDeath.Select(_ => CauseOfDeath.Hypothermia)) - .Merge(ItemDestroyed.Where(i=>i.GetBehavior()!=null).Select(_ => CauseOfDeath.CockpitDestroyed)); + .Merge(ItemDestroyed.Where(i=>i.GetBehavior()!=null).Select(_ => CauseOfDeath.CockpitDestroyed)) + // Death is terminal: the first cause ends the stream, so no damage path (a blast whose cockpit kill is + // followed by its own hull damage, a second item, a heat tick) can raise it twice to any subscriber. + .Take(1); //CurrentSecurityLevel.Value = SecurityLevel.Open; } diff --git a/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs index d976f1a1c..bf5268f6e 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs @@ -535,6 +535,100 @@ public sealed class FireControlCut124Tests : IDisposable Assert.Equal(afterFirstTick, targetDamage, 4); } + // AOneBlastKillsEveryShipInsideIt: three ships, each dead to one hit, inside one blast. Each takes hull + // damage exactly once and dies exactly once, and the shot leaves the queue once. Kills: the snapshot loop + // breaking after the first kill (the other two would never be touched). + [Fact] + public void AOneBlastKillsEveryShipInsideIt() + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: 30f, damage: 100f); + var ships = new[] + { + AddShip(e, e.Target.Position + float3(8, 0, 0), 1f, 300), + AddShip(e, e.Target.Position + float3(-8, 0, 0), 1f, 302), + AddShip(e, e.Target.Position + float3(0, 0, 8), 1f, 304), + }; + var hullHits = new int[3]; + var deaths = new int[3]; + var subs = new List(); + for (var n = 0; n < 3; n++) + { + var k = n; + subs.Add(ships[k].HullDamage.Subscribe(_ => hullHits[k]++)); + subs.Add(ships[k].Death.Subscribe(_ => deaths[k]++)); + } + var resolved = 0; + subs.Add(e.Zone.ShotResolved.Subscribe(_ => resolved++)); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + foreach (var s in subs) s.Dispose(); + + Assert.Equal(new[] { 1, 1, 1 }, hullHits); + Assert.Equal(new[] { 1, 1, 1 }, deaths); + foreach (var s in ships) Assert.DoesNotContain(s, e.Zone.Entities); + Assert.Empty(e.Zone.PendingShots); + Assert.Equal(1, resolved); + } + + // AShieldlessShipOutsideTheDiscIsNotTouched: nothing of any kind reaches an entity the disc does not + // overlap. Kills: an uncovered entity falling through to the damage call (`covered ??= new List`). + [Fact] + public void AShieldlessShipOutsideTheDiscIsNotTouched() + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: 3f, damage: 100f); + var outside = AddShip(e, e.Target.Position + float3(60, 0, 0), 1f, 300); + var touched = 0; + using var a = outside.ArmorDamage.Subscribe(_ => touched++); + using var h = outside.HullDamage.Subscribe(_ => touched++); + using var i = outside.ItemDamage.Subscribe(_ => touched++); + var targetHit = 0f; + using var t = e.Target.HullDamage.Subscribe(x => targetHit += x); + var armourHit = 0f; + using var ta = e.Target.ArmorDamage.Subscribe(x => armourHit += x.damage); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + + Assert.Equal(0, touched); + Assert.Equal(1f, outside.Hull.Durability); + Assert.Contains(outside, e.Zone.Entities); + Assert.True(targetHit + armourHit > 0f, "fixture: the blast must land on the target"); + } + + // ADestroyedCockpitRaisesDeathOnce: the blast destroys the cockpit, Death removes and deactivates the + // entity, and the blast's own DamageHull then still runs; no subscriber sees a second Death. + [Fact] + public void ADestroyedCockpitRaisesDeathOnce() + { + var e = Build(TestSettings(), SolidShape(3, 3), cockpitCell: new int2(1, 1), cockpitDurability: 10f); + var deaths = new List(); + using var d = e.Target.Death.Subscribe(deaths.Add); + var hullEvents = 0; + using var h = e.Target.HullDamage.Subscribe(_ => hullEvents++); + + FireControl.Detonate(e.Zone, e.Target.Position.xz, .6f, 5000f, DamageType.Kinetic); + + Assert.Equal(new[] { CauseOfDeath.CockpitDestroyed }, deaths); + Assert.DoesNotContain(e.Target, e.Zone.Entities); + Assert.True(hullEvents >= 1, "fixture: the blast's hull damage still lands after the cockpit dies"); + } + + // ADirectHullKillRaisesDeathOnce: the non-blast path (Entity.DamageHull straight, as a projectile hit does) + // raises Death once, and further damage to the corpse does not raise it again. + [Fact] + public void ADirectHullKillRaisesDeathOnce() + { + var e = Build(TestSettings(), SolidShape(3, 3)); + var deaths = 0; + using var d = e.Target.Death.Subscribe(_ => deaths++); + + e.Target.DamageHull(2000000f); + e.Target.DamageHull(5f); + + Assert.Equal(1, deaths); + } + // ==== Absorption order ==== // ABlastIsAbsorbedThroughArmourFirstPerCell (replaces AbsorbSpendsArmourBeforeTheItemOnTheSameCell): a disc From c7e20f088a9ccb992204c47f60197d7b2d979251 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 07:54:46 +0200 Subject: [PATCH 07/49] Fire control 12.4 closing batch: the new tests must fail without crashing the run Assert.Contains on a Ship formats the whole entity graph on failure and overflowed the stack, aborting the test run instead of reporting red; the new tests check membership with a boolean and a message. The cockpit-kill test now gives the target a hull the blast also kills, the case where the double Death actually occurred. Co-Authored-By: Claude Sonnet 5 --- tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs index bf5268f6e..f3225336e 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs @@ -566,7 +566,7 @@ public sealed class FireControlCut124Tests : IDisposable Assert.Equal(new[] { 1, 1, 1 }, hullHits); Assert.Equal(new[] { 1, 1, 1 }, deaths); - foreach (var s in ships) Assert.DoesNotContain(s, e.Zone.Entities); + foreach (var s in ships) Assert.False(e.Zone.Entities.Contains(s), "a killed ship must have left the zone"); Assert.Empty(e.Zone.PendingShots); Assert.Equal(1, resolved); } @@ -592,7 +592,7 @@ public sealed class FireControlCut124Tests : IDisposable Assert.Equal(0, touched); Assert.Equal(1f, outside.Hull.Durability); - Assert.Contains(outside, e.Zone.Entities); + Assert.True(e.Zone.Entities.Contains(outside)); Assert.True(targetHit + armourHit > 0f, "fixture: the blast must land on the target"); } @@ -602,6 +602,7 @@ public sealed class FireControlCut124Tests : IDisposable public void ADestroyedCockpitRaisesDeathOnce() { var e = Build(TestSettings(), SolidShape(3, 3), cockpitCell: new int2(1, 1), cockpitDurability: 10f); + e.Target.Hull.Durability = 10f; // fragile enough that the blast's own hull damage is lethal too var deaths = new List(); using var d = e.Target.Death.Subscribe(deaths.Add); var hullEvents = 0; @@ -610,8 +611,9 @@ public sealed class FireControlCut124Tests : IDisposable FireControl.Detonate(e.Zone, e.Target.Position.xz, .6f, 5000f, DamageType.Kinetic); Assert.Equal(new[] { CauseOfDeath.CockpitDestroyed }, deaths); - Assert.DoesNotContain(e.Target, e.Zone.Entities); + Assert.False(e.Zone.Entities.Contains(e.Target), "the dead target must have left the zone"); Assert.True(hullEvents >= 1, "fixture: the blast's hull damage still lands after the cockpit dies"); + Assert.True(e.Target.Hull.Durability < .01f, "fixture: and that hull damage is itself lethal"); } // ADirectHullKillRaisesDeathOnce: the non-blast path (Entity.DamageHull straight, as a projectile hit does) From c50029d5117a33e1c4c3b2bde387d76459344b43 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 07:59:29 +0200 Subject: [PATCH 08/49] Record fire-control 12.4 closed, and the no-target fuse ruling it needs Co-Authored-By: Claude Opus 5.5 --- docs/fire-control-cut.md | 6586 +++++++++++++++++++------------------- 1 file changed, 3306 insertions(+), 3280 deletions(-) diff --git a/docs/fire-control-cut.md b/docs/fire-control-cut.md index 4d610f551..fe48b2c80 100644 --- a/docs/fire-control-cut.md +++ b/docs/fire-control-cut.md @@ -1,3280 +1,3306 @@ -# 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 `Tool` gear. -- **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 `TargetArmorInfoThreshold` and - `TargetGearInfoThreshold`**, 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 from `GameplaySettings`; an optional - per-hardpoint override; turrets 360. -- **R7. The simulation is 2D.** The 3D is set dressing. -- **R8. Unity weapon effects and `HullCollider` become presentation only.** Their - `Physics.*` queries stop deciding damage. -- **R9. Every weapon type in the catalog's history stays** - (`docs/headless-playground-cut.md` fork 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)**: - -1. `InstantWeapon.Execute` (`Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs:216-237`) - walks the burst and raises `OnFire` (`:228`), a parameterless `event Action` declared - at `:73`. It carries no outcome and nothing in `ServerShared` subscribes. -2. `Assets/Scripts/Gameplay/EntityInstance.cs:203-204` subscribes and calls - `InstantWeaponEffectManager.Fire(weapon, item, source, target)` - (`Assets/Scripts/Gameplay/Weapons/InstantWeaponEffectManager.cs:7`). The constant - family goes the same way at `:232-235`. -3. The manager spawns an effect and copies the weapon's resolved numbers onto it. - `ProjectileManager.cs:18-25` jitters the barrel forward vector by - `Random.Range(±Spread/2)` on all three Euler axes — that is the entire aiming model. -4. 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`, plus `Projectile.cs:114` for airburst. -5. On a hull collider it calls `HullCollider.SendHit` - (`Assets/Scripts/Gameplay/HullCollider.cs:18-30`), or `SendSplash` (`:32-41`). -6. `EntityInstance.cs:316-396` subscribes to those subjects, converts a UV texture - coordinate into schematic cells, expands by `DamageSpread`, marches a penetration ray, - and calls the local function `DamageSchematic` (`:277-314`), which is the only writer - of `Armor`, item `Durability` and `Hull.Durability` from combat. -7. Death: `EntityInstance.cs:405-439` subscribes to `HullDamage`, rolls loot with - `UnityEngine.Random`, and calls `entity.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`, a - `ReactiveProperty`. Cleared when the target leaves `VisibleEnemies` (`:196`, - `:222`). `TargetRange` is recomputed each tick at `:920`. -- `Entity.EntityInfoGathered` — `Entity.cs:57`, a `ReactiveDictionary` in - [0,1], accumulated with decay by `Sensor.Execute` - (`Assets/Scripts/ServerShared/Behaviors/Sensor.cs:152-184`). -- `TargetDetectionInfoThreshold` gates `VisibleEnemies` at `Entity.cs:232-246`. -- **`TargetArmorInfoThreshold` and `TargetGearInfoThreshold` are authored and read by no - code.** They are declared at `Assets/Scripts/ServerShared/Settings.cs:212-213` and - appear nowhere else in `Assets/Scripts` **(read)**. R5's reveal tiers are unwired - settings. -- `LockWeapon` — `Assets/Scripts/ServerShared/Behaviors/LockWeapon.cs`. Integrates a - 0..1 `_lock` scaled by `pow(EntityInfoGathered[target], SensorImpact)` (`:92`), gated by - a `LockAngle` cone off `Entity.LookDirection` (`:88-89`). `CanFire` (`:56`) requires - `_lock > .99f` and 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`. - `TurretControllerData` has no fields (`:15-25`). It writes `Entity.LookDirection` to a - first-order intercept (`:67-72`) and fires each weapon when - `dot(x.Direction, Entity.LookDirection) > .99f` (`:81-83`). -- `Sensor` — sensitivity, ping boost/energy/visibility/range/cooldown, all - `PerformanceStat` (`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|Aim` finds one `#region Targeting` - label at `ActionGameManager.cs:368` and 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 on `HardpointData`, `WeaponData`, or any effect.** - A per-hardpoint override attaches here, as catalog data already authored per hull. -- `ItemRotation` is four-way (`Assets/Scripts/ServerShared/Enums.cs:7`); - `Extensions.cs:135-166` gives `None` = forward, `Reversed` = aft, `Clockwise` = right, - `CounterClockwise` = left. `Agent.cs:72` reads `Clockwise` as right and `Thruster` uses - 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`) reads `Entity.HardpointTransforms`; the second - (`:37-38`) is the item-rotation fallback R6 wants. -- **`Entity.HardpointTransforms` (`Entity.cs:58-59`) is written only by Unity**, at - `EntityInstance.cs:514-517`, from the first `WeaponHardpoint.FiringPoint` barrel's - world forward. Headless it is empty. That single dictionary is why - `Combat.cs:100-102` throws `KeyNotFoundException` on 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 toward `LookAtPoint` - (`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 `Shape` and a cell size give an angular radius; `SchematicCellSize` - does 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.** `ConstantWeaponData` adds only `AmmoInterval` - (`ConstantWeapon.cs:15`); `Spread` is inherited and unread by any beam code, and - `Velocity` is 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.ChargeFiringSpreadMultiplier` is a plain `float`** - (`ChargedWeapon.cs:34`), not a `PerformanceStat`, 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, and `GuidedWeaponData` (`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)**: - -1. `Combat.cs:100-102` indexes `HardpointTransforms`, which only Unity writes (§0.3). - `Minion` enters `CombatState` as soon as `VisibleEnemies` is non-empty, so any NPC - that sees an enemy throws on the tick it sees them. -2. There is no owner between `InstantWeapon.OnFire` and `Entity.HullDamage`. The only - `ServerShared` writers 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`), 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`). | 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`. | 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 - -1. **Mount direction and arc become simulation data.** Deletes `HardpointTransforms` - and the Unity readback; adds the arc gate; unbreaks headless combat. -2. **Targeting system and reveal.** The new subsystem, the reveal rule, the selection - slot, and the two dead settings wired up. -3. **Hit authority moves.** The Physics path in the four effects the catalog actually - uses is deleted and `FireControl` plus the shot lifecycle replaces it, in one cut, so - damage is never applied twice. -4. **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`, branch `codex/fire-control` from `codex/item-provenance` - `8a00fc92`. Depends on nothing. -- **First:** update `F:\Projects\Aetheria-stats` to HEAD (it sits 11 commits behind at - `c4f6029e`); capture `AetherDb census`, `loadout 1`, `dangling` as 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 in - `Behavior.Direction` (6). The item-rotation path at `:37-38` becomes the whole body. -- `Assets/Scripts/ServerShared/Agents/States/Combat.cs:100-102` — the - `HardpointTransforms` dot test (3). -- `Assets/Scripts/ServerShared/Behaviors/TurretController.cs:81-83` — the `.99f` dot - 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 from - `Behavior.Direction`), `ArcFor(EquippedItem)` and - `bool InArc(EquippedItem weapon, float3 toTarget)`, which tests - `dot(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`, next `Key`) — full width in degrees, - `0` meaning "use `GameplaySettings.FiringArc`". -- `GameplaySettings.FiringArc = 120` (`Settings.cs`, after `:214`). -- `GameplaySettings.AgentMinHitProbability` is **not** added here; Cut 1's AI gate is - `InArc` alone, 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-98` stays — it still aims `LookDirection`, 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, whose - `Direction` never rotates (`TurretController` writes only `LookDirection`), would - engage only within 60 degrees of its spawn facing. R6 says turrets are 360; the turret - hull's hardpoints author `FiringArc: 360`. See Q1. -- Catalog: author `FiringArc` on the `Turret` hull's two Ballistic hardpoints. Follows - the Cut C precedent — a Hands scratch console upserts, Unity and Studio closed. - -**Authority map:** - -- **Owner:** `FireControl` owns whether a weapon bears on a point. `HardpointData` - authors the arc; `ItemRotation` authors 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 - by `FireControl.Fire`. -- **Derived state:** `Behavior.Direction` becomes purely derived from item rotation. -- **Forbidden writers:** no Unity transform, `ArticulationPoint`, `WeaponHardpoint` or - prefab node may influence aim. `Barrels`, `GetBarrel` and `ArticulationPoint` survive - as presentation and must never be read by `ServerShared`. -- **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:** `HardpointTransforms` and its writer and readers are gone before - `FireControl.cs` is written. - -**Verification:** - -- builds: `Aetheria.Shared/Aetheria.Shared.csproj`, `tests/Aetheria.Shared.Tests`, - `tools/AetherDb`. -- tests, each named with the rule it pins: - - `ArcFollowsMountRotation` pins *mount direction is the item's rotation, not the - hull's facing*: a fixture hull with one weapon on a `None` hardpoint and one on a - `Clockwise` hardpoint; a target 90 degrees to starboard passes only for the - `Clockwise` weapon, a target dead ahead only for the `None` weapon. Mutation: make - `InArc` use `Entity.Direction`; swap `Rotate`'s `Clockwise`/`CounterClockwise` cases. - - `ArcBoundaryIsHalfWidth` pins *`FiringArc` is full width*: at 120, a target 59 - degrees off passes and one at 61 does not. Mutation: compare against - `cos(radians(arc))`. - - `ArcIsPlanar` pins R7: a target inside the arc horizontally but far above the firer - still passes. Mutation: use the 3D vector. - - `HardpointOverrideBeatsDefault` pins *the per-hardpoint override wins*: the same - weapon on a hardpoint with `FiringArc: 360` passes at 170 degrees, and fails there on - a hardpoint with `0`. Mutation: ignore the field; treat `0` as an arc of zero. - - `CombatStateStepsHeadless` pins *the simulation decides aim without Unity*: a - `Minion` with a target in range steps through `Zone.Update` without throwing. - Mutation: restore the `HardpointTransforms` read. -- negative: `rg -n "HardpointTransforms" Assets/Scripts` empty; - `rg -n "ArticulationPoint|FiringPoint|WeaponHardpoint" Assets/Scripts/ServerShared` - empty; `rg -n "AgentFiringMinDot" Assets/Scripts` empty. (`\.99f` is **not** a safe - negative grep — it occurs legitimately in `LockWeapon.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 every `ItemData`. -- **First:** `AetherDb census` and `loadout 1` over 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 free `Union` index on `BehaviorData`; Hands - reads the current list in `Behaviors.cs` rather than trusting the number 39 quoted in - the superseded draft) plus a `TargetingSystem : Behavior` that 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 and `PowerSupply` reach them - through `EquippedItem.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 existing `EnergyDraw` and `Heat` behaviours 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 uses `Resolution = 1` and `Precision = 0`: it can still - select a revealed item, and its hits scatter. -- `FireControl.IsRevealed(Entity observer, EquippedItem item)` — derived, never cached: - 1. Rank the target's non-hull equipment: hardpoint-mounted first, then size - descending, then equipment index. - 2. Item `i` of `N` is revealed when - `info >= lerp(TargetArmorInfoThreshold, TargetGearInfoThreshold, i / max(1, N - 1))`. - - This is the first and only reader of `Settings.cs:212-213`. -- `Entity.TargetItem` (`ReactiveProperty`, beside `Target` at - `Entity.cs:46`) and `bool Entity.TrySelectTargetItem(EquippedItem)`, the only writer. - It accepts null, or an item of `Target.Value` that `IsRevealed`. A `Target` change - 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`, calling - `TrySelectTargetItem`. 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: `CombatState` selects the revealed target `Weapon` item with the highest - `RangeDamagePerSecond(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 `IsAvailable` finds one in every - galaxy. `LoadoutGenerator.FillInterior` picks one with `required: true` for every - entity with `Weapons` — the starting LonginusX, NPC ships and turrets. Zenith, with no - weapon hardpoints, gets none. - -**Authority map:** - -- **Owner:** `FireControl` owns the reveal rule. `Entity` owns the selection slot. - `TargetingSystem` owns 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 `EquippedItem` per 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 `TargetItem` except through - `TrySelectTargetItem`; no code may store a reveal result; no code may read the two - thresholds except `IsRevealed`. -- **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: - - `SelectionNeedsReveal` pins *you cannot aim at what you have not resolved*: below - the item's tier `TrySelectTargetItem` returns false; above it, true; a target change - nulls it; info decaying below the tier makes the read null. Mutation: `IsRevealed` - returns true; the `Target` change skips the null; the accessor returns the stored - value without re-checking. - - `RevealOrder` pins *the big obvious things resolve first*: hardpoint items reveal - before interior items, larger before smaller. Mutation: drop the ordering. - - `RevealSpansBothThresholds` pins *the tiers are the authored ones*: with N items, - the first reveals at `TargetArmorInfoThreshold` and the last at - `TargetGearInfoThreshold`. Mutation: swap them; use the detection threshold. - - `UnaidedFiresWorse` pins *the subsystem is what supplies accuracy*: an entity with - no targeting system resolves `UnaidedAccuracy` and `Precision` 0; one with a - destroyed or offline system resolves the same. Mutation: fall back to the system's - stats when offline. - - `StarvedTargetingRollsWorse` pins the brownout ruling on accuracy: with an - `Accuracy` stat carrying a `PowerSupply` term, a half-grant halves it rather than - switching the item off. Mutation: gate on `PowerSupply <= 1e-4`. - - `EveryArmedLoadoutGetsATargetingSystem` pins the required-item rule across seeds. - Mutation: drop `required: true`. -- negative: `rg -n "TargetArmorInfoThreshold|TargetGearInfoThreshold" Assets/Scripts` - returns `Settings.cs` and `FireControl.cs` **only**. -- operator: `AetherDb census` shows 2 more designs; `loadout 1` shows one more interior - item per armed entity; `dangling` is 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` — `DamageSchematic` and both - `HullCollider` subscriptions (120). -- `Assets/Scripts/Gameplay/HullCollider.cs:13-14,18-41,48-65` — `Hit`, `Splash`, - `SendHit`, `SendSplash` and 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 and - `SendHit` (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; the `LineRenderer` - endpoint writes at `:59,70,77` are presentation and are rebuilt from the outcome (36). -- `Assets/Scripts/Gameplay/Weapons/Lightning.cs:34-72` — the same; the endpoint capture - at `:67-71` stays as a visual (39). -- `Assets/Scripts/Gameplay/EntityInstance.cs:411-431` — the `UnityEngine.Random` loot - 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 from - `EntityInstance.cs:277-314`, now a method on `Entity` in `ServerShared`, writing - `Armor`, item `Durability`, `Hull.Durability` and raising the existing `ArmorDamage`, - `ItemDamage` and `HullDamage` subjects. Not a redesign: the same arithmetic, the same - order, the same thresholds. -- `Entity.ApplyHit(...)` — the shape construction moved from - `EntityInstance.cs:334-394`, with two changes forced by R7: the hit cell comes from the - rolled `Cell` rather 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.Direction` instead of `transform.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]`; a `LockWeapon` is not - locked; or `InArc` fails. - - Otherwise `p = Accuracy * pSensor * pSpread`, where - `pSensor = saturate(unlerp(TargetDetectionInfoThreshold, Resolution, info))` and - `pSpread = Spread > 0 ? saturate(angularRadius / (Spread / 2)) : 1`, with - `angularRadius = degrees(atan(0.5 * max(Shape.Width, Shape.Height) * SchematicCellSize / range))`. - - `SchematicCellSize` is the one new tuning constant (Q4). -- `FireControl.Fire(weapon, item, source)` — called once per burst step from - `InstantWeapon.Execute` (replacing the bare `OnFire?.Invoke()` at - `InstantWeapon.cs:228`). It computes the predicted intercept, the flight time - (`range / Velocity`, or zero when `Velocity` is at or near zero), **freezes the payload - snapshot** (R10) and appends a `PendingShot` to `Zone.PendingShots`. The snapshot holds - `Damage`, `Penetration`, `DamageSpread`, `DamageType`, `Aimed`, the resolved targeting - stats, and the intercept point. Nothing downstream re-evaluates a stat. -- `FireControl.Step(Zone, dt)` — called from `Zone.Update` after the entity loop - (`Zone.cs:150`). For each pending shot: age it; at `ArrivalTime - CommitHorizon` - compute deviation, roll, write `Outcome`, publish `Zone.ShotCommitted`; at - `ArrivalTime` apply the outcome and remove the shot. A shot whose flight is shorter - than the horizon commits at fire time, in the same call as `Fire` — 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: `pDeviation` folds in how far the target is from the predicted - intercept at commit time, forgiven by the targeting system's `Tracking`. 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 against `Precision` decides - where — on `Aimed`'s footprint cells on success, otherwise on one cell drawn uniformly - over the hull `Shape`. An active shield that `CanTakeHit` takes the hit instead, and - the outcome is marked `Shielded`; that rule now exists once, in `FireControl`, rather - than seven times in the effects. -- `Zone.ShotCommitted` and `Zone.ShotResolved` (`Subject`), the presentation - feed. -- `InstantWeapon.OnFire` becomes `event Action` carrying the `ShotId`, 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 into `Damage` and `Spread` before this point. -- `Combat.cs:100-102`: - `shouldFire = FireControl.HitProbability(...) >= Settings.AgentMinHitProbability`, - replacing Cut 1's bare `InArc` (which the probability already subsumes, returning zero - out of arc). `AgentMinHitProbability` is 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 the `ShotId` and looks the - outcome up, or subscribes to `ShotCommitted`. 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:** `FireControl` owns the engage gate, hit probability, the roll, the cell, the - shield branch, and when each of those happens. `Entity` owns the application of damage - to its own schematic. `Zone` owns the pending-shot collection. -- **Inputs:** weapon stats through `EquippedItem.Evaluate`; targeting-system stats; - `EntityInfoGathered`; `Target` and `TargetItem`; both entities' positions, velocities - and planar directions; mount direction and arc; `ItemManager.Random`. -- **Outputs:** `ShotOutcome` on two subjects; `Entity.DamageSchematic`; shield - `TakeHit`; `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 in - `Assets/Scripts/Gameplay/Weapons` (except the unused four Cut 4 handles) may reach - durability; `HullCollider` may not raise a hit; `EntityInstance` may not damage a - schematic; no effect may call `TakeHit`; nothing outside `FireControl` may 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.Fire` and `FireControl.Step`. - There is exactly one commit primitive. -- **Deletion line:** the six deletions above land before `FireControl.Fire` exists. - -**Verification:** - -- builds: `Aetheria.Shared`, `tests/Aetheria.Shared.Tests`, `tools/AetherDb`; and Unity - batchmode with the editor closed (`docs/item-provenance-cut.md` §6) reporting no - `error CS`. -- tests, each with the mutation that must kill it: - - | Test | Pins | Mutation | - |---|---|---| - | `RollsAreSeeded` | *a fight is reproducible* — two zones, same seed, 50 shots, identical outcome sequences | `FireControl` draws from `new Random()` | - | `ProbabilityFollowsInputs` | *the roll reads what the ruling says it reads* — zero below detection and outside range; rising with info to `Resolution`; falling with `Spread`; capped at `UnaidedAccuracy` with no system | drop `pSensor`; drop the range gate; drop the cap | - | `OutOfArcConsumesNoDraw` | *the gate is a gate, not a penalty* — the RNG sequence is unchanged by an out-of-arc trigger | roll first and multiply by zero | - | `ShotResolvesOnArrival` | R3 — no durability change before `range / Velocity`; a target removed mid-flight takes none | apply at fire time | - | `OutcomeCommitsBeforeImpact` | R4 — `ShotCommitted` fires exactly `CommitHorizon` before `ShotResolved`, once per shot | publish at arrival; publish twice | - | `EvasionCountsUntilCommitAndNotAfter` | R4'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 | - | `ShortFlightCommitsAtFire` | *the 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 | - | `OutcomeIsSnapshotNotReread` | R10, the seam the stats campaign named — a weapon whose `Damage` collapses (power cut, heat spike) between fire and arrival still lands the damage it rolled | re-evaluate `Damage` at arrival | - | `AimedHitLandsOnSelectedItem` | R5's payoff — `Precision` 1 and `p` 1: only the aimed item's durability falls | the roll ignores `Aimed` | - | `HardpointHitDamagesItemThenHull` | the 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 | - | `PenetrationMarchIsPlanar` | R7 — the march rotates by the target's planar direction and gives the same cells whatever the height difference | use a 3D rotation | - | `ShieldTakesHit` | *the absorb rule has one owner* — an active shield absorbs and the schematic is untouched | remove the shield branch | - | `DeadEntityStopsTakingShots` | *a 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-wide `Physics\.` grep is **not** valid until Cut 4, and never for - `TractorBeam.cs`, which stays.) - - `rg -n "SendHit|SendSplash|DamageSchematic" Assets/Scripts --glob '!**/ServerShared/**'` - — empty. - - `rg -n "TakeHit\(" Assets/Scripts --glob '!**/ServerShared/**'` — empty. Note - `CanTakeHit` shares the substring; the trailing `(` on `TakeHit` distinguishes them, - and the `Can` prefix 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 needs `AetherDb roles-migrate` as a dry run, which the permission classifier - blocked; the operator can unblock it or Hands can add a read-only `behaviors` command. - **If any of these is live, it moves into Cut 3 instead.** - -**Deletes first:** - -- `Assets/Scripts/Gameplay/Weapons/Projectile.cs:109-122` — airburst `OverlapSphere` and - `SendSplash` (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:72` and its `_hull` plumbing - (~8) — every remaining `Physics.*` hit query and `SendHit`/`SendSplash` call in - `Assets/Scripts/Gameplay/Weapons`. - -**Adds:** - -- `ConstantWeapon` rolls once per `GameplaySettings.BeamResolveInterval` for - `Damage * interval`, through the same `FireControl.Fire`/`Step` pair with a flight time - of zero, raising `OnBeamShot(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 existing - `DamageSchematic` over the directional half-shape the deleted - `EntityInstance.cs:318-329` computed, 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`, `TractorBeam` and 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: - - `BeamRollsPerInterval` pins *a beam is a sequence of rolls, not a continuous truth*: - over one second a beam produces `1 / BeamResolveInterval` outcomes totalling - `Damage`. Mutation: roll per tick; roll once. - - `SplashHitsEveryEntityInRadius` pins *splash is one rule, not per-effect*: two - targets in radius both take damage, one outside takes none. Mutation: damage only - the nearest. - - `SplashIsDirectional` pins 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/Weapons` returns **only** - `Mine.cs`'s placement query, if Q7 keeps it — otherwise empty. `TractorBeam.cs:21` is - outside this directory and stays. - `rg -n "SendHit|SendSplash" Assets/Scripts` empty. - `rg -n "class HullHitEventArgs|class HullSplashEventArgs" Assets/Scripts` empty. -- 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 - from `c4f6029e` to HEAD). Never the main tree — the operator plays there. Never - `F:\Projects\Aetheria-tiers`. CultLib via - `-p:CultLibRoot=\cultlib-45c2f40`, the shared pinned worktree, which is - never removed. `--artifacts-path` into scratch so no `bin`/`obj` lands 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 touches `Assets/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, run - `dotnet test tests/Aetheria.Shared.Tests --cultlib-root `, assert the named test - goes red, with a no-op control. Note the scar from `8dddda4d`: 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.ShotCommitted` and `Zone.ShotResolved` are the whole - contract. What a projectile, panel, whip or near-miss *does* with a commit belongs to - `docs/shield-presentation-contract.md` and the panel and Lariat maps. This campaign - guarantees only that the commit exists, is authoritative, arrives `CommitHorizon` - before 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 in `GameplaySettings`. -- **Death, removal and loot.** `Entity.Death` is already composed in `ServerShared` - (`Entity.cs:363-366`), but `Zone.Entities.Remove` and the loot roll live in Unity - (`EntityInstance.cs:411-437`) and use `UnityEngine.Random`. Fire control makes a - headless kill *happen*; it does not make the corpse go away. Q3. -- **Pickup.** `ShieldManager.cs:40-51` stores 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`, beside `UnaidedAccuracy`: add `public 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.Tracking` falls back to `settings.UnaidedTracking`, exactly as - `Accuracy` falls back to `UnaidedAccuracy`. One shape for both. -- `Commit` then 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`. - -- `ShotOutcome` gains `public bool ShieldBroken;`. -- `Commit` decides it where it already asks `CanTakeHit`: shield present, active, - and `CanTakeHit` false means `ShieldBroken = true`. The decision is frozen with - the rest of the outcome (R4) -- a shield that recharges during the flight does - not retroactively survive. -- `Apply` performs it: `if (shot.Outcome.ShieldBroken) shot.Target.Shield.Break();` - before the hull damage. `CanTakeHit` is 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`, matching `InstantWeapon.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. - -- `InArc` takes the rule: a planar bearing shorter than `1e-6` returns true -- - point-blank cannot fail a bearing test. -- `Weapon.ArcAllowsFire` drops its own `lengthsq` special case and simply calls - `InArc`. 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.IsRevealed` returns false for an item whose - `EquippableItem.Durability < .01f`, and the ranking excludes destroyed items so - the reveal tiers of the survivors close up rather than leaving a gap. -- `Entity.ResolvedTargetItem` therefore 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. - -- `Zone` subscribes each entity's `Death` observable when the entity joins, and - removes it from `Entities` on death. The simulation owns the corpse. -- `EntityInstance.cs:324` deletes its `Entities.Remove` call 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 `flightTime` stays; it still - computes `ArrivalTime` and `CommitTime`. - -### 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` -- delete `shield.Break()` from `Splash`. Must die. -- `Weapon.cs:117` -- `ArcAllowsFire => true`. Must die. -- `InstantWeapon.cs:105` -- delete the `if (!ArcAllowsFire) return;` player arc gate - (Q2, the ruling Cut 1 deferred to Cut 3 and nothing verifies). Must die. -- `FireControl.cs:270` -- `elapsed = shot.FlightTime` in place of `now - shot.FireTime`. - This is the map's own declared mutation for `EvasionCountsUntilCommitAndNotAfter`, - and it survives because that test's fixture gives the target zero velocity, so - `FireTargetVelocity * elapsed` is identically zero and the mutation is a no-op - inside it. Give the fixture a moving target. (After 5.7 deletes `FlightTime` the - mutation's spelling becomes `elapsed = 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. - -- `Zone` exposes `public 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. -- `Commit` stops touching `ItemManager.Random` entirely. It builds its own local - `CultMath.Random` from the zone seed and the shot's own id: - `var random = new Random((zone.CombatSeed * 2654435761u) ^ (uint) shot.ShotId | 1u);` - The `| 1u` guards 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: - -- `PendingShot` regains a burst point — `public float3 BurstPosition;` — frozen - at fire like every other payload field, from `PredictedIntercept` (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). -- `PendingShot` gains `public float BurstRadius;`, frozen from the weapon's - authored airburst range, and zero for a weapon without the `Airburst` flag - (`Enums.cs:101`). -- `FireControl.Fire` sets both when the weapon's `WeaponData` carries `Airburst`. -- `FireControl.Step`, at arrival, resolves an airburst shot by calling - `Splash(zone, shot.BurstPosition, shot.BurstRadius, shot.Damage, shot.DamageType)` - **instead of** `Apply`. 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.cs` deletes `AirburstDistance` and `AirburstRange` and the stale - comment; `ProjectileManager.cs:32` deletes 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. - -- `SameFightRollsSameThroughUnrelatedDraws` pins 6.1: run a fixed fight twice from - one zone seed, drawing an arbitrary number of values from `ItemManager.Random` - between the two runs, and the outcomes must be identical. Mutation: restore the - `ItemManager.Random` draw. Must die — and note that this is the test Soul's own - probe could not be, because a hermetic fixture cannot see the defect. -- `ShotIdDecidesTheDie` pins 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: drop `ShotId` from the seed. -- `AirburstSplashesAndDoesNotAlsoRoll` pins 6.2: an airburst shot damages - everything in its radius, and its target takes area damage rather than a - discrete cell hit. Mutation: call `Apply` as well as `Splash`. Must die. -- `NonAirburstNeverSplashes` pins the other side. Mutation: splash unconditionally. -- negative: `ItemManager.Random` appears nowhere in `FireControl.cs`; - `Airburst` appears nowhere in `Assets/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: - -- `HitProbability` derives the ceiling rather than reading it directly: - `var demandCeiling = detection + (1f - detection) / max(Resolution(source), 1e-3f);` - then `pSensor = saturate(unlerp(detection, demandCeiling, info))`. -- Resolution `1` therefore means "needs complete information before sensor data - stops penalising this system" -- which is precisely what the unaided fallback - of `1f` should 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 the `max`, 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-cell `4` (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: true` interior - 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, and `AetherDb - loadout` reports 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 higher - `HitProbability`, 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 loadout` across 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 raw `TargetItem.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 test` green, 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: - -```csharp -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 - `CenterOfMass` when 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`, joining - `UnaidedAccuracy` and `UnaidedTracking` in `GameplaySettings`) is a sigma wide - enough to read as spraying at the silhouette. -- **Weights.** `w(cell) = exp(-d² / 2σ²)` for each occupied hull cell, `d` the - 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. - -- `HitProbability` gains `pOnHull` as 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. -- `Commit` draws the cell as a weighted pick over the same `w(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 `Precision` values (`.05-.2` and - `.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 reciprocal `Resolution`. -- **Double-charging risk.** `HitProbability` already carries `pSpread`, which - penalises a small target against a wide weapon spread. `pOnHull` also - 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.cs` and `TurretController.cs` gate on - `AgentMinHitProbability` against this number, so NPCs will hold fire more than - they used to the moment `pOnHull` lands. 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`: `HitProbability` against 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: drop - `pOnHull` from the probability. Must die. -- `PlacementAndProbabilityShareOneKernel`: the empirical on-hull rate over many - seeded draws matches the `pOnHull` the 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 - through `AetheriaStores.Open`, enumerate every `EquippableItemData` so every - document type is actually deserialized (a lazy read that never touches - `WeaponItemData` would have passed on the broken merge), and generate a - loadout for `LonginusX`. 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();`, matching - `TryDock`'s existing pairing exactly. One transition, one owner. -- **Ordering risk to verify, not assume:** `EntityInstance` also subscribes to - `Entity.Death` (its loot drop and destroy effect, `EntityInstance.cs:300-325`). - Subscription order decides whether loot drops before or after `Deactivate`. - `Deactivate` does not touch `Equipment` or `CargoBays`, 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.Update` returns 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 further - `Update`, and a `LockWeapon` on a ship whose target dies the same frame does not - throw. Mutation: drop the `_active` guard. Must die. -- `DeathDeactivates`: a dead entity reports `IsActive == false` and holds no live - subscriptions. Mutation: remove the `Deactivate()` call. Must die. -- `LootStillDropsOnDeath` if 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.Random` a 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 - intermediate `p` (say .3 and .6) and assert the observed hit fraction is near - `p`, 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: 1` or `accuracy: 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 `pOnHull` monotonic 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, whenever `totalWeight == 0`, returning a fixed corner cell. - It is unreachable today only because `pOnHull == 0` forces `PBase == 0` first. - 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. - -- `Deactivate` nulls `Target` (and `TargetItem`), or `Activate` reconciles it - against the reseeded `EntityInfoGathered`. One of the two owns it; say which. -- Still not a `TryGetValue` in `LockWeapon`. The invariant is that an active - entity's `Target` is 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.sh` with 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; `dangling` 0. -- 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= CULTMATH_ROOT= dotnet stryker --since: - -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 - source` equals `target + source`, so four - separate position mutations survive (`FireControl.cs` HitProbability, Inspect, - Fire, Apply). -- **Fire time zero.** `now - FireTime` equals `now + FireTime`, so the elapsed-time - mutation survives -- which means `EvasionCountsUntilCommitAndNotAfter` still does - not defend the rule Cut 5 gave it a moving target to defend. -- **Spread zero everywhere.** `PSpread` returns 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 since `482c47bf`, 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.TakeHit` from 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; `TheDieIsUniform` checks uniformity within a zone only. Tighten it - enough that the `MixSeed` weakenings 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: - -- `Inspect` recomputed `PBase` as 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, so `PSpread` was 1). `PBase` is now - `HitProbability`'s own answer; the test became - `TheHudShowsTheFactorsTheSimulationMultiplies`, over a spread cone that does not - fill the silhouette. `Fire`'s redundant `target != null ?` guard around - `HitProbability` went with it. -- A beam shot's `damageOverride` could be dropped: `BeamRollsPerInterval` computed - the expected damage from its own roll count, a tautology, now deleted. - `ADamageOverrideIsWhatTheShotCarries`. -- `Tracking` could 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. -- *`MixSeed` shift 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 - nonzero `PBase` implies 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.cs` leads a moving target), L435 (three; the aimed flag on an outcome), - L464 and L465 negate (the `Apply` hit direction through the `FireControl` - path), 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. - - **Amended 2026-09-25, lane spacing.** Soul found that lanes one cell apart share cells at - angled bearings (a cell's shadow is up to √2 wide), so whichever lane went first ate the - shared armour and mirror-image shots did different damage in ~30% of angled spread hits. - Operator: "I would prefer if the overlapping damage cells were diffused sideways to - thicken the line rather than doubling up." Self's reading, confirmed ("nope, you got it"): - **lanes are spaced by one cell's shadow width at the committed bearing** (the `Extent` - width `2h`: 1 when axis-aligned, up to √2 at 45°). Each cell's admitted interval is exactly - that wide and half-open, so no two lanes can strike the same cell; the footprint widens at - angles instead of doubling up, and axis-aligned shots are unchanged. -- **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. (Superseded for items by the 2026-09-25 one-path - ruling: armour stays per cell, and an item pools its covered cells and resolves once. See 12.4.) - - **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.Splash` and 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). `ApplyHit` only walks when - `penetration > .5f` (`Entity.cs:392`), and `WeaponData.Penetration` is inspector-ranged - `0..1` (`Behaviors/Weapon.cs:28`). -- **LonginusX's cockpit cannot be reached edge-on until penetration reaches about two - cells.** **(probe)** `exposure.py` walked 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.Expand` is 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`: - every `WeaponItemData.AirburstRange` is null, and no `WeaponModifiers` includes 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 at `FireControl.cs:282`. -- **The handedness history.** `right = float2(forward.y, -forward.x)` has not changed since - `b7743789`/`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 at `Entity.cs:394-396` and `FireControl.cs:516-520`, and 12.1 - collapses the two copies. -- **The code has two cell conventions.** The kernel, `Shape.CenterOfMass` - (`ItemData.cs:121-123`) and `ResolveAimPoint` put cell *i* at the integer point *i*. - `ApplyHit` instead starts from `cell + .5` and 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 only `FireControl.cs:292` - (PendingShot, which stays), `:432`, `:435` and `:481`, plus `FireControlCut7Tests.cs:190` - (which also reads PendingShot). -- **`Apply` reads 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 by `PSpread`, and ApplyHit and Splash convert - directions only. A blast needs world↔schematic *points*, so 12.4 names that rule. - `HullCollider` is 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"}`, 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.BuildCompatibleResolutionResult` (`CultCache.cs:587-670`) runs `CompareSchemaShapes` - (`:672-752`), which compares slot type, reference, cardinality, target schema, name - semantics and index alias, **not the member name**. So renaming slot 29 opens as - `CompatibleDrift` with warnings. A new nullable slot opens with a `defaulted_missing_slot` - warning. The warnings persist until the catalog is next written with the new descriptor. - Readers of the field: `FireControl.cs:284` and `FireControlCut6Tests.cs:81-101, 249, 268, - 279`. `tools/AetherDb` has none. The `AirburstRange:` lines in nine weapon prefabs - (for example `Flak Cannon.prefab:65`) are orphaned YAML from a deleted MonoBehaviour field: - no C# field of that name exists outside `ItemData.cs:498`. Measured at `2625768f`: the catalog opens as - `CompatibleDrift`, with slot 32 reported as `defaulted_missing_slot`. One write through AetherDb or Studio clears the drift. - -### Frame, geometry and the bearing (the rules every sub-cut reads) - -- **Schematic frame, one owner.** `Entity.ToSchematic(float2 worldPlanar) → float2` maps - world to schematic with x = starboard and y = bow: `forward = normalize(Direction)`, - `right = float2(forward.y, -forward.x)`, returning `float2(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 forms `ToSchematicPoint(float2 worldPlanar) = - ToSchematic(worldPlanar - Position.xz) / SchematicCellSize + Shape.CenterOfMass` and its - inverse `ToWorldPoint`, 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 `+ .5` convention dies with ApplyHit. -- **Travel direction.** `FireControl.TravelDirection(Weapon, Entity source, Entity target) → - float2` is the planar world direction of `PredictedIntercept(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's - `Apply` fallback (`:463`). It belongs to the shot: Fire freezes it into `PendingShot`. -- **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)`, with - `h = (|ℓ.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)`** replaces `HullKernel` and is the one function. It returns the merged - intervals, `a = dot(aimPoint, ℓ)`, sigma, `Span = hi_max - lo_min`, and - `POnHull = Σ_k Φ((hi_k - a)/σ) - Φ((lo_k - a)/σ)`, where `Φ(z) = ½(1 + erf(z/√2))`. - `aimPoint` comes from `ResolveAimPoint` unchanged; only its lateral projection matters. - `Sigma` keeps 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 * cellSize` instead of `max(Width, Height)` (`:224`). PSpread and pOnHull read - one silhouette, so they cannot disagree about the target's size. -- **Lateral draw.** One `NextFloat` u, taken after the hit roll (the draw `WeightedPick` used - to take). Walk the cumulative interval mass to `u · POnHull` to select interval k, then - invert inside it with `s = 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 parameters `t` along b, where - `t = dot(point, b)`. Cells are ordered by entry, with ties broken by `dot(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.Shared`; netstandard2.1 has - `System.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 at `Entity.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 - `> .5f` threshold (`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.PBase` is renamed - `PFire` because its meaning changes: it is `Accuracy × PSensor` behind the same gates, and - it is 0 when any gate fails. Also frozen: `TravelDirection`, `Precision`, `Aimed`, the - weapon's `Spread`, and `FireRange` (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. `Commit` computes, in this order: - 1. `pDeviation`, as today. - 2. `bearing = normalize(shot.Target.ToSchematic(shot.TravelDirection))`, from the - target's facing at the commit tick. - 3. `sil = Silhouette(target, hull, shot.Aimed, bearing, shot.Precision)`. - 4. `p = PFire × pDeviation × PSpread(shot.Spread, sil.Span, shot.FireRange, cellSize) × - sil.POnHull`. - 5. The one roll against p. On a hit, the lateral draw from `sil`, and the impact cell - from `Lane`. - - Steps 2-5 are one function, `FireControl.CommitProbability(PendingShot, float now, - out Silhouette)`. The debug HUD calls it, which retires the inline estimate at - `ActionGameManager.cs:1317-1318` (`shot.PBase * pDeviation`, which would otherwise be a - second, wrong copy of the model). -- **The outcome freezes the committed geometry** (R4). `ShotOutcome` gains `float2 Bearing` - (schematic frame, as committed) and `float Lateral`, and keeps `Cell`. `Apply` walks - `Lane(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 × pOnHull` at the *current* - travel direction and facing. That is a forecast, in the same way the HUD already treats - deviation. `Inspect.PBase == HitProbability` still 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 `sil` that 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: when `PFire × pDeviation` - is 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 current `main`. -- **Adds:** `math.erf(float)` and `math.erfinv(float)` in - `packages/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 in - `packages/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`, bump - `unity/org.gamecult.cultmath/package.json` from 0.2.3 to 0.2.4, and tag - `cultmath-unity-v0.2.4`. Aetheria then bumps `Packages/manifest.json:56` and the headless - `CultLibRoot` pin. -- **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-520` becomes `target.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-396` becomes `ToSchematic(hitDirection)`. - - `ShotOutcome.Aimed` and its plumbing (Q12-4 = A): - - the field, `FireControl.cs:702`; - - the `aimed` parameter of `MakeOutcome` (`:470`, `:481`); - - `Commit`'s `aimed` local and flag (`:420`, `:435`); - - the `AimedCells` half of `ResolveAimPoint`'s tuple (`:583-589`), which now returns - `float2`. -- **Adds:** `Entity.ToSchematic(float2)`. -- **Verification:** - - Builds: headless `Aetheria.Shared`, plus a Unity batchmode compile by Self. - - All 244 tests pass unchanged. `SplashDamagesTheSideTheBlastCameFrom` and - `PenetrationMarchIsPlanar` pin the frame through its new owner. - - Negative greps: - - `rg -n "forward\.y, -forward\.x" Assets/Scripts` returns exactly one hit, in - `Entity.cs`. - - `rg -n "Outcome\.Aimed|outcome\.Aimed|o\.Aimed" Assets/Scripts tests` returns - 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:** - - `HullKernel` and 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`) and `Inspect.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`, `Spread` and `FireRange`; `PBase` is renamed to - `PFire`. - - `ShotOutcome`: `Bearing` and `Lateral`. -- **Per-file changes:** - - `FireControl.cs:161-167`: after the gates, `PFire(...) × PSpread(...) × - Silhouette(...).POnHull`. The bearing comes from `TravelDirection` and the target's - current facing. Nothing new runs before `:159`. - - `:197-202`: Inspect uses the same functions. It gains nothing new beyond - `diagnostic.PFire` if the HUD wants it (optional; presentation). - - `:263`, `:286-308`: Fire freezes `PFire`, `TravelDirection`, `Spread = weapon.Spread` - and `FireRange`. - - `:409-435`: Commit takes steps 1-5 of **Bearing timing** through `CommitProbability`. - - `:463-467`: Apply passes `shot.Outcome.Bearing` (schematic frame) to `ApplyHit`. For - this one cut, ApplyHit's parameter becomes a schematic bearing and the `ToSchematic` - call 12.1 put inside it is removed. - - `ActionGameManager.cs:1317-1318`: the pending line prints - `FireControl.CommitProbability(shot, now, out _)` instead of `shot.PBase * pDeviation`. - - `Combat.cs:117` and `TurretController.cs:84` need 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.Silhouette` owns scatter. - - `FireControl.CommitProbability` owns the commit-time roll price and placement - source. - - `FireControl.TravelDirection` owns the shot's world direction. - - `Entity.ToSchematic` owns the frame. - - Inputs: - - Frozen: `PFire`, `TravelDirection`, `Precision`, `Aimed`, `Spread`, `FireRange`. - - Live at Commit: target position (deviation) and target facing (bearing). - - Outputs: p for the one roll, then `Bearing`, `Lateral` and `Cell` in the immutable - outcome. - - Derived state: - - HitProbability and Inspect are a forecast of the commit price and decide nothing. - - `ShotOutcome.Cell` is 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`, `POnHull` and the HUD estimate are gone - before `Silhouette` has a caller. -- **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 - old `max(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's - `TravelDirection` before commit. - - Pass: every hit's `Cell` is on the bow row. - - Kills: freezing the bearing at Fire; reading the facing at Fire. - - `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. - - `PlacementAndProbabilityShareOneSilhouette` replaces 6d's - `PlacementAndProbabilityShareOneKernel` (`FireControlCut6dTests.cs:318`). - - Setup: a static target at a diagonal bearing, so the intervals overlap and the - forecast equals the commit price. - - Pass: `CommitProbability` equals a pOnHull that the test computes independently, by - numerically integrating the Gaussian over a union of intervals the test builds itself. - The empirical `Lateral` distribution 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. - - `HitsLandOnTheFacingEdge` supersedes `EveryHitLandsOnMetal` (`:357`): on the concave - fixture, each hit's `Cell` is occupied, and nothing occupied precedes it in its own lane. - The test recomputes the lane from `Bearing` and `Lateral`. - - `AimingAtAnItemCentresTheScatterOnItsLane` closes L585/L587 and replaces - `AimingAtTheSternHitsTheStern` (`: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 old `max(5,3)`, so its numbers stand. - - `ThinLimbCostsHitChance`, with its comment rewritten in 1D terms. - - Every test that reads `shot.PBase`, updated to `PFire` with 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 `hull` - factor should read higher from the beam. - - Turn your armoured face into a slow missile and watch where it lands. - -**Status (2026-09-25): closed after three Soul passes.** Landed on `codex/fire-control-12`: -`7dbb3bcf`, `efe4f7c9` (cut and first Stryker batch), `8bd25f6f` (Soul pass 1 fixes), `d38efba3`..`ca1670a2` -(Soul pass 2 fixes). 263 tests, identical across runs; Unity batchmode compile clean at each stage. - -What the Soul passes found, in order, because two of them are lessons for 12.3: -- **Pass 1.** A lateral draw clamped to closed `[Lo, Hi]` could land exactly on `Hi`, return an empty lane, - and fall back silently to cell (0,0), a hit on nothing. The statistical tests used `Guid` zone names, so - every run rolled different dice (the source of a "flake" that got its threshold tuned instead of its cause - found). Where a hit lands was untested, only its price; the HUD test compared the commit price to itself; - `HitProbability` and `Inspect` each assembled the forecast, and only Inspect's copy was guarded; penetration - could follow a turn made after commit; multi-cell aimed items were untested. -- **Pass 2.** The pass-1 fix clamped the top edge with an epsilon and turned the silent (0,0) into a throw. - The **bottom** edge still produced empty lanes at angled bearings, so the fix converted a rare wrong result - into a rare crash (~1 per 2^24 hits, every shipped hull), reproduced through `Fire`→`Zone.Update`. Root - cause: **two geometric models answering one question** — `Silhouette` priced each cell's projected interval, - `Lane` re-derived admission with an independent slab test, and floats disagreed at corners. Also: the - two-prong placement test passed five wrong-sigma draws; "never allocates" was still false (CoreCLR wraps an - `IComparer` in a delegate per sort). -- **Pass 3 closed it.** `Lane` now admits with the same `Extent` support function `Silhouette` uses, and the - slab test is gone (`AlongBearing` survives as a non-rejecting entry/exit computer). Proof and check: all - cells' intervals have equal width, so the merge cannot open a gap; exhaustive check at every breakpoint and - float neighbour (8.9M lateral values, 13 hulls, 1,540 bearings down to 1e-9 rad) and a 402M-draw enumeration - found 0 empty lanes and 0 non-finite values; a split-arithmetic mutant of `Lane` (equal in exact - arithmetic, different in float) is killed by two tests, so the one-owner rule is guarded, not incidental. - The `Commit` invariant throw is unreachable. - -Cost, measured in Release: pooling the forecast's interval buffer took gated-in `HitProbability` on LonginusX -from 672 to 184 B/call (time unchanged; the ~1.9× over the old kernel is the sort). What remains is outside -fire control's arithmetic: 40 B per `GetBehavior` (the `Equipment` enumerator), three per call; and with an -item aimed at, `ResolveAimPoint`→`CellsOf` builds a list and `ToArray`s it every call — 944 B per -`HitProbability`, 1928 B per `Inspect`. Recorded as follow-ups. - -**Carried into 12.3's brief** (low, all on code 12.3 builds on): pin `Lane`'s seam contract (a 2x1 hull at -b=(0,1): `Lane(s=-0.5)` returns only (0,0), `Lane(s=0.5)` returns nothing — the `s >= Hi` → `s > Hi` mutant -survives today and would interleave two columns in 12.3's walk); `AlongBearing`/`SlabAxis` have no test of -their values (every mutant there survives, equivalent for 12.2 only); correct the allocation comments in -`Forecast` and `LaneAndSilhouetteAllocateNothingGivenAPooledBuffer`; make -`ZeroProbabilityShotCommitsAMissWithoutBuildingASilhouette` behavioural (produce `PFire = 0` through -visibility, assert zero allocation rather than `sil.Intervals == null`); drop the unused `System.Reflection` -import. - -### Cut 12.3. How a direct hit travels - -- **Repo/branch:** Aetheria, on top of 12.2. -- **Deletes first:** `Entity.DamageSchematic` (`Entity.cs:333-374`) and `Entity.ApplyHit` - (`Entity.cs:376-409`). Going with them: - - the 0.5-step march; - - the `+ .5` cell convention; - - the `> .5f` threshold; - - the Expand spread footprint. -- **Adds:** - - `Entity.Absorb(int2 cell, float damage) → float`, moved verbatim from - `Entity.cs:348-366`. Armour absorbs up to its value, then the item when `d > .1f`, and - the remainder is returned. It emits `ArmorDamage((cell, incoming))` and - `ItemDamage((item, incoming))` with today's meaning, and only for incoming > 0. - - `Entity.DamageHull(float)`, moved from `Entity.cs:369-373` (`> .1f`, one `HullDamage` - event). -- **Per-file changes:** `FireControl.Apply` (`:449-468`), with the shield branches - unchanged: - 1. `IncomingHit.OnNext(source)` moves here from `Entity.cs:383`. - 2. There are `2n+1` lanes, `k ∈ [-n, n]`, where `n = (int) floor(DamageSpread + .5f)` (today's - rounding, `Entity.cs:389`). Lane `k` sits `k` cell *shadows* (`2h`) from `Lateral` along ℓ, and - **no cell belongs to two lanes** (Q12-2 = A, amended 2026-09-25). Membership is decided once per - cell, not by testing float lane positions against float intervals (Soul, 12.3 pass 2: those two - derivations disagreed at cell edges and struck a cell twice). - 3. Damage splits evenly over the lanes that meet metal. The centre lane always does. - 4. Each lane walks from its own facing cell, clipped to the penetration depth, with - `rem = target.Absorb(cell, rem)` at each cell. - 5. 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 - `DamageHull` once. - 6. A weapon with a contact or delayed fuse does not take this path. It detonates instead - (12.4), never both. 12.4 moves this choice into Apply's switch on the frozen `Fuse`; `Step` no longer branches. - - **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 `Absorb` per - footprint cell and passes the summed remainder to `DamageHull`, which is arithmetically - identical to today. -- **Authority map:** - - Owner: `Entity.Absorb` owns what one cell does with incoming damage. - `FireControl.Apply` owns the order and the share per lane. - - Inputs: the committed `Bearing`, `Lateral` and `Cell`, plus the frozen `Penetration`, - `DamageSpread` and `Damage`. - - Outputs: `Armor[]`, item `Durability` and `Hull.Durability`, plus the events - `IncomingHit`, `ArmorDamage`, `ItemDamage` and `HullDamage`. Their consumers: - `ActionGameManager.cs:1070`, `EntityInstance.cs:292`, `InventoryPanel.cs:470-482`, - and `Entity.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 `Absorb` gets a second caller. -- **Verification:** - - `DamageIsAbsorbedInOrderAlongTheRay` replaces `HardpointHitDamagesItemThenHull` and - `PenetrationMarchIsPlanar` (`FireAuthorityTests.cs:481-526`, which call the deleted - `ApplyHit`). - - Setup: through `Fire` at 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. - - `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/lanes` minus what the - ring absorbed. - - Enough damage fires `Death` with `CockpitDestroyed`. - - `SpreadWidensTheImpactAcrossLanes`: spread 1 at penetration 0 puts damage/3 on each of - three facing-edge cells and nothing behind them. - - `ARayThroughACornerCrossesTheCornerCell`: the diagonal case from `Probe1` reaches (2,2). - - `LaneRemainderReachesTheHull`: a ray that exits a thin hull delivers its remainder to - `Hull.Durability` (Q12-3 = A). - - Kept green: every Splash test, and `AirburstSplashes…` (Cut 6). - - Negative: `rg -n "DamageSchematic|ApplyHit\(" Assets/Scripts tests` returns nothing. - Today it matches `Entity.cs`, `FireControl.cs`, `FireAuthorityTests.cs`, and two comment - lines at `Gameplay/EntityInstance.cs:280-281`. Rewrite those comments and - `FireControl.cs:492`. - - Stryker: `--since:<12.2 head>` over `FireControl.cs` and `Entity.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. - -**Status (2026-09-25): closed.** Landed on `codex/fire-control-12`: -- `70c3e0cc`..`73b15d9f`: the cut and the first Stryker batch. -- `b82b7eab`..`be538217`: Soul pass 1 fixes and the lane-spacing ruling. -- `4e6495dd`, `742354c6`, `cb71a3cf`: Soul pass 2 fixes. -- `56757913`, `b55894a0`: the proportional-absorption batch, since superseded. -- `e9339a56`, `ab14552a`, `166b6202`: the one-path rebuild. -- `64d17a29`, `c07b4fee`, `cccf3a2a`: Soul pass 5 fixes. - -297 tests. Unity batchmode is clean at `cccf3a2a`. - -Proportional absorption, passes 4 and 5: -- **Pass 4 (on the two-path version).** The split between `ApplySequential` and `ApplyWithSharedItems` - was already ruled out by the operator. Soul found three more defects: - - The cycle tie-break fell back to lane index at axis-aligned bearings, and a probe broke mirror symmetry. - - The pooled resolve skipped the item's `.1f` threshold. - - The model sweep still passed with proportionality deleted. -- **The rebuild.** Every item goes through a pool: clip once, and one pooled walk. - `Entity.ItemAbsorb(EquippedItem, Span)` is the one absorption owner. The cycle rule is - Self's default (above). `FireControl.cs` shrank by 40 lines against the two-path version. -- **Pass 5 (on the rebuild).** There was no second authority left, mirror symmetry held at angled - bearings across 2,800 draws, damage was conserved, and the cycle rule never fired early. - Soul found gaps in the tests (a lane revisiting an item, event cardinality), dead code, and - stale comments. All are fixed, and each mutant dies to a committed test. - -Recorded, not fixed: -- Soul's angled-bearing mirror probes (P1, P2) are not committed. They need an exact lateral draw, - which only a private seam offers. The operator rule forbids reflection, and no test seam is added - for them. - - Angled correctness is covered by the model sweep and `DirectHitAtAnAngledBearingMatchesAnIndependentModel`. - - The cycle rule's symmetry is covered at axis-aligned bearings, and holds at any bearing by construction (it has no ordering key). - - 12.4 grows no such seam. `Detonate` takes a world point, not a lane, so P1 and P2 stay uncommitted (see 12.4, **Test seam**). -- `Entity.Absorb` is deleted with `Splash` in 12.4, and `Detonate` goes through `ArmorAbsorb` and `ItemAbsorb`. - -What the three Soul passes found: -- **Pass 1.** Production correct (an independent double-precision ray model agreed on 14,984 of - 15,000 shots; the rest sit on the √2 float boundary), tests blind: no test drove `Apply` at an - angled bearing, the gap rule, penetration depth or side-lane remainders; one test could not fail. - It also found lanes one cell apart **share cells** at angled bearings, which the operator ruled on - (Q12-2 amendment: no doubling up, the line thickens). -- **Pass 2.** The spacing fix was disjoint only by float luck (78 of 26,377 boundary-lateral hits - struck a cell twice), and the disjointness test missed four spacing breakages. Hands' first - structural fix (a `ceil` lane index) introduced a **third** membership computation, which left the - centre lane empty on `solid5, b=(0,1), s=0.49999997` while `Lane(s)` found the row — a committed hit - with no damage. Final shape: `Lanes` is built from `Lane`, `Lane(s)` is literally lane 0, and float - drift is resolved by ownership (nearest the centre wins). **The third time this campaign met two - derivations of one geometric answer disagreeing in float** (12.2's crash, 12.3's double strike, - the `ceil` empty centre); the cure each time was one owner. -- **Pass 3 closed it.** 2.09M boundary cases over 196 bearings and 9 hull shapes: no cell in two - lanes, lane 0 always equals `Lane(s)`, no empty centre lane. The tie-break is mirror-symmetric by - construction. Residual float effects (a side lane starting one cell deeper, a column falling - between lanes, rarely a footprint narrowed by one column) occur at about 2×10⁻⁶ of draws; damage - is never lost. - -**Owed with the pending ruling's batch:** a test that many axis-aligned spread-n shots each strike -exactly 2n+1 facing cells (a 1% spacing error, R1b, survives today and would drop a column from 1-2% -of spread shots). **Recorded, not reachable through the public pipeline at a usable rate:** nothing -ties `Apply` to `Lanes` behaviourally (reverting `Apply` to a per-lane `Lane()` loop survives, since -it only reintroduces boundary double strikes); the behaviour after a cession is unpinned. - -**Pending operator ruling (blocks closing 12.3):** when two lanes cross one multi-cell item, the -first-processed lane drains its durability and the second carries the leftover into its own deeper -cells, so the left-of-travel lane always gets the damage that passes wide components (mirror shots on -a symmetric hull with 2x1 items differ in ~30% of axis-aligned trials). Recommended: the item absorbs -from all lanes that strike it at once, and each lane's leftover is in proportion to what it brought. -Alternative: keep the processing order and pin it. - -**Ruled 2026-09-25: proportional absorption.** Operator: "proportional absorption; go". A multi-cell -item struck by several lanes of one shot absorbs from their combined incoming damage, and each lane -carries on with a leftover in proportion to what it brought. The result must not depend on the order -lanes are processed. A single-lane item is the degenerate case of the same rule, not a separate path. - -**Ruled 2026-09-25: one code path.** Operator: "Do not expect items taking up multiple cells to be an -exception, this should be one code path." The first implementation (`56757913`) surveyed each shot -for shared items and took either `ApplySequential` or `ApplyWithSharedItems`. That was a split -authority, and the second reachability derivation it needed is the same failure as the 12.2 empty -lane and the 12.3 double strike. `Apply` has one walk, in which every item pools the deposits of -the lanes that reach it, a pool of one lane included. The survey and the sequential path die. - -**Self's default 2026-09-25 (not an operator ruling): the cycle rule.** When every unfinished lane is blocked at an item pool and no pool is ready (two lanes crossing two shared non-convex items in opposite order), every open pool resolves at once with the deposits it holds. It needs no ordering key, so mirrored shots stay mirrored. The first implementation used the smallest first-contact `Entry`, which ties at axis-aligned bearings and fell back to lane index. Soul broke mirror symmetry with it. The operator may overrule this default. - -### Cut 12.4. The detonation primitive - -Revised 2026-09-25, after 12.3 closed. The first draft predates 12.3's proportional-absorption and one-path -rulings, and the parts of it those rulings overturn are listed under **History** at the end of this section. -Anchors are against `d402283c`. **(probe)** marks claims measured in a scratch worktree at that commit (see -Part 5 of the revision note); **(read)** marks claims read from source at the cited anchor. - -- **Repo/branch:** Aetheria, on top of 12.3. It lands as two commits: - - (a) The data fields and the label demotion. On shipped content this changes no behaviour, because no - weapon carries the label or a radius. That is the Cut 12 probe (`Probe4`), and `GameData/Aetheria.cc` has - not changed since it ran (`git log cd846916..d402283c -- GameData/Aetheria.cc` is empty). - - (b) `Detonate`, with `Splash` and `Entity.Absorb` deleted. - -#### Deletes first - -In (a): - -- The label read in `Fire`: `FireControl.cs:382-388`, which is the Cut 6b comment, `isAirburst` (`:386`), - `burstPosition` (`:387`) and `burstRadius` (`:388`). Afterwards `WeaponModifiers` has no simulation reader. - `rg -n "WeaponModifiers\." Assets/Scripts/ServerShared` returns nothing; today it returns exactly `:386`. -- `PendingShot.BurstRadius` and its comment (`FireControl.cs:1246-1250`) become `Fuse` and `BlastRadius`. The - initialiser at `:412-413` changes with them. `BurstPosition` stays, and it now has meaning only for a - proximity fuse. -- The slot 29 comment (`ItemData.cs:493-496`), rewritten for the renamed field. - -In (b): - -- `FireControl.Splash`, its comment and its half-hull footprint: `FireControl.cs:946-994`. This absorbs F12-1. -- `Step`'s dispatch between Splash and Apply: `FireControl.cs:458-467`, comment `:460-462`, branch `:463`. - `Step` calls `Apply` for every arrived shot, and Apply owns the fuse switch (see **Which model a shot uses**). -- `Entity.Absorb` and its comment (`Entity.cs:386-400`). It gates the item's `.1f` threshold on each cell - separately, which contradicts the one-path ruling as soon as an area covers two cells of one item. - **(probe)** `ProbeC2`: a 2x1 item with no armour, and 0.08 on each of its cells. Pooled through `ItemAbsorb` - the item absorbs 0.16. Through `Absorb` per cell it absorbs nothing, and 0.16 goes to the hull. - 12.3 recorded that `Absorb` survives only for Splash; it dies with Splash, and nothing brings it back. -- Commit's shield decision, for shots that carry a fuse (`FireControl.cs:578-581`), under Q12-9's recommended - answer. It is not deleted: it is gated on `shot.Fuse == null`. -- Apply's inline penetration clip (`FireControl.cs:666-678`) is **moved**, not deleted. It becomes the one - reach function (see **The penetrator**). -- Stale comments that name a deleted owner as live: - - `Entity.cs:391` and `:417`; - - `FireControl.cs:1201`; - - `Gameplay/EntityInstance.cs:280-283`; - - `Gameplay/HullCollider.cs:4-6`; - - `Gameplay/Weapons/Projectile.cs:8-9`; - - `Gameplay/Weapons/Mine.cs:90-95`. - -#### Data - -The smallest field set: two fields, both on `WeaponItemData` (`ItemData.cs:475-499`). - -- **Slot 29.** `float? AirburstRange` (`ItemData.cs:497-498`) is renamed `float? BlastRadius`, with JSON name - `"blastRadius"`. It is in world units and keeps its meaning. The slot and the type are unchanged, so CultCache - opens the catalog as compatible drift (see 0b). -- **New slot 32** (landed `2625768f`; the map said 30, but 30 and 31 are `OptimalTemperature`/`PlateauWidth`, inherited from `EquippableItemData`). `WeaponFuse? Fuse`, with JSON name `"fuse"`, and `enum WeaponFuse { Contact, Proximity, - Delayed }` in `Enums.cs`, beside `WeaponModifiers` (`Enums.cs:97-108`). It is nullable under the 7.4 rule: - null means no blast, and every shipped record reads as null. -- **Fire decides "detonates" once.** Fire freezes `PendingShot.Fuse` as the weapon's fuse only when - `BlastRadius > 0`, and as null otherwise, and it freezes `BlastRadius` beside it. After Fire, nothing - re-derives whether a shot detonates; every reader reads `shot.Fuse`. A fuse without a radius, or a radius - without a fuse, is inert and resolves as a direct hit. Nothing reports it: Q12-6 ruled that the catalog is - not policed. The inert half takes effect with (b)'s switch in `Apply`. At (a), `Step` still splashes on - `BlastRadius` alone (Soul, `2625768f`). -- **The drift warnings.** The catalog needs one write through `tools/AetherDb` or Studio to clear them. That - write is content and changes no data. It belongs to F12-2. -- **Readers to update:** - - `FireControl.cs:386-388`. - - `FireControlCut6Tests.cs:81`, `:95` and `:100-101`: the fixture's `airburst` and `airburstRange` - parameters. They become `fuse` and `blastRadius`. Whether the fixture keeps the label does not matter, - because Q12-6 ruled that nothing checks labels against behaviour. - -#### Which model a shot uses - -`FireControl.Apply` (`:631-693`) is the one owner of this decision, and it switches on the frozen `shot.Fuse`: - -- **null: a direct hit.** This is 12.3's lane path, unchanged, and it needs a hit. -- **Proximity: detonates at arrival, with no roll.** It detonates at `shot.BurstPosition.xz`, which - `PredictedIntercept` froze at Fire, whether the shot hit or missed. That keeps today's rule, - `AirburstSplashesEvenOnAGuaranteedMiss`. -- **Contact or Delayed: detonates only on a committed hit.** P comes from the committed lane (see **Where P - is**). On a miss nothing happens. - -Today `Step` makes this choice at `:463`, and 12.3 describes the same choice as a separate step 6. Both collapse -into Apply's switch, so `Step` has no damage branch left. - -`IncomingHit` (`:648`) fires for a committed hit, for a direct hit and a contact or delayed blast alike, and -only on the host. A proximity burst and the bystanders in any blast do not fire it, as Splash never did. Its one -consumer is the player's hit marker (`ActionGameManager.cs:1070`). - -This is a default, not a ruling. - -#### Where P is - -The lane's own parametrisation gives the point directly. `Lane` places the line at lateral offset `s` as -`point(t) = t·b + s·ℓ`, in schematic cell coordinates (`AlongBearing`/`SlabAxis`, `FireControl.cs:1177-1198`). - -**(probe)** `ProbeE`, 20,000 random lanes on a 6x9 hull: - -- the mid-slab point of every walked cell lies inside that cell, with a worst Chebyshev distance of 0.499999 - from its centre; -- `point(impact.Entry)` lies on the impact cell's boundary to within 9.5e-7. - -So the contact and delayed fuse points need no geometry beyond `Lane` and the reach clip. - -- **Proximity:** `BurstPosition.xz`, a world point. -- **Contact:** the schematic point `point(lane[0].Entry)`, on the committed lane: the committed `Bearing` and - `Lateral`, and the lane `Lane(hull, Bearing, Lateral)`. That is where the shot strikes the hull's face. -- **Delayed:** the schematic point `point(t_fuse)`, with - `t_fuse = min(lane[0].Entry + Penetration, lane[reach-1].Exit)`, where `reach` comes from the same clip that - 12.3's walk uses. In words: the fuse fires where the penetration runs out, or where the metal runs out, - whichever comes first. Metal runs out at the far side or at the first gap. `t_fuse` lies inside the last - reached cell's slab, so the disc is always centred on metal. Penetration 0 gives the contact point. -- **Which lane.** It is always the centre lane: `Lane(s)` at the committed Lateral, which Commit's guard already - proved non-empty (R3), and which `Lanes` guarantees to equal lane `n`. A blast shot's `DamageSpread` is not - read. The disc's radius is its width. This is a default, not a ruling. -- **Conversion to the world.** Contact and delayed points are converted with `host.ToWorldPoint`, using the - host's pose at arrival, and handed to `Detonate` like any other world point. The host is then treated like - every other entity in the radius: its cells are found again by `ToSchematicPoint`. - - That round trip cancels the host's pose, so the host's own damage is fixed by the commit, and R4 holds for - it. **(probe)** `ProbeF`: the worst schematic→world→schematic error is 7.6e-4 cells at 4×10⁴ world units. - - Bystanders are judged against where the host actually is at arrival. That is the same live rule a - proximity burst already applies to everyone. - - There is exactly one input shape, a world point. There is no host-only schematic entry. - -#### The penetrator (Q12-8) - -A delayed-fuse shot does not spend damage along its burrow. The lane walk **locates P and hands `Detonate` -the shot's whole `Damage`**. - -That keeps the ruling recorded as Q12-8 = A: nothing is absorbed along an explosive penetrator's burrow, and the -explosion is the payload. See Part 4 of the revision note, because the brief phrased this the other way. - -The walk and the blast share exactly one piece: **the reach clip**. Today it is inline at -`FireControl.cs:666-678`. It moves into `FireControl.Reach(LaneCell[] cells, int walked, float penetration) → -int` (private). The rule is unchanged: the impact cell is always reached, and a later cell is reached while -`entry - impact.entry < penetration`. - -Two callers read it: - -- Apply's direct-hit walk, which calls it once per lane in place of the inline loop; -- the delayed fuse point, which calls it on the centre lane. - -No second "lane damage with a blast" path exists, because no shot does both: - -- a null fuse runs the lane walk with `ApplyPooled`, which absorbs; -- a contact or delayed fuse runs `Lane` and `Reach`, which only locate P, and then `Detonate`. - -A shot takes one branch of Apply's switch or the other, never both. - -The mutant this must kill is a second reach derivation: for example, a delayed P computed as -`impact.Entry + Penetration` without the clip, which would detonate past a gap or outside the far side. - -#### Area, per entity: `FireControl.Detonate` - -The signature is `Detonate(Zone zone, float2 worldPlanar, float radius, float damage, DamageType type)`, -public, and it is the one owner of blast damage. It is Splash's signature with the point made planar (R7). - -- **Callers:** Apply (all three fuses) and `Mine.Explode` (`Mine.cs:99`, which passes - `position.ToCultMath().xz`). -- **Point transforms.** It uses `Entity.ToSchematicPoint(float2) = ToSchematic(w − Position.xz) / - SchematicCellSize + hullShape.CenterOfMass` and its inverse, `ToWorldPoint`. These are the frame rules; the - frame owner is `ToSchematic` at `Entity.cs:419-424`. `CenterOfMass` is `ItemData.cs:127-128`, and - `SchematicCellSize` is `Settings.cs:255`. - -For each entity in the zone: - -1. **Candidates.** An entity is a candidate when `|Position.xz − P| ≤ radius + ½·√(W²+H²)·SchematicCellSize`. - Splash culled on the entity's *centre* being inside the radius (`FireControl.cs:964-965`), which misses a - long hull whose end lies inside the blast. -2. **Shares, computed once.** Take `Ps = ToSchematicPoint(P)` and `rCells = radius / SchematicCellSize`. For - every occupied hull cell whose unit square meets the box `Ps ± rCells`, the share is `share_c = damage × - overlap(c) / (π·rCells²)`. Here `overlap(c)` is the exact area of the disc inside that cell's unit square, a - cell being the unit square centred on its integer coordinate. - - The shares go into one pooled buffer, which the shield step and the absorption step both read. Nothing - computes an overlap twice. - - Every share of the disc that falls on an unoccupied cell, or outside the schematic, is lost. -3. **Shield.** Every shield decision for a blast is made here, live, and charged the entity's summed shares, - under today's `CanTakeHit`/`Break`/`TakeHit` rules (`Shield.cs:166-190`). That includes the host of a contact - or delayed blast, under Q12-9's recommended answer. - - If the shield absorbs, no cell of that entity takes anything. - - If it cannot, it breaks, and the cells take their shares. -4. **Armour, per cell.** For each share, `d_c = ArmorAbsorb(c, share_c)` (`Entity.cs:339-352`). Armour protects - its own cell's share and nothing else. -5. **Items, one pool each.** If `GearOccupancy[c]` holds an item, `d_c` is deposited into that item's pool, in - hull `Coordinates` order. If not, `d_c` goes to the entity's hull total. Only after every covered cell has - deposited does each item resolve, once: `ItemAbsorb(item, pool)` (`Entity.cs:369-384`). The leftovers join - the hull total. -6. **Hull.** The entity's `DamageHull(total)` runs once (`Entity.cs:406-413`). - -Entities are independent. Two ships caught by one blast each cover their own cells, and neither shadows the -other (F12-8). - -**Order: armour first per cell, then the item from the pooled post-armour sum.** Existing rulings settle it, -and no operator question is needed: - -- "Armor absorbs first", from the first round of rulings; -- "Each covered cell absorbs … in the existing order (armour, then item, then hull)", from the area ruling; -- the proportional ruling, under which a multi-cell item "absorbs from their combined incoming damage", where - incoming means the post-armour remainder each contributor brings, exactly as a lane deposits after - `ArmorAbsorb`; -- the one-path ruling: "Do not expect items taking up multiple cells to be an exception." - -The area ruling's word "independently" survives for armour, and is superseded for items by the one-path ruling: -an item's cells deposit into one pool, and they do not absorb independently. - -**One owner, and why there is no walk.** - -- `ItemAbsorb` is the one owner of what an item does with incoming damage: the `.1f` threshold decided once on - the pooled total, the pro-rata split, and one `ItemDamage` event per contributing cell with a positive share. -- `ArmorAbsorb` is the one owner of what armour does. -- `Detonate` owns the schedule, and a disc's schedule has no walk. Apply's pooled walk (`ApplyPooled`, - `FireControl.cs:717-860`) exists only because lanes are ordered: a lane's deeper cells receive whatever the - item in front of them leaves, so a pool must wait until no unfinished lane can still reach it. In a disc, no - cell's share depends on another cell's leftover. Every deposit is known before any item resolves, and so the - reachability test and the cycle rule have nothing to decide. - -The flat pass is exactly `ApplyPooled`'s degenerate case. **(probe)** `ProbeC`: 200 random discs on a 6x4 hull -with two 2x1 items, a one-cell item and varied armour. `ApplyPooled`, fed one one-cell lane per covered cell -with that cell's share, left identical armour, item durability and `ItemDamage` event counts in 200 of 200 -discs. Its hull total differed only by float summation order, at most 3.8e-6. - -`Detonate` does **not** call `ApplyPooled`. Doing so would mean changing `ApplyPooled`'s signature to take a -starting amount per lane and renting a `LaneCell[]` per covered cell, and it would buy no behaviour. - -**What the pro-rata split does inside a blast:** nothing observable. Every leftover of a blast reaches the same -per-entity hull sink, so a sequential split and a pro-rata split deliver identical totals. The only -order-sensitive rule left in a blast is the pooled threshold, and that is what the verification pins. - -**Exact overlap, in float.** `overlap(c)` is the standard circle-rectangle intersection. It integrates the -disc's chord height over the cell's clipped x-span, using the antiderivative `½(x√(r²−x²) + r²·asin(x/r))` -between the breakpoints where the chord crosses the cell's top and bottom edges. It allocates nothing, and it -conserves exactly up to float rounding. - -- **(probe)** `ProbeA`: in float, the overlaps over the bounding box sum to `π·r²` within 1e-7 relative, at - radii 0.3, 0.5, 1.7, 5, 12.5 and 25 cells, centred on a cell centre, on a cell corner and off the grid. Double - precision is not needed. -- **(probe)** `ProbeB`: named cells agree with a 3000² numeric integration within 1.5e-6. -- Supersampling is rejected, for the reason the first draft gave. It would deliver the wrong total for a disc - smaller than a cell, or for one straddling a cell boundary. - -**Cost.** Per candidate, there is at most one overlap evaluation per occupied cell inside the box: no more than -128 on shipped hulls, whatever the radius. It runs once per detonation and never per tick. Allocation is not -the constraint here (Splash builds a `Shape` per target today), but each share is computed exactly once. - -**Accepted loss (unchanged).** Interior armour does not shadow the cells behind it, because nothing inside an -area is occluded. A bulkhead protects its own cell's share and no other. 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 -forecast still applies. A proximity blast has no roll, and it is still reported with the direct-hit number. -That is pre-existing, and it is F12-5. - -#### Authority map - -**Owners:** - -- `FireControl.Apply` owns which model a shot uses, through the switch on the frozen `Fuse`. -- `FireControl.Detonate` owns blast damage: candidates, shares, every blast shield decision, and the - per-entity armour→pool→hull pass. -- `Entity.ArmorAbsorb` owns armour per cell, and `Entity.ItemAbsorb` owns item absorption. Both are shared with - 12.3's walk. -- `FireControl.Reach` owns penetration reach, for the direct-hit walk and the delayed fuse point. -- `FireControl.Lane` owns traversal. A blast shot calls it once, for its fuse point. -- `Entity.ToSchematicPoint` and `ToWorldPoint` own points. `ToSchematic` still owns the frame. - -**Inputs:** - -- P, the radius, the damage and the type; -- each candidate's position, facing, hull, armour, occupancy and shield at the detonation tick; -- the fuse and the radius, frozen at Fire; -- for contact and delayed shots, the committed `Bearing` and `Lateral`, and the frozen `Penetration`. - -**Outputs:** the same mutations and events as 12.3: `Armor[]`, item durability, hull durability, -`ArmorDamage`, `ItemDamage`, `HullDamage` and, for the host of a hit, `IncomingHit`. - -**Derived state:** - -- `WeaponModifiers` is display-only. Its readers are the `SchematicListElement.cs:54` icons, - `GameSettings.cs:67` and `GameplaySettingsEditor.cs:44`. -- `BurstPosition` is read only for a proximity fuse. -- Under Q12-9 = A, `ShotOutcome.Shielded` and `ShieldBroken` are never set for a shot that carries a fuse. - Nothing outside `FireControl.cs` reads either flag (`rg "\.Shielded\b|\.ShieldBroken\b" Assets/Scripts` - finds only `FireControl.cs`). - -**Forbidden writers:** - -- No label decides behaviour. -- Nothing but `Detonate` applies area damage. -- No per-cell path gates an item's threshold. `Absorb` is deleted and is not reintroduced under another name. -- Nothing but `Reach` decides how deep a lane reaches. -- `Commit` decides no shield for a shot that carries a fuse (Q12-9 = A). -- `Step` has no damage branch. -- Mine's Unity `BlastRange` and its arming query stay presentation-side inputs (R8 residue, Cut 4 Q7), and they - decide no damage themselves. - -**Shared paths:** - -- the proximity burst: Apply at arrival, with no roll; -- the contact and delayed blasts: Apply on a committed hit; -- `Mine.Explode`; -- the tests, which call `Detonate` directly (see **Test seam**). - -**Deletion line:** `Splash`, `Absorb`, the label read and `Step`'s branch are deleted before `Detonate` has a -caller. - -#### Test seam - -`Detonate` takes a world point because its production callers already have one: a proximity burst's frozen -intercept, a mine's position, and a contact or delayed hit's converted lane point. That is an honest production -interface and not a hook, and it is public for the same reason `Splash` is today: Mine calls it from -`Assembly-CSharp`. Blast tests call it directly with exact points. So blast symmetry at angled facings is -committable with no seam and no reflection. - -It does **not** make Soul's 12.3 probes P1 and P2 committable. Those probes measure *direct-hit lane* damage at -an exact lateral draw. `Detonate` takes a point, not a bearing and a lateral, and no production interface takes -a built shot or outcome. The delayed fuse point's `Lane`→`Reach`→`point(t)` chain sits inside Apply, and -exposing it for tests would be exactly the seam the operator forbids. 12.3's record stands: - -- angled direct-hit correctness is covered by the model sweep and - `DirectHitAtAnAngledBearingMatchesAnIndependentModel`; -- P1 and P2 stay uncommitted. - -#### Verification - -These fixture rules apply to every blast test that touches the frame: - -- `SchematicCellSize ≠ 1`: use 2. The 12.3 fixture uses 1 (`FireControlCut123Tests.cs:49`), under which a - deleted `/ SchematicCellSize` survives. -- The target is off the origin. -- The facings include one with |fx| > |fy|. -- The hull's centre of mass is not at an integer cell, so a missing `+ CenterOfMass` survives nowhere. - -Tolerances come from the probes. Conservation holds within 1e-5 relative. Mirrored results agree within 1e-4 -absolute at damage ≤ 150: **(probe)** `ProbeD` measured at most 3.05e-5, all of it float summation order. - -**Geometry.** - -- `TheDiscIsConservedOverTheGrid`. - - Setup: on a solid hull that contains the disc, total armour, item and hull damage delivered, at a radius - under one cell, one centred on a cell corner, and one off the grid. - - Pass: the total equals `damage`. - - Kills: sampling in place of the exact overlap; the normaliser `(2r)²` in place of `πr²`; a bounding box - that clips the rim cells; a dropped chord breakpoint. -- `EachCellTakesItsShareOfTheDisc`. - - Setup: a solid hull with no armour and no items. - - Pass: a named cell takes `damage × overlap / (πr²)`, with the overlap integrated numerically by the test. - - Kills: an even split over the covered cells (Splash's rule), and a share decided by the distance from the - cell's centre. -- `TheShareOffTheHullIsLost` (was `ExternalBlastWastesMostOfItsEnergy`). - - Setup: a blast straddling the hull's edge. - - Pass: the total delivered equals `damage` times the disc's on-hull fraction, which the test computes. The - shares sum to the blast's damage minus what falls off the hull. - - Kills: renormalising the shares over the occupied cells, which would deliver the full damage. -- `CandidatesIncludeHullsWhoseCentreIsOutsideTheRadius`. - - Setup: a long hull whose centre lies beyond the radius, while its near end lies inside. - - Pass: the near end takes damage. - - Kills: Splash's centre-distance cull. - -**Absorption order.** - -- `ABlastIsAbsorbedThroughArmourFirstPerCell`. This replaces `AbsorbSpendsArmourBeforeTheItemOnTheSameCell` - (`FireControlCut123Tests.cs:750`). - - Setup: a disc wholly inside one armoured item cell, so its share is the whole damage. The three damage - levels of the old test: below the armour, between the armour and armour plus durability, and above both. - - Pass: the armour is spent first, the item takes the excess, and the hull takes what remains. - - Kills: the item absorbing before the armour, and armour being skipped. -- `ArmourProtectsOnlyItsOwnCellsShare`. - - Setup: a 2x1 item with armour on one cell only, and a disc covering both cells. - - Pass: the item takes `(share_A − armour_A)⁺ + share_B`. - - Kills: pooling the raw shares and then applying armour to the pool, which would let cell A's plate eat - cell B's share. -- `AMultiCellItemAbsorbsItsCoveredCellsAsOnePool`. - - Setup: a 2x1 item with no armour, and a disc straddling its seam that puts less than `.1` on each cell but - more than `.1` on the two together (the `ProbeC2` geometry). - - Pass: the item absorbs the sum. - - Kills: resolving the item per cell, in any form: `Absorb` resurrected, `ItemAbsorb` called per deposit, or - a pool that resolves before every covered cell has deposited. - - This test is also what pins that the result does not depend on which cell is considered first. As shown - above, the threshold is the only order-sensitive rule a blast has left. -- `ItemDamageFiresOncePerCoveredCell`. - - Pass: for a covered item, the number of `ItemDamage` events equals the number of its cells with a positive - post-armour deposit, and each event carries that cell's deposit. - - Kills: one event per item, and events for zero-deposit cells. - -**Symmetry and frame.** - -- `MirroredBlastsDoMirroredDamage`. - - Setup: a hull, its armour and its items all mirror-symmetric about the bow axis, facing (2,1)/√5. Pairs of - world points mirrored across the target's bow axis, with the mirror computed by the test from the facing - and not through `ToSchematic`. - - Pass: per-cell armour, item durability and hull damage mirror. - - Kills: - - the `+ .5` cell convention returning in the overlap (cell bounds `[c, c+1)`); - - a missing or wrong `CenterOfMass` anchor; - - any scheduling rule that depends on enumeration order. - - It does **not** kill a global handedness flip, because a flipped pair is still a mirrored pair. The next test - does. -- `ABlastDamagesTheCellsNearestIt`. This succeeds `SplashDamagesTheSideTheBlastCameFrom` - (`FireControlCut11Tests.cs:371`) and `SplashIsDirectional` (`FireControlCut4Tests.cs:306`). - - Setup: facings (0,1), (1,0), (2,1)/√5 and (−.6,.8). A blast to port, with a radius under half the hull's - width. - - Pass: port cells are damaged, and starboard cells are untouched. - - Kills: a flip of `ToSchematicPoint`'s right axis, an x/y swap, and a missing `/ SchematicCellSize`. - -**Shields** (under Q12-9 = A). - -- `ShieldPaysForWhatReachesIt`. - - Pass: the reserve drops by the entity's covered share × `EnergyUsage`, not by the whole blast. - - `SplashShieldAbsorptionDrainsTheReserve` (`FireControlCut11Tests.cs:237`) and `SplashBreaksUnabsorbedShield` - (`FireControlCut5Tests.cs:507`) are rewritten to `Detonate`, with the expected amounts recomputed from the - covered share, never loosened. - - Kills: charging the full damage. -- `ABlastShotsShieldIsDecidedAtDetonation`. - - Setup: through `Fire`, a contact-fuse shot at a target whose shield is inactive at commit and active at - arrival. - - Pass: the shield is charged the covered share. - - Second setup: a shield that could absorb the whole shot at commit. - - Pass: the shot still detonates, and the shield pays its covered share. - - Kills: Commit's shield block left ungated for fused shots, which puts a second shield authority on the - host. - -**Fuses** (through `Fire`, the 12.3 fixture, `SchematicCellSize` 2). - -- `PenetratorBurrowsBeforeItBursts`, which pins Q12-8 in both halves. The fixture is the enclosed cockpit - behind an armoured ring, with the penetration chosen so that `t_fuse` falls inside the cockpit cell. - - With a delayed fuse and a radius under half a cell, the cockpit takes the whole damage, **and the armour - of every ring cell on the lane is unchanged.** Nothing is absorbed along the burrow. - - A contact fuse with the same stats leaves the cockpit untouched. - - With the fuse removed and everything else held, the shot is a dumb AP round. The lane's armour is spent in - order, and the cockpit takes only what the ring left (12.3). Damage in a line. - - Kills: - - absorbing along the burrow; - - a contact fuse reading penetration; - - routing every penetrating shot through the fuse path; - - a fuse without a radius detonating. -- `TheFusePointStaysOnMetal`. - - Setup: a delayed fuse with a penetration deeper than the hull, and again on a concave hull with a gap - behind the first run. - - Pass: the damage delivered equals the test's own area integral for a disc centred on the far edge (or the - gap's near edge). - - Kills: an unclamped `t_fuse`, and a second reach derivation that ignores the gap. -- `TurningAfterCommitDoesNotMoveTheBlastOnItsHost`. - - Setup: a contact-fuse shot. After commit and before arrival, the host turns 90° and moves. - - Pass: the host's per-cell damage equals that of the same shot with no turn. - - Kills: taking P from the live bearing, or from `Outcome.Cell`'s centre in place of the lane point. -- `LabelsDoNotDecideBehaviour`. - - Pass: a weapon labelled `Airburst` with no fuse resolves as a direct hit, and a weapon with - `Fuse = Proximity` and no label detonates. -- `AFuseWithoutARadiusIsADirectHit`. - - Pass: `Fuse = Contact` with a null or zero radius does lane damage. - - Kills: Fire freezing `Fuse` without the radius check. -- Re-fixtured to `Fuse`, with their behaviour unchanged: - - `AirburstSplashesEvenOnAGuaranteedMiss` (`FireControlCut6Tests.cs:245`), which becomes - `ProximityDetonatesEvenOnAGuaranteedMiss`; - - `AirburstAndDiscreteNeverDoubleUp` (`:263`), whose reference becomes a bare `Detonate` call (`:281`); - - `NonAirburstNeverSplashes` (`:296`), which becomes `NoFuseNeverDetonates`; - - `SplashHitsEveryEntityInRadius` (`FireControlCut4Tests.cs:285`), which becomes - `ABlastHitsEveryEntityItCovers`. -- `AContactBlastReportsTheHit`. - - Pass: `IncomingHit` fires once, for the host, on a committed contact hit, and never for a proximity burst. - - Kills: `IncomingHit` moved inside the direct-hit branch. - -**Retargeted, because `Absorb` is deleted.** These keep their boundary behaviour, called on the public owner: - -- `AbsorbEmitsNothingAtExactlyZeroIncomingDamage` (`FireControlCut123Tests.cs:502`) becomes - `ArmorAbsorbEmitsNothingAtExactlyZero`, calling `ArmorAbsorb(cell, 0)`. A spent lane still walks cells with - a remainder of 0. -- `AbsorbLeavesTheItemUntouchedAtExactlyPointOneRemaining` (`:526`) becomes a call to `ItemAbsorb` with a pool - of `{ .1f }`. - -**Kept green:** every 12.3 test, including the model sweep. `Reach` is a move: `ApplyPooled`, `Lanes` and the -clip rule are otherwise untouched. - -**Negative greps:** - -- `rg -n "float Absorb\(|\.Absorb\(new int2|arget\.Absorb\(|Splash\(|HasFlag\(WeaponModifiers|AirburstRange|BurstRadius" Assets/Scripts/ServerShared Assets/Scripts/Gameplay/Weapons tests/Aetheria.Shared.Tests` - returns nothing. Today it returns 27 lines, in `Entity.cs`, `FireControl.cs`, `ItemData.cs`, `Mine.cs` and - `FireControlCut4/5/6/11/123Tests.cs`. - - The pattern is code-shaped on purpose. `CapabilityEvents.Absorb` and `ShieldInterceptor.Absorb` are - unrelated. - - Exclude `StrykerOutput`, `bin` and `obj`. -- `rg -n "FireControl\.Splash|Entity\.Absorb\b|calls Splash" Assets/Scripts` returns nothing. That is the - stale-comment list above. - -**Stryker:** `--mutate` on `FireControl.cs` and `Entity.cs`, with the report filtered to the changed lines. -`--since` does not work in this repo (12.1 status). Float boundary flips count as equivalent (11.2). - -#### 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 verifies `ToSchematicPoint`'s centre-of-mass anchor, which is the half of - the frame that only a running blast can show. - -#### History (superseded 2026-09-25, kept for the record) - -The first draft (2026-09-22) was written before 12.3's rulings. Its design is retired in these parts: - -- **"An occupied cell takes its share through `Absorb(c, …)`; each covered cell absorbs independently."** - Superseded by the proportional and one-path rulings. `Absorb` gates the item per cell (`ProbeC2`). Armour - stays per cell, and the item pools its cells and resolves once through `ItemAbsorb`. -- **"`Entity.Absorb` owns per-cell absorption, shared with 12.3."** Superseded. 12.3 ended with - `ArmorAbsorb` and `ItemAbsorb` as the owners, and `Absorb` dies with Splash. -- **"A shielded contact or delayed hit is absorbed whole, as a direct hit is, and does not detonate."** - Withdrawn pending Q12-9. As written, Commit decided the host's shield on the whole damage, and then - `Detonate` decided it again, live. A shield raised between commit and arrival could absorb a hit the commit - had judged unshielded: two authorities over one shield. -- **"Step calls it at arrival with the frozen `BurstPosition`."** Superseded. Apply owns the fuse switch, and - `Step` has no damage branch. -- **"The walk only locates P" for the delayed fuse.** Kept, and made precise: the reach clip becomes the one - `Reach` function, and P is `point(min(impact.Entry + Penetration, lastReached.Exit))`. -- **Anchors.** All anchors were against `cd846916`, and every one has moved. For example, the label read was - `:281-284` and is now `:382-388`, and Splash was `:488-529` and is now `:946-994`. - -### 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-752`, CultLib `45c2f40`); drift warnings until the next catalog write | -| `Fuse` | 32 | 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.Aimed` is deleted. -- **Q12-6: no.** Operator: "12-6, nope". No catalog test ties a label to behaviour. - `WeaponModifiers` stays 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-9: A, ruled 2026-09-26.** The blast decides every blast's shields, the host included, live at detonation. Each shield pays for its covered share of the disc. The operator chose the recommended option: "The blast decides". Commit decides no shield for a shot that carries a fuse. The question as asked is kept below. - -**Q12-9 (new, blocks only the contact- and delayed-fuse half of 12.4(b)). Who decides the host's shield for a -blast that needs a hit?** - -The current map lets two places decide it: - -- Commit decides the shield on the whole damage (`FireControl.cs:578-581`), as for any hit. -- `Detonate` then decides every shield in the radius live, and that includes the host. - -**(read)** When the commit breaks the shield, Apply calls `Break()` and `Detonate` finds it already broken, so -the two agree. When the shield is inactive at commit and raised before arrival, `Detonate`'s live check can -absorb a hit the commit judged unshielded. That is a second authority over one shield: the same kind of split -this campaign has cut three times. - -- **A (recommended): `Detonate` decides every blast's shields, the host included.** Commit decides no shield - for a shot that carries a fuse. The shield is charged the host's covered share, like every entity in the - radius, live at detonation. - - Proximity bursts and mines already work this way, so there is one rule for all blasts: "a blast has no - direction", applied to shields. - - Cost: a shield that could have absorbed the whole shot now pays only for the part of the disc that - reaches the hull. For a contact detonation on the hull's face, that is roughly half. - - `ShotOutcome.Shielded` and `ShieldBroken` become direct-hit-only. Nothing outside `FireControl.cs` reads - them. -- **B: Commit owns the host's shield, as it does for a direct hit.** A shielded contact hit is absorbed whole - and does not detonate. `Detonate` must then skip the host's shield, which needs an "already decided" - parameter: a compensator that keeps two owners alive, one per entity. - -What depends on it: - -- the gate on Commit's shield block; -- whether `Detonate` takes a host parameter; -- `ABlastShotsShieldIsDecidedAtDetonation` and the host case of `ShieldPaysForWhatReachesIt`. - -Proximity bursts, mines and the whole area model are unaffected either way, so 12.4(a) and the proximity half -of (b) can proceed while this waits. - -**Defaults taken, not rulings.** The operator may overrule any of these: - -- A blast shot ignores `DamageSpread`, and P sits on the centre lane. -- `IncomingHit` fires for the host of a committed contact or delayed hit, and for no one else in a blast. -- The delayed fuse point is clamped to the last reached cell's exit. -- `Detonate` runs a flat armour→pool→hull pass, not `ApplyPooled`. `ProbeC` shows the two are equivalent. - -- **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 `Silhouette` - and `Lane`, 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. - -### 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:117` gates 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 `BlastRange` and the arming OverlapSphere - (`Mine.cs:69-78`, `:99`) are presentation-side (Cut 4 Q7). Mine has no catalog weapon - record carrying `Fuse`/`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 and - `HullDamage` without `Absorb`. 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 (estimates, with actuals) - -| Sub-cut | Removed | Added | Notes | -|---|---|---|---| -| 12.0 | 0 | ~40 src + ~30 test (CultLib) | release `cultmath-unity-v0.2.4`, manifest bump | -| 12.1 | est. ~15, **actual 18** | est. ~6, **actual 16** | no behaviour change | -| 12.2 | est. ~65, **actual 63** | est. ~95, **actual 235** | `Forecast`, pooling, the `Extent` owner and the fix batches | -| 12.3 | est. ~80, **actual 53** | est. ~45, **actual 227** | `ApplyPooled` (~140), `Lanes` (~40), `ArmorAbsorb`/`ItemAbsorb` | -| 12.4 (est.) | ~50: Splash (~35), `Absorb` (~9), the label read (~4), `Step`'s branch (~2) | ~110: `Detonate` (~40), the exact overlap (~25), the point transforms (~12), Apply's fuse switch and P (~15), `Reach` (moved, ~+2 net), `WeaponFuse`, slot 32 and `PendingShot.Fuse` (~10) | 8 Splash/airburst tests rewritten, 2 `Absorb` tests retargeted and 1 replaced, ~12 new | -| **Net Aetheria src** | **~184** | **~588** | one persisted slot added (nullable) and one renamed; no targets, dependencies or formats; CultMath gains 2 functions | - -Actuals count non-comment, non-blank changed lines under `Assets/Scripts`: - -- 12.1: `b851b0d1^..b851b0d1`; -- 12.2: `b851b0d1..c7b6e80c`; -- 12.3: `c7b6e80c..d402283c`. - -The net is no longer roughly flat, and the old sentence claiming it was should go. What the growth bought: - -- 12.2: exact 1D placement with one geometric owner, and the fix batches that removed two crash classes; -- 12.3: sequential, proportional, order-free absorption; -- 12.4: a conserving area model that replaces a half-hull approximation, and a net deletion of `Splash`, - `Absorb` and `Step`'s branch. - -12.4's own delta is ~+60, and that is the estimate to hold it to. - -**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:946-959`) -describe models this cut replaces. Self should mark them as history in the status header -as 12.2 and 12.4 land. +# 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 `Tool` gear. +- **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 `TargetArmorInfoThreshold` and + `TargetGearInfoThreshold`**, 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 from `GameplaySettings`; an optional + per-hardpoint override; turrets 360. +- **R7. The simulation is 2D.** The 3D is set dressing. +- **R8. Unity weapon effects and `HullCollider` become presentation only.** Their + `Physics.*` queries stop deciding damage. +- **R9. Every weapon type in the catalog's history stays** + (`docs/headless-playground-cut.md` fork 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)**: + +1. `InstantWeapon.Execute` (`Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs:216-237`) + walks the burst and raises `OnFire` (`:228`), a parameterless `event Action` declared + at `:73`. It carries no outcome and nothing in `ServerShared` subscribes. +2. `Assets/Scripts/Gameplay/EntityInstance.cs:203-204` subscribes and calls + `InstantWeaponEffectManager.Fire(weapon, item, source, target)` + (`Assets/Scripts/Gameplay/Weapons/InstantWeaponEffectManager.cs:7`). The constant + family goes the same way at `:232-235`. +3. The manager spawns an effect and copies the weapon's resolved numbers onto it. + `ProjectileManager.cs:18-25` jitters the barrel forward vector by + `Random.Range(±Spread/2)` on all three Euler axes — that is the entire aiming model. +4. 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`, plus `Projectile.cs:114` for airburst. +5. On a hull collider it calls `HullCollider.SendHit` + (`Assets/Scripts/Gameplay/HullCollider.cs:18-30`), or `SendSplash` (`:32-41`). +6. `EntityInstance.cs:316-396` subscribes to those subjects, converts a UV texture + coordinate into schematic cells, expands by `DamageSpread`, marches a penetration ray, + and calls the local function `DamageSchematic` (`:277-314`), which is the only writer + of `Armor`, item `Durability` and `Hull.Durability` from combat. +7. Death: `EntityInstance.cs:405-439` subscribes to `HullDamage`, rolls loot with + `UnityEngine.Random`, and calls `entity.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`, a + `ReactiveProperty`. Cleared when the target leaves `VisibleEnemies` (`:196`, + `:222`). `TargetRange` is recomputed each tick at `:920`. +- `Entity.EntityInfoGathered` — `Entity.cs:57`, a `ReactiveDictionary` in + [0,1], accumulated with decay by `Sensor.Execute` + (`Assets/Scripts/ServerShared/Behaviors/Sensor.cs:152-184`). +- `TargetDetectionInfoThreshold` gates `VisibleEnemies` at `Entity.cs:232-246`. +- **`TargetArmorInfoThreshold` and `TargetGearInfoThreshold` are authored and read by no + code.** They are declared at `Assets/Scripts/ServerShared/Settings.cs:212-213` and + appear nowhere else in `Assets/Scripts` **(read)**. R5's reveal tiers are unwired + settings. +- `LockWeapon` — `Assets/Scripts/ServerShared/Behaviors/LockWeapon.cs`. Integrates a + 0..1 `_lock` scaled by `pow(EntityInfoGathered[target], SensorImpact)` (`:92`), gated by + a `LockAngle` cone off `Entity.LookDirection` (`:88-89`). `CanFire` (`:56`) requires + `_lock > .99f` and 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`. + `TurretControllerData` has no fields (`:15-25`). It writes `Entity.LookDirection` to a + first-order intercept (`:67-72`) and fires each weapon when + `dot(x.Direction, Entity.LookDirection) > .99f` (`:81-83`). +- `Sensor` — sensitivity, ping boost/energy/visibility/range/cooldown, all + `PerformanceStat` (`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|Aim` finds one `#region Targeting` + label at `ActionGameManager.cs:368` and 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 on `HardpointData`, `WeaponData`, or any effect.** + A per-hardpoint override attaches here, as catalog data already authored per hull. +- `ItemRotation` is four-way (`Assets/Scripts/ServerShared/Enums.cs:7`); + `Extensions.cs:135-166` gives `None` = forward, `Reversed` = aft, `Clockwise` = right, + `CounterClockwise` = left. `Agent.cs:72` reads `Clockwise` as right and `Thruster` uses + 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`) reads `Entity.HardpointTransforms`; the second + (`:37-38`) is the item-rotation fallback R6 wants. +- **`Entity.HardpointTransforms` (`Entity.cs:58-59`) is written only by Unity**, at + `EntityInstance.cs:514-517`, from the first `WeaponHardpoint.FiringPoint` barrel's + world forward. Headless it is empty. That single dictionary is why + `Combat.cs:100-102` throws `KeyNotFoundException` on 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 toward `LookAtPoint` + (`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 `Shape` and a cell size give an angular radius; `SchematicCellSize` + does 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.** `ConstantWeaponData` adds only `AmmoInterval` + (`ConstantWeapon.cs:15`); `Spread` is inherited and unread by any beam code, and + `Velocity` is 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.ChargeFiringSpreadMultiplier` is a plain `float`** + (`ChargedWeapon.cs:34`), not a `PerformanceStat`, 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, and `GuidedWeaponData` (`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)**: + +1. `Combat.cs:100-102` indexes `HardpointTransforms`, which only Unity writes (§0.3). + `Minion` enters `CombatState` as soon as `VisibleEnemies` is non-empty, so any NPC + that sees an enemy throws on the tick it sees them. +2. There is no owner between `InstantWeapon.OnFire` and `Entity.HullDamage`. The only + `ServerShared` writers 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`), 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`). | 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`. | 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 + +1. **Mount direction and arc become simulation data.** Deletes `HardpointTransforms` + and the Unity readback; adds the arc gate; unbreaks headless combat. +2. **Targeting system and reveal.** The new subsystem, the reveal rule, the selection + slot, and the two dead settings wired up. +3. **Hit authority moves.** The Physics path in the four effects the catalog actually + uses is deleted and `FireControl` plus the shot lifecycle replaces it, in one cut, so + damage is never applied twice. +4. **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`, branch `codex/fire-control` from `codex/item-provenance` + `8a00fc92`. Depends on nothing. +- **First:** update `F:\Projects\Aetheria-stats` to HEAD (it sits 11 commits behind at + `c4f6029e`); capture `AetherDb census`, `loadout 1`, `dangling` as 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 in + `Behavior.Direction` (6). The item-rotation path at `:37-38` becomes the whole body. +- `Assets/Scripts/ServerShared/Agents/States/Combat.cs:100-102` — the + `HardpointTransforms` dot test (3). +- `Assets/Scripts/ServerShared/Behaviors/TurretController.cs:81-83` — the `.99f` dot + 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 from + `Behavior.Direction`), `ArcFor(EquippedItem)` and + `bool InArc(EquippedItem weapon, float3 toTarget)`, which tests + `dot(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`, next `Key`) — full width in degrees, + `0` meaning "use `GameplaySettings.FiringArc`". +- `GameplaySettings.FiringArc = 120` (`Settings.cs`, after `:214`). +- `GameplaySettings.AgentMinHitProbability` is **not** added here; Cut 1's AI gate is + `InArc` alone, 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-98` stays — it still aims `LookDirection`, 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, whose + `Direction` never rotates (`TurretController` writes only `LookDirection`), would + engage only within 60 degrees of its spawn facing. R6 says turrets are 360; the turret + hull's hardpoints author `FiringArc: 360`. See Q1. +- Catalog: author `FiringArc` on the `Turret` hull's two Ballistic hardpoints. Follows + the Cut C precedent — a Hands scratch console upserts, Unity and Studio closed. + +**Authority map:** + +- **Owner:** `FireControl` owns whether a weapon bears on a point. `HardpointData` + authors the arc; `ItemRotation` authors 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 + by `FireControl.Fire`. +- **Derived state:** `Behavior.Direction` becomes purely derived from item rotation. +- **Forbidden writers:** no Unity transform, `ArticulationPoint`, `WeaponHardpoint` or + prefab node may influence aim. `Barrels`, `GetBarrel` and `ArticulationPoint` survive + as presentation and must never be read by `ServerShared`. +- **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:** `HardpointTransforms` and its writer and readers are gone before + `FireControl.cs` is written. + +**Verification:** + +- builds: `Aetheria.Shared/Aetheria.Shared.csproj`, `tests/Aetheria.Shared.Tests`, + `tools/AetherDb`. +- tests, each named with the rule it pins: + - `ArcFollowsMountRotation` pins *mount direction is the item's rotation, not the + hull's facing*: a fixture hull with one weapon on a `None` hardpoint and one on a + `Clockwise` hardpoint; a target 90 degrees to starboard passes only for the + `Clockwise` weapon, a target dead ahead only for the `None` weapon. Mutation: make + `InArc` use `Entity.Direction`; swap `Rotate`'s `Clockwise`/`CounterClockwise` cases. + - `ArcBoundaryIsHalfWidth` pins *`FiringArc` is full width*: at 120, a target 59 + degrees off passes and one at 61 does not. Mutation: compare against + `cos(radians(arc))`. + - `ArcIsPlanar` pins R7: a target inside the arc horizontally but far above the firer + still passes. Mutation: use the 3D vector. + - `HardpointOverrideBeatsDefault` pins *the per-hardpoint override wins*: the same + weapon on a hardpoint with `FiringArc: 360` passes at 170 degrees, and fails there on + a hardpoint with `0`. Mutation: ignore the field; treat `0` as an arc of zero. + - `CombatStateStepsHeadless` pins *the simulation decides aim without Unity*: a + `Minion` with a target in range steps through `Zone.Update` without throwing. + Mutation: restore the `HardpointTransforms` read. +- negative: `rg -n "HardpointTransforms" Assets/Scripts` empty; + `rg -n "ArticulationPoint|FiringPoint|WeaponHardpoint" Assets/Scripts/ServerShared` + empty; `rg -n "AgentFiringMinDot" Assets/Scripts` empty. (`\.99f` is **not** a safe + negative grep — it occurs legitimately in `LockWeapon.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 every `ItemData`. +- **First:** `AetherDb census` and `loadout 1` over 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 free `Union` index on `BehaviorData`; Hands + reads the current list in `Behaviors.cs` rather than trusting the number 39 quoted in + the superseded draft) plus a `TargetingSystem : Behavior` that 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 and `PowerSupply` reach them + through `EquippedItem.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 existing `EnergyDraw` and `Heat` behaviours 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 uses `Resolution = 1` and `Precision = 0`: it can still + select a revealed item, and its hits scatter. +- `FireControl.IsRevealed(Entity observer, EquippedItem item)` — derived, never cached: + 1. Rank the target's non-hull equipment: hardpoint-mounted first, then size + descending, then equipment index. + 2. Item `i` of `N` is revealed when + `info >= lerp(TargetArmorInfoThreshold, TargetGearInfoThreshold, i / max(1, N - 1))`. + + This is the first and only reader of `Settings.cs:212-213`. +- `Entity.TargetItem` (`ReactiveProperty`, beside `Target` at + `Entity.cs:46`) and `bool Entity.TrySelectTargetItem(EquippedItem)`, the only writer. + It accepts null, or an item of `Target.Value` that `IsRevealed`. A `Target` change + 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`, calling + `TrySelectTargetItem`. 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: `CombatState` selects the revealed target `Weapon` item with the highest + `RangeDamagePerSecond(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 `IsAvailable` finds one in every + galaxy. `LoadoutGenerator.FillInterior` picks one with `required: true` for every + entity with `Weapons` — the starting LonginusX, NPC ships and turrets. Zenith, with no + weapon hardpoints, gets none. + +**Authority map:** + +- **Owner:** `FireControl` owns the reveal rule. `Entity` owns the selection slot. + `TargetingSystem` owns 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 `EquippedItem` per 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 `TargetItem` except through + `TrySelectTargetItem`; no code may store a reveal result; no code may read the two + thresholds except `IsRevealed`. +- **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: + - `SelectionNeedsReveal` pins *you cannot aim at what you have not resolved*: below + the item's tier `TrySelectTargetItem` returns false; above it, true; a target change + nulls it; info decaying below the tier makes the read null. Mutation: `IsRevealed` + returns true; the `Target` change skips the null; the accessor returns the stored + value without re-checking. + - `RevealOrder` pins *the big obvious things resolve first*: hardpoint items reveal + before interior items, larger before smaller. Mutation: drop the ordering. + - `RevealSpansBothThresholds` pins *the tiers are the authored ones*: with N items, + the first reveals at `TargetArmorInfoThreshold` and the last at + `TargetGearInfoThreshold`. Mutation: swap them; use the detection threshold. + - `UnaidedFiresWorse` pins *the subsystem is what supplies accuracy*: an entity with + no targeting system resolves `UnaidedAccuracy` and `Precision` 0; one with a + destroyed or offline system resolves the same. Mutation: fall back to the system's + stats when offline. + - `StarvedTargetingRollsWorse` pins the brownout ruling on accuracy: with an + `Accuracy` stat carrying a `PowerSupply` term, a half-grant halves it rather than + switching the item off. Mutation: gate on `PowerSupply <= 1e-4`. + - `EveryArmedLoadoutGetsATargetingSystem` pins the required-item rule across seeds. + Mutation: drop `required: true`. +- negative: `rg -n "TargetArmorInfoThreshold|TargetGearInfoThreshold" Assets/Scripts` + returns `Settings.cs` and `FireControl.cs` **only**. +- operator: `AetherDb census` shows 2 more designs; `loadout 1` shows one more interior + item per armed entity; `dangling` is 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` — `DamageSchematic` and both + `HullCollider` subscriptions (120). +- `Assets/Scripts/Gameplay/HullCollider.cs:13-14,18-41,48-65` — `Hit`, `Splash`, + `SendHit`, `SendSplash` and 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 and + `SendHit` (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; the `LineRenderer` + endpoint writes at `:59,70,77` are presentation and are rebuilt from the outcome (36). +- `Assets/Scripts/Gameplay/Weapons/Lightning.cs:34-72` — the same; the endpoint capture + at `:67-71` stays as a visual (39). +- `Assets/Scripts/Gameplay/EntityInstance.cs:411-431` — the `UnityEngine.Random` loot + 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 from + `EntityInstance.cs:277-314`, now a method on `Entity` in `ServerShared`, writing + `Armor`, item `Durability`, `Hull.Durability` and raising the existing `ArmorDamage`, + `ItemDamage` and `HullDamage` subjects. Not a redesign: the same arithmetic, the same + order, the same thresholds. +- `Entity.ApplyHit(...)` — the shape construction moved from + `EntityInstance.cs:334-394`, with two changes forced by R7: the hit cell comes from the + rolled `Cell` rather 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.Direction` instead of `transform.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]`; a `LockWeapon` is not + locked; or `InArc` fails. + - Otherwise `p = Accuracy * pSensor * pSpread`, where + `pSensor = saturate(unlerp(TargetDetectionInfoThreshold, Resolution, info))` and + `pSpread = Spread > 0 ? saturate(angularRadius / (Spread / 2)) : 1`, with + `angularRadius = degrees(atan(0.5 * max(Shape.Width, Shape.Height) * SchematicCellSize / range))`. + - `SchematicCellSize` is the one new tuning constant (Q4). +- `FireControl.Fire(weapon, item, source)` — called once per burst step from + `InstantWeapon.Execute` (replacing the bare `OnFire?.Invoke()` at + `InstantWeapon.cs:228`). It computes the predicted intercept, the flight time + (`range / Velocity`, or zero when `Velocity` is at or near zero), **freezes the payload + snapshot** (R10) and appends a `PendingShot` to `Zone.PendingShots`. The snapshot holds + `Damage`, `Penetration`, `DamageSpread`, `DamageType`, `Aimed`, the resolved targeting + stats, and the intercept point. Nothing downstream re-evaluates a stat. +- `FireControl.Step(Zone, dt)` — called from `Zone.Update` after the entity loop + (`Zone.cs:150`). For each pending shot: age it; at `ArrivalTime - CommitHorizon` + compute deviation, roll, write `Outcome`, publish `Zone.ShotCommitted`; at + `ArrivalTime` apply the outcome and remove the shot. A shot whose flight is shorter + than the horizon commits at fire time, in the same call as `Fire` — 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: `pDeviation` folds in how far the target is from the predicted + intercept at commit time, forgiven by the targeting system's `Tracking`. 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 against `Precision` decides + where — on `Aimed`'s footprint cells on success, otherwise on one cell drawn uniformly + over the hull `Shape`. An active shield that `CanTakeHit` takes the hit instead, and + the outcome is marked `Shielded`; that rule now exists once, in `FireControl`, rather + than seven times in the effects. +- `Zone.ShotCommitted` and `Zone.ShotResolved` (`Subject`), the presentation + feed. +- `InstantWeapon.OnFire` becomes `event Action` carrying the `ShotId`, 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 into `Damage` and `Spread` before this point. +- `Combat.cs:100-102`: + `shouldFire = FireControl.HitProbability(...) >= Settings.AgentMinHitProbability`, + replacing Cut 1's bare `InArc` (which the probability already subsumes, returning zero + out of arc). `AgentMinHitProbability` is 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 the `ShotId` and looks the + outcome up, or subscribes to `ShotCommitted`. 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:** `FireControl` owns the engage gate, hit probability, the roll, the cell, the + shield branch, and when each of those happens. `Entity` owns the application of damage + to its own schematic. `Zone` owns the pending-shot collection. +- **Inputs:** weapon stats through `EquippedItem.Evaluate`; targeting-system stats; + `EntityInfoGathered`; `Target` and `TargetItem`; both entities' positions, velocities + and planar directions; mount direction and arc; `ItemManager.Random`. +- **Outputs:** `ShotOutcome` on two subjects; `Entity.DamageSchematic`; shield + `TakeHit`; `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 in + `Assets/Scripts/Gameplay/Weapons` (except the unused four Cut 4 handles) may reach + durability; `HullCollider` may not raise a hit; `EntityInstance` may not damage a + schematic; no effect may call `TakeHit`; nothing outside `FireControl` may 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.Fire` and `FireControl.Step`. + There is exactly one commit primitive. +- **Deletion line:** the six deletions above land before `FireControl.Fire` exists. + +**Verification:** + +- builds: `Aetheria.Shared`, `tests/Aetheria.Shared.Tests`, `tools/AetherDb`; and Unity + batchmode with the editor closed (`docs/item-provenance-cut.md` §6) reporting no + `error CS`. +- tests, each with the mutation that must kill it: + + | Test | Pins | Mutation | + |---|---|---| + | `RollsAreSeeded` | *a fight is reproducible* — two zones, same seed, 50 shots, identical outcome sequences | `FireControl` draws from `new Random()` | + | `ProbabilityFollowsInputs` | *the roll reads what the ruling says it reads* — zero below detection and outside range; rising with info to `Resolution`; falling with `Spread`; capped at `UnaidedAccuracy` with no system | drop `pSensor`; drop the range gate; drop the cap | + | `OutOfArcConsumesNoDraw` | *the gate is a gate, not a penalty* — the RNG sequence is unchanged by an out-of-arc trigger | roll first and multiply by zero | + | `ShotResolvesOnArrival` | R3 — no durability change before `range / Velocity`; a target removed mid-flight takes none | apply at fire time | + | `OutcomeCommitsBeforeImpact` | R4 — `ShotCommitted` fires exactly `CommitHorizon` before `ShotResolved`, once per shot | publish at arrival; publish twice | + | `EvasionCountsUntilCommitAndNotAfter` | R4'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 | + | `ShortFlightCommitsAtFire` | *the 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 | + | `OutcomeIsSnapshotNotReread` | R10, the seam the stats campaign named — a weapon whose `Damage` collapses (power cut, heat spike) between fire and arrival still lands the damage it rolled | re-evaluate `Damage` at arrival | + | `AimedHitLandsOnSelectedItem` | R5's payoff — `Precision` 1 and `p` 1: only the aimed item's durability falls | the roll ignores `Aimed` | + | `HardpointHitDamagesItemThenHull` | the 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 | + | `PenetrationMarchIsPlanar` | R7 — the march rotates by the target's planar direction and gives the same cells whatever the height difference | use a 3D rotation | + | `ShieldTakesHit` | *the absorb rule has one owner* — an active shield absorbs and the schematic is untouched | remove the shield branch | + | `DeadEntityStopsTakingShots` | *a 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-wide `Physics\.` grep is **not** valid until Cut 4, and never for + `TractorBeam.cs`, which stays.) + - `rg -n "SendHit|SendSplash|DamageSchematic" Assets/Scripts --glob '!**/ServerShared/**'` + — empty. + - `rg -n "TakeHit\(" Assets/Scripts --glob '!**/ServerShared/**'` — empty. Note + `CanTakeHit` shares the substring; the trailing `(` on `TakeHit` distinguishes them, + and the `Can` prefix 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 needs `AetherDb roles-migrate` as a dry run, which the permission classifier + blocked; the operator can unblock it or Hands can add a read-only `behaviors` command. + **If any of these is live, it moves into Cut 3 instead.** + +**Deletes first:** + +- `Assets/Scripts/Gameplay/Weapons/Projectile.cs:109-122` — airburst `OverlapSphere` and + `SendSplash` (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:72` and its `_hull` plumbing + (~8) — every remaining `Physics.*` hit query and `SendHit`/`SendSplash` call in + `Assets/Scripts/Gameplay/Weapons`. + +**Adds:** + +- `ConstantWeapon` rolls once per `GameplaySettings.BeamResolveInterval` for + `Damage * interval`, through the same `FireControl.Fire`/`Step` pair with a flight time + of zero, raising `OnBeamShot(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 existing + `DamageSchematic` over the directional half-shape the deleted + `EntityInstance.cs:318-329` computed, 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`, `TractorBeam` and 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: + - `BeamRollsPerInterval` pins *a beam is a sequence of rolls, not a continuous truth*: + over one second a beam produces `1 / BeamResolveInterval` outcomes totalling + `Damage`. Mutation: roll per tick; roll once. + - `SplashHitsEveryEntityInRadius` pins *splash is one rule, not per-effect*: two + targets in radius both take damage, one outside takes none. Mutation: damage only + the nearest. + - `SplashIsDirectional` pins 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/Weapons` returns **only** + `Mine.cs`'s placement query, if Q7 keeps it — otherwise empty. `TractorBeam.cs:21` is + outside this directory and stays. + `rg -n "SendHit|SendSplash" Assets/Scripts` empty. + `rg -n "class HullHitEventArgs|class HullSplashEventArgs" Assets/Scripts` empty. +- 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 + from `c4f6029e` to HEAD). Never the main tree — the operator plays there. Never + `F:\Projects\Aetheria-tiers`. CultLib via + `-p:CultLibRoot=\cultlib-45c2f40`, the shared pinned worktree, which is + never removed. `--artifacts-path` into scratch so no `bin`/`obj` lands 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 touches `Assets/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, run + `dotnet test tests/Aetheria.Shared.Tests --cultlib-root `, assert the named test + goes red, with a no-op control. Note the scar from `8dddda4d`: 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.ShotCommitted` and `Zone.ShotResolved` are the whole + contract. What a projectile, panel, whip or near-miss *does* with a commit belongs to + `docs/shield-presentation-contract.md` and the panel and Lariat maps. This campaign + guarantees only that the commit exists, is authoritative, arrives `CommitHorizon` + before 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 in `GameplaySettings`. +- **Death, removal and loot.** `Entity.Death` is already composed in `ServerShared` + (`Entity.cs:363-366`), but `Zone.Entities.Remove` and the loot roll live in Unity + (`EntityInstance.cs:411-437`) and use `UnityEngine.Random`. Fire control makes a + headless kill *happen*; it does not make the corpse go away. Q3. +- **Pickup.** `ShieldManager.cs:40-51` stores 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`, beside `UnaidedAccuracy`: add `public 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.Tracking` falls back to `settings.UnaidedTracking`, exactly as + `Accuracy` falls back to `UnaidedAccuracy`. One shape for both. +- `Commit` then 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`. + +- `ShotOutcome` gains `public bool ShieldBroken;`. +- `Commit` decides it where it already asks `CanTakeHit`: shield present, active, + and `CanTakeHit` false means `ShieldBroken = true`. The decision is frozen with + the rest of the outcome (R4) -- a shield that recharges during the flight does + not retroactively survive. +- `Apply` performs it: `if (shot.Outcome.ShieldBroken) shot.Target.Shield.Break();` + before the hull damage. `CanTakeHit` is 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`, matching `InstantWeapon.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. + +- `InArc` takes the rule: a planar bearing shorter than `1e-6` returns true -- + point-blank cannot fail a bearing test. +- `Weapon.ArcAllowsFire` drops its own `lengthsq` special case and simply calls + `InArc`. 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.IsRevealed` returns false for an item whose + `EquippableItem.Durability < .01f`, and the ranking excludes destroyed items so + the reveal tiers of the survivors close up rather than leaving a gap. +- `Entity.ResolvedTargetItem` therefore 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. + +- `Zone` subscribes each entity's `Death` observable when the entity joins, and + removes it from `Entities` on death. The simulation owns the corpse. +- `EntityInstance.cs:324` deletes its `Entities.Remove` call 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 `flightTime` stays; it still + computes `ArrivalTime` and `CommitTime`. + +### 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` -- delete `shield.Break()` from `Splash`. Must die. +- `Weapon.cs:117` -- `ArcAllowsFire => true`. Must die. +- `InstantWeapon.cs:105` -- delete the `if (!ArcAllowsFire) return;` player arc gate + (Q2, the ruling Cut 1 deferred to Cut 3 and nothing verifies). Must die. +- `FireControl.cs:270` -- `elapsed = shot.FlightTime` in place of `now - shot.FireTime`. + This is the map's own declared mutation for `EvasionCountsUntilCommitAndNotAfter`, + and it survives because that test's fixture gives the target zero velocity, so + `FireTargetVelocity * elapsed` is identically zero and the mutation is a no-op + inside it. Give the fixture a moving target. (After 5.7 deletes `FlightTime` the + mutation's spelling becomes `elapsed = 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. + +- `Zone` exposes `public 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. +- `Commit` stops touching `ItemManager.Random` entirely. It builds its own local + `CultMath.Random` from the zone seed and the shot's own id: + `var random = new Random((zone.CombatSeed * 2654435761u) ^ (uint) shot.ShotId | 1u);` + The `| 1u` guards 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: + +- `PendingShot` regains a burst point — `public float3 BurstPosition;` — frozen + at fire like every other payload field, from `PredictedIntercept` (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). +- `PendingShot` gains `public float BurstRadius;`, frozen from the weapon's + authored airburst range, and zero for a weapon without the `Airburst` flag + (`Enums.cs:101`). +- `FireControl.Fire` sets both when the weapon's `WeaponData` carries `Airburst`. +- `FireControl.Step`, at arrival, resolves an airburst shot by calling + `Splash(zone, shot.BurstPosition, shot.BurstRadius, shot.Damage, shot.DamageType)` + **instead of** `Apply`. 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.cs` deletes `AirburstDistance` and `AirburstRange` and the stale + comment; `ProjectileManager.cs:32` deletes 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. + +- `SameFightRollsSameThroughUnrelatedDraws` pins 6.1: run a fixed fight twice from + one zone seed, drawing an arbitrary number of values from `ItemManager.Random` + between the two runs, and the outcomes must be identical. Mutation: restore the + `ItemManager.Random` draw. Must die — and note that this is the test Soul's own + probe could not be, because a hermetic fixture cannot see the defect. +- `ShotIdDecidesTheDie` pins 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: drop `ShotId` from the seed. +- `AirburstSplashesAndDoesNotAlsoRoll` pins 6.2: an airburst shot damages + everything in its radius, and its target takes area damage rather than a + discrete cell hit. Mutation: call `Apply` as well as `Splash`. Must die. +- `NonAirburstNeverSplashes` pins the other side. Mutation: splash unconditionally. +- negative: `ItemManager.Random` appears nowhere in `FireControl.cs`; + `Airburst` appears nowhere in `Assets/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: + +- `HitProbability` derives the ceiling rather than reading it directly: + `var demandCeiling = detection + (1f - detection) / max(Resolution(source), 1e-3f);` + then `pSensor = saturate(unlerp(detection, demandCeiling, info))`. +- Resolution `1` therefore means "needs complete information before sensor data + stops penalising this system" -- which is precisely what the unaided fallback + of `1f` should 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 the `max`, 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-cell `4` (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: true` interior + 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, and `AetherDb + loadout` reports 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 higher + `HitProbability`, 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 loadout` across 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 raw `TargetItem.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 test` green, 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: + +```csharp +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 + `CenterOfMass` when 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`, joining + `UnaidedAccuracy` and `UnaidedTracking` in `GameplaySettings`) is a sigma wide + enough to read as spraying at the silhouette. +- **Weights.** `w(cell) = exp(-d² / 2σ²)` for each occupied hull cell, `d` the + 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. + +- `HitProbability` gains `pOnHull` as 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. +- `Commit` draws the cell as a weighted pick over the same `w(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 `Precision` values (`.05-.2` and + `.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 reciprocal `Resolution`. +- **Double-charging risk.** `HitProbability` already carries `pSpread`, which + penalises a small target against a wide weapon spread. `pOnHull` also + 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.cs` and `TurretController.cs` gate on + `AgentMinHitProbability` against this number, so NPCs will hold fire more than + they used to the moment `pOnHull` lands. 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`: `HitProbability` against 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: drop + `pOnHull` from the probability. Must die. +- `PlacementAndProbabilityShareOneKernel`: the empirical on-hull rate over many + seeded draws matches the `pOnHull` the 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 + through `AetheriaStores.Open`, enumerate every `EquippableItemData` so every + document type is actually deserialized (a lazy read that never touches + `WeaponItemData` would have passed on the broken merge), and generate a + loadout for `LonginusX`. 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();`, matching + `TryDock`'s existing pairing exactly. One transition, one owner. +- **Ordering risk to verify, not assume:** `EntityInstance` also subscribes to + `Entity.Death` (its loot drop and destroy effect, `EntityInstance.cs:300-325`). + Subscription order decides whether loot drops before or after `Deactivate`. + `Deactivate` does not touch `Equipment` or `CargoBays`, 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.Update` returns 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 further + `Update`, and a `LockWeapon` on a ship whose target dies the same frame does not + throw. Mutation: drop the `_active` guard. Must die. +- `DeathDeactivates`: a dead entity reports `IsActive == false` and holds no live + subscriptions. Mutation: remove the `Deactivate()` call. Must die. +- `LootStillDropsOnDeath` if 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.Random` a 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 + intermediate `p` (say .3 and .6) and assert the observed hit fraction is near + `p`, 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: 1` or `accuracy: 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 `pOnHull` monotonic 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, whenever `totalWeight == 0`, returning a fixed corner cell. + It is unreachable today only because `pOnHull == 0` forces `PBase == 0` first. + 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. + +- `Deactivate` nulls `Target` (and `TargetItem`), or `Activate` reconciles it + against the reseeded `EntityInfoGathered`. One of the two owns it; say which. +- Still not a `TryGetValue` in `LockWeapon`. The invariant is that an active + entity's `Target` is 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.sh` with 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; `dangling` 0. +- 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= CULTMATH_ROOT= dotnet stryker --since: + +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 - source` equals `target + source`, so four + separate position mutations survive (`FireControl.cs` HitProbability, Inspect, + Fire, Apply). +- **Fire time zero.** `now - FireTime` equals `now + FireTime`, so the elapsed-time + mutation survives -- which means `EvasionCountsUntilCommitAndNotAfter` still does + not defend the rule Cut 5 gave it a moving target to defend. +- **Spread zero everywhere.** `PSpread` returns 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 since `482c47bf`, 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.TakeHit` from 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; `TheDieIsUniform` checks uniformity within a zone only. Tighten it + enough that the `MixSeed` weakenings 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: + +- `Inspect` recomputed `PBase` as 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, so `PSpread` was 1). `PBase` is now + `HitProbability`'s own answer; the test became + `TheHudShowsTheFactorsTheSimulationMultiplies`, over a spread cone that does not + fill the silhouette. `Fire`'s redundant `target != null ?` guard around + `HitProbability` went with it. +- A beam shot's `damageOverride` could be dropped: `BeamRollsPerInterval` computed + the expected damage from its own roll count, a tautology, now deleted. + `ADamageOverrideIsWhatTheShotCarries`. +- `Tracking` could 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. +- *`MixSeed` shift 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 + nonzero `PBase` implies 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.cs` leads a moving target), L435 (three; the aimed flag on an outcome), + L464 and L465 negate (the `Apply` hit direction through the `FireControl` + path), 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. + - **Amended 2026-09-25, lane spacing.** Soul found that lanes one cell apart share cells at + angled bearings (a cell's shadow is up to √2 wide), so whichever lane went first ate the + shared armour and mirror-image shots did different damage in ~30% of angled spread hits. + Operator: "I would prefer if the overlapping damage cells were diffused sideways to + thicken the line rather than doubling up." Self's reading, confirmed ("nope, you got it"): + **lanes are spaced by one cell's shadow width at the committed bearing** (the `Extent` + width `2h`: 1 when axis-aligned, up to √2 at 45°). Each cell's admitted interval is exactly + that wide and half-open, so no two lanes can strike the same cell; the footprint widens at + angles instead of doubling up, and axis-aligned shots are unchanged. +- **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. (Superseded for items by the 2026-09-25 one-path + ruling: armour stays per cell, and an item pools its covered cells and resolves once. See 12.4.) + + **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.Splash` and 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). `ApplyHit` only walks when + `penetration > .5f` (`Entity.cs:392`), and `WeaponData.Penetration` is inspector-ranged + `0..1` (`Behaviors/Weapon.cs:28`). +- **LonginusX's cockpit cannot be reached edge-on until penetration reaches about two + cells.** **(probe)** `exposure.py` walked 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.Expand` is 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`: + every `WeaponItemData.AirburstRange` is null, and no `WeaponModifiers` includes 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 at `FireControl.cs:282`. +- **The handedness history.** `right = float2(forward.y, -forward.x)` has not changed since + `b7743789`/`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 at `Entity.cs:394-396` and `FireControl.cs:516-520`, and 12.1 + collapses the two copies. +- **The code has two cell conventions.** The kernel, `Shape.CenterOfMass` + (`ItemData.cs:121-123`) and `ResolveAimPoint` put cell *i* at the integer point *i*. + `ApplyHit` instead starts from `cell + .5` and 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 only `FireControl.cs:292` + (PendingShot, which stays), `:432`, `:435` and `:481`, plus `FireControlCut7Tests.cs:190` + (which also reads PendingShot). +- **`Apply` reads 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 by `PSpread`, and ApplyHit and Splash convert + directions only. A blast needs world↔schematic *points*, so 12.4 names that rule. + `HullCollider` is 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"}`, 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.BuildCompatibleResolutionResult` (`CultCache.cs:587-670`) runs `CompareSchemaShapes` + (`:672-752`), which compares slot type, reference, cardinality, target schema, name + semantics and index alias, **not the member name**. So renaming slot 29 opens as + `CompatibleDrift` with warnings. A new nullable slot opens with a `defaulted_missing_slot` + warning. The warnings persist until the catalog is next written with the new descriptor. + Readers of the field: `FireControl.cs:284` and `FireControlCut6Tests.cs:81-101, 249, 268, + 279`. `tools/AetherDb` has none. The `AirburstRange:` lines in nine weapon prefabs + (for example `Flak Cannon.prefab:65`) are orphaned YAML from a deleted MonoBehaviour field: + no C# field of that name exists outside `ItemData.cs:498`. Measured at `2625768f`: the catalog opens as + `CompatibleDrift`, with slot 32 reported as `defaulted_missing_slot`. One write through AetherDb or Studio clears the drift. + +### Frame, geometry and the bearing (the rules every sub-cut reads) + +- **Schematic frame, one owner.** `Entity.ToSchematic(float2 worldPlanar) → float2` maps + world to schematic with x = starboard and y = bow: `forward = normalize(Direction)`, + `right = float2(forward.y, -forward.x)`, returning `float2(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 forms `ToSchematicPoint(float2 worldPlanar) = + ToSchematic(worldPlanar - Position.xz) / SchematicCellSize + Shape.CenterOfMass` and its + inverse `ToWorldPoint`, 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 `+ .5` convention dies with ApplyHit. +- **Travel direction.** `FireControl.TravelDirection(Weapon, Entity source, Entity target) → + float2` is the planar world direction of `PredictedIntercept(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's + `Apply` fallback (`:463`). It belongs to the shot: Fire freezes it into `PendingShot`. +- **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)`, with + `h = (|ℓ.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)`** replaces `HullKernel` and is the one function. It returns the merged + intervals, `a = dot(aimPoint, ℓ)`, sigma, `Span = hi_max - lo_min`, and + `POnHull = Σ_k Φ((hi_k - a)/σ) - Φ((lo_k - a)/σ)`, where `Φ(z) = ½(1 + erf(z/√2))`. + `aimPoint` comes from `ResolveAimPoint` unchanged; only its lateral projection matters. + `Sigma` keeps 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 * cellSize` instead of `max(Width, Height)` (`:224`). PSpread and pOnHull read + one silhouette, so they cannot disagree about the target's size. +- **Lateral draw.** One `NextFloat` u, taken after the hit roll (the draw `WeightedPick` used + to take). Walk the cumulative interval mass to `u · POnHull` to select interval k, then + invert inside it with `s = 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 parameters `t` along b, where + `t = dot(point, b)`. Cells are ordered by entry, with ties broken by `dot(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.Shared`; netstandard2.1 has + `System.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 at `Entity.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 + `> .5f` threshold (`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.PBase` is renamed + `PFire` because its meaning changes: it is `Accuracy × PSensor` behind the same gates, and + it is 0 when any gate fails. Also frozen: `TravelDirection`, `Precision`, `Aimed`, the + weapon's `Spread`, and `FireRange` (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. `Commit` computes, in this order: + 1. `pDeviation`, as today. + 2. `bearing = normalize(shot.Target.ToSchematic(shot.TravelDirection))`, from the + target's facing at the commit tick. + 3. `sil = Silhouette(target, hull, shot.Aimed, bearing, shot.Precision)`. + 4. `p = PFire × pDeviation × PSpread(shot.Spread, sil.Span, shot.FireRange, cellSize) × + sil.POnHull`. + 5. The one roll against p. On a hit, the lateral draw from `sil`, and the impact cell + from `Lane`. + + Steps 2-5 are one function, `FireControl.CommitProbability(PendingShot, float now, + out Silhouette)`. The debug HUD calls it, which retires the inline estimate at + `ActionGameManager.cs:1317-1318` (`shot.PBase * pDeviation`, which would otherwise be a + second, wrong copy of the model). +- **The outcome freezes the committed geometry** (R4). `ShotOutcome` gains `float2 Bearing` + (schematic frame, as committed) and `float Lateral`, and keeps `Cell`. `Apply` walks + `Lane(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 × pOnHull` at the *current* + travel direction and facing. That is a forecast, in the same way the HUD already treats + deviation. `Inspect.PBase == HitProbability` still 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 `sil` that 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: when `PFire × pDeviation` + is 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 current `main`. +- **Adds:** `math.erf(float)` and `math.erfinv(float)` in + `packages/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 in + `packages/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`, bump + `unity/org.gamecult.cultmath/package.json` from 0.2.3 to 0.2.4, and tag + `cultmath-unity-v0.2.4`. Aetheria then bumps `Packages/manifest.json:56` and the headless + `CultLibRoot` pin. +- **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-520` becomes `target.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-396` becomes `ToSchematic(hitDirection)`. + - `ShotOutcome.Aimed` and its plumbing (Q12-4 = A): + - the field, `FireControl.cs:702`; + - the `aimed` parameter of `MakeOutcome` (`:470`, `:481`); + - `Commit`'s `aimed` local and flag (`:420`, `:435`); + - the `AimedCells` half of `ResolveAimPoint`'s tuple (`:583-589`), which now returns + `float2`. +- **Adds:** `Entity.ToSchematic(float2)`. +- **Verification:** + - Builds: headless `Aetheria.Shared`, plus a Unity batchmode compile by Self. + - All 244 tests pass unchanged. `SplashDamagesTheSideTheBlastCameFrom` and + `PenetrationMarchIsPlanar` pin the frame through its new owner. + - Negative greps: + - `rg -n "forward\.y, -forward\.x" Assets/Scripts` returns exactly one hit, in + `Entity.cs`. + - `rg -n "Outcome\.Aimed|outcome\.Aimed|o\.Aimed" Assets/Scripts tests` returns + 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:** + - `HullKernel` and 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`) and `Inspect.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`, `Spread` and `FireRange`; `PBase` is renamed to + `PFire`. + - `ShotOutcome`: `Bearing` and `Lateral`. +- **Per-file changes:** + - `FireControl.cs:161-167`: after the gates, `PFire(...) × PSpread(...) × + Silhouette(...).POnHull`. The bearing comes from `TravelDirection` and the target's + current facing. Nothing new runs before `:159`. + - `:197-202`: Inspect uses the same functions. It gains nothing new beyond + `diagnostic.PFire` if the HUD wants it (optional; presentation). + - `:263`, `:286-308`: Fire freezes `PFire`, `TravelDirection`, `Spread = weapon.Spread` + and `FireRange`. + - `:409-435`: Commit takes steps 1-5 of **Bearing timing** through `CommitProbability`. + - `:463-467`: Apply passes `shot.Outcome.Bearing` (schematic frame) to `ApplyHit`. For + this one cut, ApplyHit's parameter becomes a schematic bearing and the `ToSchematic` + call 12.1 put inside it is removed. + - `ActionGameManager.cs:1317-1318`: the pending line prints + `FireControl.CommitProbability(shot, now, out _)` instead of `shot.PBase * pDeviation`. + - `Combat.cs:117` and `TurretController.cs:84` need 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.Silhouette` owns scatter. + - `FireControl.CommitProbability` owns the commit-time roll price and placement + source. + - `FireControl.TravelDirection` owns the shot's world direction. + - `Entity.ToSchematic` owns the frame. + - Inputs: + - Frozen: `PFire`, `TravelDirection`, `Precision`, `Aimed`, `Spread`, `FireRange`. + - Live at Commit: target position (deviation) and target facing (bearing). + - Outputs: p for the one roll, then `Bearing`, `Lateral` and `Cell` in the immutable + outcome. + - Derived state: + - HitProbability and Inspect are a forecast of the commit price and decide nothing. + - `ShotOutcome.Cell` is 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`, `POnHull` and the HUD estimate are gone + before `Silhouette` has a caller. +- **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 + old `max(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's + `TravelDirection` before commit. + - Pass: every hit's `Cell` is on the bow row. + - Kills: freezing the bearing at Fire; reading the facing at Fire. + - `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. + - `PlacementAndProbabilityShareOneSilhouette` replaces 6d's + `PlacementAndProbabilityShareOneKernel` (`FireControlCut6dTests.cs:318`). + - Setup: a static target at a diagonal bearing, so the intervals overlap and the + forecast equals the commit price. + - Pass: `CommitProbability` equals a pOnHull that the test computes independently, by + numerically integrating the Gaussian over a union of intervals the test builds itself. + The empirical `Lateral` distribution 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. + - `HitsLandOnTheFacingEdge` supersedes `EveryHitLandsOnMetal` (`:357`): on the concave + fixture, each hit's `Cell` is occupied, and nothing occupied precedes it in its own lane. + The test recomputes the lane from `Bearing` and `Lateral`. + - `AimingAtAnItemCentresTheScatterOnItsLane` closes L585/L587 and replaces + `AimingAtTheSternHitsTheStern` (`: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 old `max(5,3)`, so its numbers stand. + - `ThinLimbCostsHitChance`, with its comment rewritten in 1D terms. + - Every test that reads `shot.PBase`, updated to `PFire` with 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 `hull` + factor should read higher from the beam. + - Turn your armoured face into a slow missile and watch where it lands. + +**Status (2026-09-25): closed after three Soul passes.** Landed on `codex/fire-control-12`: +`7dbb3bcf`, `efe4f7c9` (cut and first Stryker batch), `8bd25f6f` (Soul pass 1 fixes), `d38efba3`..`ca1670a2` +(Soul pass 2 fixes). 263 tests, identical across runs; Unity batchmode compile clean at each stage. + +What the Soul passes found, in order, because two of them are lessons for 12.3: +- **Pass 1.** A lateral draw clamped to closed `[Lo, Hi]` could land exactly on `Hi`, return an empty lane, + and fall back silently to cell (0,0), a hit on nothing. The statistical tests used `Guid` zone names, so + every run rolled different dice (the source of a "flake" that got its threshold tuned instead of its cause + found). Where a hit lands was untested, only its price; the HUD test compared the commit price to itself; + `HitProbability` and `Inspect` each assembled the forecast, and only Inspect's copy was guarded; penetration + could follow a turn made after commit; multi-cell aimed items were untested. +- **Pass 2.** The pass-1 fix clamped the top edge with an epsilon and turned the silent (0,0) into a throw. + The **bottom** edge still produced empty lanes at angled bearings, so the fix converted a rare wrong result + into a rare crash (~1 per 2^24 hits, every shipped hull), reproduced through `Fire`→`Zone.Update`. Root + cause: **two geometric models answering one question** — `Silhouette` priced each cell's projected interval, + `Lane` re-derived admission with an independent slab test, and floats disagreed at corners. Also: the + two-prong placement test passed five wrong-sigma draws; "never allocates" was still false (CoreCLR wraps an + `IComparer` in a delegate per sort). +- **Pass 3 closed it.** `Lane` now admits with the same `Extent` support function `Silhouette` uses, and the + slab test is gone (`AlongBearing` survives as a non-rejecting entry/exit computer). Proof and check: all + cells' intervals have equal width, so the merge cannot open a gap; exhaustive check at every breakpoint and + float neighbour (8.9M lateral values, 13 hulls, 1,540 bearings down to 1e-9 rad) and a 402M-draw enumeration + found 0 empty lanes and 0 non-finite values; a split-arithmetic mutant of `Lane` (equal in exact + arithmetic, different in float) is killed by two tests, so the one-owner rule is guarded, not incidental. + The `Commit` invariant throw is unreachable. + +Cost, measured in Release: pooling the forecast's interval buffer took gated-in `HitProbability` on LonginusX +from 672 to 184 B/call (time unchanged; the ~1.9× over the old kernel is the sort). What remains is outside +fire control's arithmetic: 40 B per `GetBehavior` (the `Equipment` enumerator), three per call; and with an +item aimed at, `ResolveAimPoint`→`CellsOf` builds a list and `ToArray`s it every call — 944 B per +`HitProbability`, 1928 B per `Inspect`. Recorded as follow-ups. + +**Carried into 12.3's brief** (low, all on code 12.3 builds on): pin `Lane`'s seam contract (a 2x1 hull at +b=(0,1): `Lane(s=-0.5)` returns only (0,0), `Lane(s=0.5)` returns nothing — the `s >= Hi` → `s > Hi` mutant +survives today and would interleave two columns in 12.3's walk); `AlongBearing`/`SlabAxis` have no test of +their values (every mutant there survives, equivalent for 12.2 only); correct the allocation comments in +`Forecast` and `LaneAndSilhouetteAllocateNothingGivenAPooledBuffer`; make +`ZeroProbabilityShotCommitsAMissWithoutBuildingASilhouette` behavioural (produce `PFire = 0` through +visibility, assert zero allocation rather than `sil.Intervals == null`); drop the unused `System.Reflection` +import. + +### Cut 12.3. How a direct hit travels + +- **Repo/branch:** Aetheria, on top of 12.2. +- **Deletes first:** `Entity.DamageSchematic` (`Entity.cs:333-374`) and `Entity.ApplyHit` + (`Entity.cs:376-409`). Going with them: + - the 0.5-step march; + - the `+ .5` cell convention; + - the `> .5f` threshold; + - the Expand spread footprint. +- **Adds:** + - `Entity.Absorb(int2 cell, float damage) → float`, moved verbatim from + `Entity.cs:348-366`. Armour absorbs up to its value, then the item when `d > .1f`, and + the remainder is returned. It emits `ArmorDamage((cell, incoming))` and + `ItemDamage((item, incoming))` with today's meaning, and only for incoming > 0. + - `Entity.DamageHull(float)`, moved from `Entity.cs:369-373` (`> .1f`, one `HullDamage` + event). +- **Per-file changes:** `FireControl.Apply` (`:449-468`), with the shield branches + unchanged: + 1. `IncomingHit.OnNext(source)` moves here from `Entity.cs:383`. + 2. There are `2n+1` lanes, `k ∈ [-n, n]`, where `n = (int) floor(DamageSpread + .5f)` (today's + rounding, `Entity.cs:389`). Lane `k` sits `k` cell *shadows* (`2h`) from `Lateral` along ℓ, and + **no cell belongs to two lanes** (Q12-2 = A, amended 2026-09-25). Membership is decided once per + cell, not by testing float lane positions against float intervals (Soul, 12.3 pass 2: those two + derivations disagreed at cell edges and struck a cell twice). + 3. Damage splits evenly over the lanes that meet metal. The centre lane always does. + 4. Each lane walks from its own facing cell, clipped to the penetration depth, with + `rem = target.Absorb(cell, rem)` at each cell. + 5. 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 + `DamageHull` once. + 6. A weapon with a contact or delayed fuse does not take this path. It detonates instead + (12.4), never both. 12.4 moves this choice into Apply's switch on the frozen `Fuse`; `Step` no longer branches. + + **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 `Absorb` per + footprint cell and passes the summed remainder to `DamageHull`, which is arithmetically + identical to today. +- **Authority map:** + - Owner: `Entity.Absorb` owns what one cell does with incoming damage. + `FireControl.Apply` owns the order and the share per lane. + - Inputs: the committed `Bearing`, `Lateral` and `Cell`, plus the frozen `Penetration`, + `DamageSpread` and `Damage`. + - Outputs: `Armor[]`, item `Durability` and `Hull.Durability`, plus the events + `IncomingHit`, `ArmorDamage`, `ItemDamage` and `HullDamage`. Their consumers: + `ActionGameManager.cs:1070`, `EntityInstance.cs:292`, `InventoryPanel.cs:470-482`, + and `Entity.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 `Absorb` gets a second caller. +- **Verification:** + - `DamageIsAbsorbedInOrderAlongTheRay` replaces `HardpointHitDamagesItemThenHull` and + `PenetrationMarchIsPlanar` (`FireAuthorityTests.cs:481-526`, which call the deleted + `ApplyHit`). + - Setup: through `Fire` at 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. + - `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/lanes` minus what the + ring absorbed. + - Enough damage fires `Death` with `CockpitDestroyed`. + - `SpreadWidensTheImpactAcrossLanes`: spread 1 at penetration 0 puts damage/3 on each of + three facing-edge cells and nothing behind them. + - `ARayThroughACornerCrossesTheCornerCell`: the diagonal case from `Probe1` reaches (2,2). + - `LaneRemainderReachesTheHull`: a ray that exits a thin hull delivers its remainder to + `Hull.Durability` (Q12-3 = A). + - Kept green: every Splash test, and `AirburstSplashes…` (Cut 6). + - Negative: `rg -n "DamageSchematic|ApplyHit\(" Assets/Scripts tests` returns nothing. + Today it matches `Entity.cs`, `FireControl.cs`, `FireAuthorityTests.cs`, and two comment + lines at `Gameplay/EntityInstance.cs:280-281`. Rewrite those comments and + `FireControl.cs:492`. + - Stryker: `--since:<12.2 head>` over `FireControl.cs` and `Entity.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. + +**Status (2026-09-25): closed.** Landed on `codex/fire-control-12`: +- `70c3e0cc`..`73b15d9f`: the cut and the first Stryker batch. +- `b82b7eab`..`be538217`: Soul pass 1 fixes and the lane-spacing ruling. +- `4e6495dd`, `742354c6`, `cb71a3cf`: Soul pass 2 fixes. +- `56757913`, `b55894a0`: the proportional-absorption batch, since superseded. +- `e9339a56`, `ab14552a`, `166b6202`: the one-path rebuild. +- `64d17a29`, `c07b4fee`, `cccf3a2a`: Soul pass 5 fixes. + +297 tests. Unity batchmode is clean at `cccf3a2a`. + +Proportional absorption, passes 4 and 5: +- **Pass 4 (on the two-path version).** The split between `ApplySequential` and `ApplyWithSharedItems` + was already ruled out by the operator. Soul found three more defects: + - The cycle tie-break fell back to lane index at axis-aligned bearings, and a probe broke mirror symmetry. + - The pooled resolve skipped the item's `.1f` threshold. + - The model sweep still passed with proportionality deleted. +- **The rebuild.** Every item goes through a pool: clip once, and one pooled walk. + `Entity.ItemAbsorb(EquippedItem, Span)` is the one absorption owner. The cycle rule is + Self's default (above). `FireControl.cs` shrank by 40 lines against the two-path version. +- **Pass 5 (on the rebuild).** There was no second authority left, mirror symmetry held at angled + bearings across 2,800 draws, damage was conserved, and the cycle rule never fired early. + Soul found gaps in the tests (a lane revisiting an item, event cardinality), dead code, and + stale comments. All are fixed, and each mutant dies to a committed test. + +Recorded, not fixed: +- Soul's angled-bearing mirror probes (P1, P2) are not committed. They need an exact lateral draw, + which only a private seam offers. The operator rule forbids reflection, and no test seam is added + for them. + - Angled correctness is covered by the model sweep and `DirectHitAtAnAngledBearingMatchesAnIndependentModel`. + - The cycle rule's symmetry is covered at axis-aligned bearings, and holds at any bearing by construction (it has no ordering key). + - 12.4 grows no such seam. `Detonate` takes a world point, not a lane, so P1 and P2 stay uncommitted (see 12.4, **Test seam**). +- `Entity.Absorb` is deleted with `Splash` in 12.4, and `Detonate` goes through `ArmorAbsorb` and `ItemAbsorb`. + +What the three Soul passes found: +- **Pass 1.** Production correct (an independent double-precision ray model agreed on 14,984 of + 15,000 shots; the rest sit on the √2 float boundary), tests blind: no test drove `Apply` at an + angled bearing, the gap rule, penetration depth or side-lane remainders; one test could not fail. + It also found lanes one cell apart **share cells** at angled bearings, which the operator ruled on + (Q12-2 amendment: no doubling up, the line thickens). +- **Pass 2.** The spacing fix was disjoint only by float luck (78 of 26,377 boundary-lateral hits + struck a cell twice), and the disjointness test missed four spacing breakages. Hands' first + structural fix (a `ceil` lane index) introduced a **third** membership computation, which left the + centre lane empty on `solid5, b=(0,1), s=0.49999997` while `Lane(s)` found the row — a committed hit + with no damage. Final shape: `Lanes` is built from `Lane`, `Lane(s)` is literally lane 0, and float + drift is resolved by ownership (nearest the centre wins). **The third time this campaign met two + derivations of one geometric answer disagreeing in float** (12.2's crash, 12.3's double strike, + the `ceil` empty centre); the cure each time was one owner. +- **Pass 3 closed it.** 2.09M boundary cases over 196 bearings and 9 hull shapes: no cell in two + lanes, lane 0 always equals `Lane(s)`, no empty centre lane. The tie-break is mirror-symmetric by + construction. Residual float effects (a side lane starting one cell deeper, a column falling + between lanes, rarely a footprint narrowed by one column) occur at about 2×10⁻⁶ of draws; damage + is never lost. + +**Owed with the pending ruling's batch:** a test that many axis-aligned spread-n shots each strike +exactly 2n+1 facing cells (a 1% spacing error, R1b, survives today and would drop a column from 1-2% +of spread shots). **Recorded, not reachable through the public pipeline at a usable rate:** nothing +ties `Apply` to `Lanes` behaviourally (reverting `Apply` to a per-lane `Lane()` loop survives, since +it only reintroduces boundary double strikes); the behaviour after a cession is unpinned. + +**Pending operator ruling (blocks closing 12.3):** when two lanes cross one multi-cell item, the +first-processed lane drains its durability and the second carries the leftover into its own deeper +cells, so the left-of-travel lane always gets the damage that passes wide components (mirror shots on +a symmetric hull with 2x1 items differ in ~30% of axis-aligned trials). Recommended: the item absorbs +from all lanes that strike it at once, and each lane's leftover is in proportion to what it brought. +Alternative: keep the processing order and pin it. + +**Ruled 2026-09-25: proportional absorption.** Operator: "proportional absorption; go". A multi-cell +item struck by several lanes of one shot absorbs from their combined incoming damage, and each lane +carries on with a leftover in proportion to what it brought. The result must not depend on the order +lanes are processed. A single-lane item is the degenerate case of the same rule, not a separate path. + +**Ruled 2026-09-25: one code path.** Operator: "Do not expect items taking up multiple cells to be an +exception, this should be one code path." The first implementation (`56757913`) surveyed each shot +for shared items and took either `ApplySequential` or `ApplyWithSharedItems`. That was a split +authority, and the second reachability derivation it needed is the same failure as the 12.2 empty +lane and the 12.3 double strike. `Apply` has one walk, in which every item pools the deposits of +the lanes that reach it, a pool of one lane included. The survey and the sequential path die. + +**Self's default 2026-09-25 (not an operator ruling): the cycle rule.** When every unfinished lane is blocked at an item pool and no pool is ready (two lanes crossing two shared non-convex items in opposite order), every open pool resolves at once with the deposits it holds. It needs no ordering key, so mirrored shots stay mirrored. The first implementation used the smallest first-contact `Entry`, which ties at axis-aligned bearings and fell back to lane index. Soul broke mirror symmetry with it. The operator may overrule this default. + +### Cut 12.4. The detonation primitive + +**Status (2026-09-29, Self): closed.** 12.4 landed at `2625768f`, `7c99909d` and `7e63d744`. + +Soul pass 1 (Opus) found three defects: +- **F1:** a tangent disc created damage, 127 of 100. +- **F2:** a candidate cull measured from the centre of mass missed hull corners. +- **F3:** a lethal blast threw mid-loop and detonated again. + +It also found four rules pinned only by tests that could not fail. + +The fixes landed at `8b4bf01e`, `76e98598` and `fd792013`: +- the tangent comparisons now include equality; +- the cull and the `radius <= 0` guard are deleted, so `RectDiskOverlap` is the only decider; +- the blast loop iterates a snapshot. + +Soul pass 2 closed it. Conservation held over 1,944 exact-lattice discs, with a worst error +of 2e-5. A blast costs 0.3-0.7 ms in a zone of 40-100 entities. + +Pins landed at `4629bc67` and `c7e20f08`. `Death` fires once per entity (`Entity.cs`, `.Take(1)`), +which also fixed a direct hit on a corpse raising `Death` again. A three-kill blast test was added. +362 tests pass. + +**Operator ruling needed:** a fused weapon fired with no target bursts at the shooter's own +position (`FireControl.cs:391`). This predates 12.4. Self recommends a max-range burst along the +aim line. Recorded unguarded: if a `Death` handler removes another ship mid-blast, that ship +still takes blast damage. + +Revised 2026-09-25, after 12.3 closed. The first draft predates 12.3's proportional-absorption and one-path +rulings, and the parts of it those rulings overturn are listed under **History** at the end of this section. +Anchors are against `d402283c`. **(probe)** marks claims measured in a scratch worktree at that commit (see +Part 5 of the revision note); **(read)** marks claims read from source at the cited anchor. + +- **Repo/branch:** Aetheria, on top of 12.3. It lands as two commits: + - (a) The data fields and the label demotion. On shipped content this changes no behaviour, because no + weapon carries the label or a radius. That is the Cut 12 probe (`Probe4`), and `GameData/Aetheria.cc` has + not changed since it ran (`git log cd846916..d402283c -- GameData/Aetheria.cc` is empty). + - (b) `Detonate`, with `Splash` and `Entity.Absorb` deleted. + +#### Deletes first + +In (a): + +- The label read in `Fire`: `FireControl.cs:382-388`, which is the Cut 6b comment, `isAirburst` (`:386`), + `burstPosition` (`:387`) and `burstRadius` (`:388`). Afterwards `WeaponModifiers` has no simulation reader. + `rg -n "WeaponModifiers\." Assets/Scripts/ServerShared` returns nothing; today it returns exactly `:386`. +- `PendingShot.BurstRadius` and its comment (`FireControl.cs:1246-1250`) become `Fuse` and `BlastRadius`. The + initialiser at `:412-413` changes with them. `BurstPosition` stays, and it now has meaning only for a + proximity fuse. +- The slot 29 comment (`ItemData.cs:493-496`), rewritten for the renamed field. + +In (b): + +- `FireControl.Splash`, its comment and its half-hull footprint: `FireControl.cs:946-994`. This absorbs F12-1. +- `Step`'s dispatch between Splash and Apply: `FireControl.cs:458-467`, comment `:460-462`, branch `:463`. + `Step` calls `Apply` for every arrived shot, and Apply owns the fuse switch (see **Which model a shot uses**). +- `Entity.Absorb` and its comment (`Entity.cs:386-400`). It gates the item's `.1f` threshold on each cell + separately, which contradicts the one-path ruling as soon as an area covers two cells of one item. + **(probe)** `ProbeC2`: a 2x1 item with no armour, and 0.08 on each of its cells. Pooled through `ItemAbsorb` + the item absorbs 0.16. Through `Absorb` per cell it absorbs nothing, and 0.16 goes to the hull. + 12.3 recorded that `Absorb` survives only for Splash; it dies with Splash, and nothing brings it back. +- Commit's shield decision, for shots that carry a fuse (`FireControl.cs:578-581`), under Q12-9's recommended + answer. It is not deleted: it is gated on `shot.Fuse == null`. +- Apply's inline penetration clip (`FireControl.cs:666-678`) is **moved**, not deleted. It becomes the one + reach function (see **The penetrator**). +- Stale comments that name a deleted owner as live: + - `Entity.cs:391` and `:417`; + - `FireControl.cs:1201`; + - `Gameplay/EntityInstance.cs:280-283`; + - `Gameplay/HullCollider.cs:4-6`; + - `Gameplay/Weapons/Projectile.cs:8-9`; + - `Gameplay/Weapons/Mine.cs:90-95`. + +#### Data + +The smallest field set: two fields, both on `WeaponItemData` (`ItemData.cs:475-499`). + +- **Slot 29.** `float? AirburstRange` (`ItemData.cs:497-498`) is renamed `float? BlastRadius`, with JSON name + `"blastRadius"`. It is in world units and keeps its meaning. The slot and the type are unchanged, so CultCache + opens the catalog as compatible drift (see 0b). +- **New slot 32** (landed `2625768f`; the map said 30, but 30 and 31 are `OptimalTemperature`/`PlateauWidth`, inherited from `EquippableItemData`). `WeaponFuse? Fuse`, with JSON name `"fuse"`, and `enum WeaponFuse { Contact, Proximity, + Delayed }` in `Enums.cs`, beside `WeaponModifiers` (`Enums.cs:97-108`). It is nullable under the 7.4 rule: + null means no blast, and every shipped record reads as null. +- **Fire decides "detonates" once.** Fire freezes `PendingShot.Fuse` as the weapon's fuse only when + `BlastRadius > 0`, and as null otherwise, and it freezes `BlastRadius` beside it. After Fire, nothing + re-derives whether a shot detonates; every reader reads `shot.Fuse`. A fuse without a radius, or a radius + without a fuse, is inert and resolves as a direct hit. Nothing reports it: Q12-6 ruled that the catalog is + not policed. The inert half takes effect with (b)'s switch in `Apply`. At (a), `Step` still splashes on + `BlastRadius` alone (Soul, `2625768f`). +- **The drift warnings.** The catalog needs one write through `tools/AetherDb` or Studio to clear them. That + write is content and changes no data. It belongs to F12-2. +- **Readers to update:** + - `FireControl.cs:386-388`. + - `FireControlCut6Tests.cs:81`, `:95` and `:100-101`: the fixture's `airburst` and `airburstRange` + parameters. They become `fuse` and `blastRadius`. Whether the fixture keeps the label does not matter, + because Q12-6 ruled that nothing checks labels against behaviour. + +#### Which model a shot uses + +`FireControl.Apply` (`:631-693`) is the one owner of this decision, and it switches on the frozen `shot.Fuse`: + +- **null: a direct hit.** This is 12.3's lane path, unchanged, and it needs a hit. +- **Proximity: detonates at arrival, with no roll.** It detonates at `shot.BurstPosition.xz`, which + `PredictedIntercept` froze at Fire, whether the shot hit or missed. That keeps today's rule, + `AirburstSplashesEvenOnAGuaranteedMiss`. +- **Contact or Delayed: detonates only on a committed hit.** P comes from the committed lane (see **Where P + is**). On a miss nothing happens. + +Today `Step` makes this choice at `:463`, and 12.3 describes the same choice as a separate step 6. Both collapse +into Apply's switch, so `Step` has no damage branch left. + +`IncomingHit` (`:648`) fires for a committed hit, for a direct hit and a contact or delayed blast alike, and +only on the host. A proximity burst and the bystanders in any blast do not fire it, as Splash never did. Its one +consumer is the player's hit marker (`ActionGameManager.cs:1070`). + +This is a default, not a ruling. + +#### Where P is + +The lane's own parametrisation gives the point directly. `Lane` places the line at lateral offset `s` as +`point(t) = t·b + s·ℓ`, in schematic cell coordinates (`AlongBearing`/`SlabAxis`, `FireControl.cs:1177-1198`). + +**(probe)** `ProbeE`, 20,000 random lanes on a 6x9 hull: + +- the mid-slab point of every walked cell lies inside that cell, with a worst Chebyshev distance of 0.499999 + from its centre; +- `point(impact.Entry)` lies on the impact cell's boundary to within 9.5e-7. + +So the contact and delayed fuse points need no geometry beyond `Lane` and the reach clip. + +- **Proximity:** `BurstPosition.xz`, a world point. +- **Contact:** the schematic point `point(lane[0].Entry)`, on the committed lane: the committed `Bearing` and + `Lateral`, and the lane `Lane(hull, Bearing, Lateral)`. That is where the shot strikes the hull's face. +- **Delayed:** the schematic point `point(t_fuse)`, with + `t_fuse = min(lane[0].Entry + Penetration, lane[reach-1].Exit)`, where `reach` comes from the same clip that + 12.3's walk uses. In words: the fuse fires where the penetration runs out, or where the metal runs out, + whichever comes first. Metal runs out at the far side or at the first gap. `t_fuse` lies inside the last + reached cell's slab, so the disc is always centred on metal. Penetration 0 gives the contact point. +- **Which lane.** It is always the centre lane: `Lane(s)` at the committed Lateral, which Commit's guard already + proved non-empty (R3), and which `Lanes` guarantees to equal lane `n`. A blast shot's `DamageSpread` is not + read. The disc's radius is its width. This is a default, not a ruling. +- **Conversion to the world.** Contact and delayed points are converted with `host.ToWorldPoint`, using the + host's pose at arrival, and handed to `Detonate` like any other world point. The host is then treated like + every other entity in the radius: its cells are found again by `ToSchematicPoint`. + - That round trip cancels the host's pose, so the host's own damage is fixed by the commit, and R4 holds for + it. **(probe)** `ProbeF`: the worst schematic→world→schematic error is 7.6e-4 cells at 4×10⁴ world units. + - Bystanders are judged against where the host actually is at arrival. That is the same live rule a + proximity burst already applies to everyone. + + There is exactly one input shape, a world point. There is no host-only schematic entry. + +#### The penetrator (Q12-8) + +A delayed-fuse shot does not spend damage along its burrow. The lane walk **locates P and hands `Detonate` +the shot's whole `Damage`**. + +That keeps the ruling recorded as Q12-8 = A: nothing is absorbed along an explosive penetrator's burrow, and the +explosion is the payload. See Part 4 of the revision note, because the brief phrased this the other way. + +The walk and the blast share exactly one piece: **the reach clip**. Today it is inline at +`FireControl.cs:666-678`. It moves into `FireControl.Reach(LaneCell[] cells, int walked, float penetration) → +int` (private). The rule is unchanged: the impact cell is always reached, and a later cell is reached while +`entry - impact.entry < penetration`. + +Two callers read it: + +- Apply's direct-hit walk, which calls it once per lane in place of the inline loop; +- the delayed fuse point, which calls it on the centre lane. + +No second "lane damage with a blast" path exists, because no shot does both: + +- a null fuse runs the lane walk with `ApplyPooled`, which absorbs; +- a contact or delayed fuse runs `Lane` and `Reach`, which only locate P, and then `Detonate`. + +A shot takes one branch of Apply's switch or the other, never both. + +The mutant this must kill is a second reach derivation: for example, a delayed P computed as +`impact.Entry + Penetration` without the clip, which would detonate past a gap or outside the far side. + +#### Area, per entity: `FireControl.Detonate` + +The signature is `Detonate(Zone zone, float2 worldPlanar, float radius, float damage, DamageType type)`, +public, and it is the one owner of blast damage. It is Splash's signature with the point made planar (R7). + +- **Callers:** Apply (all three fuses) and `Mine.Explode` (`Mine.cs:99`, which passes + `position.ToCultMath().xz`). +- **Point transforms.** It uses `Entity.ToSchematicPoint(float2) = ToSchematic(w − Position.xz) / + SchematicCellSize + hullShape.CenterOfMass` and its inverse, `ToWorldPoint`. These are the frame rules; the + frame owner is `ToSchematic` at `Entity.cs:419-424`. `CenterOfMass` is `ItemData.cs:127-128`, and + `SchematicCellSize` is `Settings.cs:255`. + +For each entity in the zone: + +1. **Candidates.** An entity is a candidate when `|Position.xz − P| ≤ radius + ½·√(W²+H²)·SchematicCellSize`. + Splash culled on the entity's *centre* being inside the radius (`FireControl.cs:964-965`), which misses a + long hull whose end lies inside the blast. +2. **Shares, computed once.** Take `Ps = ToSchematicPoint(P)` and `rCells = radius / SchematicCellSize`. For + every occupied hull cell whose unit square meets the box `Ps ± rCells`, the share is `share_c = damage × + overlap(c) / (π·rCells²)`. Here `overlap(c)` is the exact area of the disc inside that cell's unit square, a + cell being the unit square centred on its integer coordinate. + - The shares go into one pooled buffer, which the shield step and the absorption step both read. Nothing + computes an overlap twice. + - Every share of the disc that falls on an unoccupied cell, or outside the schematic, is lost. +3. **Shield.** Every shield decision for a blast is made here, live, and charged the entity's summed shares, + under today's `CanTakeHit`/`Break`/`TakeHit` rules (`Shield.cs:166-190`). That includes the host of a contact + or delayed blast, under Q12-9's recommended answer. + - If the shield absorbs, no cell of that entity takes anything. + - If it cannot, it breaks, and the cells take their shares. +4. **Armour, per cell.** For each share, `d_c = ArmorAbsorb(c, share_c)` (`Entity.cs:339-352`). Armour protects + its own cell's share and nothing else. +5. **Items, one pool each.** If `GearOccupancy[c]` holds an item, `d_c` is deposited into that item's pool, in + hull `Coordinates` order. If not, `d_c` goes to the entity's hull total. Only after every covered cell has + deposited does each item resolve, once: `ItemAbsorb(item, pool)` (`Entity.cs:369-384`). The leftovers join + the hull total. +6. **Hull.** The entity's `DamageHull(total)` runs once (`Entity.cs:406-413`). + +Entities are independent. Two ships caught by one blast each cover their own cells, and neither shadows the +other (F12-8). + +**Order: armour first per cell, then the item from the pooled post-armour sum.** Existing rulings settle it, +and no operator question is needed: + +- "Armor absorbs first", from the first round of rulings; +- "Each covered cell absorbs … in the existing order (armour, then item, then hull)", from the area ruling; +- the proportional ruling, under which a multi-cell item "absorbs from their combined incoming damage", where + incoming means the post-armour remainder each contributor brings, exactly as a lane deposits after + `ArmorAbsorb`; +- the one-path ruling: "Do not expect items taking up multiple cells to be an exception." + +The area ruling's word "independently" survives for armour, and is superseded for items by the one-path ruling: +an item's cells deposit into one pool, and they do not absorb independently. + +**One owner, and why there is no walk.** + +- `ItemAbsorb` is the one owner of what an item does with incoming damage: the `.1f` threshold decided once on + the pooled total, the pro-rata split, and one `ItemDamage` event per contributing cell with a positive share. +- `ArmorAbsorb` is the one owner of what armour does. +- `Detonate` owns the schedule, and a disc's schedule has no walk. Apply's pooled walk (`ApplyPooled`, + `FireControl.cs:717-860`) exists only because lanes are ordered: a lane's deeper cells receive whatever the + item in front of them leaves, so a pool must wait until no unfinished lane can still reach it. In a disc, no + cell's share depends on another cell's leftover. Every deposit is known before any item resolves, and so the + reachability test and the cycle rule have nothing to decide. + +The flat pass is exactly `ApplyPooled`'s degenerate case. **(probe)** `ProbeC`: 200 random discs on a 6x4 hull +with two 2x1 items, a one-cell item and varied armour. `ApplyPooled`, fed one one-cell lane per covered cell +with that cell's share, left identical armour, item durability and `ItemDamage` event counts in 200 of 200 +discs. Its hull total differed only by float summation order, at most 3.8e-6. + +`Detonate` does **not** call `ApplyPooled`. Doing so would mean changing `ApplyPooled`'s signature to take a +starting amount per lane and renting a `LaneCell[]` per covered cell, and it would buy no behaviour. + +**What the pro-rata split does inside a blast:** nothing observable. Every leftover of a blast reaches the same +per-entity hull sink, so a sequential split and a pro-rata split deliver identical totals. The only +order-sensitive rule left in a blast is the pooled threshold, and that is what the verification pins. + +**Exact overlap, in float.** `overlap(c)` is the standard circle-rectangle intersection. It integrates the +disc's chord height over the cell's clipped x-span, using the antiderivative `½(x√(r²−x²) + r²·asin(x/r))` +between the breakpoints where the chord crosses the cell's top and bottom edges. It allocates nothing, and it +conserves exactly up to float rounding. + +- **(probe)** `ProbeA`: in float, the overlaps over the bounding box sum to `π·r²` within 1e-7 relative, at + radii 0.3, 0.5, 1.7, 5, 12.5 and 25 cells, centred on a cell centre, on a cell corner and off the grid. Double + precision is not needed. +- **(probe)** `ProbeB`: named cells agree with a 3000² numeric integration within 1.5e-6. +- Supersampling is rejected, for the reason the first draft gave. It would deliver the wrong total for a disc + smaller than a cell, or for one straddling a cell boundary. + +**Cost.** Per candidate, there is at most one overlap evaluation per occupied cell inside the box: no more than +128 on shipped hulls, whatever the radius. It runs once per detonation and never per tick. Allocation is not +the constraint here (Splash builds a `Shape` per target today), but each share is computed exactly once. + +**Accepted loss (unchanged).** Interior armour does not shadow the cells behind it, because nothing inside an +area is occluded. A bulkhead protects its own cell's share and no other. 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 +forecast still applies. A proximity blast has no roll, and it is still reported with the direct-hit number. +That is pre-existing, and it is F12-5. + +#### Authority map + +**Owners:** + +- `FireControl.Apply` owns which model a shot uses, through the switch on the frozen `Fuse`. +- `FireControl.Detonate` owns blast damage: candidates, shares, every blast shield decision, and the + per-entity armour→pool→hull pass. +- `Entity.ArmorAbsorb` owns armour per cell, and `Entity.ItemAbsorb` owns item absorption. Both are shared with + 12.3's walk. +- `FireControl.Reach` owns penetration reach, for the direct-hit walk and the delayed fuse point. +- `FireControl.Lane` owns traversal. A blast shot calls it once, for its fuse point. +- `Entity.ToSchematicPoint` and `ToWorldPoint` own points. `ToSchematic` still owns the frame. + +**Inputs:** + +- P, the radius, the damage and the type; +- each candidate's position, facing, hull, armour, occupancy and shield at the detonation tick; +- the fuse and the radius, frozen at Fire; +- for contact and delayed shots, the committed `Bearing` and `Lateral`, and the frozen `Penetration`. + +**Outputs:** the same mutations and events as 12.3: `Armor[]`, item durability, hull durability, +`ArmorDamage`, `ItemDamage`, `HullDamage` and, for the host of a hit, `IncomingHit`. + +**Derived state:** + +- `WeaponModifiers` is display-only. Its readers are the `SchematicListElement.cs:54` icons, + `GameSettings.cs:67` and `GameplaySettingsEditor.cs:44`. +- `BurstPosition` is read only for a proximity fuse. +- Under Q12-9 = A, `ShotOutcome.Shielded` and `ShieldBroken` are never set for a shot that carries a fuse. + Nothing outside `FireControl.cs` reads either flag (`rg "\.Shielded\b|\.ShieldBroken\b" Assets/Scripts` + finds only `FireControl.cs`). + +**Forbidden writers:** + +- No label decides behaviour. +- Nothing but `Detonate` applies area damage. +- No per-cell path gates an item's threshold. `Absorb` is deleted and is not reintroduced under another name. +- Nothing but `Reach` decides how deep a lane reaches. +- `Commit` decides no shield for a shot that carries a fuse (Q12-9 = A). +- `Step` has no damage branch. +- Mine's Unity `BlastRange` and its arming query stay presentation-side inputs (R8 residue, Cut 4 Q7), and they + decide no damage themselves. + +**Shared paths:** + +- the proximity burst: Apply at arrival, with no roll; +- the contact and delayed blasts: Apply on a committed hit; +- `Mine.Explode`; +- the tests, which call `Detonate` directly (see **Test seam**). + +**Deletion line:** `Splash`, `Absorb`, the label read and `Step`'s branch are deleted before `Detonate` has a +caller. + +#### Test seam + +`Detonate` takes a world point because its production callers already have one: a proximity burst's frozen +intercept, a mine's position, and a contact or delayed hit's converted lane point. That is an honest production +interface and not a hook, and it is public for the same reason `Splash` is today: Mine calls it from +`Assembly-CSharp`. Blast tests call it directly with exact points. So blast symmetry at angled facings is +committable with no seam and no reflection. + +It does **not** make Soul's 12.3 probes P1 and P2 committable. Those probes measure *direct-hit lane* damage at +an exact lateral draw. `Detonate` takes a point, not a bearing and a lateral, and no production interface takes +a built shot or outcome. The delayed fuse point's `Lane`→`Reach`→`point(t)` chain sits inside Apply, and +exposing it for tests would be exactly the seam the operator forbids. 12.3's record stands: + +- angled direct-hit correctness is covered by the model sweep and + `DirectHitAtAnAngledBearingMatchesAnIndependentModel`; +- P1 and P2 stay uncommitted. + +#### Verification + +These fixture rules apply to every blast test that touches the frame: + +- `SchematicCellSize ≠ 1`: use 2. The 12.3 fixture uses 1 (`FireControlCut123Tests.cs:49`), under which a + deleted `/ SchematicCellSize` survives. +- The target is off the origin. +- The facings include one with |fx| > |fy|. +- The hull's centre of mass is not at an integer cell, so a missing `+ CenterOfMass` survives nowhere. + +Tolerances come from the probes. Conservation holds within 1e-5 relative. Mirrored results agree within 1e-4 +absolute at damage ≤ 150: **(probe)** `ProbeD` measured at most 3.05e-5, all of it float summation order. + +**Geometry.** + +- `TheDiscIsConservedOverTheGrid`. + - Setup: on a solid hull that contains the disc, total armour, item and hull damage delivered, at a radius + under one cell, one centred on a cell corner, and one off the grid. + - Pass: the total equals `damage`. + - Kills: sampling in place of the exact overlap; the normaliser `(2r)²` in place of `πr²`; a bounding box + that clips the rim cells; a dropped chord breakpoint. +- `EachCellTakesItsShareOfTheDisc`. + - Setup: a solid hull with no armour and no items. + - Pass: a named cell takes `damage × overlap / (πr²)`, with the overlap integrated numerically by the test. + - Kills: an even split over the covered cells (Splash's rule), and a share decided by the distance from the + cell's centre. +- `TheShareOffTheHullIsLost` (was `ExternalBlastWastesMostOfItsEnergy`). + - Setup: a blast straddling the hull's edge. + - Pass: the total delivered equals `damage` times the disc's on-hull fraction, which the test computes. The + shares sum to the blast's damage minus what falls off the hull. + - Kills: renormalising the shares over the occupied cells, which would deliver the full damage. +- `CandidatesIncludeHullsWhoseCentreIsOutsideTheRadius`. + - Setup: a long hull whose centre lies beyond the radius, while its near end lies inside. + - Pass: the near end takes damage. + - Kills: Splash's centre-distance cull. + +**Absorption order.** + +- `ABlastIsAbsorbedThroughArmourFirstPerCell`. This replaces `AbsorbSpendsArmourBeforeTheItemOnTheSameCell` + (`FireControlCut123Tests.cs:750`). + - Setup: a disc wholly inside one armoured item cell, so its share is the whole damage. The three damage + levels of the old test: below the armour, between the armour and armour plus durability, and above both. + - Pass: the armour is spent first, the item takes the excess, and the hull takes what remains. + - Kills: the item absorbing before the armour, and armour being skipped. +- `ArmourProtectsOnlyItsOwnCellsShare`. + - Setup: a 2x1 item with armour on one cell only, and a disc covering both cells. + - Pass: the item takes `(share_A − armour_A)⁺ + share_B`. + - Kills: pooling the raw shares and then applying armour to the pool, which would let cell A's plate eat + cell B's share. +- `AMultiCellItemAbsorbsItsCoveredCellsAsOnePool`. + - Setup: a 2x1 item with no armour, and a disc straddling its seam that puts less than `.1` on each cell but + more than `.1` on the two together (the `ProbeC2` geometry). + - Pass: the item absorbs the sum. + - Kills: resolving the item per cell, in any form: `Absorb` resurrected, `ItemAbsorb` called per deposit, or + a pool that resolves before every covered cell has deposited. + + This test is also what pins that the result does not depend on which cell is considered first. As shown + above, the threshold is the only order-sensitive rule a blast has left. +- `ItemDamageFiresOncePerCoveredCell`. + - Pass: for a covered item, the number of `ItemDamage` events equals the number of its cells with a positive + post-armour deposit, and each event carries that cell's deposit. + - Kills: one event per item, and events for zero-deposit cells. + +**Symmetry and frame.** + +- `MirroredBlastsDoMirroredDamage`. + - Setup: a hull, its armour and its items all mirror-symmetric about the bow axis, facing (2,1)/√5. Pairs of + world points mirrored across the target's bow axis, with the mirror computed by the test from the facing + and not through `ToSchematic`. + - Pass: per-cell armour, item durability and hull damage mirror. + - Kills: + - the `+ .5` cell convention returning in the overlap (cell bounds `[c, c+1)`); + - a missing or wrong `CenterOfMass` anchor; + - any scheduling rule that depends on enumeration order. + + It does **not** kill a global handedness flip, because a flipped pair is still a mirrored pair. The next test + does. +- `ABlastDamagesTheCellsNearestIt`. This succeeds `SplashDamagesTheSideTheBlastCameFrom` + (`FireControlCut11Tests.cs:371`) and `SplashIsDirectional` (`FireControlCut4Tests.cs:306`). + - Setup: facings (0,1), (1,0), (2,1)/√5 and (−.6,.8). A blast to port, with a radius under half the hull's + width. + - Pass: port cells are damaged, and starboard cells are untouched. + - Kills: a flip of `ToSchematicPoint`'s right axis, an x/y swap, and a missing `/ SchematicCellSize`. + +**Shields** (under Q12-9 = A). + +- `ShieldPaysForWhatReachesIt`. + - Pass: the reserve drops by the entity's covered share × `EnergyUsage`, not by the whole blast. + - `SplashShieldAbsorptionDrainsTheReserve` (`FireControlCut11Tests.cs:237`) and `SplashBreaksUnabsorbedShield` + (`FireControlCut5Tests.cs:507`) are rewritten to `Detonate`, with the expected amounts recomputed from the + covered share, never loosened. + - Kills: charging the full damage. +- `ABlastShotsShieldIsDecidedAtDetonation`. + - Setup: through `Fire`, a contact-fuse shot at a target whose shield is inactive at commit and active at + arrival. + - Pass: the shield is charged the covered share. + - Second setup: a shield that could absorb the whole shot at commit. + - Pass: the shot still detonates, and the shield pays its covered share. + - Kills: Commit's shield block left ungated for fused shots, which puts a second shield authority on the + host. + +**Fuses** (through `Fire`, the 12.3 fixture, `SchematicCellSize` 2). + +- `PenetratorBurrowsBeforeItBursts`, which pins Q12-8 in both halves. The fixture is the enclosed cockpit + behind an armoured ring, with the penetration chosen so that `t_fuse` falls inside the cockpit cell. + - With a delayed fuse and a radius under half a cell, the cockpit takes the whole damage, **and the armour + of every ring cell on the lane is unchanged.** Nothing is absorbed along the burrow. + - A contact fuse with the same stats leaves the cockpit untouched. + - With the fuse removed and everything else held, the shot is a dumb AP round. The lane's armour is spent in + order, and the cockpit takes only what the ring left (12.3). Damage in a line. + - Kills: + - absorbing along the burrow; + - a contact fuse reading penetration; + - routing every penetrating shot through the fuse path; + - a fuse without a radius detonating. +- `TheFusePointStaysOnMetal`. + - Setup: a delayed fuse with a penetration deeper than the hull, and again on a concave hull with a gap + behind the first run. + - Pass: the damage delivered equals the test's own area integral for a disc centred on the far edge (or the + gap's near edge). + - Kills: an unclamped `t_fuse`, and a second reach derivation that ignores the gap. +- `TurningAfterCommitDoesNotMoveTheBlastOnItsHost`. + - Setup: a contact-fuse shot. After commit and before arrival, the host turns 90° and moves. + - Pass: the host's per-cell damage equals that of the same shot with no turn. + - Kills: taking P from the live bearing, or from `Outcome.Cell`'s centre in place of the lane point. +- `LabelsDoNotDecideBehaviour`. + - Pass: a weapon labelled `Airburst` with no fuse resolves as a direct hit, and a weapon with + `Fuse = Proximity` and no label detonates. +- `AFuseWithoutARadiusIsADirectHit`. + - Pass: `Fuse = Contact` with a null or zero radius does lane damage. + - Kills: Fire freezing `Fuse` without the radius check. +- Re-fixtured to `Fuse`, with their behaviour unchanged: + - `AirburstSplashesEvenOnAGuaranteedMiss` (`FireControlCut6Tests.cs:245`), which becomes + `ProximityDetonatesEvenOnAGuaranteedMiss`; + - `AirburstAndDiscreteNeverDoubleUp` (`:263`), whose reference becomes a bare `Detonate` call (`:281`); + - `NonAirburstNeverSplashes` (`:296`), which becomes `NoFuseNeverDetonates`; + - `SplashHitsEveryEntityInRadius` (`FireControlCut4Tests.cs:285`), which becomes + `ABlastHitsEveryEntityItCovers`. +- `AContactBlastReportsTheHit`. + - Pass: `IncomingHit` fires once, for the host, on a committed contact hit, and never for a proximity burst. + - Kills: `IncomingHit` moved inside the direct-hit branch. + +**Retargeted, because `Absorb` is deleted.** These keep their boundary behaviour, called on the public owner: + +- `AbsorbEmitsNothingAtExactlyZeroIncomingDamage` (`FireControlCut123Tests.cs:502`) becomes + `ArmorAbsorbEmitsNothingAtExactlyZero`, calling `ArmorAbsorb(cell, 0)`. A spent lane still walks cells with + a remainder of 0. +- `AbsorbLeavesTheItemUntouchedAtExactlyPointOneRemaining` (`:526`) becomes a call to `ItemAbsorb` with a pool + of `{ .1f }`. + +**Kept green:** every 12.3 test, including the model sweep. `Reach` is a move: `ApplyPooled`, `Lanes` and the +clip rule are otherwise untouched. + +**Negative greps:** + +- `rg -n "float Absorb\(|\.Absorb\(new int2|arget\.Absorb\(|Splash\(|HasFlag\(WeaponModifiers|AirburstRange|BurstRadius" Assets/Scripts/ServerShared Assets/Scripts/Gameplay/Weapons tests/Aetheria.Shared.Tests` + returns nothing. Today it returns 27 lines, in `Entity.cs`, `FireControl.cs`, `ItemData.cs`, `Mine.cs` and + `FireControlCut4/5/6/11/123Tests.cs`. + - The pattern is code-shaped on purpose. `CapabilityEvents.Absorb` and `ShieldInterceptor.Absorb` are + unrelated. + - Exclude `StrykerOutput`, `bin` and `obj`. +- `rg -n "FireControl\.Splash|Entity\.Absorb\b|calls Splash" Assets/Scripts` returns nothing. That is the + stale-comment list above. + +**Stryker:** `--mutate` on `FireControl.cs` and `Entity.cs`, with the report filtered to the changed lines. +`--since` does not work in this repo (12.1 status). Float boundary flips count as equivalent (11.2). + +#### 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 verifies `ToSchematicPoint`'s centre-of-mass anchor, which is the half of + the frame that only a running blast can show. + +#### History (superseded 2026-09-25, kept for the record) + +The first draft (2026-09-22) was written before 12.3's rulings. Its design is retired in these parts: + +- **"An occupied cell takes its share through `Absorb(c, …)`; each covered cell absorbs independently."** + Superseded by the proportional and one-path rulings. `Absorb` gates the item per cell (`ProbeC2`). Armour + stays per cell, and the item pools its cells and resolves once through `ItemAbsorb`. +- **"`Entity.Absorb` owns per-cell absorption, shared with 12.3."** Superseded. 12.3 ended with + `ArmorAbsorb` and `ItemAbsorb` as the owners, and `Absorb` dies with Splash. +- **"A shielded contact or delayed hit is absorbed whole, as a direct hit is, and does not detonate."** + Withdrawn pending Q12-9. As written, Commit decided the host's shield on the whole damage, and then + `Detonate` decided it again, live. A shield raised between commit and arrival could absorb a hit the commit + had judged unshielded: two authorities over one shield. +- **"Step calls it at arrival with the frozen `BurstPosition`."** Superseded. Apply owns the fuse switch, and + `Step` has no damage branch. +- **"The walk only locates P" for the delayed fuse.** Kept, and made precise: the reach clip becomes the one + `Reach` function, and P is `point(min(impact.Entry + Penetration, lastReached.Exit))`. +- **Anchors.** All anchors were against `cd846916`, and every one has moved. For example, the label read was + `:281-284` and is now `:382-388`, and Splash was `:488-529` and is now `:946-994`. + +### 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-752`, CultLib `45c2f40`); drift warnings until the next catalog write | +| `Fuse` | 32 | 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.Aimed` is deleted. +- **Q12-6: no.** Operator: "12-6, nope". No catalog test ties a label to behaviour. + `WeaponModifiers` stays 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-9: A, ruled 2026-09-26.** The blast decides every blast's shields, the host included, live at detonation. Each shield pays for its covered share of the disc. The operator chose the recommended option: "The blast decides". Commit decides no shield for a shot that carries a fuse. The question as asked is kept below. + +**Q12-9 (new, blocks only the contact- and delayed-fuse half of 12.4(b)). Who decides the host's shield for a +blast that needs a hit?** + +The current map lets two places decide it: + +- Commit decides the shield on the whole damage (`FireControl.cs:578-581`), as for any hit. +- `Detonate` then decides every shield in the radius live, and that includes the host. + +**(read)** When the commit breaks the shield, Apply calls `Break()` and `Detonate` finds it already broken, so +the two agree. When the shield is inactive at commit and raised before arrival, `Detonate`'s live check can +absorb a hit the commit judged unshielded. That is a second authority over one shield: the same kind of split +this campaign has cut three times. + +- **A (recommended): `Detonate` decides every blast's shields, the host included.** Commit decides no shield + for a shot that carries a fuse. The shield is charged the host's covered share, like every entity in the + radius, live at detonation. + - Proximity bursts and mines already work this way, so there is one rule for all blasts: "a blast has no + direction", applied to shields. + - Cost: a shield that could have absorbed the whole shot now pays only for the part of the disc that + reaches the hull. For a contact detonation on the hull's face, that is roughly half. + - `ShotOutcome.Shielded` and `ShieldBroken` become direct-hit-only. Nothing outside `FireControl.cs` reads + them. +- **B: Commit owns the host's shield, as it does for a direct hit.** A shielded contact hit is absorbed whole + and does not detonate. `Detonate` must then skip the host's shield, which needs an "already decided" + parameter: a compensator that keeps two owners alive, one per entity. + +What depends on it: + +- the gate on Commit's shield block; +- whether `Detonate` takes a host parameter; +- `ABlastShotsShieldIsDecidedAtDetonation` and the host case of `ShieldPaysForWhatReachesIt`. + +Proximity bursts, mines and the whole area model are unaffected either way, so 12.4(a) and the proximity half +of (b) can proceed while this waits. + +**Defaults taken, not rulings.** The operator may overrule any of these: + +- A blast shot ignores `DamageSpread`, and P sits on the centre lane. +- `IncomingHit` fires for the host of a committed contact or delayed hit, and for no one else in a blast. +- The delayed fuse point is clamped to the last reached cell's exit. +- `Detonate` runs a flat armour→pool→hull pass, not `ApplyPooled`. `ProbeC` shows the two are equivalent. + +- **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 `Silhouette` + and `Lane`, 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. + +### 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:117` gates 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 `BlastRange` and the arming OverlapSphere + (`Mine.cs:69-78`, `:99`) are presentation-side (Cut 4 Q7). Mine has no catalog weapon + record carrying `Fuse`/`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 and + `HullDamage` without `Absorb`. 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 (estimates, with actuals) + +| Sub-cut | Removed | Added | Notes | +|---|---|---|---| +| 12.0 | 0 | ~40 src + ~30 test (CultLib) | release `cultmath-unity-v0.2.4`, manifest bump | +| 12.1 | est. ~15, **actual 18** | est. ~6, **actual 16** | no behaviour change | +| 12.2 | est. ~65, **actual 63** | est. ~95, **actual 235** | `Forecast`, pooling, the `Extent` owner and the fix batches | +| 12.3 | est. ~80, **actual 53** | est. ~45, **actual 227** | `ApplyPooled` (~140), `Lanes` (~40), `ArmorAbsorb`/`ItemAbsorb` | +| 12.4 (est.) | ~50: Splash (~35), `Absorb` (~9), the label read (~4), `Step`'s branch (~2) | ~110: `Detonate` (~40), the exact overlap (~25), the point transforms (~12), Apply's fuse switch and P (~15), `Reach` (moved, ~+2 net), `WeaponFuse`, slot 32 and `PendingShot.Fuse` (~10) | 8 Splash/airburst tests rewritten, 2 `Absorb` tests retargeted and 1 replaced, ~12 new | +| **Net Aetheria src** | **~184** | **~588** | one persisted slot added (nullable) and one renamed; no targets, dependencies or formats; CultMath gains 2 functions | + +Actuals count non-comment, non-blank changed lines under `Assets/Scripts`: + +- 12.1: `b851b0d1^..b851b0d1`; +- 12.2: `b851b0d1..c7b6e80c`; +- 12.3: `c7b6e80c..d402283c`. + +The net is no longer roughly flat, and the old sentence claiming it was should go. What the growth bought: + +- 12.2: exact 1D placement with one geometric owner, and the fix batches that removed two crash classes; +- 12.3: sequential, proportional, order-free absorption; +- 12.4: a conserving area model that replaces a half-hull approximation, and a net deletion of `Splash`, + `Absorb` and `Step`'s branch. + +12.4's own delta is ~+60, and that is the estimate to hold it to. + +**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:946-959`) +describe models this cut replaces. Self should mark them as history in the status header +as 12.2 and 12.4 land. From b66ba5246a6c355b39b20d35a73b24c17a4672c8 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 08:00:22 +0200 Subject: [PATCH 09/49] Restore LF line endings in the fire-control map c50029d5 committed the file with CRLF endings: the inserted paragraph had mixed endings, and autocrlf does not normalise a mixed file. Content is unchanged, and the net diff against c50029d5^ is the 26-line status paragraph. Co-Authored-By: Claude Opus 5.5 --- docs/fire-control-cut.md | 6610 +++++++++++++++++++------------------- 1 file changed, 3305 insertions(+), 3305 deletions(-) diff --git a/docs/fire-control-cut.md b/docs/fire-control-cut.md index fe48b2c80..915c755dd 100644 --- a/docs/fire-control-cut.md +++ b/docs/fire-control-cut.md @@ -1,3306 +1,3306 @@ -# 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 `Tool` gear. -- **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 `TargetArmorInfoThreshold` and - `TargetGearInfoThreshold`**, 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 from `GameplaySettings`; an optional - per-hardpoint override; turrets 360. -- **R7. The simulation is 2D.** The 3D is set dressing. -- **R8. Unity weapon effects and `HullCollider` become presentation only.** Their - `Physics.*` queries stop deciding damage. -- **R9. Every weapon type in the catalog's history stays** - (`docs/headless-playground-cut.md` fork 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)**: - -1. `InstantWeapon.Execute` (`Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs:216-237`) - walks the burst and raises `OnFire` (`:228`), a parameterless `event Action` declared - at `:73`. It carries no outcome and nothing in `ServerShared` subscribes. -2. `Assets/Scripts/Gameplay/EntityInstance.cs:203-204` subscribes and calls - `InstantWeaponEffectManager.Fire(weapon, item, source, target)` - (`Assets/Scripts/Gameplay/Weapons/InstantWeaponEffectManager.cs:7`). The constant - family goes the same way at `:232-235`. -3. The manager spawns an effect and copies the weapon's resolved numbers onto it. - `ProjectileManager.cs:18-25` jitters the barrel forward vector by - `Random.Range(±Spread/2)` on all three Euler axes — that is the entire aiming model. -4. 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`, plus `Projectile.cs:114` for airburst. -5. On a hull collider it calls `HullCollider.SendHit` - (`Assets/Scripts/Gameplay/HullCollider.cs:18-30`), or `SendSplash` (`:32-41`). -6. `EntityInstance.cs:316-396` subscribes to those subjects, converts a UV texture - coordinate into schematic cells, expands by `DamageSpread`, marches a penetration ray, - and calls the local function `DamageSchematic` (`:277-314`), which is the only writer - of `Armor`, item `Durability` and `Hull.Durability` from combat. -7. Death: `EntityInstance.cs:405-439` subscribes to `HullDamage`, rolls loot with - `UnityEngine.Random`, and calls `entity.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`, a - `ReactiveProperty`. Cleared when the target leaves `VisibleEnemies` (`:196`, - `:222`). `TargetRange` is recomputed each tick at `:920`. -- `Entity.EntityInfoGathered` — `Entity.cs:57`, a `ReactiveDictionary` in - [0,1], accumulated with decay by `Sensor.Execute` - (`Assets/Scripts/ServerShared/Behaviors/Sensor.cs:152-184`). -- `TargetDetectionInfoThreshold` gates `VisibleEnemies` at `Entity.cs:232-246`. -- **`TargetArmorInfoThreshold` and `TargetGearInfoThreshold` are authored and read by no - code.** They are declared at `Assets/Scripts/ServerShared/Settings.cs:212-213` and - appear nowhere else in `Assets/Scripts` **(read)**. R5's reveal tiers are unwired - settings. -- `LockWeapon` — `Assets/Scripts/ServerShared/Behaviors/LockWeapon.cs`. Integrates a - 0..1 `_lock` scaled by `pow(EntityInfoGathered[target], SensorImpact)` (`:92`), gated by - a `LockAngle` cone off `Entity.LookDirection` (`:88-89`). `CanFire` (`:56`) requires - `_lock > .99f` and 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`. - `TurretControllerData` has no fields (`:15-25`). It writes `Entity.LookDirection` to a - first-order intercept (`:67-72`) and fires each weapon when - `dot(x.Direction, Entity.LookDirection) > .99f` (`:81-83`). -- `Sensor` — sensitivity, ping boost/energy/visibility/range/cooldown, all - `PerformanceStat` (`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|Aim` finds one `#region Targeting` - label at `ActionGameManager.cs:368` and 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 on `HardpointData`, `WeaponData`, or any effect.** - A per-hardpoint override attaches here, as catalog data already authored per hull. -- `ItemRotation` is four-way (`Assets/Scripts/ServerShared/Enums.cs:7`); - `Extensions.cs:135-166` gives `None` = forward, `Reversed` = aft, `Clockwise` = right, - `CounterClockwise` = left. `Agent.cs:72` reads `Clockwise` as right and `Thruster` uses - 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`) reads `Entity.HardpointTransforms`; the second - (`:37-38`) is the item-rotation fallback R6 wants. -- **`Entity.HardpointTransforms` (`Entity.cs:58-59`) is written only by Unity**, at - `EntityInstance.cs:514-517`, from the first `WeaponHardpoint.FiringPoint` barrel's - world forward. Headless it is empty. That single dictionary is why - `Combat.cs:100-102` throws `KeyNotFoundException` on 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 toward `LookAtPoint` - (`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 `Shape` and a cell size give an angular radius; `SchematicCellSize` - does 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.** `ConstantWeaponData` adds only `AmmoInterval` - (`ConstantWeapon.cs:15`); `Spread` is inherited and unread by any beam code, and - `Velocity` is 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.ChargeFiringSpreadMultiplier` is a plain `float`** - (`ChargedWeapon.cs:34`), not a `PerformanceStat`, 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, and `GuidedWeaponData` (`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)**: - -1. `Combat.cs:100-102` indexes `HardpointTransforms`, which only Unity writes (§0.3). - `Minion` enters `CombatState` as soon as `VisibleEnemies` is non-empty, so any NPC - that sees an enemy throws on the tick it sees them. -2. There is no owner between `InstantWeapon.OnFire` and `Entity.HullDamage`. The only - `ServerShared` writers 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`), 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`). | 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`. | 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 - -1. **Mount direction and arc become simulation data.** Deletes `HardpointTransforms` - and the Unity readback; adds the arc gate; unbreaks headless combat. -2. **Targeting system and reveal.** The new subsystem, the reveal rule, the selection - slot, and the two dead settings wired up. -3. **Hit authority moves.** The Physics path in the four effects the catalog actually - uses is deleted and `FireControl` plus the shot lifecycle replaces it, in one cut, so - damage is never applied twice. -4. **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`, branch `codex/fire-control` from `codex/item-provenance` - `8a00fc92`. Depends on nothing. -- **First:** update `F:\Projects\Aetheria-stats` to HEAD (it sits 11 commits behind at - `c4f6029e`); capture `AetherDb census`, `loadout 1`, `dangling` as 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 in - `Behavior.Direction` (6). The item-rotation path at `:37-38` becomes the whole body. -- `Assets/Scripts/ServerShared/Agents/States/Combat.cs:100-102` — the - `HardpointTransforms` dot test (3). -- `Assets/Scripts/ServerShared/Behaviors/TurretController.cs:81-83` — the `.99f` dot - 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 from - `Behavior.Direction`), `ArcFor(EquippedItem)` and - `bool InArc(EquippedItem weapon, float3 toTarget)`, which tests - `dot(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`, next `Key`) — full width in degrees, - `0` meaning "use `GameplaySettings.FiringArc`". -- `GameplaySettings.FiringArc = 120` (`Settings.cs`, after `:214`). -- `GameplaySettings.AgentMinHitProbability` is **not** added here; Cut 1's AI gate is - `InArc` alone, 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-98` stays — it still aims `LookDirection`, 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, whose - `Direction` never rotates (`TurretController` writes only `LookDirection`), would - engage only within 60 degrees of its spawn facing. R6 says turrets are 360; the turret - hull's hardpoints author `FiringArc: 360`. See Q1. -- Catalog: author `FiringArc` on the `Turret` hull's two Ballistic hardpoints. Follows - the Cut C precedent — a Hands scratch console upserts, Unity and Studio closed. - -**Authority map:** - -- **Owner:** `FireControl` owns whether a weapon bears on a point. `HardpointData` - authors the arc; `ItemRotation` authors 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 - by `FireControl.Fire`. -- **Derived state:** `Behavior.Direction` becomes purely derived from item rotation. -- **Forbidden writers:** no Unity transform, `ArticulationPoint`, `WeaponHardpoint` or - prefab node may influence aim. `Barrels`, `GetBarrel` and `ArticulationPoint` survive - as presentation and must never be read by `ServerShared`. -- **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:** `HardpointTransforms` and its writer and readers are gone before - `FireControl.cs` is written. - -**Verification:** - -- builds: `Aetheria.Shared/Aetheria.Shared.csproj`, `tests/Aetheria.Shared.Tests`, - `tools/AetherDb`. -- tests, each named with the rule it pins: - - `ArcFollowsMountRotation` pins *mount direction is the item's rotation, not the - hull's facing*: a fixture hull with one weapon on a `None` hardpoint and one on a - `Clockwise` hardpoint; a target 90 degrees to starboard passes only for the - `Clockwise` weapon, a target dead ahead only for the `None` weapon. Mutation: make - `InArc` use `Entity.Direction`; swap `Rotate`'s `Clockwise`/`CounterClockwise` cases. - - `ArcBoundaryIsHalfWidth` pins *`FiringArc` is full width*: at 120, a target 59 - degrees off passes and one at 61 does not. Mutation: compare against - `cos(radians(arc))`. - - `ArcIsPlanar` pins R7: a target inside the arc horizontally but far above the firer - still passes. Mutation: use the 3D vector. - - `HardpointOverrideBeatsDefault` pins *the per-hardpoint override wins*: the same - weapon on a hardpoint with `FiringArc: 360` passes at 170 degrees, and fails there on - a hardpoint with `0`. Mutation: ignore the field; treat `0` as an arc of zero. - - `CombatStateStepsHeadless` pins *the simulation decides aim without Unity*: a - `Minion` with a target in range steps through `Zone.Update` without throwing. - Mutation: restore the `HardpointTransforms` read. -- negative: `rg -n "HardpointTransforms" Assets/Scripts` empty; - `rg -n "ArticulationPoint|FiringPoint|WeaponHardpoint" Assets/Scripts/ServerShared` - empty; `rg -n "AgentFiringMinDot" Assets/Scripts` empty. (`\.99f` is **not** a safe - negative grep — it occurs legitimately in `LockWeapon.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 every `ItemData`. -- **First:** `AetherDb census` and `loadout 1` over 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 free `Union` index on `BehaviorData`; Hands - reads the current list in `Behaviors.cs` rather than trusting the number 39 quoted in - the superseded draft) plus a `TargetingSystem : Behavior` that 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 and `PowerSupply` reach them - through `EquippedItem.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 existing `EnergyDraw` and `Heat` behaviours 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 uses `Resolution = 1` and `Precision = 0`: it can still - select a revealed item, and its hits scatter. -- `FireControl.IsRevealed(Entity observer, EquippedItem item)` — derived, never cached: - 1. Rank the target's non-hull equipment: hardpoint-mounted first, then size - descending, then equipment index. - 2. Item `i` of `N` is revealed when - `info >= lerp(TargetArmorInfoThreshold, TargetGearInfoThreshold, i / max(1, N - 1))`. - - This is the first and only reader of `Settings.cs:212-213`. -- `Entity.TargetItem` (`ReactiveProperty`, beside `Target` at - `Entity.cs:46`) and `bool Entity.TrySelectTargetItem(EquippedItem)`, the only writer. - It accepts null, or an item of `Target.Value` that `IsRevealed`. A `Target` change - 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`, calling - `TrySelectTargetItem`. 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: `CombatState` selects the revealed target `Weapon` item with the highest - `RangeDamagePerSecond(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 `IsAvailable` finds one in every - galaxy. `LoadoutGenerator.FillInterior` picks one with `required: true` for every - entity with `Weapons` — the starting LonginusX, NPC ships and turrets. Zenith, with no - weapon hardpoints, gets none. - -**Authority map:** - -- **Owner:** `FireControl` owns the reveal rule. `Entity` owns the selection slot. - `TargetingSystem` owns 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 `EquippedItem` per 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 `TargetItem` except through - `TrySelectTargetItem`; no code may store a reveal result; no code may read the two - thresholds except `IsRevealed`. -- **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: - - `SelectionNeedsReveal` pins *you cannot aim at what you have not resolved*: below - the item's tier `TrySelectTargetItem` returns false; above it, true; a target change - nulls it; info decaying below the tier makes the read null. Mutation: `IsRevealed` - returns true; the `Target` change skips the null; the accessor returns the stored - value without re-checking. - - `RevealOrder` pins *the big obvious things resolve first*: hardpoint items reveal - before interior items, larger before smaller. Mutation: drop the ordering. - - `RevealSpansBothThresholds` pins *the tiers are the authored ones*: with N items, - the first reveals at `TargetArmorInfoThreshold` and the last at - `TargetGearInfoThreshold`. Mutation: swap them; use the detection threshold. - - `UnaidedFiresWorse` pins *the subsystem is what supplies accuracy*: an entity with - no targeting system resolves `UnaidedAccuracy` and `Precision` 0; one with a - destroyed or offline system resolves the same. Mutation: fall back to the system's - stats when offline. - - `StarvedTargetingRollsWorse` pins the brownout ruling on accuracy: with an - `Accuracy` stat carrying a `PowerSupply` term, a half-grant halves it rather than - switching the item off. Mutation: gate on `PowerSupply <= 1e-4`. - - `EveryArmedLoadoutGetsATargetingSystem` pins the required-item rule across seeds. - Mutation: drop `required: true`. -- negative: `rg -n "TargetArmorInfoThreshold|TargetGearInfoThreshold" Assets/Scripts` - returns `Settings.cs` and `FireControl.cs` **only**. -- operator: `AetherDb census` shows 2 more designs; `loadout 1` shows one more interior - item per armed entity; `dangling` is 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` — `DamageSchematic` and both - `HullCollider` subscriptions (120). -- `Assets/Scripts/Gameplay/HullCollider.cs:13-14,18-41,48-65` — `Hit`, `Splash`, - `SendHit`, `SendSplash` and 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 and - `SendHit` (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; the `LineRenderer` - endpoint writes at `:59,70,77` are presentation and are rebuilt from the outcome (36). -- `Assets/Scripts/Gameplay/Weapons/Lightning.cs:34-72` — the same; the endpoint capture - at `:67-71` stays as a visual (39). -- `Assets/Scripts/Gameplay/EntityInstance.cs:411-431` — the `UnityEngine.Random` loot - 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 from - `EntityInstance.cs:277-314`, now a method on `Entity` in `ServerShared`, writing - `Armor`, item `Durability`, `Hull.Durability` and raising the existing `ArmorDamage`, - `ItemDamage` and `HullDamage` subjects. Not a redesign: the same arithmetic, the same - order, the same thresholds. -- `Entity.ApplyHit(...)` — the shape construction moved from - `EntityInstance.cs:334-394`, with two changes forced by R7: the hit cell comes from the - rolled `Cell` rather 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.Direction` instead of `transform.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]`; a `LockWeapon` is not - locked; or `InArc` fails. - - Otherwise `p = Accuracy * pSensor * pSpread`, where - `pSensor = saturate(unlerp(TargetDetectionInfoThreshold, Resolution, info))` and - `pSpread = Spread > 0 ? saturate(angularRadius / (Spread / 2)) : 1`, with - `angularRadius = degrees(atan(0.5 * max(Shape.Width, Shape.Height) * SchematicCellSize / range))`. - - `SchematicCellSize` is the one new tuning constant (Q4). -- `FireControl.Fire(weapon, item, source)` — called once per burst step from - `InstantWeapon.Execute` (replacing the bare `OnFire?.Invoke()` at - `InstantWeapon.cs:228`). It computes the predicted intercept, the flight time - (`range / Velocity`, or zero when `Velocity` is at or near zero), **freezes the payload - snapshot** (R10) and appends a `PendingShot` to `Zone.PendingShots`. The snapshot holds - `Damage`, `Penetration`, `DamageSpread`, `DamageType`, `Aimed`, the resolved targeting - stats, and the intercept point. Nothing downstream re-evaluates a stat. -- `FireControl.Step(Zone, dt)` — called from `Zone.Update` after the entity loop - (`Zone.cs:150`). For each pending shot: age it; at `ArrivalTime - CommitHorizon` - compute deviation, roll, write `Outcome`, publish `Zone.ShotCommitted`; at - `ArrivalTime` apply the outcome and remove the shot. A shot whose flight is shorter - than the horizon commits at fire time, in the same call as `Fire` — 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: `pDeviation` folds in how far the target is from the predicted - intercept at commit time, forgiven by the targeting system's `Tracking`. 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 against `Precision` decides - where — on `Aimed`'s footprint cells on success, otherwise on one cell drawn uniformly - over the hull `Shape`. An active shield that `CanTakeHit` takes the hit instead, and - the outcome is marked `Shielded`; that rule now exists once, in `FireControl`, rather - than seven times in the effects. -- `Zone.ShotCommitted` and `Zone.ShotResolved` (`Subject`), the presentation - feed. -- `InstantWeapon.OnFire` becomes `event Action` carrying the `ShotId`, 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 into `Damage` and `Spread` before this point. -- `Combat.cs:100-102`: - `shouldFire = FireControl.HitProbability(...) >= Settings.AgentMinHitProbability`, - replacing Cut 1's bare `InArc` (which the probability already subsumes, returning zero - out of arc). `AgentMinHitProbability` is 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 the `ShotId` and looks the - outcome up, or subscribes to `ShotCommitted`. 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:** `FireControl` owns the engage gate, hit probability, the roll, the cell, the - shield branch, and when each of those happens. `Entity` owns the application of damage - to its own schematic. `Zone` owns the pending-shot collection. -- **Inputs:** weapon stats through `EquippedItem.Evaluate`; targeting-system stats; - `EntityInfoGathered`; `Target` and `TargetItem`; both entities' positions, velocities - and planar directions; mount direction and arc; `ItemManager.Random`. -- **Outputs:** `ShotOutcome` on two subjects; `Entity.DamageSchematic`; shield - `TakeHit`; `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 in - `Assets/Scripts/Gameplay/Weapons` (except the unused four Cut 4 handles) may reach - durability; `HullCollider` may not raise a hit; `EntityInstance` may not damage a - schematic; no effect may call `TakeHit`; nothing outside `FireControl` may 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.Fire` and `FireControl.Step`. - There is exactly one commit primitive. -- **Deletion line:** the six deletions above land before `FireControl.Fire` exists. - -**Verification:** - -- builds: `Aetheria.Shared`, `tests/Aetheria.Shared.Tests`, `tools/AetherDb`; and Unity - batchmode with the editor closed (`docs/item-provenance-cut.md` §6) reporting no - `error CS`. -- tests, each with the mutation that must kill it: - - | Test | Pins | Mutation | - |---|---|---| - | `RollsAreSeeded` | *a fight is reproducible* — two zones, same seed, 50 shots, identical outcome sequences | `FireControl` draws from `new Random()` | - | `ProbabilityFollowsInputs` | *the roll reads what the ruling says it reads* — zero below detection and outside range; rising with info to `Resolution`; falling with `Spread`; capped at `UnaidedAccuracy` with no system | drop `pSensor`; drop the range gate; drop the cap | - | `OutOfArcConsumesNoDraw` | *the gate is a gate, not a penalty* — the RNG sequence is unchanged by an out-of-arc trigger | roll first and multiply by zero | - | `ShotResolvesOnArrival` | R3 — no durability change before `range / Velocity`; a target removed mid-flight takes none | apply at fire time | - | `OutcomeCommitsBeforeImpact` | R4 — `ShotCommitted` fires exactly `CommitHorizon` before `ShotResolved`, once per shot | publish at arrival; publish twice | - | `EvasionCountsUntilCommitAndNotAfter` | R4'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 | - | `ShortFlightCommitsAtFire` | *the 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 | - | `OutcomeIsSnapshotNotReread` | R10, the seam the stats campaign named — a weapon whose `Damage` collapses (power cut, heat spike) between fire and arrival still lands the damage it rolled | re-evaluate `Damage` at arrival | - | `AimedHitLandsOnSelectedItem` | R5's payoff — `Precision` 1 and `p` 1: only the aimed item's durability falls | the roll ignores `Aimed` | - | `HardpointHitDamagesItemThenHull` | the 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 | - | `PenetrationMarchIsPlanar` | R7 — the march rotates by the target's planar direction and gives the same cells whatever the height difference | use a 3D rotation | - | `ShieldTakesHit` | *the absorb rule has one owner* — an active shield absorbs and the schematic is untouched | remove the shield branch | - | `DeadEntityStopsTakingShots` | *a 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-wide `Physics\.` grep is **not** valid until Cut 4, and never for - `TractorBeam.cs`, which stays.) - - `rg -n "SendHit|SendSplash|DamageSchematic" Assets/Scripts --glob '!**/ServerShared/**'` - — empty. - - `rg -n "TakeHit\(" Assets/Scripts --glob '!**/ServerShared/**'` — empty. Note - `CanTakeHit` shares the substring; the trailing `(` on `TakeHit` distinguishes them, - and the `Can` prefix 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 needs `AetherDb roles-migrate` as a dry run, which the permission classifier - blocked; the operator can unblock it or Hands can add a read-only `behaviors` command. - **If any of these is live, it moves into Cut 3 instead.** - -**Deletes first:** - -- `Assets/Scripts/Gameplay/Weapons/Projectile.cs:109-122` — airburst `OverlapSphere` and - `SendSplash` (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:72` and its `_hull` plumbing - (~8) — every remaining `Physics.*` hit query and `SendHit`/`SendSplash` call in - `Assets/Scripts/Gameplay/Weapons`. - -**Adds:** - -- `ConstantWeapon` rolls once per `GameplaySettings.BeamResolveInterval` for - `Damage * interval`, through the same `FireControl.Fire`/`Step` pair with a flight time - of zero, raising `OnBeamShot(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 existing - `DamageSchematic` over the directional half-shape the deleted - `EntityInstance.cs:318-329` computed, 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`, `TractorBeam` and 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: - - `BeamRollsPerInterval` pins *a beam is a sequence of rolls, not a continuous truth*: - over one second a beam produces `1 / BeamResolveInterval` outcomes totalling - `Damage`. Mutation: roll per tick; roll once. - - `SplashHitsEveryEntityInRadius` pins *splash is one rule, not per-effect*: two - targets in radius both take damage, one outside takes none. Mutation: damage only - the nearest. - - `SplashIsDirectional` pins 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/Weapons` returns **only** - `Mine.cs`'s placement query, if Q7 keeps it — otherwise empty. `TractorBeam.cs:21` is - outside this directory and stays. - `rg -n "SendHit|SendSplash" Assets/Scripts` empty. - `rg -n "class HullHitEventArgs|class HullSplashEventArgs" Assets/Scripts` empty. -- 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 - from `c4f6029e` to HEAD). Never the main tree — the operator plays there. Never - `F:\Projects\Aetheria-tiers`. CultLib via - `-p:CultLibRoot=\cultlib-45c2f40`, the shared pinned worktree, which is - never removed. `--artifacts-path` into scratch so no `bin`/`obj` lands 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 touches `Assets/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, run - `dotnet test tests/Aetheria.Shared.Tests --cultlib-root `, assert the named test - goes red, with a no-op control. Note the scar from `8dddda4d`: 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.ShotCommitted` and `Zone.ShotResolved` are the whole - contract. What a projectile, panel, whip or near-miss *does* with a commit belongs to - `docs/shield-presentation-contract.md` and the panel and Lariat maps. This campaign - guarantees only that the commit exists, is authoritative, arrives `CommitHorizon` - before 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 in `GameplaySettings`. -- **Death, removal and loot.** `Entity.Death` is already composed in `ServerShared` - (`Entity.cs:363-366`), but `Zone.Entities.Remove` and the loot roll live in Unity - (`EntityInstance.cs:411-437`) and use `UnityEngine.Random`. Fire control makes a - headless kill *happen*; it does not make the corpse go away. Q3. -- **Pickup.** `ShieldManager.cs:40-51` stores 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`, beside `UnaidedAccuracy`: add `public 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.Tracking` falls back to `settings.UnaidedTracking`, exactly as - `Accuracy` falls back to `UnaidedAccuracy`. One shape for both. -- `Commit` then 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`. - -- `ShotOutcome` gains `public bool ShieldBroken;`. -- `Commit` decides it where it already asks `CanTakeHit`: shield present, active, - and `CanTakeHit` false means `ShieldBroken = true`. The decision is frozen with - the rest of the outcome (R4) -- a shield that recharges during the flight does - not retroactively survive. -- `Apply` performs it: `if (shot.Outcome.ShieldBroken) shot.Target.Shield.Break();` - before the hull damage. `CanTakeHit` is 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`, matching `InstantWeapon.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. - -- `InArc` takes the rule: a planar bearing shorter than `1e-6` returns true -- - point-blank cannot fail a bearing test. -- `Weapon.ArcAllowsFire` drops its own `lengthsq` special case and simply calls - `InArc`. 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.IsRevealed` returns false for an item whose - `EquippableItem.Durability < .01f`, and the ranking excludes destroyed items so - the reveal tiers of the survivors close up rather than leaving a gap. -- `Entity.ResolvedTargetItem` therefore 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. - -- `Zone` subscribes each entity's `Death` observable when the entity joins, and - removes it from `Entities` on death. The simulation owns the corpse. -- `EntityInstance.cs:324` deletes its `Entities.Remove` call 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 `flightTime` stays; it still - computes `ArrivalTime` and `CommitTime`. - -### 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` -- delete `shield.Break()` from `Splash`. Must die. -- `Weapon.cs:117` -- `ArcAllowsFire => true`. Must die. -- `InstantWeapon.cs:105` -- delete the `if (!ArcAllowsFire) return;` player arc gate - (Q2, the ruling Cut 1 deferred to Cut 3 and nothing verifies). Must die. -- `FireControl.cs:270` -- `elapsed = shot.FlightTime` in place of `now - shot.FireTime`. - This is the map's own declared mutation for `EvasionCountsUntilCommitAndNotAfter`, - and it survives because that test's fixture gives the target zero velocity, so - `FireTargetVelocity * elapsed` is identically zero and the mutation is a no-op - inside it. Give the fixture a moving target. (After 5.7 deletes `FlightTime` the - mutation's spelling becomes `elapsed = 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. - -- `Zone` exposes `public 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. -- `Commit` stops touching `ItemManager.Random` entirely. It builds its own local - `CultMath.Random` from the zone seed and the shot's own id: - `var random = new Random((zone.CombatSeed * 2654435761u) ^ (uint) shot.ShotId | 1u);` - The `| 1u` guards 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: - -- `PendingShot` regains a burst point — `public float3 BurstPosition;` — frozen - at fire like every other payload field, from `PredictedIntercept` (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). -- `PendingShot` gains `public float BurstRadius;`, frozen from the weapon's - authored airburst range, and zero for a weapon without the `Airburst` flag - (`Enums.cs:101`). -- `FireControl.Fire` sets both when the weapon's `WeaponData` carries `Airburst`. -- `FireControl.Step`, at arrival, resolves an airburst shot by calling - `Splash(zone, shot.BurstPosition, shot.BurstRadius, shot.Damage, shot.DamageType)` - **instead of** `Apply`. 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.cs` deletes `AirburstDistance` and `AirburstRange` and the stale - comment; `ProjectileManager.cs:32` deletes 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. - -- `SameFightRollsSameThroughUnrelatedDraws` pins 6.1: run a fixed fight twice from - one zone seed, drawing an arbitrary number of values from `ItemManager.Random` - between the two runs, and the outcomes must be identical. Mutation: restore the - `ItemManager.Random` draw. Must die — and note that this is the test Soul's own - probe could not be, because a hermetic fixture cannot see the defect. -- `ShotIdDecidesTheDie` pins 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: drop `ShotId` from the seed. -- `AirburstSplashesAndDoesNotAlsoRoll` pins 6.2: an airburst shot damages - everything in its radius, and its target takes area damage rather than a - discrete cell hit. Mutation: call `Apply` as well as `Splash`. Must die. -- `NonAirburstNeverSplashes` pins the other side. Mutation: splash unconditionally. -- negative: `ItemManager.Random` appears nowhere in `FireControl.cs`; - `Airburst` appears nowhere in `Assets/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: - -- `HitProbability` derives the ceiling rather than reading it directly: - `var demandCeiling = detection + (1f - detection) / max(Resolution(source), 1e-3f);` - then `pSensor = saturate(unlerp(detection, demandCeiling, info))`. -- Resolution `1` therefore means "needs complete information before sensor data - stops penalising this system" -- which is precisely what the unaided fallback - of `1f` should 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 the `max`, 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-cell `4` (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: true` interior - 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, and `AetherDb - loadout` reports 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 higher - `HitProbability`, 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 loadout` across 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 raw `TargetItem.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 test` green, 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: - -```csharp -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 - `CenterOfMass` when 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`, joining - `UnaidedAccuracy` and `UnaidedTracking` in `GameplaySettings`) is a sigma wide - enough to read as spraying at the silhouette. -- **Weights.** `w(cell) = exp(-d² / 2σ²)` for each occupied hull cell, `d` the - 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. - -- `HitProbability` gains `pOnHull` as 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. -- `Commit` draws the cell as a weighted pick over the same `w(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 `Precision` values (`.05-.2` and - `.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 reciprocal `Resolution`. -- **Double-charging risk.** `HitProbability` already carries `pSpread`, which - penalises a small target against a wide weapon spread. `pOnHull` also - 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.cs` and `TurretController.cs` gate on - `AgentMinHitProbability` against this number, so NPCs will hold fire more than - they used to the moment `pOnHull` lands. 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`: `HitProbability` against 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: drop - `pOnHull` from the probability. Must die. -- `PlacementAndProbabilityShareOneKernel`: the empirical on-hull rate over many - seeded draws matches the `pOnHull` the 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 - through `AetheriaStores.Open`, enumerate every `EquippableItemData` so every - document type is actually deserialized (a lazy read that never touches - `WeaponItemData` would have passed on the broken merge), and generate a - loadout for `LonginusX`. 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();`, matching - `TryDock`'s existing pairing exactly. One transition, one owner. -- **Ordering risk to verify, not assume:** `EntityInstance` also subscribes to - `Entity.Death` (its loot drop and destroy effect, `EntityInstance.cs:300-325`). - Subscription order decides whether loot drops before or after `Deactivate`. - `Deactivate` does not touch `Equipment` or `CargoBays`, 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.Update` returns 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 further - `Update`, and a `LockWeapon` on a ship whose target dies the same frame does not - throw. Mutation: drop the `_active` guard. Must die. -- `DeathDeactivates`: a dead entity reports `IsActive == false` and holds no live - subscriptions. Mutation: remove the `Deactivate()` call. Must die. -- `LootStillDropsOnDeath` if 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.Random` a 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 - intermediate `p` (say .3 and .6) and assert the observed hit fraction is near - `p`, 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: 1` or `accuracy: 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 `pOnHull` monotonic 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, whenever `totalWeight == 0`, returning a fixed corner cell. - It is unreachable today only because `pOnHull == 0` forces `PBase == 0` first. - 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. - -- `Deactivate` nulls `Target` (and `TargetItem`), or `Activate` reconciles it - against the reseeded `EntityInfoGathered`. One of the two owns it; say which. -- Still not a `TryGetValue` in `LockWeapon`. The invariant is that an active - entity's `Target` is 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.sh` with 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; `dangling` 0. -- 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= CULTMATH_ROOT= dotnet stryker --since: - -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 - source` equals `target + source`, so four - separate position mutations survive (`FireControl.cs` HitProbability, Inspect, - Fire, Apply). -- **Fire time zero.** `now - FireTime` equals `now + FireTime`, so the elapsed-time - mutation survives -- which means `EvasionCountsUntilCommitAndNotAfter` still does - not defend the rule Cut 5 gave it a moving target to defend. -- **Spread zero everywhere.** `PSpread` returns 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 since `482c47bf`, 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.TakeHit` from 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; `TheDieIsUniform` checks uniformity within a zone only. Tighten it - enough that the `MixSeed` weakenings 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: - -- `Inspect` recomputed `PBase` as 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, so `PSpread` was 1). `PBase` is now - `HitProbability`'s own answer; the test became - `TheHudShowsTheFactorsTheSimulationMultiplies`, over a spread cone that does not - fill the silhouette. `Fire`'s redundant `target != null ?` guard around - `HitProbability` went with it. -- A beam shot's `damageOverride` could be dropped: `BeamRollsPerInterval` computed - the expected damage from its own roll count, a tautology, now deleted. - `ADamageOverrideIsWhatTheShotCarries`. -- `Tracking` could 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. -- *`MixSeed` shift 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 - nonzero `PBase` implies 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.cs` leads a moving target), L435 (three; the aimed flag on an outcome), - L464 and L465 negate (the `Apply` hit direction through the `FireControl` - path), 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. - - **Amended 2026-09-25, lane spacing.** Soul found that lanes one cell apart share cells at - angled bearings (a cell's shadow is up to √2 wide), so whichever lane went first ate the - shared armour and mirror-image shots did different damage in ~30% of angled spread hits. - Operator: "I would prefer if the overlapping damage cells were diffused sideways to - thicken the line rather than doubling up." Self's reading, confirmed ("nope, you got it"): - **lanes are spaced by one cell's shadow width at the committed bearing** (the `Extent` - width `2h`: 1 when axis-aligned, up to √2 at 45°). Each cell's admitted interval is exactly - that wide and half-open, so no two lanes can strike the same cell; the footprint widens at - angles instead of doubling up, and axis-aligned shots are unchanged. -- **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. (Superseded for items by the 2026-09-25 one-path - ruling: armour stays per cell, and an item pools its covered cells and resolves once. See 12.4.) - - **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.Splash` and 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). `ApplyHit` only walks when - `penetration > .5f` (`Entity.cs:392`), and `WeaponData.Penetration` is inspector-ranged - `0..1` (`Behaviors/Weapon.cs:28`). -- **LonginusX's cockpit cannot be reached edge-on until penetration reaches about two - cells.** **(probe)** `exposure.py` walked 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.Expand` is 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`: - every `WeaponItemData.AirburstRange` is null, and no `WeaponModifiers` includes 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 at `FireControl.cs:282`. -- **The handedness history.** `right = float2(forward.y, -forward.x)` has not changed since - `b7743789`/`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 at `Entity.cs:394-396` and `FireControl.cs:516-520`, and 12.1 - collapses the two copies. -- **The code has two cell conventions.** The kernel, `Shape.CenterOfMass` - (`ItemData.cs:121-123`) and `ResolveAimPoint` put cell *i* at the integer point *i*. - `ApplyHit` instead starts from `cell + .5` and 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 only `FireControl.cs:292` - (PendingShot, which stays), `:432`, `:435` and `:481`, plus `FireControlCut7Tests.cs:190` - (which also reads PendingShot). -- **`Apply` reads 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 by `PSpread`, and ApplyHit and Splash convert - directions only. A blast needs world↔schematic *points*, so 12.4 names that rule. - `HullCollider` is 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"}`, 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.BuildCompatibleResolutionResult` (`CultCache.cs:587-670`) runs `CompareSchemaShapes` - (`:672-752`), which compares slot type, reference, cardinality, target schema, name - semantics and index alias, **not the member name**. So renaming slot 29 opens as - `CompatibleDrift` with warnings. A new nullable slot opens with a `defaulted_missing_slot` - warning. The warnings persist until the catalog is next written with the new descriptor. - Readers of the field: `FireControl.cs:284` and `FireControlCut6Tests.cs:81-101, 249, 268, - 279`. `tools/AetherDb` has none. The `AirburstRange:` lines in nine weapon prefabs - (for example `Flak Cannon.prefab:65`) are orphaned YAML from a deleted MonoBehaviour field: - no C# field of that name exists outside `ItemData.cs:498`. Measured at `2625768f`: the catalog opens as - `CompatibleDrift`, with slot 32 reported as `defaulted_missing_slot`. One write through AetherDb or Studio clears the drift. - -### Frame, geometry and the bearing (the rules every sub-cut reads) - -- **Schematic frame, one owner.** `Entity.ToSchematic(float2 worldPlanar) → float2` maps - world to schematic with x = starboard and y = bow: `forward = normalize(Direction)`, - `right = float2(forward.y, -forward.x)`, returning `float2(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 forms `ToSchematicPoint(float2 worldPlanar) = - ToSchematic(worldPlanar - Position.xz) / SchematicCellSize + Shape.CenterOfMass` and its - inverse `ToWorldPoint`, 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 `+ .5` convention dies with ApplyHit. -- **Travel direction.** `FireControl.TravelDirection(Weapon, Entity source, Entity target) → - float2` is the planar world direction of `PredictedIntercept(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's - `Apply` fallback (`:463`). It belongs to the shot: Fire freezes it into `PendingShot`. -- **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)`, with - `h = (|ℓ.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)`** replaces `HullKernel` and is the one function. It returns the merged - intervals, `a = dot(aimPoint, ℓ)`, sigma, `Span = hi_max - lo_min`, and - `POnHull = Σ_k Φ((hi_k - a)/σ) - Φ((lo_k - a)/σ)`, where `Φ(z) = ½(1 + erf(z/√2))`. - `aimPoint` comes from `ResolveAimPoint` unchanged; only its lateral projection matters. - `Sigma` keeps 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 * cellSize` instead of `max(Width, Height)` (`:224`). PSpread and pOnHull read - one silhouette, so they cannot disagree about the target's size. -- **Lateral draw.** One `NextFloat` u, taken after the hit roll (the draw `WeightedPick` used - to take). Walk the cumulative interval mass to `u · POnHull` to select interval k, then - invert inside it with `s = 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 parameters `t` along b, where - `t = dot(point, b)`. Cells are ordered by entry, with ties broken by `dot(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.Shared`; netstandard2.1 has - `System.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 at `Entity.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 - `> .5f` threshold (`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.PBase` is renamed - `PFire` because its meaning changes: it is `Accuracy × PSensor` behind the same gates, and - it is 0 when any gate fails. Also frozen: `TravelDirection`, `Precision`, `Aimed`, the - weapon's `Spread`, and `FireRange` (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. `Commit` computes, in this order: - 1. `pDeviation`, as today. - 2. `bearing = normalize(shot.Target.ToSchematic(shot.TravelDirection))`, from the - target's facing at the commit tick. - 3. `sil = Silhouette(target, hull, shot.Aimed, bearing, shot.Precision)`. - 4. `p = PFire × pDeviation × PSpread(shot.Spread, sil.Span, shot.FireRange, cellSize) × - sil.POnHull`. - 5. The one roll against p. On a hit, the lateral draw from `sil`, and the impact cell - from `Lane`. - - Steps 2-5 are one function, `FireControl.CommitProbability(PendingShot, float now, - out Silhouette)`. The debug HUD calls it, which retires the inline estimate at - `ActionGameManager.cs:1317-1318` (`shot.PBase * pDeviation`, which would otherwise be a - second, wrong copy of the model). -- **The outcome freezes the committed geometry** (R4). `ShotOutcome` gains `float2 Bearing` - (schematic frame, as committed) and `float Lateral`, and keeps `Cell`. `Apply` walks - `Lane(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 × pOnHull` at the *current* - travel direction and facing. That is a forecast, in the same way the HUD already treats - deviation. `Inspect.PBase == HitProbability` still 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 `sil` that 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: when `PFire × pDeviation` - is 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 current `main`. -- **Adds:** `math.erf(float)` and `math.erfinv(float)` in - `packages/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 in - `packages/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`, bump - `unity/org.gamecult.cultmath/package.json` from 0.2.3 to 0.2.4, and tag - `cultmath-unity-v0.2.4`. Aetheria then bumps `Packages/manifest.json:56` and the headless - `CultLibRoot` pin. -- **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-520` becomes `target.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-396` becomes `ToSchematic(hitDirection)`. - - `ShotOutcome.Aimed` and its plumbing (Q12-4 = A): - - the field, `FireControl.cs:702`; - - the `aimed` parameter of `MakeOutcome` (`:470`, `:481`); - - `Commit`'s `aimed` local and flag (`:420`, `:435`); - - the `AimedCells` half of `ResolveAimPoint`'s tuple (`:583-589`), which now returns - `float2`. -- **Adds:** `Entity.ToSchematic(float2)`. -- **Verification:** - - Builds: headless `Aetheria.Shared`, plus a Unity batchmode compile by Self. - - All 244 tests pass unchanged. `SplashDamagesTheSideTheBlastCameFrom` and - `PenetrationMarchIsPlanar` pin the frame through its new owner. - - Negative greps: - - `rg -n "forward\.y, -forward\.x" Assets/Scripts` returns exactly one hit, in - `Entity.cs`. - - `rg -n "Outcome\.Aimed|outcome\.Aimed|o\.Aimed" Assets/Scripts tests` returns - 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:** - - `HullKernel` and 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`) and `Inspect.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`, `Spread` and `FireRange`; `PBase` is renamed to - `PFire`. - - `ShotOutcome`: `Bearing` and `Lateral`. -- **Per-file changes:** - - `FireControl.cs:161-167`: after the gates, `PFire(...) × PSpread(...) × - Silhouette(...).POnHull`. The bearing comes from `TravelDirection` and the target's - current facing. Nothing new runs before `:159`. - - `:197-202`: Inspect uses the same functions. It gains nothing new beyond - `diagnostic.PFire` if the HUD wants it (optional; presentation). - - `:263`, `:286-308`: Fire freezes `PFire`, `TravelDirection`, `Spread = weapon.Spread` - and `FireRange`. - - `:409-435`: Commit takes steps 1-5 of **Bearing timing** through `CommitProbability`. - - `:463-467`: Apply passes `shot.Outcome.Bearing` (schematic frame) to `ApplyHit`. For - this one cut, ApplyHit's parameter becomes a schematic bearing and the `ToSchematic` - call 12.1 put inside it is removed. - - `ActionGameManager.cs:1317-1318`: the pending line prints - `FireControl.CommitProbability(shot, now, out _)` instead of `shot.PBase * pDeviation`. - - `Combat.cs:117` and `TurretController.cs:84` need 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.Silhouette` owns scatter. - - `FireControl.CommitProbability` owns the commit-time roll price and placement - source. - - `FireControl.TravelDirection` owns the shot's world direction. - - `Entity.ToSchematic` owns the frame. - - Inputs: - - Frozen: `PFire`, `TravelDirection`, `Precision`, `Aimed`, `Spread`, `FireRange`. - - Live at Commit: target position (deviation) and target facing (bearing). - - Outputs: p for the one roll, then `Bearing`, `Lateral` and `Cell` in the immutable - outcome. - - Derived state: - - HitProbability and Inspect are a forecast of the commit price and decide nothing. - - `ShotOutcome.Cell` is 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`, `POnHull` and the HUD estimate are gone - before `Silhouette` has a caller. -- **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 - old `max(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's - `TravelDirection` before commit. - - Pass: every hit's `Cell` is on the bow row. - - Kills: freezing the bearing at Fire; reading the facing at Fire. - - `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. - - `PlacementAndProbabilityShareOneSilhouette` replaces 6d's - `PlacementAndProbabilityShareOneKernel` (`FireControlCut6dTests.cs:318`). - - Setup: a static target at a diagonal bearing, so the intervals overlap and the - forecast equals the commit price. - - Pass: `CommitProbability` equals a pOnHull that the test computes independently, by - numerically integrating the Gaussian over a union of intervals the test builds itself. - The empirical `Lateral` distribution 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. - - `HitsLandOnTheFacingEdge` supersedes `EveryHitLandsOnMetal` (`:357`): on the concave - fixture, each hit's `Cell` is occupied, and nothing occupied precedes it in its own lane. - The test recomputes the lane from `Bearing` and `Lateral`. - - `AimingAtAnItemCentresTheScatterOnItsLane` closes L585/L587 and replaces - `AimingAtTheSternHitsTheStern` (`: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 old `max(5,3)`, so its numbers stand. - - `ThinLimbCostsHitChance`, with its comment rewritten in 1D terms. - - Every test that reads `shot.PBase`, updated to `PFire` with 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 `hull` - factor should read higher from the beam. - - Turn your armoured face into a slow missile and watch where it lands. - -**Status (2026-09-25): closed after three Soul passes.** Landed on `codex/fire-control-12`: -`7dbb3bcf`, `efe4f7c9` (cut and first Stryker batch), `8bd25f6f` (Soul pass 1 fixes), `d38efba3`..`ca1670a2` -(Soul pass 2 fixes). 263 tests, identical across runs; Unity batchmode compile clean at each stage. - -What the Soul passes found, in order, because two of them are lessons for 12.3: -- **Pass 1.** A lateral draw clamped to closed `[Lo, Hi]` could land exactly on `Hi`, return an empty lane, - and fall back silently to cell (0,0), a hit on nothing. The statistical tests used `Guid` zone names, so - every run rolled different dice (the source of a "flake" that got its threshold tuned instead of its cause - found). Where a hit lands was untested, only its price; the HUD test compared the commit price to itself; - `HitProbability` and `Inspect` each assembled the forecast, and only Inspect's copy was guarded; penetration - could follow a turn made after commit; multi-cell aimed items were untested. -- **Pass 2.** The pass-1 fix clamped the top edge with an epsilon and turned the silent (0,0) into a throw. - The **bottom** edge still produced empty lanes at angled bearings, so the fix converted a rare wrong result - into a rare crash (~1 per 2^24 hits, every shipped hull), reproduced through `Fire`→`Zone.Update`. Root - cause: **two geometric models answering one question** — `Silhouette` priced each cell's projected interval, - `Lane` re-derived admission with an independent slab test, and floats disagreed at corners. Also: the - two-prong placement test passed five wrong-sigma draws; "never allocates" was still false (CoreCLR wraps an - `IComparer` in a delegate per sort). -- **Pass 3 closed it.** `Lane` now admits with the same `Extent` support function `Silhouette` uses, and the - slab test is gone (`AlongBearing` survives as a non-rejecting entry/exit computer). Proof and check: all - cells' intervals have equal width, so the merge cannot open a gap; exhaustive check at every breakpoint and - float neighbour (8.9M lateral values, 13 hulls, 1,540 bearings down to 1e-9 rad) and a 402M-draw enumeration - found 0 empty lanes and 0 non-finite values; a split-arithmetic mutant of `Lane` (equal in exact - arithmetic, different in float) is killed by two tests, so the one-owner rule is guarded, not incidental. - The `Commit` invariant throw is unreachable. - -Cost, measured in Release: pooling the forecast's interval buffer took gated-in `HitProbability` on LonginusX -from 672 to 184 B/call (time unchanged; the ~1.9× over the old kernel is the sort). What remains is outside -fire control's arithmetic: 40 B per `GetBehavior` (the `Equipment` enumerator), three per call; and with an -item aimed at, `ResolveAimPoint`→`CellsOf` builds a list and `ToArray`s it every call — 944 B per -`HitProbability`, 1928 B per `Inspect`. Recorded as follow-ups. - -**Carried into 12.3's brief** (low, all on code 12.3 builds on): pin `Lane`'s seam contract (a 2x1 hull at -b=(0,1): `Lane(s=-0.5)` returns only (0,0), `Lane(s=0.5)` returns nothing — the `s >= Hi` → `s > Hi` mutant -survives today and would interleave two columns in 12.3's walk); `AlongBearing`/`SlabAxis` have no test of -their values (every mutant there survives, equivalent for 12.2 only); correct the allocation comments in -`Forecast` and `LaneAndSilhouetteAllocateNothingGivenAPooledBuffer`; make -`ZeroProbabilityShotCommitsAMissWithoutBuildingASilhouette` behavioural (produce `PFire = 0` through -visibility, assert zero allocation rather than `sil.Intervals == null`); drop the unused `System.Reflection` -import. - -### Cut 12.3. How a direct hit travels - -- **Repo/branch:** Aetheria, on top of 12.2. -- **Deletes first:** `Entity.DamageSchematic` (`Entity.cs:333-374`) and `Entity.ApplyHit` - (`Entity.cs:376-409`). Going with them: - - the 0.5-step march; - - the `+ .5` cell convention; - - the `> .5f` threshold; - - the Expand spread footprint. -- **Adds:** - - `Entity.Absorb(int2 cell, float damage) → float`, moved verbatim from - `Entity.cs:348-366`. Armour absorbs up to its value, then the item when `d > .1f`, and - the remainder is returned. It emits `ArmorDamage((cell, incoming))` and - `ItemDamage((item, incoming))` with today's meaning, and only for incoming > 0. - - `Entity.DamageHull(float)`, moved from `Entity.cs:369-373` (`> .1f`, one `HullDamage` - event). -- **Per-file changes:** `FireControl.Apply` (`:449-468`), with the shield branches - unchanged: - 1. `IncomingHit.OnNext(source)` moves here from `Entity.cs:383`. - 2. There are `2n+1` lanes, `k ∈ [-n, n]`, where `n = (int) floor(DamageSpread + .5f)` (today's - rounding, `Entity.cs:389`). Lane `k` sits `k` cell *shadows* (`2h`) from `Lateral` along ℓ, and - **no cell belongs to two lanes** (Q12-2 = A, amended 2026-09-25). Membership is decided once per - cell, not by testing float lane positions against float intervals (Soul, 12.3 pass 2: those two - derivations disagreed at cell edges and struck a cell twice). - 3. Damage splits evenly over the lanes that meet metal. The centre lane always does. - 4. Each lane walks from its own facing cell, clipped to the penetration depth, with - `rem = target.Absorb(cell, rem)` at each cell. - 5. 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 - `DamageHull` once. - 6. A weapon with a contact or delayed fuse does not take this path. It detonates instead - (12.4), never both. 12.4 moves this choice into Apply's switch on the frozen `Fuse`; `Step` no longer branches. - - **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 `Absorb` per - footprint cell and passes the summed remainder to `DamageHull`, which is arithmetically - identical to today. -- **Authority map:** - - Owner: `Entity.Absorb` owns what one cell does with incoming damage. - `FireControl.Apply` owns the order and the share per lane. - - Inputs: the committed `Bearing`, `Lateral` and `Cell`, plus the frozen `Penetration`, - `DamageSpread` and `Damage`. - - Outputs: `Armor[]`, item `Durability` and `Hull.Durability`, plus the events - `IncomingHit`, `ArmorDamage`, `ItemDamage` and `HullDamage`. Their consumers: - `ActionGameManager.cs:1070`, `EntityInstance.cs:292`, `InventoryPanel.cs:470-482`, - and `Entity.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 `Absorb` gets a second caller. -- **Verification:** - - `DamageIsAbsorbedInOrderAlongTheRay` replaces `HardpointHitDamagesItemThenHull` and - `PenetrationMarchIsPlanar` (`FireAuthorityTests.cs:481-526`, which call the deleted - `ApplyHit`). - - Setup: through `Fire` at 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. - - `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/lanes` minus what the - ring absorbed. - - Enough damage fires `Death` with `CockpitDestroyed`. - - `SpreadWidensTheImpactAcrossLanes`: spread 1 at penetration 0 puts damage/3 on each of - three facing-edge cells and nothing behind them. - - `ARayThroughACornerCrossesTheCornerCell`: the diagonal case from `Probe1` reaches (2,2). - - `LaneRemainderReachesTheHull`: a ray that exits a thin hull delivers its remainder to - `Hull.Durability` (Q12-3 = A). - - Kept green: every Splash test, and `AirburstSplashes…` (Cut 6). - - Negative: `rg -n "DamageSchematic|ApplyHit\(" Assets/Scripts tests` returns nothing. - Today it matches `Entity.cs`, `FireControl.cs`, `FireAuthorityTests.cs`, and two comment - lines at `Gameplay/EntityInstance.cs:280-281`. Rewrite those comments and - `FireControl.cs:492`. - - Stryker: `--since:<12.2 head>` over `FireControl.cs` and `Entity.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. - -**Status (2026-09-25): closed.** Landed on `codex/fire-control-12`: -- `70c3e0cc`..`73b15d9f`: the cut and the first Stryker batch. -- `b82b7eab`..`be538217`: Soul pass 1 fixes and the lane-spacing ruling. -- `4e6495dd`, `742354c6`, `cb71a3cf`: Soul pass 2 fixes. -- `56757913`, `b55894a0`: the proportional-absorption batch, since superseded. -- `e9339a56`, `ab14552a`, `166b6202`: the one-path rebuild. -- `64d17a29`, `c07b4fee`, `cccf3a2a`: Soul pass 5 fixes. - -297 tests. Unity batchmode is clean at `cccf3a2a`. - -Proportional absorption, passes 4 and 5: -- **Pass 4 (on the two-path version).** The split between `ApplySequential` and `ApplyWithSharedItems` - was already ruled out by the operator. Soul found three more defects: - - The cycle tie-break fell back to lane index at axis-aligned bearings, and a probe broke mirror symmetry. - - The pooled resolve skipped the item's `.1f` threshold. - - The model sweep still passed with proportionality deleted. -- **The rebuild.** Every item goes through a pool: clip once, and one pooled walk. - `Entity.ItemAbsorb(EquippedItem, Span)` is the one absorption owner. The cycle rule is - Self's default (above). `FireControl.cs` shrank by 40 lines against the two-path version. -- **Pass 5 (on the rebuild).** There was no second authority left, mirror symmetry held at angled - bearings across 2,800 draws, damage was conserved, and the cycle rule never fired early. - Soul found gaps in the tests (a lane revisiting an item, event cardinality), dead code, and - stale comments. All are fixed, and each mutant dies to a committed test. - -Recorded, not fixed: -- Soul's angled-bearing mirror probes (P1, P2) are not committed. They need an exact lateral draw, - which only a private seam offers. The operator rule forbids reflection, and no test seam is added - for them. - - Angled correctness is covered by the model sweep and `DirectHitAtAnAngledBearingMatchesAnIndependentModel`. - - The cycle rule's symmetry is covered at axis-aligned bearings, and holds at any bearing by construction (it has no ordering key). - - 12.4 grows no such seam. `Detonate` takes a world point, not a lane, so P1 and P2 stay uncommitted (see 12.4, **Test seam**). -- `Entity.Absorb` is deleted with `Splash` in 12.4, and `Detonate` goes through `ArmorAbsorb` and `ItemAbsorb`. - -What the three Soul passes found: -- **Pass 1.** Production correct (an independent double-precision ray model agreed on 14,984 of - 15,000 shots; the rest sit on the √2 float boundary), tests blind: no test drove `Apply` at an - angled bearing, the gap rule, penetration depth or side-lane remainders; one test could not fail. - It also found lanes one cell apart **share cells** at angled bearings, which the operator ruled on - (Q12-2 amendment: no doubling up, the line thickens). -- **Pass 2.** The spacing fix was disjoint only by float luck (78 of 26,377 boundary-lateral hits - struck a cell twice), and the disjointness test missed four spacing breakages. Hands' first - structural fix (a `ceil` lane index) introduced a **third** membership computation, which left the - centre lane empty on `solid5, b=(0,1), s=0.49999997` while `Lane(s)` found the row — a committed hit - with no damage. Final shape: `Lanes` is built from `Lane`, `Lane(s)` is literally lane 0, and float - drift is resolved by ownership (nearest the centre wins). **The third time this campaign met two - derivations of one geometric answer disagreeing in float** (12.2's crash, 12.3's double strike, - the `ceil` empty centre); the cure each time was one owner. -- **Pass 3 closed it.** 2.09M boundary cases over 196 bearings and 9 hull shapes: no cell in two - lanes, lane 0 always equals `Lane(s)`, no empty centre lane. The tie-break is mirror-symmetric by - construction. Residual float effects (a side lane starting one cell deeper, a column falling - between lanes, rarely a footprint narrowed by one column) occur at about 2×10⁻⁶ of draws; damage - is never lost. - -**Owed with the pending ruling's batch:** a test that many axis-aligned spread-n shots each strike -exactly 2n+1 facing cells (a 1% spacing error, R1b, survives today and would drop a column from 1-2% -of spread shots). **Recorded, not reachable through the public pipeline at a usable rate:** nothing -ties `Apply` to `Lanes` behaviourally (reverting `Apply` to a per-lane `Lane()` loop survives, since -it only reintroduces boundary double strikes); the behaviour after a cession is unpinned. - -**Pending operator ruling (blocks closing 12.3):** when two lanes cross one multi-cell item, the -first-processed lane drains its durability and the second carries the leftover into its own deeper -cells, so the left-of-travel lane always gets the damage that passes wide components (mirror shots on -a symmetric hull with 2x1 items differ in ~30% of axis-aligned trials). Recommended: the item absorbs -from all lanes that strike it at once, and each lane's leftover is in proportion to what it brought. -Alternative: keep the processing order and pin it. - -**Ruled 2026-09-25: proportional absorption.** Operator: "proportional absorption; go". A multi-cell -item struck by several lanes of one shot absorbs from their combined incoming damage, and each lane -carries on with a leftover in proportion to what it brought. The result must not depend on the order -lanes are processed. A single-lane item is the degenerate case of the same rule, not a separate path. - -**Ruled 2026-09-25: one code path.** Operator: "Do not expect items taking up multiple cells to be an -exception, this should be one code path." The first implementation (`56757913`) surveyed each shot -for shared items and took either `ApplySequential` or `ApplyWithSharedItems`. That was a split -authority, and the second reachability derivation it needed is the same failure as the 12.2 empty -lane and the 12.3 double strike. `Apply` has one walk, in which every item pools the deposits of -the lanes that reach it, a pool of one lane included. The survey and the sequential path die. - -**Self's default 2026-09-25 (not an operator ruling): the cycle rule.** When every unfinished lane is blocked at an item pool and no pool is ready (two lanes crossing two shared non-convex items in opposite order), every open pool resolves at once with the deposits it holds. It needs no ordering key, so mirrored shots stay mirrored. The first implementation used the smallest first-contact `Entry`, which ties at axis-aligned bearings and fell back to lane index. Soul broke mirror symmetry with it. The operator may overrule this default. - -### Cut 12.4. The detonation primitive - -**Status (2026-09-29, Self): closed.** 12.4 landed at `2625768f`, `7c99909d` and `7e63d744`. - -Soul pass 1 (Opus) found three defects: -- **F1:** a tangent disc created damage, 127 of 100. -- **F2:** a candidate cull measured from the centre of mass missed hull corners. -- **F3:** a lethal blast threw mid-loop and detonated again. - -It also found four rules pinned only by tests that could not fail. - -The fixes landed at `8b4bf01e`, `76e98598` and `fd792013`: -- the tangent comparisons now include equality; -- the cull and the `radius <= 0` guard are deleted, so `RectDiskOverlap` is the only decider; -- the blast loop iterates a snapshot. - -Soul pass 2 closed it. Conservation held over 1,944 exact-lattice discs, with a worst error -of 2e-5. A blast costs 0.3-0.7 ms in a zone of 40-100 entities. - -Pins landed at `4629bc67` and `c7e20f08`. `Death` fires once per entity (`Entity.cs`, `.Take(1)`), -which also fixed a direct hit on a corpse raising `Death` again. A three-kill blast test was added. -362 tests pass. - -**Operator ruling needed:** a fused weapon fired with no target bursts at the shooter's own -position (`FireControl.cs:391`). This predates 12.4. Self recommends a max-range burst along the -aim line. Recorded unguarded: if a `Death` handler removes another ship mid-blast, that ship -still takes blast damage. +# Fire Control: Cut Map -Revised 2026-09-25, after 12.3 closed. The first draft predates 12.3's proportional-absorption and one-path -rulings, and the parts of it those rulings overturn are listed under **History** at the end of this section. -Anchors are against `d402283c`. **(probe)** marks claims measured in a scratch worktree at that commit (see -Part 5 of the revision note); **(read)** marks claims read from source at the cited anchor. - -- **Repo/branch:** Aetheria, on top of 12.3. It lands as two commits: - - (a) The data fields and the label demotion. On shipped content this changes no behaviour, because no - weapon carries the label or a radius. That is the Cut 12 probe (`Probe4`), and `GameData/Aetheria.cc` has - not changed since it ran (`git log cd846916..d402283c -- GameData/Aetheria.cc` is empty). - - (b) `Detonate`, with `Splash` and `Entity.Absorb` deleted. - -#### Deletes first - -In (a): - -- The label read in `Fire`: `FireControl.cs:382-388`, which is the Cut 6b comment, `isAirburst` (`:386`), - `burstPosition` (`:387`) and `burstRadius` (`:388`). Afterwards `WeaponModifiers` has no simulation reader. - `rg -n "WeaponModifiers\." Assets/Scripts/ServerShared` returns nothing; today it returns exactly `:386`. -- `PendingShot.BurstRadius` and its comment (`FireControl.cs:1246-1250`) become `Fuse` and `BlastRadius`. The - initialiser at `:412-413` changes with them. `BurstPosition` stays, and it now has meaning only for a - proximity fuse. -- The slot 29 comment (`ItemData.cs:493-496`), rewritten for the renamed field. - -In (b): - -- `FireControl.Splash`, its comment and its half-hull footprint: `FireControl.cs:946-994`. This absorbs F12-1. -- `Step`'s dispatch between Splash and Apply: `FireControl.cs:458-467`, comment `:460-462`, branch `:463`. - `Step` calls `Apply` for every arrived shot, and Apply owns the fuse switch (see **Which model a shot uses**). -- `Entity.Absorb` and its comment (`Entity.cs:386-400`). It gates the item's `.1f` threshold on each cell - separately, which contradicts the one-path ruling as soon as an area covers two cells of one item. - **(probe)** `ProbeC2`: a 2x1 item with no armour, and 0.08 on each of its cells. Pooled through `ItemAbsorb` - the item absorbs 0.16. Through `Absorb` per cell it absorbs nothing, and 0.16 goes to the hull. - 12.3 recorded that `Absorb` survives only for Splash; it dies with Splash, and nothing brings it back. -- Commit's shield decision, for shots that carry a fuse (`FireControl.cs:578-581`), under Q12-9's recommended - answer. It is not deleted: it is gated on `shot.Fuse == null`. -- Apply's inline penetration clip (`FireControl.cs:666-678`) is **moved**, not deleted. It becomes the one - reach function (see **The penetrator**). -- Stale comments that name a deleted owner as live: - - `Entity.cs:391` and `:417`; - - `FireControl.cs:1201`; - - `Gameplay/EntityInstance.cs:280-283`; - - `Gameplay/HullCollider.cs:4-6`; - - `Gameplay/Weapons/Projectile.cs:8-9`; - - `Gameplay/Weapons/Mine.cs:90-95`. - -#### Data - -The smallest field set: two fields, both on `WeaponItemData` (`ItemData.cs:475-499`). - -- **Slot 29.** `float? AirburstRange` (`ItemData.cs:497-498`) is renamed `float? BlastRadius`, with JSON name - `"blastRadius"`. It is in world units and keeps its meaning. The slot and the type are unchanged, so CultCache - opens the catalog as compatible drift (see 0b). -- **New slot 32** (landed `2625768f`; the map said 30, but 30 and 31 are `OptimalTemperature`/`PlateauWidth`, inherited from `EquippableItemData`). `WeaponFuse? Fuse`, with JSON name `"fuse"`, and `enum WeaponFuse { Contact, Proximity, - Delayed }` in `Enums.cs`, beside `WeaponModifiers` (`Enums.cs:97-108`). It is nullable under the 7.4 rule: - null means no blast, and every shipped record reads as null. -- **Fire decides "detonates" once.** Fire freezes `PendingShot.Fuse` as the weapon's fuse only when - `BlastRadius > 0`, and as null otherwise, and it freezes `BlastRadius` beside it. After Fire, nothing - re-derives whether a shot detonates; every reader reads `shot.Fuse`. A fuse without a radius, or a radius - without a fuse, is inert and resolves as a direct hit. Nothing reports it: Q12-6 ruled that the catalog is - not policed. The inert half takes effect with (b)'s switch in `Apply`. At (a), `Step` still splashes on - `BlastRadius` alone (Soul, `2625768f`). -- **The drift warnings.** The catalog needs one write through `tools/AetherDb` or Studio to clear them. That - write is content and changes no data. It belongs to F12-2. -- **Readers to update:** - - `FireControl.cs:386-388`. - - `FireControlCut6Tests.cs:81`, `:95` and `:100-101`: the fixture's `airburst` and `airburstRange` - parameters. They become `fuse` and `blastRadius`. Whether the fixture keeps the label does not matter, - because Q12-6 ruled that nothing checks labels against behaviour. - -#### Which model a shot uses - -`FireControl.Apply` (`:631-693`) is the one owner of this decision, and it switches on the frozen `shot.Fuse`: - -- **null: a direct hit.** This is 12.3's lane path, unchanged, and it needs a hit. -- **Proximity: detonates at arrival, with no roll.** It detonates at `shot.BurstPosition.xz`, which - `PredictedIntercept` froze at Fire, whether the shot hit or missed. That keeps today's rule, - `AirburstSplashesEvenOnAGuaranteedMiss`. -- **Contact or Delayed: detonates only on a committed hit.** P comes from the committed lane (see **Where P - is**). On a miss nothing happens. - -Today `Step` makes this choice at `:463`, and 12.3 describes the same choice as a separate step 6. Both collapse -into Apply's switch, so `Step` has no damage branch left. - -`IncomingHit` (`:648`) fires for a committed hit, for a direct hit and a contact or delayed blast alike, and -only on the host. A proximity burst and the bystanders in any blast do not fire it, as Splash never did. Its one -consumer is the player's hit marker (`ActionGameManager.cs:1070`). - -This is a default, not a ruling. - -#### Where P is - -The lane's own parametrisation gives the point directly. `Lane` places the line at lateral offset `s` as -`point(t) = t·b + s·ℓ`, in schematic cell coordinates (`AlongBearing`/`SlabAxis`, `FireControl.cs:1177-1198`). - -**(probe)** `ProbeE`, 20,000 random lanes on a 6x9 hull: - -- the mid-slab point of every walked cell lies inside that cell, with a worst Chebyshev distance of 0.499999 - from its centre; -- `point(impact.Entry)` lies on the impact cell's boundary to within 9.5e-7. - -So the contact and delayed fuse points need no geometry beyond `Lane` and the reach clip. - -- **Proximity:** `BurstPosition.xz`, a world point. -- **Contact:** the schematic point `point(lane[0].Entry)`, on the committed lane: the committed `Bearing` and - `Lateral`, and the lane `Lane(hull, Bearing, Lateral)`. That is where the shot strikes the hull's face. -- **Delayed:** the schematic point `point(t_fuse)`, with - `t_fuse = min(lane[0].Entry + Penetration, lane[reach-1].Exit)`, where `reach` comes from the same clip that - 12.3's walk uses. In words: the fuse fires where the penetration runs out, or where the metal runs out, - whichever comes first. Metal runs out at the far side or at the first gap. `t_fuse` lies inside the last - reached cell's slab, so the disc is always centred on metal. Penetration 0 gives the contact point. -- **Which lane.** It is always the centre lane: `Lane(s)` at the committed Lateral, which Commit's guard already - proved non-empty (R3), and which `Lanes` guarantees to equal lane `n`. A blast shot's `DamageSpread` is not - read. The disc's radius is its width. This is a default, not a ruling. -- **Conversion to the world.** Contact and delayed points are converted with `host.ToWorldPoint`, using the - host's pose at arrival, and handed to `Detonate` like any other world point. The host is then treated like - every other entity in the radius: its cells are found again by `ToSchematicPoint`. - - That round trip cancels the host's pose, so the host's own damage is fixed by the commit, and R4 holds for - it. **(probe)** `ProbeF`: the worst schematic→world→schematic error is 7.6e-4 cells at 4×10⁴ world units. - - Bystanders are judged against where the host actually is at arrival. That is the same live rule a - proximity burst already applies to everyone. - - There is exactly one input shape, a world point. There is no host-only schematic entry. - -#### The penetrator (Q12-8) - -A delayed-fuse shot does not spend damage along its burrow. The lane walk **locates P and hands `Detonate` -the shot's whole `Damage`**. - -That keeps the ruling recorded as Q12-8 = A: nothing is absorbed along an explosive penetrator's burrow, and the -explosion is the payload. See Part 4 of the revision note, because the brief phrased this the other way. - -The walk and the blast share exactly one piece: **the reach clip**. Today it is inline at -`FireControl.cs:666-678`. It moves into `FireControl.Reach(LaneCell[] cells, int walked, float penetration) → -int` (private). The rule is unchanged: the impact cell is always reached, and a later cell is reached while -`entry - impact.entry < penetration`. - -Two callers read it: - -- Apply's direct-hit walk, which calls it once per lane in place of the inline loop; -- the delayed fuse point, which calls it on the centre lane. - -No second "lane damage with a blast" path exists, because no shot does both: - -- a null fuse runs the lane walk with `ApplyPooled`, which absorbs; -- a contact or delayed fuse runs `Lane` and `Reach`, which only locate P, and then `Detonate`. - -A shot takes one branch of Apply's switch or the other, never both. - -The mutant this must kill is a second reach derivation: for example, a delayed P computed as -`impact.Entry + Penetration` without the clip, which would detonate past a gap or outside the far side. - -#### Area, per entity: `FireControl.Detonate` - -The signature is `Detonate(Zone zone, float2 worldPlanar, float radius, float damage, DamageType type)`, -public, and it is the one owner of blast damage. It is Splash's signature with the point made planar (R7). - -- **Callers:** Apply (all three fuses) and `Mine.Explode` (`Mine.cs:99`, which passes - `position.ToCultMath().xz`). -- **Point transforms.** It uses `Entity.ToSchematicPoint(float2) = ToSchematic(w − Position.xz) / - SchematicCellSize + hullShape.CenterOfMass` and its inverse, `ToWorldPoint`. These are the frame rules; the - frame owner is `ToSchematic` at `Entity.cs:419-424`. `CenterOfMass` is `ItemData.cs:127-128`, and - `SchematicCellSize` is `Settings.cs:255`. - -For each entity in the zone: - -1. **Candidates.** An entity is a candidate when `|Position.xz − P| ≤ radius + ½·√(W²+H²)·SchematicCellSize`. - Splash culled on the entity's *centre* being inside the radius (`FireControl.cs:964-965`), which misses a - long hull whose end lies inside the blast. -2. **Shares, computed once.** Take `Ps = ToSchematicPoint(P)` and `rCells = radius / SchematicCellSize`. For - every occupied hull cell whose unit square meets the box `Ps ± rCells`, the share is `share_c = damage × - overlap(c) / (π·rCells²)`. Here `overlap(c)` is the exact area of the disc inside that cell's unit square, a - cell being the unit square centred on its integer coordinate. - - The shares go into one pooled buffer, which the shield step and the absorption step both read. Nothing - computes an overlap twice. - - Every share of the disc that falls on an unoccupied cell, or outside the schematic, is lost. -3. **Shield.** Every shield decision for a blast is made here, live, and charged the entity's summed shares, - under today's `CanTakeHit`/`Break`/`TakeHit` rules (`Shield.cs:166-190`). That includes the host of a contact - or delayed blast, under Q12-9's recommended answer. - - If the shield absorbs, no cell of that entity takes anything. - - If it cannot, it breaks, and the cells take their shares. -4. **Armour, per cell.** For each share, `d_c = ArmorAbsorb(c, share_c)` (`Entity.cs:339-352`). Armour protects - its own cell's share and nothing else. -5. **Items, one pool each.** If `GearOccupancy[c]` holds an item, `d_c` is deposited into that item's pool, in - hull `Coordinates` order. If not, `d_c` goes to the entity's hull total. Only after every covered cell has - deposited does each item resolve, once: `ItemAbsorb(item, pool)` (`Entity.cs:369-384`). The leftovers join - the hull total. -6. **Hull.** The entity's `DamageHull(total)` runs once (`Entity.cs:406-413`). - -Entities are independent. Two ships caught by one blast each cover their own cells, and neither shadows the -other (F12-8). - -**Order: armour first per cell, then the item from the pooled post-armour sum.** Existing rulings settle it, -and no operator question is needed: - -- "Armor absorbs first", from the first round of rulings; -- "Each covered cell absorbs … in the existing order (armour, then item, then hull)", from the area ruling; -- the proportional ruling, under which a multi-cell item "absorbs from their combined incoming damage", where - incoming means the post-armour remainder each contributor brings, exactly as a lane deposits after - `ArmorAbsorb`; -- the one-path ruling: "Do not expect items taking up multiple cells to be an exception." - -The area ruling's word "independently" survives for armour, and is superseded for items by the one-path ruling: -an item's cells deposit into one pool, and they do not absorb independently. - -**One owner, and why there is no walk.** - -- `ItemAbsorb` is the one owner of what an item does with incoming damage: the `.1f` threshold decided once on - the pooled total, the pro-rata split, and one `ItemDamage` event per contributing cell with a positive share. -- `ArmorAbsorb` is the one owner of what armour does. -- `Detonate` owns the schedule, and a disc's schedule has no walk. Apply's pooled walk (`ApplyPooled`, - `FireControl.cs:717-860`) exists only because lanes are ordered: a lane's deeper cells receive whatever the - item in front of them leaves, so a pool must wait until no unfinished lane can still reach it. In a disc, no - cell's share depends on another cell's leftover. Every deposit is known before any item resolves, and so the - reachability test and the cycle rule have nothing to decide. - -The flat pass is exactly `ApplyPooled`'s degenerate case. **(probe)** `ProbeC`: 200 random discs on a 6x4 hull -with two 2x1 items, a one-cell item and varied armour. `ApplyPooled`, fed one one-cell lane per covered cell -with that cell's share, left identical armour, item durability and `ItemDamage` event counts in 200 of 200 -discs. Its hull total differed only by float summation order, at most 3.8e-6. - -`Detonate` does **not** call `ApplyPooled`. Doing so would mean changing `ApplyPooled`'s signature to take a -starting amount per lane and renting a `LaneCell[]` per covered cell, and it would buy no behaviour. - -**What the pro-rata split does inside a blast:** nothing observable. Every leftover of a blast reaches the same -per-entity hull sink, so a sequential split and a pro-rata split deliver identical totals. The only -order-sensitive rule left in a blast is the pooled threshold, and that is what the verification pins. - -**Exact overlap, in float.** `overlap(c)` is the standard circle-rectangle intersection. It integrates the -disc's chord height over the cell's clipped x-span, using the antiderivative `½(x√(r²−x²) + r²·asin(x/r))` -between the breakpoints where the chord crosses the cell's top and bottom edges. It allocates nothing, and it -conserves exactly up to float rounding. - -- **(probe)** `ProbeA`: in float, the overlaps over the bounding box sum to `π·r²` within 1e-7 relative, at - radii 0.3, 0.5, 1.7, 5, 12.5 and 25 cells, centred on a cell centre, on a cell corner and off the grid. Double - precision is not needed. -- **(probe)** `ProbeB`: named cells agree with a 3000² numeric integration within 1.5e-6. -- Supersampling is rejected, for the reason the first draft gave. It would deliver the wrong total for a disc - smaller than a cell, or for one straddling a cell boundary. - -**Cost.** Per candidate, there is at most one overlap evaluation per occupied cell inside the box: no more than -128 on shipped hulls, whatever the radius. It runs once per detonation and never per tick. Allocation is not -the constraint here (Splash builds a `Shape` per target today), but each share is computed exactly once. - -**Accepted loss (unchanged).** Interior armour does not shadow the cells behind it, because nothing inside an -area is occluded. A bulkhead protects its own cell's share and no other. 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 -forecast still applies. A proximity blast has no roll, and it is still reported with the direct-hit number. -That is pre-existing, and it is F12-5. - -#### Authority map - -**Owners:** - -- `FireControl.Apply` owns which model a shot uses, through the switch on the frozen `Fuse`. -- `FireControl.Detonate` owns blast damage: candidates, shares, every blast shield decision, and the - per-entity armour→pool→hull pass. -- `Entity.ArmorAbsorb` owns armour per cell, and `Entity.ItemAbsorb` owns item absorption. Both are shared with - 12.3's walk. -- `FireControl.Reach` owns penetration reach, for the direct-hit walk and the delayed fuse point. -- `FireControl.Lane` owns traversal. A blast shot calls it once, for its fuse point. -- `Entity.ToSchematicPoint` and `ToWorldPoint` own points. `ToSchematic` still owns the frame. - -**Inputs:** - -- P, the radius, the damage and the type; -- each candidate's position, facing, hull, armour, occupancy and shield at the detonation tick; -- the fuse and the radius, frozen at Fire; -- for contact and delayed shots, the committed `Bearing` and `Lateral`, and the frozen `Penetration`. - -**Outputs:** the same mutations and events as 12.3: `Armor[]`, item durability, hull durability, -`ArmorDamage`, `ItemDamage`, `HullDamage` and, for the host of a hit, `IncomingHit`. - -**Derived state:** - -- `WeaponModifiers` is display-only. Its readers are the `SchematicListElement.cs:54` icons, - `GameSettings.cs:67` and `GameplaySettingsEditor.cs:44`. -- `BurstPosition` is read only for a proximity fuse. -- Under Q12-9 = A, `ShotOutcome.Shielded` and `ShieldBroken` are never set for a shot that carries a fuse. - Nothing outside `FireControl.cs` reads either flag (`rg "\.Shielded\b|\.ShieldBroken\b" Assets/Scripts` - finds only `FireControl.cs`). - -**Forbidden writers:** - -- No label decides behaviour. -- Nothing but `Detonate` applies area damage. -- No per-cell path gates an item's threshold. `Absorb` is deleted and is not reintroduced under another name. -- Nothing but `Reach` decides how deep a lane reaches. -- `Commit` decides no shield for a shot that carries a fuse (Q12-9 = A). -- `Step` has no damage branch. -- Mine's Unity `BlastRange` and its arming query stay presentation-side inputs (R8 residue, Cut 4 Q7), and they - decide no damage themselves. - -**Shared paths:** - -- the proximity burst: Apply at arrival, with no roll; -- the contact and delayed blasts: Apply on a committed hit; -- `Mine.Explode`; -- the tests, which call `Detonate` directly (see **Test seam**). - -**Deletion line:** `Splash`, `Absorb`, the label read and `Step`'s branch are deleted before `Detonate` has a -caller. - -#### Test seam - -`Detonate` takes a world point because its production callers already have one: a proximity burst's frozen -intercept, a mine's position, and a contact or delayed hit's converted lane point. That is an honest production -interface and not a hook, and it is public for the same reason `Splash` is today: Mine calls it from -`Assembly-CSharp`. Blast tests call it directly with exact points. So blast symmetry at angled facings is -committable with no seam and no reflection. - -It does **not** make Soul's 12.3 probes P1 and P2 committable. Those probes measure *direct-hit lane* damage at -an exact lateral draw. `Detonate` takes a point, not a bearing and a lateral, and no production interface takes -a built shot or outcome. The delayed fuse point's `Lane`→`Reach`→`point(t)` chain sits inside Apply, and -exposing it for tests would be exactly the seam the operator forbids. 12.3's record stands: - -- angled direct-hit correctness is covered by the model sweep and - `DirectHitAtAnAngledBearingMatchesAnIndependentModel`; -- P1 and P2 stay uncommitted. - -#### Verification - -These fixture rules apply to every blast test that touches the frame: - -- `SchematicCellSize ≠ 1`: use 2. The 12.3 fixture uses 1 (`FireControlCut123Tests.cs:49`), under which a - deleted `/ SchematicCellSize` survives. -- The target is off the origin. -- The facings include one with |fx| > |fy|. -- The hull's centre of mass is not at an integer cell, so a missing `+ CenterOfMass` survives nowhere. - -Tolerances come from the probes. Conservation holds within 1e-5 relative. Mirrored results agree within 1e-4 -absolute at damage ≤ 150: **(probe)** `ProbeD` measured at most 3.05e-5, all of it float summation order. - -**Geometry.** - -- `TheDiscIsConservedOverTheGrid`. - - Setup: on a solid hull that contains the disc, total armour, item and hull damage delivered, at a radius - under one cell, one centred on a cell corner, and one off the grid. - - Pass: the total equals `damage`. - - Kills: sampling in place of the exact overlap; the normaliser `(2r)²` in place of `πr²`; a bounding box - that clips the rim cells; a dropped chord breakpoint. -- `EachCellTakesItsShareOfTheDisc`. - - Setup: a solid hull with no armour and no items. - - Pass: a named cell takes `damage × overlap / (πr²)`, with the overlap integrated numerically by the test. - - Kills: an even split over the covered cells (Splash's rule), and a share decided by the distance from the - cell's centre. -- `TheShareOffTheHullIsLost` (was `ExternalBlastWastesMostOfItsEnergy`). - - Setup: a blast straddling the hull's edge. - - Pass: the total delivered equals `damage` times the disc's on-hull fraction, which the test computes. The - shares sum to the blast's damage minus what falls off the hull. - - Kills: renormalising the shares over the occupied cells, which would deliver the full damage. -- `CandidatesIncludeHullsWhoseCentreIsOutsideTheRadius`. - - Setup: a long hull whose centre lies beyond the radius, while its near end lies inside. - - Pass: the near end takes damage. - - Kills: Splash's centre-distance cull. - -**Absorption order.** - -- `ABlastIsAbsorbedThroughArmourFirstPerCell`. This replaces `AbsorbSpendsArmourBeforeTheItemOnTheSameCell` - (`FireControlCut123Tests.cs:750`). - - Setup: a disc wholly inside one armoured item cell, so its share is the whole damage. The three damage - levels of the old test: below the armour, between the armour and armour plus durability, and above both. - - Pass: the armour is spent first, the item takes the excess, and the hull takes what remains. - - Kills: the item absorbing before the armour, and armour being skipped. -- `ArmourProtectsOnlyItsOwnCellsShare`. - - Setup: a 2x1 item with armour on one cell only, and a disc covering both cells. - - Pass: the item takes `(share_A − armour_A)⁺ + share_B`. - - Kills: pooling the raw shares and then applying armour to the pool, which would let cell A's plate eat - cell B's share. -- `AMultiCellItemAbsorbsItsCoveredCellsAsOnePool`. - - Setup: a 2x1 item with no armour, and a disc straddling its seam that puts less than `.1` on each cell but - more than `.1` on the two together (the `ProbeC2` geometry). - - Pass: the item absorbs the sum. - - Kills: resolving the item per cell, in any form: `Absorb` resurrected, `ItemAbsorb` called per deposit, or - a pool that resolves before every covered cell has deposited. - - This test is also what pins that the result does not depend on which cell is considered first. As shown - above, the threshold is the only order-sensitive rule a blast has left. -- `ItemDamageFiresOncePerCoveredCell`. - - Pass: for a covered item, the number of `ItemDamage` events equals the number of its cells with a positive - post-armour deposit, and each event carries that cell's deposit. - - Kills: one event per item, and events for zero-deposit cells. - -**Symmetry and frame.** - -- `MirroredBlastsDoMirroredDamage`. - - Setup: a hull, its armour and its items all mirror-symmetric about the bow axis, facing (2,1)/√5. Pairs of - world points mirrored across the target's bow axis, with the mirror computed by the test from the facing - and not through `ToSchematic`. - - Pass: per-cell armour, item durability and hull damage mirror. - - Kills: - - the `+ .5` cell convention returning in the overlap (cell bounds `[c, c+1)`); - - a missing or wrong `CenterOfMass` anchor; - - any scheduling rule that depends on enumeration order. - - It does **not** kill a global handedness flip, because a flipped pair is still a mirrored pair. The next test - does. -- `ABlastDamagesTheCellsNearestIt`. This succeeds `SplashDamagesTheSideTheBlastCameFrom` - (`FireControlCut11Tests.cs:371`) and `SplashIsDirectional` (`FireControlCut4Tests.cs:306`). - - Setup: facings (0,1), (1,0), (2,1)/√5 and (−.6,.8). A blast to port, with a radius under half the hull's - width. - - Pass: port cells are damaged, and starboard cells are untouched. - - Kills: a flip of `ToSchematicPoint`'s right axis, an x/y swap, and a missing `/ SchematicCellSize`. - -**Shields** (under Q12-9 = A). - -- `ShieldPaysForWhatReachesIt`. - - Pass: the reserve drops by the entity's covered share × `EnergyUsage`, not by the whole blast. - - `SplashShieldAbsorptionDrainsTheReserve` (`FireControlCut11Tests.cs:237`) and `SplashBreaksUnabsorbedShield` - (`FireControlCut5Tests.cs:507`) are rewritten to `Detonate`, with the expected amounts recomputed from the - covered share, never loosened. - - Kills: charging the full damage. -- `ABlastShotsShieldIsDecidedAtDetonation`. - - Setup: through `Fire`, a contact-fuse shot at a target whose shield is inactive at commit and active at - arrival. - - Pass: the shield is charged the covered share. - - Second setup: a shield that could absorb the whole shot at commit. - - Pass: the shot still detonates, and the shield pays its covered share. - - Kills: Commit's shield block left ungated for fused shots, which puts a second shield authority on the - host. - -**Fuses** (through `Fire`, the 12.3 fixture, `SchematicCellSize` 2). - -- `PenetratorBurrowsBeforeItBursts`, which pins Q12-8 in both halves. The fixture is the enclosed cockpit - behind an armoured ring, with the penetration chosen so that `t_fuse` falls inside the cockpit cell. - - With a delayed fuse and a radius under half a cell, the cockpit takes the whole damage, **and the armour - of every ring cell on the lane is unchanged.** Nothing is absorbed along the burrow. - - A contact fuse with the same stats leaves the cockpit untouched. - - With the fuse removed and everything else held, the shot is a dumb AP round. The lane's armour is spent in - order, and the cockpit takes only what the ring left (12.3). Damage in a line. - - Kills: - - absorbing along the burrow; - - a contact fuse reading penetration; - - routing every penetrating shot through the fuse path; - - a fuse without a radius detonating. -- `TheFusePointStaysOnMetal`. - - Setup: a delayed fuse with a penetration deeper than the hull, and again on a concave hull with a gap - behind the first run. - - Pass: the damage delivered equals the test's own area integral for a disc centred on the far edge (or the - gap's near edge). - - Kills: an unclamped `t_fuse`, and a second reach derivation that ignores the gap. -- `TurningAfterCommitDoesNotMoveTheBlastOnItsHost`. - - Setup: a contact-fuse shot. After commit and before arrival, the host turns 90° and moves. - - Pass: the host's per-cell damage equals that of the same shot with no turn. - - Kills: taking P from the live bearing, or from `Outcome.Cell`'s centre in place of the lane point. -- `LabelsDoNotDecideBehaviour`. - - Pass: a weapon labelled `Airburst` with no fuse resolves as a direct hit, and a weapon with - `Fuse = Proximity` and no label detonates. -- `AFuseWithoutARadiusIsADirectHit`. - - Pass: `Fuse = Contact` with a null or zero radius does lane damage. - - Kills: Fire freezing `Fuse` without the radius check. -- Re-fixtured to `Fuse`, with their behaviour unchanged: - - `AirburstSplashesEvenOnAGuaranteedMiss` (`FireControlCut6Tests.cs:245`), which becomes - `ProximityDetonatesEvenOnAGuaranteedMiss`; - - `AirburstAndDiscreteNeverDoubleUp` (`:263`), whose reference becomes a bare `Detonate` call (`:281`); - - `NonAirburstNeverSplashes` (`:296`), which becomes `NoFuseNeverDetonates`; - - `SplashHitsEveryEntityInRadius` (`FireControlCut4Tests.cs:285`), which becomes - `ABlastHitsEveryEntityItCovers`. -- `AContactBlastReportsTheHit`. - - Pass: `IncomingHit` fires once, for the host, on a committed contact hit, and never for a proximity burst. - - Kills: `IncomingHit` moved inside the direct-hit branch. - -**Retargeted, because `Absorb` is deleted.** These keep their boundary behaviour, called on the public owner: - -- `AbsorbEmitsNothingAtExactlyZeroIncomingDamage` (`FireControlCut123Tests.cs:502`) becomes - `ArmorAbsorbEmitsNothingAtExactlyZero`, calling `ArmorAbsorb(cell, 0)`. A spent lane still walks cells with - a remainder of 0. -- `AbsorbLeavesTheItemUntouchedAtExactlyPointOneRemaining` (`:526`) becomes a call to `ItemAbsorb` with a pool - of `{ .1f }`. - -**Kept green:** every 12.3 test, including the model sweep. `Reach` is a move: `ApplyPooled`, `Lanes` and the -clip rule are otherwise untouched. - -**Negative greps:** - -- `rg -n "float Absorb\(|\.Absorb\(new int2|arget\.Absorb\(|Splash\(|HasFlag\(WeaponModifiers|AirburstRange|BurstRadius" Assets/Scripts/ServerShared Assets/Scripts/Gameplay/Weapons tests/Aetheria.Shared.Tests` - returns nothing. Today it returns 27 lines, in `Entity.cs`, `FireControl.cs`, `ItemData.cs`, `Mine.cs` and - `FireControlCut4/5/6/11/123Tests.cs`. - - The pattern is code-shaped on purpose. `CapabilityEvents.Absorb` and `ShieldInterceptor.Absorb` are - unrelated. - - Exclude `StrykerOutput`, `bin` and `obj`. -- `rg -n "FireControl\.Splash|Entity\.Absorb\b|calls Splash" Assets/Scripts` returns nothing. That is the - stale-comment list above. - -**Stryker:** `--mutate` on `FireControl.cs` and `Entity.cs`, with the report filtered to the changed lines. -`--since` does not work in this repo (12.1 status). Float boundary flips count as equivalent (11.2). - -#### 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 verifies `ToSchematicPoint`'s centre-of-mass anchor, which is the half of - the frame that only a running blast can show. - -#### History (superseded 2026-09-25, kept for the record) - -The first draft (2026-09-22) was written before 12.3's rulings. Its design is retired in these parts: - -- **"An occupied cell takes its share through `Absorb(c, …)`; each covered cell absorbs independently."** - Superseded by the proportional and one-path rulings. `Absorb` gates the item per cell (`ProbeC2`). Armour - stays per cell, and the item pools its cells and resolves once through `ItemAbsorb`. -- **"`Entity.Absorb` owns per-cell absorption, shared with 12.3."** Superseded. 12.3 ended with - `ArmorAbsorb` and `ItemAbsorb` as the owners, and `Absorb` dies with Splash. -- **"A shielded contact or delayed hit is absorbed whole, as a direct hit is, and does not detonate."** - Withdrawn pending Q12-9. As written, Commit decided the host's shield on the whole damage, and then - `Detonate` decided it again, live. A shield raised between commit and arrival could absorb a hit the commit - had judged unshielded: two authorities over one shield. -- **"Step calls it at arrival with the frozen `BurstPosition`."** Superseded. Apply owns the fuse switch, and - `Step` has no damage branch. -- **"The walk only locates P" for the delayed fuse.** Kept, and made precise: the reach clip becomes the one - `Reach` function, and P is `point(min(impact.Entry + Penetration, lastReached.Exit))`. -- **Anchors.** All anchors were against `cd846916`, and every one has moved. For example, the label read was - `:281-284` and is now `:382-388`, and Splash was `:488-529` and is now `:946-994`. - -### 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-752`, CultLib `45c2f40`); drift warnings until the next catalog write | -| `Fuse` | 32 | 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.Aimed` is deleted. -- **Q12-6: no.** Operator: "12-6, nope". No catalog test ties a label to behaviour. - `WeaponModifiers` stays 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-9: A, ruled 2026-09-26.** The blast decides every blast's shields, the host included, live at detonation. Each shield pays for its covered share of the disc. The operator chose the recommended option: "The blast decides". Commit decides no shield for a shot that carries a fuse. The question as asked is kept below. - -**Q12-9 (new, blocks only the contact- and delayed-fuse half of 12.4(b)). Who decides the host's shield for a -blast that needs a hit?** - -The current map lets two places decide it: - -- Commit decides the shield on the whole damage (`FireControl.cs:578-581`), as for any hit. -- `Detonate` then decides every shield in the radius live, and that includes the host. - -**(read)** When the commit breaks the shield, Apply calls `Break()` and `Detonate` finds it already broken, so -the two agree. When the shield is inactive at commit and raised before arrival, `Detonate`'s live check can -absorb a hit the commit judged unshielded. That is a second authority over one shield: the same kind of split -this campaign has cut three times. - -- **A (recommended): `Detonate` decides every blast's shields, the host included.** Commit decides no shield - for a shot that carries a fuse. The shield is charged the host's covered share, like every entity in the - radius, live at detonation. - - Proximity bursts and mines already work this way, so there is one rule for all blasts: "a blast has no - direction", applied to shields. - - Cost: a shield that could have absorbed the whole shot now pays only for the part of the disc that - reaches the hull. For a contact detonation on the hull's face, that is roughly half. - - `ShotOutcome.Shielded` and `ShieldBroken` become direct-hit-only. Nothing outside `FireControl.cs` reads - them. -- **B: Commit owns the host's shield, as it does for a direct hit.** A shielded contact hit is absorbed whole - and does not detonate. `Detonate` must then skip the host's shield, which needs an "already decided" - parameter: a compensator that keeps two owners alive, one per entity. - -What depends on it: - -- the gate on Commit's shield block; -- whether `Detonate` takes a host parameter; -- `ABlastShotsShieldIsDecidedAtDetonation` and the host case of `ShieldPaysForWhatReachesIt`. - -Proximity bursts, mines and the whole area model are unaffected either way, so 12.4(a) and the proximity half -of (b) can proceed while this waits. - -**Defaults taken, not rulings.** The operator may overrule any of these: - -- A blast shot ignores `DamageSpread`, and P sits on the centre lane. -- `IncomingHit` fires for the host of a committed contact or delayed hit, and for no one else in a blast. -- The delayed fuse point is clamped to the last reached cell's exit. -- `Detonate` runs a flat armour→pool→hull pass, not `ApplyPooled`. `ProbeC` shows the two are equivalent. - -- **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 `Silhouette` - and `Lane`, 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. - -### 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:117` gates 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 `BlastRange` and the arming OverlapSphere - (`Mine.cs:69-78`, `:99`) are presentation-side (Cut 4 Q7). Mine has no catalog weapon - record carrying `Fuse`/`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 and - `HullDamage` without `Absorb`. 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 (estimates, with actuals) - -| Sub-cut | Removed | Added | Notes | -|---|---|---|---| -| 12.0 | 0 | ~40 src + ~30 test (CultLib) | release `cultmath-unity-v0.2.4`, manifest bump | -| 12.1 | est. ~15, **actual 18** | est. ~6, **actual 16** | no behaviour change | -| 12.2 | est. ~65, **actual 63** | est. ~95, **actual 235** | `Forecast`, pooling, the `Extent` owner and the fix batches | -| 12.3 | est. ~80, **actual 53** | est. ~45, **actual 227** | `ApplyPooled` (~140), `Lanes` (~40), `ArmorAbsorb`/`ItemAbsorb` | -| 12.4 (est.) | ~50: Splash (~35), `Absorb` (~9), the label read (~4), `Step`'s branch (~2) | ~110: `Detonate` (~40), the exact overlap (~25), the point transforms (~12), Apply's fuse switch and P (~15), `Reach` (moved, ~+2 net), `WeaponFuse`, slot 32 and `PendingShot.Fuse` (~10) | 8 Splash/airburst tests rewritten, 2 `Absorb` tests retargeted and 1 replaced, ~12 new | -| **Net Aetheria src** | **~184** | **~588** | one persisted slot added (nullable) and one renamed; no targets, dependencies or formats; CultMath gains 2 functions | - -Actuals count non-comment, non-blank changed lines under `Assets/Scripts`: - -- 12.1: `b851b0d1^..b851b0d1`; -- 12.2: `b851b0d1..c7b6e80c`; -- 12.3: `c7b6e80c..d402283c`. - -The net is no longer roughly flat, and the old sentence claiming it was should go. What the growth bought: - -- 12.2: exact 1D placement with one geometric owner, and the fix batches that removed two crash classes; -- 12.3: sequential, proportional, order-free absorption; -- 12.4: a conserving area model that replaces a half-hull approximation, and a net deletion of `Splash`, - `Absorb` and `Step`'s branch. - -12.4's own delta is ~+60, and that is the estimate to hold it to. - -**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:946-959`) -describe models this cut replaces. Self should mark them as history in the status header -as 12.2 and 12.4 land. +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 `Tool` gear. +- **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 `TargetArmorInfoThreshold` and + `TargetGearInfoThreshold`**, 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 from `GameplaySettings`; an optional + per-hardpoint override; turrets 360. +- **R7. The simulation is 2D.** The 3D is set dressing. +- **R8. Unity weapon effects and `HullCollider` become presentation only.** Their + `Physics.*` queries stop deciding damage. +- **R9. Every weapon type in the catalog's history stays** + (`docs/headless-playground-cut.md` fork 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)**: + +1. `InstantWeapon.Execute` (`Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs:216-237`) + walks the burst and raises `OnFire` (`:228`), a parameterless `event Action` declared + at `:73`. It carries no outcome and nothing in `ServerShared` subscribes. +2. `Assets/Scripts/Gameplay/EntityInstance.cs:203-204` subscribes and calls + `InstantWeaponEffectManager.Fire(weapon, item, source, target)` + (`Assets/Scripts/Gameplay/Weapons/InstantWeaponEffectManager.cs:7`). The constant + family goes the same way at `:232-235`. +3. The manager spawns an effect and copies the weapon's resolved numbers onto it. + `ProjectileManager.cs:18-25` jitters the barrel forward vector by + `Random.Range(±Spread/2)` on all three Euler axes — that is the entire aiming model. +4. 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`, plus `Projectile.cs:114` for airburst. +5. On a hull collider it calls `HullCollider.SendHit` + (`Assets/Scripts/Gameplay/HullCollider.cs:18-30`), or `SendSplash` (`:32-41`). +6. `EntityInstance.cs:316-396` subscribes to those subjects, converts a UV texture + coordinate into schematic cells, expands by `DamageSpread`, marches a penetration ray, + and calls the local function `DamageSchematic` (`:277-314`), which is the only writer + of `Armor`, item `Durability` and `Hull.Durability` from combat. +7. Death: `EntityInstance.cs:405-439` subscribes to `HullDamage`, rolls loot with + `UnityEngine.Random`, and calls `entity.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`, a + `ReactiveProperty`. Cleared when the target leaves `VisibleEnemies` (`:196`, + `:222`). `TargetRange` is recomputed each tick at `:920`. +- `Entity.EntityInfoGathered` — `Entity.cs:57`, a `ReactiveDictionary` in + [0,1], accumulated with decay by `Sensor.Execute` + (`Assets/Scripts/ServerShared/Behaviors/Sensor.cs:152-184`). +- `TargetDetectionInfoThreshold` gates `VisibleEnemies` at `Entity.cs:232-246`. +- **`TargetArmorInfoThreshold` and `TargetGearInfoThreshold` are authored and read by no + code.** They are declared at `Assets/Scripts/ServerShared/Settings.cs:212-213` and + appear nowhere else in `Assets/Scripts` **(read)**. R5's reveal tiers are unwired + settings. +- `LockWeapon` — `Assets/Scripts/ServerShared/Behaviors/LockWeapon.cs`. Integrates a + 0..1 `_lock` scaled by `pow(EntityInfoGathered[target], SensorImpact)` (`:92`), gated by + a `LockAngle` cone off `Entity.LookDirection` (`:88-89`). `CanFire` (`:56`) requires + `_lock > .99f` and 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`. + `TurretControllerData` has no fields (`:15-25`). It writes `Entity.LookDirection` to a + first-order intercept (`:67-72`) and fires each weapon when + `dot(x.Direction, Entity.LookDirection) > .99f` (`:81-83`). +- `Sensor` — sensitivity, ping boost/energy/visibility/range/cooldown, all + `PerformanceStat` (`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|Aim` finds one `#region Targeting` + label at `ActionGameManager.cs:368` and 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 on `HardpointData`, `WeaponData`, or any effect.** + A per-hardpoint override attaches here, as catalog data already authored per hull. +- `ItemRotation` is four-way (`Assets/Scripts/ServerShared/Enums.cs:7`); + `Extensions.cs:135-166` gives `None` = forward, `Reversed` = aft, `Clockwise` = right, + `CounterClockwise` = left. `Agent.cs:72` reads `Clockwise` as right and `Thruster` uses + 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`) reads `Entity.HardpointTransforms`; the second + (`:37-38`) is the item-rotation fallback R6 wants. +- **`Entity.HardpointTransforms` (`Entity.cs:58-59`) is written only by Unity**, at + `EntityInstance.cs:514-517`, from the first `WeaponHardpoint.FiringPoint` barrel's + world forward. Headless it is empty. That single dictionary is why + `Combat.cs:100-102` throws `KeyNotFoundException` on 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 toward `LookAtPoint` + (`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 `Shape` and a cell size give an angular radius; `SchematicCellSize` + does 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.** `ConstantWeaponData` adds only `AmmoInterval` + (`ConstantWeapon.cs:15`); `Spread` is inherited and unread by any beam code, and + `Velocity` is 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.ChargeFiringSpreadMultiplier` is a plain `float`** + (`ChargedWeapon.cs:34`), not a `PerformanceStat`, 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, and `GuidedWeaponData` (`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)**: + +1. `Combat.cs:100-102` indexes `HardpointTransforms`, which only Unity writes (§0.3). + `Minion` enters `CombatState` as soon as `VisibleEnemies` is non-empty, so any NPC + that sees an enemy throws on the tick it sees them. +2. There is no owner between `InstantWeapon.OnFire` and `Entity.HullDamage`. The only + `ServerShared` writers 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`), 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`). | 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`. | 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 + +1. **Mount direction and arc become simulation data.** Deletes `HardpointTransforms` + and the Unity readback; adds the arc gate; unbreaks headless combat. +2. **Targeting system and reveal.** The new subsystem, the reveal rule, the selection + slot, and the two dead settings wired up. +3. **Hit authority moves.** The Physics path in the four effects the catalog actually + uses is deleted and `FireControl` plus the shot lifecycle replaces it, in one cut, so + damage is never applied twice. +4. **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`, branch `codex/fire-control` from `codex/item-provenance` + `8a00fc92`. Depends on nothing. +- **First:** update `F:\Projects\Aetheria-stats` to HEAD (it sits 11 commits behind at + `c4f6029e`); capture `AetherDb census`, `loadout 1`, `dangling` as 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 in + `Behavior.Direction` (6). The item-rotation path at `:37-38` becomes the whole body. +- `Assets/Scripts/ServerShared/Agents/States/Combat.cs:100-102` — the + `HardpointTransforms` dot test (3). +- `Assets/Scripts/ServerShared/Behaviors/TurretController.cs:81-83` — the `.99f` dot + 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 from + `Behavior.Direction`), `ArcFor(EquippedItem)` and + `bool InArc(EquippedItem weapon, float3 toTarget)`, which tests + `dot(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`, next `Key`) — full width in degrees, + `0` meaning "use `GameplaySettings.FiringArc`". +- `GameplaySettings.FiringArc = 120` (`Settings.cs`, after `:214`). +- `GameplaySettings.AgentMinHitProbability` is **not** added here; Cut 1's AI gate is + `InArc` alone, 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-98` stays — it still aims `LookDirection`, 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, whose + `Direction` never rotates (`TurretController` writes only `LookDirection`), would + engage only within 60 degrees of its spawn facing. R6 says turrets are 360; the turret + hull's hardpoints author `FiringArc: 360`. See Q1. +- Catalog: author `FiringArc` on the `Turret` hull's two Ballistic hardpoints. Follows + the Cut C precedent — a Hands scratch console upserts, Unity and Studio closed. + +**Authority map:** + +- **Owner:** `FireControl` owns whether a weapon bears on a point. `HardpointData` + authors the arc; `ItemRotation` authors 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 + by `FireControl.Fire`. +- **Derived state:** `Behavior.Direction` becomes purely derived from item rotation. +- **Forbidden writers:** no Unity transform, `ArticulationPoint`, `WeaponHardpoint` or + prefab node may influence aim. `Barrels`, `GetBarrel` and `ArticulationPoint` survive + as presentation and must never be read by `ServerShared`. +- **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:** `HardpointTransforms` and its writer and readers are gone before + `FireControl.cs` is written. + +**Verification:** + +- builds: `Aetheria.Shared/Aetheria.Shared.csproj`, `tests/Aetheria.Shared.Tests`, + `tools/AetherDb`. +- tests, each named with the rule it pins: + - `ArcFollowsMountRotation` pins *mount direction is the item's rotation, not the + hull's facing*: a fixture hull with one weapon on a `None` hardpoint and one on a + `Clockwise` hardpoint; a target 90 degrees to starboard passes only for the + `Clockwise` weapon, a target dead ahead only for the `None` weapon. Mutation: make + `InArc` use `Entity.Direction`; swap `Rotate`'s `Clockwise`/`CounterClockwise` cases. + - `ArcBoundaryIsHalfWidth` pins *`FiringArc` is full width*: at 120, a target 59 + degrees off passes and one at 61 does not. Mutation: compare against + `cos(radians(arc))`. + - `ArcIsPlanar` pins R7: a target inside the arc horizontally but far above the firer + still passes. Mutation: use the 3D vector. + - `HardpointOverrideBeatsDefault` pins *the per-hardpoint override wins*: the same + weapon on a hardpoint with `FiringArc: 360` passes at 170 degrees, and fails there on + a hardpoint with `0`. Mutation: ignore the field; treat `0` as an arc of zero. + - `CombatStateStepsHeadless` pins *the simulation decides aim without Unity*: a + `Minion` with a target in range steps through `Zone.Update` without throwing. + Mutation: restore the `HardpointTransforms` read. +- negative: `rg -n "HardpointTransforms" Assets/Scripts` empty; + `rg -n "ArticulationPoint|FiringPoint|WeaponHardpoint" Assets/Scripts/ServerShared` + empty; `rg -n "AgentFiringMinDot" Assets/Scripts` empty. (`\.99f` is **not** a safe + negative grep — it occurs legitimately in `LockWeapon.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 every `ItemData`. +- **First:** `AetherDb census` and `loadout 1` over 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 free `Union` index on `BehaviorData`; Hands + reads the current list in `Behaviors.cs` rather than trusting the number 39 quoted in + the superseded draft) plus a `TargetingSystem : Behavior` that 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 and `PowerSupply` reach them + through `EquippedItem.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 existing `EnergyDraw` and `Heat` behaviours 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 uses `Resolution = 1` and `Precision = 0`: it can still + select a revealed item, and its hits scatter. +- `FireControl.IsRevealed(Entity observer, EquippedItem item)` — derived, never cached: + 1. Rank the target's non-hull equipment: hardpoint-mounted first, then size + descending, then equipment index. + 2. Item `i` of `N` is revealed when + `info >= lerp(TargetArmorInfoThreshold, TargetGearInfoThreshold, i / max(1, N - 1))`. + + This is the first and only reader of `Settings.cs:212-213`. +- `Entity.TargetItem` (`ReactiveProperty`, beside `Target` at + `Entity.cs:46`) and `bool Entity.TrySelectTargetItem(EquippedItem)`, the only writer. + It accepts null, or an item of `Target.Value` that `IsRevealed`. A `Target` change + 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`, calling + `TrySelectTargetItem`. 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: `CombatState` selects the revealed target `Weapon` item with the highest + `RangeDamagePerSecond(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 `IsAvailable` finds one in every + galaxy. `LoadoutGenerator.FillInterior` picks one with `required: true` for every + entity with `Weapons` — the starting LonginusX, NPC ships and turrets. Zenith, with no + weapon hardpoints, gets none. + +**Authority map:** + +- **Owner:** `FireControl` owns the reveal rule. `Entity` owns the selection slot. + `TargetingSystem` owns 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 `EquippedItem` per 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 `TargetItem` except through + `TrySelectTargetItem`; no code may store a reveal result; no code may read the two + thresholds except `IsRevealed`. +- **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: + - `SelectionNeedsReveal` pins *you cannot aim at what you have not resolved*: below + the item's tier `TrySelectTargetItem` returns false; above it, true; a target change + nulls it; info decaying below the tier makes the read null. Mutation: `IsRevealed` + returns true; the `Target` change skips the null; the accessor returns the stored + value without re-checking. + - `RevealOrder` pins *the big obvious things resolve first*: hardpoint items reveal + before interior items, larger before smaller. Mutation: drop the ordering. + - `RevealSpansBothThresholds` pins *the tiers are the authored ones*: with N items, + the first reveals at `TargetArmorInfoThreshold` and the last at + `TargetGearInfoThreshold`. Mutation: swap them; use the detection threshold. + - `UnaidedFiresWorse` pins *the subsystem is what supplies accuracy*: an entity with + no targeting system resolves `UnaidedAccuracy` and `Precision` 0; one with a + destroyed or offline system resolves the same. Mutation: fall back to the system's + stats when offline. + - `StarvedTargetingRollsWorse` pins the brownout ruling on accuracy: with an + `Accuracy` stat carrying a `PowerSupply` term, a half-grant halves it rather than + switching the item off. Mutation: gate on `PowerSupply <= 1e-4`. + - `EveryArmedLoadoutGetsATargetingSystem` pins the required-item rule across seeds. + Mutation: drop `required: true`. +- negative: `rg -n "TargetArmorInfoThreshold|TargetGearInfoThreshold" Assets/Scripts` + returns `Settings.cs` and `FireControl.cs` **only**. +- operator: `AetherDb census` shows 2 more designs; `loadout 1` shows one more interior + item per armed entity; `dangling` is 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` — `DamageSchematic` and both + `HullCollider` subscriptions (120). +- `Assets/Scripts/Gameplay/HullCollider.cs:13-14,18-41,48-65` — `Hit`, `Splash`, + `SendHit`, `SendSplash` and 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 and + `SendHit` (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; the `LineRenderer` + endpoint writes at `:59,70,77` are presentation and are rebuilt from the outcome (36). +- `Assets/Scripts/Gameplay/Weapons/Lightning.cs:34-72` — the same; the endpoint capture + at `:67-71` stays as a visual (39). +- `Assets/Scripts/Gameplay/EntityInstance.cs:411-431` — the `UnityEngine.Random` loot + 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 from + `EntityInstance.cs:277-314`, now a method on `Entity` in `ServerShared`, writing + `Armor`, item `Durability`, `Hull.Durability` and raising the existing `ArmorDamage`, + `ItemDamage` and `HullDamage` subjects. Not a redesign: the same arithmetic, the same + order, the same thresholds. +- `Entity.ApplyHit(...)` — the shape construction moved from + `EntityInstance.cs:334-394`, with two changes forced by R7: the hit cell comes from the + rolled `Cell` rather 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.Direction` instead of `transform.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]`; a `LockWeapon` is not + locked; or `InArc` fails. + - Otherwise `p = Accuracy * pSensor * pSpread`, where + `pSensor = saturate(unlerp(TargetDetectionInfoThreshold, Resolution, info))` and + `pSpread = Spread > 0 ? saturate(angularRadius / (Spread / 2)) : 1`, with + `angularRadius = degrees(atan(0.5 * max(Shape.Width, Shape.Height) * SchematicCellSize / range))`. + - `SchematicCellSize` is the one new tuning constant (Q4). +- `FireControl.Fire(weapon, item, source)` — called once per burst step from + `InstantWeapon.Execute` (replacing the bare `OnFire?.Invoke()` at + `InstantWeapon.cs:228`). It computes the predicted intercept, the flight time + (`range / Velocity`, or zero when `Velocity` is at or near zero), **freezes the payload + snapshot** (R10) and appends a `PendingShot` to `Zone.PendingShots`. The snapshot holds + `Damage`, `Penetration`, `DamageSpread`, `DamageType`, `Aimed`, the resolved targeting + stats, and the intercept point. Nothing downstream re-evaluates a stat. +- `FireControl.Step(Zone, dt)` — called from `Zone.Update` after the entity loop + (`Zone.cs:150`). For each pending shot: age it; at `ArrivalTime - CommitHorizon` + compute deviation, roll, write `Outcome`, publish `Zone.ShotCommitted`; at + `ArrivalTime` apply the outcome and remove the shot. A shot whose flight is shorter + than the horizon commits at fire time, in the same call as `Fire` — 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: `pDeviation` folds in how far the target is from the predicted + intercept at commit time, forgiven by the targeting system's `Tracking`. 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 against `Precision` decides + where — on `Aimed`'s footprint cells on success, otherwise on one cell drawn uniformly + over the hull `Shape`. An active shield that `CanTakeHit` takes the hit instead, and + the outcome is marked `Shielded`; that rule now exists once, in `FireControl`, rather + than seven times in the effects. +- `Zone.ShotCommitted` and `Zone.ShotResolved` (`Subject`), the presentation + feed. +- `InstantWeapon.OnFire` becomes `event Action` carrying the `ShotId`, 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 into `Damage` and `Spread` before this point. +- `Combat.cs:100-102`: + `shouldFire = FireControl.HitProbability(...) >= Settings.AgentMinHitProbability`, + replacing Cut 1's bare `InArc` (which the probability already subsumes, returning zero + out of arc). `AgentMinHitProbability` is 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 the `ShotId` and looks the + outcome up, or subscribes to `ShotCommitted`. 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:** `FireControl` owns the engage gate, hit probability, the roll, the cell, the + shield branch, and when each of those happens. `Entity` owns the application of damage + to its own schematic. `Zone` owns the pending-shot collection. +- **Inputs:** weapon stats through `EquippedItem.Evaluate`; targeting-system stats; + `EntityInfoGathered`; `Target` and `TargetItem`; both entities' positions, velocities + and planar directions; mount direction and arc; `ItemManager.Random`. +- **Outputs:** `ShotOutcome` on two subjects; `Entity.DamageSchematic`; shield + `TakeHit`; `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 in + `Assets/Scripts/Gameplay/Weapons` (except the unused four Cut 4 handles) may reach + durability; `HullCollider` may not raise a hit; `EntityInstance` may not damage a + schematic; no effect may call `TakeHit`; nothing outside `FireControl` may 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.Fire` and `FireControl.Step`. + There is exactly one commit primitive. +- **Deletion line:** the six deletions above land before `FireControl.Fire` exists. + +**Verification:** + +- builds: `Aetheria.Shared`, `tests/Aetheria.Shared.Tests`, `tools/AetherDb`; and Unity + batchmode with the editor closed (`docs/item-provenance-cut.md` §6) reporting no + `error CS`. +- tests, each with the mutation that must kill it: + + | Test | Pins | Mutation | + |---|---|---| + | `RollsAreSeeded` | *a fight is reproducible* — two zones, same seed, 50 shots, identical outcome sequences | `FireControl` draws from `new Random()` | + | `ProbabilityFollowsInputs` | *the roll reads what the ruling says it reads* — zero below detection and outside range; rising with info to `Resolution`; falling with `Spread`; capped at `UnaidedAccuracy` with no system | drop `pSensor`; drop the range gate; drop the cap | + | `OutOfArcConsumesNoDraw` | *the gate is a gate, not a penalty* — the RNG sequence is unchanged by an out-of-arc trigger | roll first and multiply by zero | + | `ShotResolvesOnArrival` | R3 — no durability change before `range / Velocity`; a target removed mid-flight takes none | apply at fire time | + | `OutcomeCommitsBeforeImpact` | R4 — `ShotCommitted` fires exactly `CommitHorizon` before `ShotResolved`, once per shot | publish at arrival; publish twice | + | `EvasionCountsUntilCommitAndNotAfter` | R4'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 | + | `ShortFlightCommitsAtFire` | *the 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 | + | `OutcomeIsSnapshotNotReread` | R10, the seam the stats campaign named — a weapon whose `Damage` collapses (power cut, heat spike) between fire and arrival still lands the damage it rolled | re-evaluate `Damage` at arrival | + | `AimedHitLandsOnSelectedItem` | R5's payoff — `Precision` 1 and `p` 1: only the aimed item's durability falls | the roll ignores `Aimed` | + | `HardpointHitDamagesItemThenHull` | the 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 | + | `PenetrationMarchIsPlanar` | R7 — the march rotates by the target's planar direction and gives the same cells whatever the height difference | use a 3D rotation | + | `ShieldTakesHit` | *the absorb rule has one owner* — an active shield absorbs and the schematic is untouched | remove the shield branch | + | `DeadEntityStopsTakingShots` | *a 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-wide `Physics\.` grep is **not** valid until Cut 4, and never for + `TractorBeam.cs`, which stays.) + - `rg -n "SendHit|SendSplash|DamageSchematic" Assets/Scripts --glob '!**/ServerShared/**'` + — empty. + - `rg -n "TakeHit\(" Assets/Scripts --glob '!**/ServerShared/**'` — empty. Note + `CanTakeHit` shares the substring; the trailing `(` on `TakeHit` distinguishes them, + and the `Can` prefix 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 needs `AetherDb roles-migrate` as a dry run, which the permission classifier + blocked; the operator can unblock it or Hands can add a read-only `behaviors` command. + **If any of these is live, it moves into Cut 3 instead.** + +**Deletes first:** + +- `Assets/Scripts/Gameplay/Weapons/Projectile.cs:109-122` — airburst `OverlapSphere` and + `SendSplash` (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:72` and its `_hull` plumbing + (~8) — every remaining `Physics.*` hit query and `SendHit`/`SendSplash` call in + `Assets/Scripts/Gameplay/Weapons`. + +**Adds:** + +- `ConstantWeapon` rolls once per `GameplaySettings.BeamResolveInterval` for + `Damage * interval`, through the same `FireControl.Fire`/`Step` pair with a flight time + of zero, raising `OnBeamShot(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 existing + `DamageSchematic` over the directional half-shape the deleted + `EntityInstance.cs:318-329` computed, 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`, `TractorBeam` and 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: + - `BeamRollsPerInterval` pins *a beam is a sequence of rolls, not a continuous truth*: + over one second a beam produces `1 / BeamResolveInterval` outcomes totalling + `Damage`. Mutation: roll per tick; roll once. + - `SplashHitsEveryEntityInRadius` pins *splash is one rule, not per-effect*: two + targets in radius both take damage, one outside takes none. Mutation: damage only + the nearest. + - `SplashIsDirectional` pins 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/Weapons` returns **only** + `Mine.cs`'s placement query, if Q7 keeps it — otherwise empty. `TractorBeam.cs:21` is + outside this directory and stays. + `rg -n "SendHit|SendSplash" Assets/Scripts` empty. + `rg -n "class HullHitEventArgs|class HullSplashEventArgs" Assets/Scripts` empty. +- 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 + from `c4f6029e` to HEAD). Never the main tree — the operator plays there. Never + `F:\Projects\Aetheria-tiers`. CultLib via + `-p:CultLibRoot=\cultlib-45c2f40`, the shared pinned worktree, which is + never removed. `--artifacts-path` into scratch so no `bin`/`obj` lands 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 touches `Assets/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, run + `dotnet test tests/Aetheria.Shared.Tests --cultlib-root `, assert the named test + goes red, with a no-op control. Note the scar from `8dddda4d`: 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.ShotCommitted` and `Zone.ShotResolved` are the whole + contract. What a projectile, panel, whip or near-miss *does* with a commit belongs to + `docs/shield-presentation-contract.md` and the panel and Lariat maps. This campaign + guarantees only that the commit exists, is authoritative, arrives `CommitHorizon` + before 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 in `GameplaySettings`. +- **Death, removal and loot.** `Entity.Death` is already composed in `ServerShared` + (`Entity.cs:363-366`), but `Zone.Entities.Remove` and the loot roll live in Unity + (`EntityInstance.cs:411-437`) and use `UnityEngine.Random`. Fire control makes a + headless kill *happen*; it does not make the corpse go away. Q3. +- **Pickup.** `ShieldManager.cs:40-51` stores 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`, beside `UnaidedAccuracy`: add `public 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.Tracking` falls back to `settings.UnaidedTracking`, exactly as + `Accuracy` falls back to `UnaidedAccuracy`. One shape for both. +- `Commit` then 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`. + +- `ShotOutcome` gains `public bool ShieldBroken;`. +- `Commit` decides it where it already asks `CanTakeHit`: shield present, active, + and `CanTakeHit` false means `ShieldBroken = true`. The decision is frozen with + the rest of the outcome (R4) -- a shield that recharges during the flight does + not retroactively survive. +- `Apply` performs it: `if (shot.Outcome.ShieldBroken) shot.Target.Shield.Break();` + before the hull damage. `CanTakeHit` is 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`, matching `InstantWeapon.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. + +- `InArc` takes the rule: a planar bearing shorter than `1e-6` returns true -- + point-blank cannot fail a bearing test. +- `Weapon.ArcAllowsFire` drops its own `lengthsq` special case and simply calls + `InArc`. 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.IsRevealed` returns false for an item whose + `EquippableItem.Durability < .01f`, and the ranking excludes destroyed items so + the reveal tiers of the survivors close up rather than leaving a gap. +- `Entity.ResolvedTargetItem` therefore 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. + +- `Zone` subscribes each entity's `Death` observable when the entity joins, and + removes it from `Entities` on death. The simulation owns the corpse. +- `EntityInstance.cs:324` deletes its `Entities.Remove` call 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 `flightTime` stays; it still + computes `ArrivalTime` and `CommitTime`. + +### 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` -- delete `shield.Break()` from `Splash`. Must die. +- `Weapon.cs:117` -- `ArcAllowsFire => true`. Must die. +- `InstantWeapon.cs:105` -- delete the `if (!ArcAllowsFire) return;` player arc gate + (Q2, the ruling Cut 1 deferred to Cut 3 and nothing verifies). Must die. +- `FireControl.cs:270` -- `elapsed = shot.FlightTime` in place of `now - shot.FireTime`. + This is the map's own declared mutation for `EvasionCountsUntilCommitAndNotAfter`, + and it survives because that test's fixture gives the target zero velocity, so + `FireTargetVelocity * elapsed` is identically zero and the mutation is a no-op + inside it. Give the fixture a moving target. (After 5.7 deletes `FlightTime` the + mutation's spelling becomes `elapsed = 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. + +- `Zone` exposes `public 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. +- `Commit` stops touching `ItemManager.Random` entirely. It builds its own local + `CultMath.Random` from the zone seed and the shot's own id: + `var random = new Random((zone.CombatSeed * 2654435761u) ^ (uint) shot.ShotId | 1u);` + The `| 1u` guards 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: + +- `PendingShot` regains a burst point — `public float3 BurstPosition;` — frozen + at fire like every other payload field, from `PredictedIntercept` (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). +- `PendingShot` gains `public float BurstRadius;`, frozen from the weapon's + authored airburst range, and zero for a weapon without the `Airburst` flag + (`Enums.cs:101`). +- `FireControl.Fire` sets both when the weapon's `WeaponData` carries `Airburst`. +- `FireControl.Step`, at arrival, resolves an airburst shot by calling + `Splash(zone, shot.BurstPosition, shot.BurstRadius, shot.Damage, shot.DamageType)` + **instead of** `Apply`. 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.cs` deletes `AirburstDistance` and `AirburstRange` and the stale + comment; `ProjectileManager.cs:32` deletes 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. + +- `SameFightRollsSameThroughUnrelatedDraws` pins 6.1: run a fixed fight twice from + one zone seed, drawing an arbitrary number of values from `ItemManager.Random` + between the two runs, and the outcomes must be identical. Mutation: restore the + `ItemManager.Random` draw. Must die — and note that this is the test Soul's own + probe could not be, because a hermetic fixture cannot see the defect. +- `ShotIdDecidesTheDie` pins 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: drop `ShotId` from the seed. +- `AirburstSplashesAndDoesNotAlsoRoll` pins 6.2: an airburst shot damages + everything in its radius, and its target takes area damage rather than a + discrete cell hit. Mutation: call `Apply` as well as `Splash`. Must die. +- `NonAirburstNeverSplashes` pins the other side. Mutation: splash unconditionally. +- negative: `ItemManager.Random` appears nowhere in `FireControl.cs`; + `Airburst` appears nowhere in `Assets/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: + +- `HitProbability` derives the ceiling rather than reading it directly: + `var demandCeiling = detection + (1f - detection) / max(Resolution(source), 1e-3f);` + then `pSensor = saturate(unlerp(detection, demandCeiling, info))`. +- Resolution `1` therefore means "needs complete information before sensor data + stops penalising this system" -- which is precisely what the unaided fallback + of `1f` should 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 the `max`, 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-cell `4` (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: true` interior + 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, and `AetherDb + loadout` reports 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 higher + `HitProbability`, 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 loadout` across 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 raw `TargetItem.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 test` green, 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: + +```csharp +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 + `CenterOfMass` when 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`, joining + `UnaidedAccuracy` and `UnaidedTracking` in `GameplaySettings`) is a sigma wide + enough to read as spraying at the silhouette. +- **Weights.** `w(cell) = exp(-d² / 2σ²)` for each occupied hull cell, `d` the + 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. + +- `HitProbability` gains `pOnHull` as 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. +- `Commit` draws the cell as a weighted pick over the same `w(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 `Precision` values (`.05-.2` and + `.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 reciprocal `Resolution`. +- **Double-charging risk.** `HitProbability` already carries `pSpread`, which + penalises a small target against a wide weapon spread. `pOnHull` also + 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.cs` and `TurretController.cs` gate on + `AgentMinHitProbability` against this number, so NPCs will hold fire more than + they used to the moment `pOnHull` lands. 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`: `HitProbability` against 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: drop + `pOnHull` from the probability. Must die. +- `PlacementAndProbabilityShareOneKernel`: the empirical on-hull rate over many + seeded draws matches the `pOnHull` the 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 + through `AetheriaStores.Open`, enumerate every `EquippableItemData` so every + document type is actually deserialized (a lazy read that never touches + `WeaponItemData` would have passed on the broken merge), and generate a + loadout for `LonginusX`. 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();`, matching + `TryDock`'s existing pairing exactly. One transition, one owner. +- **Ordering risk to verify, not assume:** `EntityInstance` also subscribes to + `Entity.Death` (its loot drop and destroy effect, `EntityInstance.cs:300-325`). + Subscription order decides whether loot drops before or after `Deactivate`. + `Deactivate` does not touch `Equipment` or `CargoBays`, 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.Update` returns 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 further + `Update`, and a `LockWeapon` on a ship whose target dies the same frame does not + throw. Mutation: drop the `_active` guard. Must die. +- `DeathDeactivates`: a dead entity reports `IsActive == false` and holds no live + subscriptions. Mutation: remove the `Deactivate()` call. Must die. +- `LootStillDropsOnDeath` if 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.Random` a 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 + intermediate `p` (say .3 and .6) and assert the observed hit fraction is near + `p`, 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: 1` or `accuracy: 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 `pOnHull` monotonic 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, whenever `totalWeight == 0`, returning a fixed corner cell. + It is unreachable today only because `pOnHull == 0` forces `PBase == 0` first. + 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. + +- `Deactivate` nulls `Target` (and `TargetItem`), or `Activate` reconciles it + against the reseeded `EntityInfoGathered`. One of the two owns it; say which. +- Still not a `TryGetValue` in `LockWeapon`. The invariant is that an active + entity's `Target` is 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.sh` with 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; `dangling` 0. +- 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= CULTMATH_ROOT= dotnet stryker --since: + +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 - source` equals `target + source`, so four + separate position mutations survive (`FireControl.cs` HitProbability, Inspect, + Fire, Apply). +- **Fire time zero.** `now - FireTime` equals `now + FireTime`, so the elapsed-time + mutation survives -- which means `EvasionCountsUntilCommitAndNotAfter` still does + not defend the rule Cut 5 gave it a moving target to defend. +- **Spread zero everywhere.** `PSpread` returns 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 since `482c47bf`, 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.TakeHit` from 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; `TheDieIsUniform` checks uniformity within a zone only. Tighten it + enough that the `MixSeed` weakenings 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: + +- `Inspect` recomputed `PBase` as 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, so `PSpread` was 1). `PBase` is now + `HitProbability`'s own answer; the test became + `TheHudShowsTheFactorsTheSimulationMultiplies`, over a spread cone that does not + fill the silhouette. `Fire`'s redundant `target != null ?` guard around + `HitProbability` went with it. +- A beam shot's `damageOverride` could be dropped: `BeamRollsPerInterval` computed + the expected damage from its own roll count, a tautology, now deleted. + `ADamageOverrideIsWhatTheShotCarries`. +- `Tracking` could 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. +- *`MixSeed` shift 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 + nonzero `PBase` implies 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.cs` leads a moving target), L435 (three; the aimed flag on an outcome), + L464 and L465 negate (the `Apply` hit direction through the `FireControl` + path), 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. + - **Amended 2026-09-25, lane spacing.** Soul found that lanes one cell apart share cells at + angled bearings (a cell's shadow is up to √2 wide), so whichever lane went first ate the + shared armour and mirror-image shots did different damage in ~30% of angled spread hits. + Operator: "I would prefer if the overlapping damage cells were diffused sideways to + thicken the line rather than doubling up." Self's reading, confirmed ("nope, you got it"): + **lanes are spaced by one cell's shadow width at the committed bearing** (the `Extent` + width `2h`: 1 when axis-aligned, up to √2 at 45°). Each cell's admitted interval is exactly + that wide and half-open, so no two lanes can strike the same cell; the footprint widens at + angles instead of doubling up, and axis-aligned shots are unchanged. +- **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. (Superseded for items by the 2026-09-25 one-path + ruling: armour stays per cell, and an item pools its covered cells and resolves once. See 12.4.) + + **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.Splash` and 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). `ApplyHit` only walks when + `penetration > .5f` (`Entity.cs:392`), and `WeaponData.Penetration` is inspector-ranged + `0..1` (`Behaviors/Weapon.cs:28`). +- **LonginusX's cockpit cannot be reached edge-on until penetration reaches about two + cells.** **(probe)** `exposure.py` walked 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.Expand` is 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`: + every `WeaponItemData.AirburstRange` is null, and no `WeaponModifiers` includes 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 at `FireControl.cs:282`. +- **The handedness history.** `right = float2(forward.y, -forward.x)` has not changed since + `b7743789`/`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 at `Entity.cs:394-396` and `FireControl.cs:516-520`, and 12.1 + collapses the two copies. +- **The code has two cell conventions.** The kernel, `Shape.CenterOfMass` + (`ItemData.cs:121-123`) and `ResolveAimPoint` put cell *i* at the integer point *i*. + `ApplyHit` instead starts from `cell + .5` and 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 only `FireControl.cs:292` + (PendingShot, which stays), `:432`, `:435` and `:481`, plus `FireControlCut7Tests.cs:190` + (which also reads PendingShot). +- **`Apply` reads 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 by `PSpread`, and ApplyHit and Splash convert + directions only. A blast needs world↔schematic *points*, so 12.4 names that rule. + `HullCollider` is 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"}`, 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.BuildCompatibleResolutionResult` (`CultCache.cs:587-670`) runs `CompareSchemaShapes` + (`:672-752`), which compares slot type, reference, cardinality, target schema, name + semantics and index alias, **not the member name**. So renaming slot 29 opens as + `CompatibleDrift` with warnings. A new nullable slot opens with a `defaulted_missing_slot` + warning. The warnings persist until the catalog is next written with the new descriptor. + Readers of the field: `FireControl.cs:284` and `FireControlCut6Tests.cs:81-101, 249, 268, + 279`. `tools/AetherDb` has none. The `AirburstRange:` lines in nine weapon prefabs + (for example `Flak Cannon.prefab:65`) are orphaned YAML from a deleted MonoBehaviour field: + no C# field of that name exists outside `ItemData.cs:498`. Measured at `2625768f`: the catalog opens as + `CompatibleDrift`, with slot 32 reported as `defaulted_missing_slot`. One write through AetherDb or Studio clears the drift. + +### Frame, geometry and the bearing (the rules every sub-cut reads) + +- **Schematic frame, one owner.** `Entity.ToSchematic(float2 worldPlanar) → float2` maps + world to schematic with x = starboard and y = bow: `forward = normalize(Direction)`, + `right = float2(forward.y, -forward.x)`, returning `float2(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 forms `ToSchematicPoint(float2 worldPlanar) = + ToSchematic(worldPlanar - Position.xz) / SchematicCellSize + Shape.CenterOfMass` and its + inverse `ToWorldPoint`, 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 `+ .5` convention dies with ApplyHit. +- **Travel direction.** `FireControl.TravelDirection(Weapon, Entity source, Entity target) → + float2` is the planar world direction of `PredictedIntercept(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's + `Apply` fallback (`:463`). It belongs to the shot: Fire freezes it into `PendingShot`. +- **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)`, with + `h = (|ℓ.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)`** replaces `HullKernel` and is the one function. It returns the merged + intervals, `a = dot(aimPoint, ℓ)`, sigma, `Span = hi_max - lo_min`, and + `POnHull = Σ_k Φ((hi_k - a)/σ) - Φ((lo_k - a)/σ)`, where `Φ(z) = ½(1 + erf(z/√2))`. + `aimPoint` comes from `ResolveAimPoint` unchanged; only its lateral projection matters. + `Sigma` keeps 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 * cellSize` instead of `max(Width, Height)` (`:224`). PSpread and pOnHull read + one silhouette, so they cannot disagree about the target's size. +- **Lateral draw.** One `NextFloat` u, taken after the hit roll (the draw `WeightedPick` used + to take). Walk the cumulative interval mass to `u · POnHull` to select interval k, then + invert inside it with `s = 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 parameters `t` along b, where + `t = dot(point, b)`. Cells are ordered by entry, with ties broken by `dot(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.Shared`; netstandard2.1 has + `System.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 at `Entity.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 + `> .5f` threshold (`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.PBase` is renamed + `PFire` because its meaning changes: it is `Accuracy × PSensor` behind the same gates, and + it is 0 when any gate fails. Also frozen: `TravelDirection`, `Precision`, `Aimed`, the + weapon's `Spread`, and `FireRange` (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. `Commit` computes, in this order: + 1. `pDeviation`, as today. + 2. `bearing = normalize(shot.Target.ToSchematic(shot.TravelDirection))`, from the + target's facing at the commit tick. + 3. `sil = Silhouette(target, hull, shot.Aimed, bearing, shot.Precision)`. + 4. `p = PFire × pDeviation × PSpread(shot.Spread, sil.Span, shot.FireRange, cellSize) × + sil.POnHull`. + 5. The one roll against p. On a hit, the lateral draw from `sil`, and the impact cell + from `Lane`. + + Steps 2-5 are one function, `FireControl.CommitProbability(PendingShot, float now, + out Silhouette)`. The debug HUD calls it, which retires the inline estimate at + `ActionGameManager.cs:1317-1318` (`shot.PBase * pDeviation`, which would otherwise be a + second, wrong copy of the model). +- **The outcome freezes the committed geometry** (R4). `ShotOutcome` gains `float2 Bearing` + (schematic frame, as committed) and `float Lateral`, and keeps `Cell`. `Apply` walks + `Lane(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 × pOnHull` at the *current* + travel direction and facing. That is a forecast, in the same way the HUD already treats + deviation. `Inspect.PBase == HitProbability` still 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 `sil` that 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: when `PFire × pDeviation` + is 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 current `main`. +- **Adds:** `math.erf(float)` and `math.erfinv(float)` in + `packages/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 in + `packages/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`, bump + `unity/org.gamecult.cultmath/package.json` from 0.2.3 to 0.2.4, and tag + `cultmath-unity-v0.2.4`. Aetheria then bumps `Packages/manifest.json:56` and the headless + `CultLibRoot` pin. +- **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-520` becomes `target.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-396` becomes `ToSchematic(hitDirection)`. + - `ShotOutcome.Aimed` and its plumbing (Q12-4 = A): + - the field, `FireControl.cs:702`; + - the `aimed` parameter of `MakeOutcome` (`:470`, `:481`); + - `Commit`'s `aimed` local and flag (`:420`, `:435`); + - the `AimedCells` half of `ResolveAimPoint`'s tuple (`:583-589`), which now returns + `float2`. +- **Adds:** `Entity.ToSchematic(float2)`. +- **Verification:** + - Builds: headless `Aetheria.Shared`, plus a Unity batchmode compile by Self. + - All 244 tests pass unchanged. `SplashDamagesTheSideTheBlastCameFrom` and + `PenetrationMarchIsPlanar` pin the frame through its new owner. + - Negative greps: + - `rg -n "forward\.y, -forward\.x" Assets/Scripts` returns exactly one hit, in + `Entity.cs`. + - `rg -n "Outcome\.Aimed|outcome\.Aimed|o\.Aimed" Assets/Scripts tests` returns + 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:** + - `HullKernel` and 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`) and `Inspect.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`, `Spread` and `FireRange`; `PBase` is renamed to + `PFire`. + - `ShotOutcome`: `Bearing` and `Lateral`. +- **Per-file changes:** + - `FireControl.cs:161-167`: after the gates, `PFire(...) × PSpread(...) × + Silhouette(...).POnHull`. The bearing comes from `TravelDirection` and the target's + current facing. Nothing new runs before `:159`. + - `:197-202`: Inspect uses the same functions. It gains nothing new beyond + `diagnostic.PFire` if the HUD wants it (optional; presentation). + - `:263`, `:286-308`: Fire freezes `PFire`, `TravelDirection`, `Spread = weapon.Spread` + and `FireRange`. + - `:409-435`: Commit takes steps 1-5 of **Bearing timing** through `CommitProbability`. + - `:463-467`: Apply passes `shot.Outcome.Bearing` (schematic frame) to `ApplyHit`. For + this one cut, ApplyHit's parameter becomes a schematic bearing and the `ToSchematic` + call 12.1 put inside it is removed. + - `ActionGameManager.cs:1317-1318`: the pending line prints + `FireControl.CommitProbability(shot, now, out _)` instead of `shot.PBase * pDeviation`. + - `Combat.cs:117` and `TurretController.cs:84` need 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.Silhouette` owns scatter. + - `FireControl.CommitProbability` owns the commit-time roll price and placement + source. + - `FireControl.TravelDirection` owns the shot's world direction. + - `Entity.ToSchematic` owns the frame. + - Inputs: + - Frozen: `PFire`, `TravelDirection`, `Precision`, `Aimed`, `Spread`, `FireRange`. + - Live at Commit: target position (deviation) and target facing (bearing). + - Outputs: p for the one roll, then `Bearing`, `Lateral` and `Cell` in the immutable + outcome. + - Derived state: + - HitProbability and Inspect are a forecast of the commit price and decide nothing. + - `ShotOutcome.Cell` is 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`, `POnHull` and the HUD estimate are gone + before `Silhouette` has a caller. +- **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 + old `max(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's + `TravelDirection` before commit. + - Pass: every hit's `Cell` is on the bow row. + - Kills: freezing the bearing at Fire; reading the facing at Fire. + - `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. + - `PlacementAndProbabilityShareOneSilhouette` replaces 6d's + `PlacementAndProbabilityShareOneKernel` (`FireControlCut6dTests.cs:318`). + - Setup: a static target at a diagonal bearing, so the intervals overlap and the + forecast equals the commit price. + - Pass: `CommitProbability` equals a pOnHull that the test computes independently, by + numerically integrating the Gaussian over a union of intervals the test builds itself. + The empirical `Lateral` distribution 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. + - `HitsLandOnTheFacingEdge` supersedes `EveryHitLandsOnMetal` (`:357`): on the concave + fixture, each hit's `Cell` is occupied, and nothing occupied precedes it in its own lane. + The test recomputes the lane from `Bearing` and `Lateral`. + - `AimingAtAnItemCentresTheScatterOnItsLane` closes L585/L587 and replaces + `AimingAtTheSternHitsTheStern` (`: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 old `max(5,3)`, so its numbers stand. + - `ThinLimbCostsHitChance`, with its comment rewritten in 1D terms. + - Every test that reads `shot.PBase`, updated to `PFire` with 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 `hull` + factor should read higher from the beam. + - Turn your armoured face into a slow missile and watch where it lands. + +**Status (2026-09-25): closed after three Soul passes.** Landed on `codex/fire-control-12`: +`7dbb3bcf`, `efe4f7c9` (cut and first Stryker batch), `8bd25f6f` (Soul pass 1 fixes), `d38efba3`..`ca1670a2` +(Soul pass 2 fixes). 263 tests, identical across runs; Unity batchmode compile clean at each stage. + +What the Soul passes found, in order, because two of them are lessons for 12.3: +- **Pass 1.** A lateral draw clamped to closed `[Lo, Hi]` could land exactly on `Hi`, return an empty lane, + and fall back silently to cell (0,0), a hit on nothing. The statistical tests used `Guid` zone names, so + every run rolled different dice (the source of a "flake" that got its threshold tuned instead of its cause + found). Where a hit lands was untested, only its price; the HUD test compared the commit price to itself; + `HitProbability` and `Inspect` each assembled the forecast, and only Inspect's copy was guarded; penetration + could follow a turn made after commit; multi-cell aimed items were untested. +- **Pass 2.** The pass-1 fix clamped the top edge with an epsilon and turned the silent (0,0) into a throw. + The **bottom** edge still produced empty lanes at angled bearings, so the fix converted a rare wrong result + into a rare crash (~1 per 2^24 hits, every shipped hull), reproduced through `Fire`→`Zone.Update`. Root + cause: **two geometric models answering one question** — `Silhouette` priced each cell's projected interval, + `Lane` re-derived admission with an independent slab test, and floats disagreed at corners. Also: the + two-prong placement test passed five wrong-sigma draws; "never allocates" was still false (CoreCLR wraps an + `IComparer` in a delegate per sort). +- **Pass 3 closed it.** `Lane` now admits with the same `Extent` support function `Silhouette` uses, and the + slab test is gone (`AlongBearing` survives as a non-rejecting entry/exit computer). Proof and check: all + cells' intervals have equal width, so the merge cannot open a gap; exhaustive check at every breakpoint and + float neighbour (8.9M lateral values, 13 hulls, 1,540 bearings down to 1e-9 rad) and a 402M-draw enumeration + found 0 empty lanes and 0 non-finite values; a split-arithmetic mutant of `Lane` (equal in exact + arithmetic, different in float) is killed by two tests, so the one-owner rule is guarded, not incidental. + The `Commit` invariant throw is unreachable. + +Cost, measured in Release: pooling the forecast's interval buffer took gated-in `HitProbability` on LonginusX +from 672 to 184 B/call (time unchanged; the ~1.9× over the old kernel is the sort). What remains is outside +fire control's arithmetic: 40 B per `GetBehavior` (the `Equipment` enumerator), three per call; and with an +item aimed at, `ResolveAimPoint`→`CellsOf` builds a list and `ToArray`s it every call — 944 B per +`HitProbability`, 1928 B per `Inspect`. Recorded as follow-ups. + +**Carried into 12.3's brief** (low, all on code 12.3 builds on): pin `Lane`'s seam contract (a 2x1 hull at +b=(0,1): `Lane(s=-0.5)` returns only (0,0), `Lane(s=0.5)` returns nothing — the `s >= Hi` → `s > Hi` mutant +survives today and would interleave two columns in 12.3's walk); `AlongBearing`/`SlabAxis` have no test of +their values (every mutant there survives, equivalent for 12.2 only); correct the allocation comments in +`Forecast` and `LaneAndSilhouetteAllocateNothingGivenAPooledBuffer`; make +`ZeroProbabilityShotCommitsAMissWithoutBuildingASilhouette` behavioural (produce `PFire = 0` through +visibility, assert zero allocation rather than `sil.Intervals == null`); drop the unused `System.Reflection` +import. + +### Cut 12.3. How a direct hit travels + +- **Repo/branch:** Aetheria, on top of 12.2. +- **Deletes first:** `Entity.DamageSchematic` (`Entity.cs:333-374`) and `Entity.ApplyHit` + (`Entity.cs:376-409`). Going with them: + - the 0.5-step march; + - the `+ .5` cell convention; + - the `> .5f` threshold; + - the Expand spread footprint. +- **Adds:** + - `Entity.Absorb(int2 cell, float damage) → float`, moved verbatim from + `Entity.cs:348-366`. Armour absorbs up to its value, then the item when `d > .1f`, and + the remainder is returned. It emits `ArmorDamage((cell, incoming))` and + `ItemDamage((item, incoming))` with today's meaning, and only for incoming > 0. + - `Entity.DamageHull(float)`, moved from `Entity.cs:369-373` (`> .1f`, one `HullDamage` + event). +- **Per-file changes:** `FireControl.Apply` (`:449-468`), with the shield branches + unchanged: + 1. `IncomingHit.OnNext(source)` moves here from `Entity.cs:383`. + 2. There are `2n+1` lanes, `k ∈ [-n, n]`, where `n = (int) floor(DamageSpread + .5f)` (today's + rounding, `Entity.cs:389`). Lane `k` sits `k` cell *shadows* (`2h`) from `Lateral` along ℓ, and + **no cell belongs to two lanes** (Q12-2 = A, amended 2026-09-25). Membership is decided once per + cell, not by testing float lane positions against float intervals (Soul, 12.3 pass 2: those two + derivations disagreed at cell edges and struck a cell twice). + 3. Damage splits evenly over the lanes that meet metal. The centre lane always does. + 4. Each lane walks from its own facing cell, clipped to the penetration depth, with + `rem = target.Absorb(cell, rem)` at each cell. + 5. 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 + `DamageHull` once. + 6. A weapon with a contact or delayed fuse does not take this path. It detonates instead + (12.4), never both. 12.4 moves this choice into Apply's switch on the frozen `Fuse`; `Step` no longer branches. + + **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 `Absorb` per + footprint cell and passes the summed remainder to `DamageHull`, which is arithmetically + identical to today. +- **Authority map:** + - Owner: `Entity.Absorb` owns what one cell does with incoming damage. + `FireControl.Apply` owns the order and the share per lane. + - Inputs: the committed `Bearing`, `Lateral` and `Cell`, plus the frozen `Penetration`, + `DamageSpread` and `Damage`. + - Outputs: `Armor[]`, item `Durability` and `Hull.Durability`, plus the events + `IncomingHit`, `ArmorDamage`, `ItemDamage` and `HullDamage`. Their consumers: + `ActionGameManager.cs:1070`, `EntityInstance.cs:292`, `InventoryPanel.cs:470-482`, + and `Entity.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 `Absorb` gets a second caller. +- **Verification:** + - `DamageIsAbsorbedInOrderAlongTheRay` replaces `HardpointHitDamagesItemThenHull` and + `PenetrationMarchIsPlanar` (`FireAuthorityTests.cs:481-526`, which call the deleted + `ApplyHit`). + - Setup: through `Fire` at 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. + - `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/lanes` minus what the + ring absorbed. + - Enough damage fires `Death` with `CockpitDestroyed`. + - `SpreadWidensTheImpactAcrossLanes`: spread 1 at penetration 0 puts damage/3 on each of + three facing-edge cells and nothing behind them. + - `ARayThroughACornerCrossesTheCornerCell`: the diagonal case from `Probe1` reaches (2,2). + - `LaneRemainderReachesTheHull`: a ray that exits a thin hull delivers its remainder to + `Hull.Durability` (Q12-3 = A). + - Kept green: every Splash test, and `AirburstSplashes…` (Cut 6). + - Negative: `rg -n "DamageSchematic|ApplyHit\(" Assets/Scripts tests` returns nothing. + Today it matches `Entity.cs`, `FireControl.cs`, `FireAuthorityTests.cs`, and two comment + lines at `Gameplay/EntityInstance.cs:280-281`. Rewrite those comments and + `FireControl.cs:492`. + - Stryker: `--since:<12.2 head>` over `FireControl.cs` and `Entity.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. + +**Status (2026-09-25): closed.** Landed on `codex/fire-control-12`: +- `70c3e0cc`..`73b15d9f`: the cut and the first Stryker batch. +- `b82b7eab`..`be538217`: Soul pass 1 fixes and the lane-spacing ruling. +- `4e6495dd`, `742354c6`, `cb71a3cf`: Soul pass 2 fixes. +- `56757913`, `b55894a0`: the proportional-absorption batch, since superseded. +- `e9339a56`, `ab14552a`, `166b6202`: the one-path rebuild. +- `64d17a29`, `c07b4fee`, `cccf3a2a`: Soul pass 5 fixes. + +297 tests. Unity batchmode is clean at `cccf3a2a`. + +Proportional absorption, passes 4 and 5: +- **Pass 4 (on the two-path version).** The split between `ApplySequential` and `ApplyWithSharedItems` + was already ruled out by the operator. Soul found three more defects: + - The cycle tie-break fell back to lane index at axis-aligned bearings, and a probe broke mirror symmetry. + - The pooled resolve skipped the item's `.1f` threshold. + - The model sweep still passed with proportionality deleted. +- **The rebuild.** Every item goes through a pool: clip once, and one pooled walk. + `Entity.ItemAbsorb(EquippedItem, Span)` is the one absorption owner. The cycle rule is + Self's default (above). `FireControl.cs` shrank by 40 lines against the two-path version. +- **Pass 5 (on the rebuild).** There was no second authority left, mirror symmetry held at angled + bearings across 2,800 draws, damage was conserved, and the cycle rule never fired early. + Soul found gaps in the tests (a lane revisiting an item, event cardinality), dead code, and + stale comments. All are fixed, and each mutant dies to a committed test. + +Recorded, not fixed: +- Soul's angled-bearing mirror probes (P1, P2) are not committed. They need an exact lateral draw, + which only a private seam offers. The operator rule forbids reflection, and no test seam is added + for them. + - Angled correctness is covered by the model sweep and `DirectHitAtAnAngledBearingMatchesAnIndependentModel`. + - The cycle rule's symmetry is covered at axis-aligned bearings, and holds at any bearing by construction (it has no ordering key). + - 12.4 grows no such seam. `Detonate` takes a world point, not a lane, so P1 and P2 stay uncommitted (see 12.4, **Test seam**). +- `Entity.Absorb` is deleted with `Splash` in 12.4, and `Detonate` goes through `ArmorAbsorb` and `ItemAbsorb`. + +What the three Soul passes found: +- **Pass 1.** Production correct (an independent double-precision ray model agreed on 14,984 of + 15,000 shots; the rest sit on the √2 float boundary), tests blind: no test drove `Apply` at an + angled bearing, the gap rule, penetration depth or side-lane remainders; one test could not fail. + It also found lanes one cell apart **share cells** at angled bearings, which the operator ruled on + (Q12-2 amendment: no doubling up, the line thickens). +- **Pass 2.** The spacing fix was disjoint only by float luck (78 of 26,377 boundary-lateral hits + struck a cell twice), and the disjointness test missed four spacing breakages. Hands' first + structural fix (a `ceil` lane index) introduced a **third** membership computation, which left the + centre lane empty on `solid5, b=(0,1), s=0.49999997` while `Lane(s)` found the row — a committed hit + with no damage. Final shape: `Lanes` is built from `Lane`, `Lane(s)` is literally lane 0, and float + drift is resolved by ownership (nearest the centre wins). **The third time this campaign met two + derivations of one geometric answer disagreeing in float** (12.2's crash, 12.3's double strike, + the `ceil` empty centre); the cure each time was one owner. +- **Pass 3 closed it.** 2.09M boundary cases over 196 bearings and 9 hull shapes: no cell in two + lanes, lane 0 always equals `Lane(s)`, no empty centre lane. The tie-break is mirror-symmetric by + construction. Residual float effects (a side lane starting one cell deeper, a column falling + between lanes, rarely a footprint narrowed by one column) occur at about 2×10⁻⁶ of draws; damage + is never lost. + +**Owed with the pending ruling's batch:** a test that many axis-aligned spread-n shots each strike +exactly 2n+1 facing cells (a 1% spacing error, R1b, survives today and would drop a column from 1-2% +of spread shots). **Recorded, not reachable through the public pipeline at a usable rate:** nothing +ties `Apply` to `Lanes` behaviourally (reverting `Apply` to a per-lane `Lane()` loop survives, since +it only reintroduces boundary double strikes); the behaviour after a cession is unpinned. + +**Pending operator ruling (blocks closing 12.3):** when two lanes cross one multi-cell item, the +first-processed lane drains its durability and the second carries the leftover into its own deeper +cells, so the left-of-travel lane always gets the damage that passes wide components (mirror shots on +a symmetric hull with 2x1 items differ in ~30% of axis-aligned trials). Recommended: the item absorbs +from all lanes that strike it at once, and each lane's leftover is in proportion to what it brought. +Alternative: keep the processing order and pin it. + +**Ruled 2026-09-25: proportional absorption.** Operator: "proportional absorption; go". A multi-cell +item struck by several lanes of one shot absorbs from their combined incoming damage, and each lane +carries on with a leftover in proportion to what it brought. The result must not depend on the order +lanes are processed. A single-lane item is the degenerate case of the same rule, not a separate path. + +**Ruled 2026-09-25: one code path.** Operator: "Do not expect items taking up multiple cells to be an +exception, this should be one code path." The first implementation (`56757913`) surveyed each shot +for shared items and took either `ApplySequential` or `ApplyWithSharedItems`. That was a split +authority, and the second reachability derivation it needed is the same failure as the 12.2 empty +lane and the 12.3 double strike. `Apply` has one walk, in which every item pools the deposits of +the lanes that reach it, a pool of one lane included. The survey and the sequential path die. + +**Self's default 2026-09-25 (not an operator ruling): the cycle rule.** When every unfinished lane is blocked at an item pool and no pool is ready (two lanes crossing two shared non-convex items in opposite order), every open pool resolves at once with the deposits it holds. It needs no ordering key, so mirrored shots stay mirrored. The first implementation used the smallest first-contact `Entry`, which ties at axis-aligned bearings and fell back to lane index. Soul broke mirror symmetry with it. The operator may overrule this default. + +### Cut 12.4. The detonation primitive + +**Status (2026-09-29, Self): closed.** 12.4 landed at `2625768f`, `7c99909d` and `7e63d744`. + +Soul pass 1 (Opus) found three defects: +- **F1:** a tangent disc created damage, 127 of 100. +- **F2:** a candidate cull measured from the centre of mass missed hull corners. +- **F3:** a lethal blast threw mid-loop and detonated again. + +It also found four rules pinned only by tests that could not fail. + +The fixes landed at `8b4bf01e`, `76e98598` and `fd792013`: +- the tangent comparisons now include equality; +- the cull and the `radius <= 0` guard are deleted, so `RectDiskOverlap` is the only decider; +- the blast loop iterates a snapshot. + +Soul pass 2 closed it. Conservation held over 1,944 exact-lattice discs, with a worst error +of 2e-5. A blast costs 0.3-0.7 ms in a zone of 40-100 entities. + +Pins landed at `4629bc67` and `c7e20f08`. `Death` fires once per entity (`Entity.cs`, `.Take(1)`), +which also fixed a direct hit on a corpse raising `Death` again. A three-kill blast test was added. +362 tests pass. + +**Operator ruling needed:** a fused weapon fired with no target bursts at the shooter's own +position (`FireControl.cs:391`). This predates 12.4. Self recommends a max-range burst along the +aim line. Recorded unguarded: if a `Death` handler removes another ship mid-blast, that ship +still takes blast damage. + +Revised 2026-09-25, after 12.3 closed. The first draft predates 12.3's proportional-absorption and one-path +rulings, and the parts of it those rulings overturn are listed under **History** at the end of this section. +Anchors are against `d402283c`. **(probe)** marks claims measured in a scratch worktree at that commit (see +Part 5 of the revision note); **(read)** marks claims read from source at the cited anchor. + +- **Repo/branch:** Aetheria, on top of 12.3. It lands as two commits: + - (a) The data fields and the label demotion. On shipped content this changes no behaviour, because no + weapon carries the label or a radius. That is the Cut 12 probe (`Probe4`), and `GameData/Aetheria.cc` has + not changed since it ran (`git log cd846916..d402283c -- GameData/Aetheria.cc` is empty). + - (b) `Detonate`, with `Splash` and `Entity.Absorb` deleted. + +#### Deletes first + +In (a): + +- The label read in `Fire`: `FireControl.cs:382-388`, which is the Cut 6b comment, `isAirburst` (`:386`), + `burstPosition` (`:387`) and `burstRadius` (`:388`). Afterwards `WeaponModifiers` has no simulation reader. + `rg -n "WeaponModifiers\." Assets/Scripts/ServerShared` returns nothing; today it returns exactly `:386`. +- `PendingShot.BurstRadius` and its comment (`FireControl.cs:1246-1250`) become `Fuse` and `BlastRadius`. The + initialiser at `:412-413` changes with them. `BurstPosition` stays, and it now has meaning only for a + proximity fuse. +- The slot 29 comment (`ItemData.cs:493-496`), rewritten for the renamed field. + +In (b): + +- `FireControl.Splash`, its comment and its half-hull footprint: `FireControl.cs:946-994`. This absorbs F12-1. +- `Step`'s dispatch between Splash and Apply: `FireControl.cs:458-467`, comment `:460-462`, branch `:463`. + `Step` calls `Apply` for every arrived shot, and Apply owns the fuse switch (see **Which model a shot uses**). +- `Entity.Absorb` and its comment (`Entity.cs:386-400`). It gates the item's `.1f` threshold on each cell + separately, which contradicts the one-path ruling as soon as an area covers two cells of one item. + **(probe)** `ProbeC2`: a 2x1 item with no armour, and 0.08 on each of its cells. Pooled through `ItemAbsorb` + the item absorbs 0.16. Through `Absorb` per cell it absorbs nothing, and 0.16 goes to the hull. + 12.3 recorded that `Absorb` survives only for Splash; it dies with Splash, and nothing brings it back. +- Commit's shield decision, for shots that carry a fuse (`FireControl.cs:578-581`), under Q12-9's recommended + answer. It is not deleted: it is gated on `shot.Fuse == null`. +- Apply's inline penetration clip (`FireControl.cs:666-678`) is **moved**, not deleted. It becomes the one + reach function (see **The penetrator**). +- Stale comments that name a deleted owner as live: + - `Entity.cs:391` and `:417`; + - `FireControl.cs:1201`; + - `Gameplay/EntityInstance.cs:280-283`; + - `Gameplay/HullCollider.cs:4-6`; + - `Gameplay/Weapons/Projectile.cs:8-9`; + - `Gameplay/Weapons/Mine.cs:90-95`. + +#### Data + +The smallest field set: two fields, both on `WeaponItemData` (`ItemData.cs:475-499`). + +- **Slot 29.** `float? AirburstRange` (`ItemData.cs:497-498`) is renamed `float? BlastRadius`, with JSON name + `"blastRadius"`. It is in world units and keeps its meaning. The slot and the type are unchanged, so CultCache + opens the catalog as compatible drift (see 0b). +- **New slot 32** (landed `2625768f`; the map said 30, but 30 and 31 are `OptimalTemperature`/`PlateauWidth`, inherited from `EquippableItemData`). `WeaponFuse? Fuse`, with JSON name `"fuse"`, and `enum WeaponFuse { Contact, Proximity, + Delayed }` in `Enums.cs`, beside `WeaponModifiers` (`Enums.cs:97-108`). It is nullable under the 7.4 rule: + null means no blast, and every shipped record reads as null. +- **Fire decides "detonates" once.** Fire freezes `PendingShot.Fuse` as the weapon's fuse only when + `BlastRadius > 0`, and as null otherwise, and it freezes `BlastRadius` beside it. After Fire, nothing + re-derives whether a shot detonates; every reader reads `shot.Fuse`. A fuse without a radius, or a radius + without a fuse, is inert and resolves as a direct hit. Nothing reports it: Q12-6 ruled that the catalog is + not policed. The inert half takes effect with (b)'s switch in `Apply`. At (a), `Step` still splashes on + `BlastRadius` alone (Soul, `2625768f`). +- **The drift warnings.** The catalog needs one write through `tools/AetherDb` or Studio to clear them. That + write is content and changes no data. It belongs to F12-2. +- **Readers to update:** + - `FireControl.cs:386-388`. + - `FireControlCut6Tests.cs:81`, `:95` and `:100-101`: the fixture's `airburst` and `airburstRange` + parameters. They become `fuse` and `blastRadius`. Whether the fixture keeps the label does not matter, + because Q12-6 ruled that nothing checks labels against behaviour. + +#### Which model a shot uses + +`FireControl.Apply` (`:631-693`) is the one owner of this decision, and it switches on the frozen `shot.Fuse`: + +- **null: a direct hit.** This is 12.3's lane path, unchanged, and it needs a hit. +- **Proximity: detonates at arrival, with no roll.** It detonates at `shot.BurstPosition.xz`, which + `PredictedIntercept` froze at Fire, whether the shot hit or missed. That keeps today's rule, + `AirburstSplashesEvenOnAGuaranteedMiss`. +- **Contact or Delayed: detonates only on a committed hit.** P comes from the committed lane (see **Where P + is**). On a miss nothing happens. + +Today `Step` makes this choice at `:463`, and 12.3 describes the same choice as a separate step 6. Both collapse +into Apply's switch, so `Step` has no damage branch left. + +`IncomingHit` (`:648`) fires for a committed hit, for a direct hit and a contact or delayed blast alike, and +only on the host. A proximity burst and the bystanders in any blast do not fire it, as Splash never did. Its one +consumer is the player's hit marker (`ActionGameManager.cs:1070`). + +This is a default, not a ruling. + +#### Where P is + +The lane's own parametrisation gives the point directly. `Lane` places the line at lateral offset `s` as +`point(t) = t·b + s·ℓ`, in schematic cell coordinates (`AlongBearing`/`SlabAxis`, `FireControl.cs:1177-1198`). + +**(probe)** `ProbeE`, 20,000 random lanes on a 6x9 hull: + +- the mid-slab point of every walked cell lies inside that cell, with a worst Chebyshev distance of 0.499999 + from its centre; +- `point(impact.Entry)` lies on the impact cell's boundary to within 9.5e-7. + +So the contact and delayed fuse points need no geometry beyond `Lane` and the reach clip. + +- **Proximity:** `BurstPosition.xz`, a world point. +- **Contact:** the schematic point `point(lane[0].Entry)`, on the committed lane: the committed `Bearing` and + `Lateral`, and the lane `Lane(hull, Bearing, Lateral)`. That is where the shot strikes the hull's face. +- **Delayed:** the schematic point `point(t_fuse)`, with + `t_fuse = min(lane[0].Entry + Penetration, lane[reach-1].Exit)`, where `reach` comes from the same clip that + 12.3's walk uses. In words: the fuse fires where the penetration runs out, or where the metal runs out, + whichever comes first. Metal runs out at the far side or at the first gap. `t_fuse` lies inside the last + reached cell's slab, so the disc is always centred on metal. Penetration 0 gives the contact point. +- **Which lane.** It is always the centre lane: `Lane(s)` at the committed Lateral, which Commit's guard already + proved non-empty (R3), and which `Lanes` guarantees to equal lane `n`. A blast shot's `DamageSpread` is not + read. The disc's radius is its width. This is a default, not a ruling. +- **Conversion to the world.** Contact and delayed points are converted with `host.ToWorldPoint`, using the + host's pose at arrival, and handed to `Detonate` like any other world point. The host is then treated like + every other entity in the radius: its cells are found again by `ToSchematicPoint`. + - That round trip cancels the host's pose, so the host's own damage is fixed by the commit, and R4 holds for + it. **(probe)** `ProbeF`: the worst schematic→world→schematic error is 7.6e-4 cells at 4×10⁴ world units. + - Bystanders are judged against where the host actually is at arrival. That is the same live rule a + proximity burst already applies to everyone. + + There is exactly one input shape, a world point. There is no host-only schematic entry. + +#### The penetrator (Q12-8) + +A delayed-fuse shot does not spend damage along its burrow. The lane walk **locates P and hands `Detonate` +the shot's whole `Damage`**. + +That keeps the ruling recorded as Q12-8 = A: nothing is absorbed along an explosive penetrator's burrow, and the +explosion is the payload. See Part 4 of the revision note, because the brief phrased this the other way. + +The walk and the blast share exactly one piece: **the reach clip**. Today it is inline at +`FireControl.cs:666-678`. It moves into `FireControl.Reach(LaneCell[] cells, int walked, float penetration) → +int` (private). The rule is unchanged: the impact cell is always reached, and a later cell is reached while +`entry - impact.entry < penetration`. + +Two callers read it: + +- Apply's direct-hit walk, which calls it once per lane in place of the inline loop; +- the delayed fuse point, which calls it on the centre lane. + +No second "lane damage with a blast" path exists, because no shot does both: + +- a null fuse runs the lane walk with `ApplyPooled`, which absorbs; +- a contact or delayed fuse runs `Lane` and `Reach`, which only locate P, and then `Detonate`. + +A shot takes one branch of Apply's switch or the other, never both. + +The mutant this must kill is a second reach derivation: for example, a delayed P computed as +`impact.Entry + Penetration` without the clip, which would detonate past a gap or outside the far side. + +#### Area, per entity: `FireControl.Detonate` + +The signature is `Detonate(Zone zone, float2 worldPlanar, float radius, float damage, DamageType type)`, +public, and it is the one owner of blast damage. It is Splash's signature with the point made planar (R7). + +- **Callers:** Apply (all three fuses) and `Mine.Explode` (`Mine.cs:99`, which passes + `position.ToCultMath().xz`). +- **Point transforms.** It uses `Entity.ToSchematicPoint(float2) = ToSchematic(w − Position.xz) / + SchematicCellSize + hullShape.CenterOfMass` and its inverse, `ToWorldPoint`. These are the frame rules; the + frame owner is `ToSchematic` at `Entity.cs:419-424`. `CenterOfMass` is `ItemData.cs:127-128`, and + `SchematicCellSize` is `Settings.cs:255`. + +For each entity in the zone: + +1. **Candidates.** An entity is a candidate when `|Position.xz − P| ≤ radius + ½·√(W²+H²)·SchematicCellSize`. + Splash culled on the entity's *centre* being inside the radius (`FireControl.cs:964-965`), which misses a + long hull whose end lies inside the blast. +2. **Shares, computed once.** Take `Ps = ToSchematicPoint(P)` and `rCells = radius / SchematicCellSize`. For + every occupied hull cell whose unit square meets the box `Ps ± rCells`, the share is `share_c = damage × + overlap(c) / (π·rCells²)`. Here `overlap(c)` is the exact area of the disc inside that cell's unit square, a + cell being the unit square centred on its integer coordinate. + - The shares go into one pooled buffer, which the shield step and the absorption step both read. Nothing + computes an overlap twice. + - Every share of the disc that falls on an unoccupied cell, or outside the schematic, is lost. +3. **Shield.** Every shield decision for a blast is made here, live, and charged the entity's summed shares, + under today's `CanTakeHit`/`Break`/`TakeHit` rules (`Shield.cs:166-190`). That includes the host of a contact + or delayed blast, under Q12-9's recommended answer. + - If the shield absorbs, no cell of that entity takes anything. + - If it cannot, it breaks, and the cells take their shares. +4. **Armour, per cell.** For each share, `d_c = ArmorAbsorb(c, share_c)` (`Entity.cs:339-352`). Armour protects + its own cell's share and nothing else. +5. **Items, one pool each.** If `GearOccupancy[c]` holds an item, `d_c` is deposited into that item's pool, in + hull `Coordinates` order. If not, `d_c` goes to the entity's hull total. Only after every covered cell has + deposited does each item resolve, once: `ItemAbsorb(item, pool)` (`Entity.cs:369-384`). The leftovers join + the hull total. +6. **Hull.** The entity's `DamageHull(total)` runs once (`Entity.cs:406-413`). + +Entities are independent. Two ships caught by one blast each cover their own cells, and neither shadows the +other (F12-8). + +**Order: armour first per cell, then the item from the pooled post-armour sum.** Existing rulings settle it, +and no operator question is needed: + +- "Armor absorbs first", from the first round of rulings; +- "Each covered cell absorbs … in the existing order (armour, then item, then hull)", from the area ruling; +- the proportional ruling, under which a multi-cell item "absorbs from their combined incoming damage", where + incoming means the post-armour remainder each contributor brings, exactly as a lane deposits after + `ArmorAbsorb`; +- the one-path ruling: "Do not expect items taking up multiple cells to be an exception." + +The area ruling's word "independently" survives for armour, and is superseded for items by the one-path ruling: +an item's cells deposit into one pool, and they do not absorb independently. + +**One owner, and why there is no walk.** + +- `ItemAbsorb` is the one owner of what an item does with incoming damage: the `.1f` threshold decided once on + the pooled total, the pro-rata split, and one `ItemDamage` event per contributing cell with a positive share. +- `ArmorAbsorb` is the one owner of what armour does. +- `Detonate` owns the schedule, and a disc's schedule has no walk. Apply's pooled walk (`ApplyPooled`, + `FireControl.cs:717-860`) exists only because lanes are ordered: a lane's deeper cells receive whatever the + item in front of them leaves, so a pool must wait until no unfinished lane can still reach it. In a disc, no + cell's share depends on another cell's leftover. Every deposit is known before any item resolves, and so the + reachability test and the cycle rule have nothing to decide. + +The flat pass is exactly `ApplyPooled`'s degenerate case. **(probe)** `ProbeC`: 200 random discs on a 6x4 hull +with two 2x1 items, a one-cell item and varied armour. `ApplyPooled`, fed one one-cell lane per covered cell +with that cell's share, left identical armour, item durability and `ItemDamage` event counts in 200 of 200 +discs. Its hull total differed only by float summation order, at most 3.8e-6. + +`Detonate` does **not** call `ApplyPooled`. Doing so would mean changing `ApplyPooled`'s signature to take a +starting amount per lane and renting a `LaneCell[]` per covered cell, and it would buy no behaviour. + +**What the pro-rata split does inside a blast:** nothing observable. Every leftover of a blast reaches the same +per-entity hull sink, so a sequential split and a pro-rata split deliver identical totals. The only +order-sensitive rule left in a blast is the pooled threshold, and that is what the verification pins. + +**Exact overlap, in float.** `overlap(c)` is the standard circle-rectangle intersection. It integrates the +disc's chord height over the cell's clipped x-span, using the antiderivative `½(x√(r²−x²) + r²·asin(x/r))` +between the breakpoints where the chord crosses the cell's top and bottom edges. It allocates nothing, and it +conserves exactly up to float rounding. + +- **(probe)** `ProbeA`: in float, the overlaps over the bounding box sum to `π·r²` within 1e-7 relative, at + radii 0.3, 0.5, 1.7, 5, 12.5 and 25 cells, centred on a cell centre, on a cell corner and off the grid. Double + precision is not needed. +- **(probe)** `ProbeB`: named cells agree with a 3000² numeric integration within 1.5e-6. +- Supersampling is rejected, for the reason the first draft gave. It would deliver the wrong total for a disc + smaller than a cell, or for one straddling a cell boundary. + +**Cost.** Per candidate, there is at most one overlap evaluation per occupied cell inside the box: no more than +128 on shipped hulls, whatever the radius. It runs once per detonation and never per tick. Allocation is not +the constraint here (Splash builds a `Shape` per target today), but each share is computed exactly once. + +**Accepted loss (unchanged).** Interior armour does not shadow the cells behind it, because nothing inside an +area is occluded. A bulkhead protects its own cell's share and no other. 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 +forecast still applies. A proximity blast has no roll, and it is still reported with the direct-hit number. +That is pre-existing, and it is F12-5. + +#### Authority map + +**Owners:** + +- `FireControl.Apply` owns which model a shot uses, through the switch on the frozen `Fuse`. +- `FireControl.Detonate` owns blast damage: candidates, shares, every blast shield decision, and the + per-entity armour→pool→hull pass. +- `Entity.ArmorAbsorb` owns armour per cell, and `Entity.ItemAbsorb` owns item absorption. Both are shared with + 12.3's walk. +- `FireControl.Reach` owns penetration reach, for the direct-hit walk and the delayed fuse point. +- `FireControl.Lane` owns traversal. A blast shot calls it once, for its fuse point. +- `Entity.ToSchematicPoint` and `ToWorldPoint` own points. `ToSchematic` still owns the frame. + +**Inputs:** + +- P, the radius, the damage and the type; +- each candidate's position, facing, hull, armour, occupancy and shield at the detonation tick; +- the fuse and the radius, frozen at Fire; +- for contact and delayed shots, the committed `Bearing` and `Lateral`, and the frozen `Penetration`. + +**Outputs:** the same mutations and events as 12.3: `Armor[]`, item durability, hull durability, +`ArmorDamage`, `ItemDamage`, `HullDamage` and, for the host of a hit, `IncomingHit`. + +**Derived state:** + +- `WeaponModifiers` is display-only. Its readers are the `SchematicListElement.cs:54` icons, + `GameSettings.cs:67` and `GameplaySettingsEditor.cs:44`. +- `BurstPosition` is read only for a proximity fuse. +- Under Q12-9 = A, `ShotOutcome.Shielded` and `ShieldBroken` are never set for a shot that carries a fuse. + Nothing outside `FireControl.cs` reads either flag (`rg "\.Shielded\b|\.ShieldBroken\b" Assets/Scripts` + finds only `FireControl.cs`). + +**Forbidden writers:** + +- No label decides behaviour. +- Nothing but `Detonate` applies area damage. +- No per-cell path gates an item's threshold. `Absorb` is deleted and is not reintroduced under another name. +- Nothing but `Reach` decides how deep a lane reaches. +- `Commit` decides no shield for a shot that carries a fuse (Q12-9 = A). +- `Step` has no damage branch. +- Mine's Unity `BlastRange` and its arming query stay presentation-side inputs (R8 residue, Cut 4 Q7), and they + decide no damage themselves. + +**Shared paths:** + +- the proximity burst: Apply at arrival, with no roll; +- the contact and delayed blasts: Apply on a committed hit; +- `Mine.Explode`; +- the tests, which call `Detonate` directly (see **Test seam**). + +**Deletion line:** `Splash`, `Absorb`, the label read and `Step`'s branch are deleted before `Detonate` has a +caller. + +#### Test seam + +`Detonate` takes a world point because its production callers already have one: a proximity burst's frozen +intercept, a mine's position, and a contact or delayed hit's converted lane point. That is an honest production +interface and not a hook, and it is public for the same reason `Splash` is today: Mine calls it from +`Assembly-CSharp`. Blast tests call it directly with exact points. So blast symmetry at angled facings is +committable with no seam and no reflection. + +It does **not** make Soul's 12.3 probes P1 and P2 committable. Those probes measure *direct-hit lane* damage at +an exact lateral draw. `Detonate` takes a point, not a bearing and a lateral, and no production interface takes +a built shot or outcome. The delayed fuse point's `Lane`→`Reach`→`point(t)` chain sits inside Apply, and +exposing it for tests would be exactly the seam the operator forbids. 12.3's record stands: + +- angled direct-hit correctness is covered by the model sweep and + `DirectHitAtAnAngledBearingMatchesAnIndependentModel`; +- P1 and P2 stay uncommitted. + +#### Verification + +These fixture rules apply to every blast test that touches the frame: + +- `SchematicCellSize ≠ 1`: use 2. The 12.3 fixture uses 1 (`FireControlCut123Tests.cs:49`), under which a + deleted `/ SchematicCellSize` survives. +- The target is off the origin. +- The facings include one with |fx| > |fy|. +- The hull's centre of mass is not at an integer cell, so a missing `+ CenterOfMass` survives nowhere. + +Tolerances come from the probes. Conservation holds within 1e-5 relative. Mirrored results agree within 1e-4 +absolute at damage ≤ 150: **(probe)** `ProbeD` measured at most 3.05e-5, all of it float summation order. + +**Geometry.** + +- `TheDiscIsConservedOverTheGrid`. + - Setup: on a solid hull that contains the disc, total armour, item and hull damage delivered, at a radius + under one cell, one centred on a cell corner, and one off the grid. + - Pass: the total equals `damage`. + - Kills: sampling in place of the exact overlap; the normaliser `(2r)²` in place of `πr²`; a bounding box + that clips the rim cells; a dropped chord breakpoint. +- `EachCellTakesItsShareOfTheDisc`. + - Setup: a solid hull with no armour and no items. + - Pass: a named cell takes `damage × overlap / (πr²)`, with the overlap integrated numerically by the test. + - Kills: an even split over the covered cells (Splash's rule), and a share decided by the distance from the + cell's centre. +- `TheShareOffTheHullIsLost` (was `ExternalBlastWastesMostOfItsEnergy`). + - Setup: a blast straddling the hull's edge. + - Pass: the total delivered equals `damage` times the disc's on-hull fraction, which the test computes. The + shares sum to the blast's damage minus what falls off the hull. + - Kills: renormalising the shares over the occupied cells, which would deliver the full damage. +- `CandidatesIncludeHullsWhoseCentreIsOutsideTheRadius`. + - Setup: a long hull whose centre lies beyond the radius, while its near end lies inside. + - Pass: the near end takes damage. + - Kills: Splash's centre-distance cull. + +**Absorption order.** + +- `ABlastIsAbsorbedThroughArmourFirstPerCell`. This replaces `AbsorbSpendsArmourBeforeTheItemOnTheSameCell` + (`FireControlCut123Tests.cs:750`). + - Setup: a disc wholly inside one armoured item cell, so its share is the whole damage. The three damage + levels of the old test: below the armour, between the armour and armour plus durability, and above both. + - Pass: the armour is spent first, the item takes the excess, and the hull takes what remains. + - Kills: the item absorbing before the armour, and armour being skipped. +- `ArmourProtectsOnlyItsOwnCellsShare`. + - Setup: a 2x1 item with armour on one cell only, and a disc covering both cells. + - Pass: the item takes `(share_A − armour_A)⁺ + share_B`. + - Kills: pooling the raw shares and then applying armour to the pool, which would let cell A's plate eat + cell B's share. +- `AMultiCellItemAbsorbsItsCoveredCellsAsOnePool`. + - Setup: a 2x1 item with no armour, and a disc straddling its seam that puts less than `.1` on each cell but + more than `.1` on the two together (the `ProbeC2` geometry). + - Pass: the item absorbs the sum. + - Kills: resolving the item per cell, in any form: `Absorb` resurrected, `ItemAbsorb` called per deposit, or + a pool that resolves before every covered cell has deposited. + + This test is also what pins that the result does not depend on which cell is considered first. As shown + above, the threshold is the only order-sensitive rule a blast has left. +- `ItemDamageFiresOncePerCoveredCell`. + - Pass: for a covered item, the number of `ItemDamage` events equals the number of its cells with a positive + post-armour deposit, and each event carries that cell's deposit. + - Kills: one event per item, and events for zero-deposit cells. + +**Symmetry and frame.** + +- `MirroredBlastsDoMirroredDamage`. + - Setup: a hull, its armour and its items all mirror-symmetric about the bow axis, facing (2,1)/√5. Pairs of + world points mirrored across the target's bow axis, with the mirror computed by the test from the facing + and not through `ToSchematic`. + - Pass: per-cell armour, item durability and hull damage mirror. + - Kills: + - the `+ .5` cell convention returning in the overlap (cell bounds `[c, c+1)`); + - a missing or wrong `CenterOfMass` anchor; + - any scheduling rule that depends on enumeration order. + + It does **not** kill a global handedness flip, because a flipped pair is still a mirrored pair. The next test + does. +- `ABlastDamagesTheCellsNearestIt`. This succeeds `SplashDamagesTheSideTheBlastCameFrom` + (`FireControlCut11Tests.cs:371`) and `SplashIsDirectional` (`FireControlCut4Tests.cs:306`). + - Setup: facings (0,1), (1,0), (2,1)/√5 and (−.6,.8). A blast to port, with a radius under half the hull's + width. + - Pass: port cells are damaged, and starboard cells are untouched. + - Kills: a flip of `ToSchematicPoint`'s right axis, an x/y swap, and a missing `/ SchematicCellSize`. + +**Shields** (under Q12-9 = A). + +- `ShieldPaysForWhatReachesIt`. + - Pass: the reserve drops by the entity's covered share × `EnergyUsage`, not by the whole blast. + - `SplashShieldAbsorptionDrainsTheReserve` (`FireControlCut11Tests.cs:237`) and `SplashBreaksUnabsorbedShield` + (`FireControlCut5Tests.cs:507`) are rewritten to `Detonate`, with the expected amounts recomputed from the + covered share, never loosened. + - Kills: charging the full damage. +- `ABlastShotsShieldIsDecidedAtDetonation`. + - Setup: through `Fire`, a contact-fuse shot at a target whose shield is inactive at commit and active at + arrival. + - Pass: the shield is charged the covered share. + - Second setup: a shield that could absorb the whole shot at commit. + - Pass: the shot still detonates, and the shield pays its covered share. + - Kills: Commit's shield block left ungated for fused shots, which puts a second shield authority on the + host. + +**Fuses** (through `Fire`, the 12.3 fixture, `SchematicCellSize` 2). + +- `PenetratorBurrowsBeforeItBursts`, which pins Q12-8 in both halves. The fixture is the enclosed cockpit + behind an armoured ring, with the penetration chosen so that `t_fuse` falls inside the cockpit cell. + - With a delayed fuse and a radius under half a cell, the cockpit takes the whole damage, **and the armour + of every ring cell on the lane is unchanged.** Nothing is absorbed along the burrow. + - A contact fuse with the same stats leaves the cockpit untouched. + - With the fuse removed and everything else held, the shot is a dumb AP round. The lane's armour is spent in + order, and the cockpit takes only what the ring left (12.3). Damage in a line. + - Kills: + - absorbing along the burrow; + - a contact fuse reading penetration; + - routing every penetrating shot through the fuse path; + - a fuse without a radius detonating. +- `TheFusePointStaysOnMetal`. + - Setup: a delayed fuse with a penetration deeper than the hull, and again on a concave hull with a gap + behind the first run. + - Pass: the damage delivered equals the test's own area integral for a disc centred on the far edge (or the + gap's near edge). + - Kills: an unclamped `t_fuse`, and a second reach derivation that ignores the gap. +- `TurningAfterCommitDoesNotMoveTheBlastOnItsHost`. + - Setup: a contact-fuse shot. After commit and before arrival, the host turns 90° and moves. + - Pass: the host's per-cell damage equals that of the same shot with no turn. + - Kills: taking P from the live bearing, or from `Outcome.Cell`'s centre in place of the lane point. +- `LabelsDoNotDecideBehaviour`. + - Pass: a weapon labelled `Airburst` with no fuse resolves as a direct hit, and a weapon with + `Fuse = Proximity` and no label detonates. +- `AFuseWithoutARadiusIsADirectHit`. + - Pass: `Fuse = Contact` with a null or zero radius does lane damage. + - Kills: Fire freezing `Fuse` without the radius check. +- Re-fixtured to `Fuse`, with their behaviour unchanged: + - `AirburstSplashesEvenOnAGuaranteedMiss` (`FireControlCut6Tests.cs:245`), which becomes + `ProximityDetonatesEvenOnAGuaranteedMiss`; + - `AirburstAndDiscreteNeverDoubleUp` (`:263`), whose reference becomes a bare `Detonate` call (`:281`); + - `NonAirburstNeverSplashes` (`:296`), which becomes `NoFuseNeverDetonates`; + - `SplashHitsEveryEntityInRadius` (`FireControlCut4Tests.cs:285`), which becomes + `ABlastHitsEveryEntityItCovers`. +- `AContactBlastReportsTheHit`. + - Pass: `IncomingHit` fires once, for the host, on a committed contact hit, and never for a proximity burst. + - Kills: `IncomingHit` moved inside the direct-hit branch. + +**Retargeted, because `Absorb` is deleted.** These keep their boundary behaviour, called on the public owner: + +- `AbsorbEmitsNothingAtExactlyZeroIncomingDamage` (`FireControlCut123Tests.cs:502`) becomes + `ArmorAbsorbEmitsNothingAtExactlyZero`, calling `ArmorAbsorb(cell, 0)`. A spent lane still walks cells with + a remainder of 0. +- `AbsorbLeavesTheItemUntouchedAtExactlyPointOneRemaining` (`:526`) becomes a call to `ItemAbsorb` with a pool + of `{ .1f }`. + +**Kept green:** every 12.3 test, including the model sweep. `Reach` is a move: `ApplyPooled`, `Lanes` and the +clip rule are otherwise untouched. + +**Negative greps:** + +- `rg -n "float Absorb\(|\.Absorb\(new int2|arget\.Absorb\(|Splash\(|HasFlag\(WeaponModifiers|AirburstRange|BurstRadius" Assets/Scripts/ServerShared Assets/Scripts/Gameplay/Weapons tests/Aetheria.Shared.Tests` + returns nothing. Today it returns 27 lines, in `Entity.cs`, `FireControl.cs`, `ItemData.cs`, `Mine.cs` and + `FireControlCut4/5/6/11/123Tests.cs`. + - The pattern is code-shaped on purpose. `CapabilityEvents.Absorb` and `ShieldInterceptor.Absorb` are + unrelated. + - Exclude `StrykerOutput`, `bin` and `obj`. +- `rg -n "FireControl\.Splash|Entity\.Absorb\b|calls Splash" Assets/Scripts` returns nothing. That is the + stale-comment list above. + +**Stryker:** `--mutate` on `FireControl.cs` and `Entity.cs`, with the report filtered to the changed lines. +`--since` does not work in this repo (12.1 status). Float boundary flips count as equivalent (11.2). + +#### 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 verifies `ToSchematicPoint`'s centre-of-mass anchor, which is the half of + the frame that only a running blast can show. + +#### History (superseded 2026-09-25, kept for the record) + +The first draft (2026-09-22) was written before 12.3's rulings. Its design is retired in these parts: + +- **"An occupied cell takes its share through `Absorb(c, …)`; each covered cell absorbs independently."** + Superseded by the proportional and one-path rulings. `Absorb` gates the item per cell (`ProbeC2`). Armour + stays per cell, and the item pools its cells and resolves once through `ItemAbsorb`. +- **"`Entity.Absorb` owns per-cell absorption, shared with 12.3."** Superseded. 12.3 ended with + `ArmorAbsorb` and `ItemAbsorb` as the owners, and `Absorb` dies with Splash. +- **"A shielded contact or delayed hit is absorbed whole, as a direct hit is, and does not detonate."** + Withdrawn pending Q12-9. As written, Commit decided the host's shield on the whole damage, and then + `Detonate` decided it again, live. A shield raised between commit and arrival could absorb a hit the commit + had judged unshielded: two authorities over one shield. +- **"Step calls it at arrival with the frozen `BurstPosition`."** Superseded. Apply owns the fuse switch, and + `Step` has no damage branch. +- **"The walk only locates P" for the delayed fuse.** Kept, and made precise: the reach clip becomes the one + `Reach` function, and P is `point(min(impact.Entry + Penetration, lastReached.Exit))`. +- **Anchors.** All anchors were against `cd846916`, and every one has moved. For example, the label read was + `:281-284` and is now `:382-388`, and Splash was `:488-529` and is now `:946-994`. + +### 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-752`, CultLib `45c2f40`); drift warnings until the next catalog write | +| `Fuse` | 32 | 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.Aimed` is deleted. +- **Q12-6: no.** Operator: "12-6, nope". No catalog test ties a label to behaviour. + `WeaponModifiers` stays 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-9: A, ruled 2026-09-26.** The blast decides every blast's shields, the host included, live at detonation. Each shield pays for its covered share of the disc. The operator chose the recommended option: "The blast decides". Commit decides no shield for a shot that carries a fuse. The question as asked is kept below. + +**Q12-9 (new, blocks only the contact- and delayed-fuse half of 12.4(b)). Who decides the host's shield for a +blast that needs a hit?** + +The current map lets two places decide it: + +- Commit decides the shield on the whole damage (`FireControl.cs:578-581`), as for any hit. +- `Detonate` then decides every shield in the radius live, and that includes the host. + +**(read)** When the commit breaks the shield, Apply calls `Break()` and `Detonate` finds it already broken, so +the two agree. When the shield is inactive at commit and raised before arrival, `Detonate`'s live check can +absorb a hit the commit judged unshielded. That is a second authority over one shield: the same kind of split +this campaign has cut three times. + +- **A (recommended): `Detonate` decides every blast's shields, the host included.** Commit decides no shield + for a shot that carries a fuse. The shield is charged the host's covered share, like every entity in the + radius, live at detonation. + - Proximity bursts and mines already work this way, so there is one rule for all blasts: "a blast has no + direction", applied to shields. + - Cost: a shield that could have absorbed the whole shot now pays only for the part of the disc that + reaches the hull. For a contact detonation on the hull's face, that is roughly half. + - `ShotOutcome.Shielded` and `ShieldBroken` become direct-hit-only. Nothing outside `FireControl.cs` reads + them. +- **B: Commit owns the host's shield, as it does for a direct hit.** A shielded contact hit is absorbed whole + and does not detonate. `Detonate` must then skip the host's shield, which needs an "already decided" + parameter: a compensator that keeps two owners alive, one per entity. + +What depends on it: + +- the gate on Commit's shield block; +- whether `Detonate` takes a host parameter; +- `ABlastShotsShieldIsDecidedAtDetonation` and the host case of `ShieldPaysForWhatReachesIt`. + +Proximity bursts, mines and the whole area model are unaffected either way, so 12.4(a) and the proximity half +of (b) can proceed while this waits. + +**Defaults taken, not rulings.** The operator may overrule any of these: + +- A blast shot ignores `DamageSpread`, and P sits on the centre lane. +- `IncomingHit` fires for the host of a committed contact or delayed hit, and for no one else in a blast. +- The delayed fuse point is clamped to the last reached cell's exit. +- `Detonate` runs a flat armour→pool→hull pass, not `ApplyPooled`. `ProbeC` shows the two are equivalent. + +- **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 `Silhouette` + and `Lane`, 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. + +### 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:117` gates 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 `BlastRange` and the arming OverlapSphere + (`Mine.cs:69-78`, `:99`) are presentation-side (Cut 4 Q7). Mine has no catalog weapon + record carrying `Fuse`/`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 and + `HullDamage` without `Absorb`. 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 (estimates, with actuals) + +| Sub-cut | Removed | Added | Notes | +|---|---|---|---| +| 12.0 | 0 | ~40 src + ~30 test (CultLib) | release `cultmath-unity-v0.2.4`, manifest bump | +| 12.1 | est. ~15, **actual 18** | est. ~6, **actual 16** | no behaviour change | +| 12.2 | est. ~65, **actual 63** | est. ~95, **actual 235** | `Forecast`, pooling, the `Extent` owner and the fix batches | +| 12.3 | est. ~80, **actual 53** | est. ~45, **actual 227** | `ApplyPooled` (~140), `Lanes` (~40), `ArmorAbsorb`/`ItemAbsorb` | +| 12.4 (est.) | ~50: Splash (~35), `Absorb` (~9), the label read (~4), `Step`'s branch (~2) | ~110: `Detonate` (~40), the exact overlap (~25), the point transforms (~12), Apply's fuse switch and P (~15), `Reach` (moved, ~+2 net), `WeaponFuse`, slot 32 and `PendingShot.Fuse` (~10) | 8 Splash/airburst tests rewritten, 2 `Absorb` tests retargeted and 1 replaced, ~12 new | +| **Net Aetheria src** | **~184** | **~588** | one persisted slot added (nullable) and one renamed; no targets, dependencies or formats; CultMath gains 2 functions | + +Actuals count non-comment, non-blank changed lines under `Assets/Scripts`: + +- 12.1: `b851b0d1^..b851b0d1`; +- 12.2: `b851b0d1..c7b6e80c`; +- 12.3: `c7b6e80c..d402283c`. + +The net is no longer roughly flat, and the old sentence claiming it was should go. What the growth bought: + +- 12.2: exact 1D placement with one geometric owner, and the fix batches that removed two crash classes; +- 12.3: sequential, proportional, order-free absorption; +- 12.4: a conserving area model that replaces a half-hull approximation, and a net deletion of `Splash`, + `Absorb` and `Step`'s branch. + +12.4's own delta is ~+60, and that is the estimate to hold it to. + +**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:946-959`) +describe models this cut replaces. Self should mark them as history in the status header +as 12.2 and 12.4 land. From 8c00263740fa371f773daf7cc0d5e06b679ca5f0 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 12:28:10 +0200 Subject: [PATCH 10/49] A fused shot with no target bursts at max range along the aim line Operator ruling 2026-09-30: fused weapons without a lock explode at max range. Fire froze BurstPosition at the shooter's own position with flight time 0, so a proximity shot detonated on its own shooter and contact and delayed shots did nothing. With no target and a fuse, Fire now freezes the burst point as the mount direction times the weapon's Range stat from the source, and flight time as Range over Velocity. Apply sends every such shot through Detonate at that point whatever its fuse. Targeted shots and unfused shots are untouched. Co-Authored-By: Claude Sonnet 5 --- Assets/Scripts/ServerShared/FireControl.cs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/Assets/Scripts/ServerShared/FireControl.cs b/Assets/Scripts/ServerShared/FireControl.cs index 8e8cc53c4..f3fcf6b8c 100644 --- a/Assets/Scripts/ServerShared/FireControl.cs +++ b/Assets/Scripts/ServerShared/FireControl.cs @@ -389,6 +389,15 @@ public static class FireControl var blastRadius = weaponItemData?.BlastRadius > 0f ? weaponItemData.BlastRadius.Value : 0f; var fuse = blastRadius > 0f ? weaponItemData.Fuse : null; var burstPosition = blastRadius > 0f && target != null ? PredictedIntercept(weapon, source, target) : targetPosition; + // Operator ruling 2026-09-30 ("fused weapons without a lock explode at max range"): a fused shot with no + // target bursts where its own aim line ends -- the mount direction at the weapon's Range stat, the same + // stat PFire's range gate reads -- and flies there at the weapon's Velocity, like a targeted shot flies + // to its intercept. Only the burst point and flight time differ; Apply detonates it through Detonate. + if (fuse != null && target == null) + { + burstPosition = source.Position + MountDirection(item) * weapon.Range; + flightTime = weapon.Velocity > .01f ? weapon.Range / weapon.Velocity : 0f; + } var shot = new PendingShot { @@ -650,6 +659,14 @@ public static class FireControl // centre lane (see the fuse-point comment on the Contact/Delayed branch below). private static void Apply(PendingShot shot) { + // A fused shot with no target has nothing to hit or miss: whatever its fuse, it bursts at the point Fire + // froze (max range along the aim line). Detonate is still the one blast path. + if (shot.Fuse != null && shot.Target == null) + { + Detonate(shot.Source.Zone, shot.BurstPosition.xz, shot.BlastRadius, shot.Damage, shot.DamageType); + return; + } + switch (shot.Fuse) { case null: From 3e8ee022767f62a3ccfc14f9d9003a62e5686a90 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 12:28:11 +0200 Subject: [PATCH 11/49] Pin the no-lock fused shot: burst at max range, shooter unharmed Proximity, contact and delayed shots fired with no target burst at Range along a non-axis aim (2,1) and leave the shooter undamaged; the shot flies there at the weapon's Velocity. A targeted proximity shot keeps the burst point and the damage value measured at b66ba524 (5.65884256). Co-Authored-By: Claude Sonnet 5 --- .../FireControlCut124Tests.cs | 82 ++++++++++++++++++- 1 file changed, 80 insertions(+), 2 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs index f3225336e..3f5e28e97 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs @@ -91,7 +91,7 @@ public sealed class FireControlCut124Tests : IDisposable bool equipShield = false, float shieldCapacity = 1000f, bool shieldActive = true, int2? cockpitCell = null, float cockpitDurability = 1000f, (string Name, Shape Shape, int2 Cell, float Durability)[] custom = null, - float accuracy = 1f, int2? gunCell = null) + float accuracy = 1f, int2? gunCell = null, float weaponRange = 100000f) { var hullData = new HullData { @@ -116,7 +116,7 @@ public sealed class FireControlCut124Tests : IDisposable Fuse = fuse, BlastRadius = blastRadius, WeaponModifiers = modifiers, Behaviors = { new InstantWeaponData { - Damage = Constant(damage), Range = Constant(100000), MinRange = Constant(0), + Damage = Constant(damage), Range = Constant(weaponRange), MinRange = Constant(0), Velocity = Constant(velocity), Spread = Constant(0), DamageSpread = Constant(0), Penetration = Constant(penetration), Count = Constant(1), BurstTime = Constant(0), Cooldown = Constant(1000), DamageCurve = new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 1, 0, 0) } } @@ -1132,6 +1132,84 @@ public sealed class FireControlCut124Tests : IDisposable Assert.Equal(before, e.Target.Hull.Durability); } + // Operator ruling 2026-09-30: "fused weapons without a lock explode at max range." A fused shot with no + // target bursts at the weapon's Range along its aim (the mount direction), whatever its fuse. The fixture + // aims the shooter along (2,1), a non-axis facing; witnesses sit at the burst point and beside it. Radius 4 + // with the witness hull 5x4 cells of 2 world units: a witness centred on the burst point is covered, and + // one 30 units off the aim line is not. + private const float NoLockRange = 60f; + + private static float3 AimLine(Engagement e) + { + var aim = normalize(float2(2, 1)); + return e.Shooter.Position + float3(aim.x, 0, aim.y) * NoLockRange; + } + + [Theory] + [InlineData(WeaponFuse.Proximity)] + [InlineData(WeaponFuse.Contact)] + [InlineData(WeaponFuse.Delayed)] + public void AFusedShotWithNoTargetBurstsAtMaxRangeAlongTheAimLine(WeaponFuse fuse) + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: fuse, blastRadius: 4f, penetration: 1f, damage: 100f, + weaponRange: NoLockRange); + e.Shooter.Direction = normalize(float2(2, 1)); + e.Shooter.Target.Value = null; + var burst = AimLine(e); + var onLine = AddShip(e, burst, 1f, 300); + var offLine = AddShip(e, burst + float3(-30, 0, 30), 1f, 302); + var shooterHit = 0f; + using var a = e.Shooter.ArmorDamage.Subscribe(x => shooterHit += x.damage); + using var h = e.Shooter.HullDamage.Subscribe(x => shooterHit += x); + using var i = e.Shooter.ItemDamage.Subscribe(x => shooterHit += x.damage); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + + Assert.Equal(0f, shooterHit); + Assert.Empty(e.Zone.PendingShots); + Assert.False(e.Zone.Entities.Contains(onLine), "the blast must reach the burst point (fixture: 1 durability, so any damage kills it)"); + Assert.True(e.Zone.Entities.Contains(offLine), "the blast must not reach 42 units off the aim line"); + } + + // The no-lock shot flies to max range at the weapon's Velocity, like a targeted shot flies to its intercept. + [Fact] + public void ANoLockFusedShotFliesToMaxRangeAtItsVelocity() + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 20f, fuse: WeaponFuse.Proximity, blastRadius: 4f, damage: 100f, + weaponRange: NoLockRange); + e.Shooter.Direction = normalize(float2(2, 1)); + e.Shooter.Target.Value = null; + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + + var shot = Assert.Single(e.Zone.PendingShots); + Assert.Equal(NoLockRange / 20f, shot.ArrivalTime - shot.FireTime, 4); + var burst = AimLine(e); + Assert.Equal(burst.x, shot.BurstPosition.x, 3); + Assert.Equal(burst.z, shot.BurstPosition.z, 3); + } + + // A targeted shot is unchanged. Expected values computed at the base commit (b66ba524, before the no-lock + // rule), not read back from this code: the target's total damage from a proximity blast at the intercept. + [Fact] + public void ATargetedFusedShotIsUnchangedByTheNoLockRule() + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: 30f, damage: 100f); + var damage = 0f; + using var a = e.Target.ArmorDamage.Subscribe(x => damage += x.damage); + using var h = e.Target.HullDamage.Subscribe(x => damage += x); + using var i = e.Target.ItemDamage.Subscribe(x => damage += x.damage); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + var shot = Assert.Single(e.Zone.PendingShots); + Assert.Equal(e.Target.Position.x, shot.BurstPosition.x, 3); + Assert.Equal(e.Target.Position.z, shot.BurstPosition.z, 3); + e.Zone.Update(.01f); + + Assert.Equal(5.65884256f, damage, 3); + } + // ToWorldPoint and ToSchematicPoint are inverses, and ToWorldPoint puts one cell to starboard of the centre // of mass two world units along the starboard axis (right = (forward.y, -forward.x), spelled out here). At // facing (0,1) a flipped right axis is still masked by a centre-lane point; (2,1) is not. From 187caa7d81efe1f3aa77cfca21e2913a6600f5ba Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 13:11:18 +0200 Subject: [PATCH 12/49] Record the no-lock fuse rulings and their Soul follow-ups (2026-09-30) Co-Authored-By: Claude Opus 5.5 --- docs/fire-control-cut.md | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/docs/fire-control-cut.md b/docs/fire-control-cut.md index 915c755dd..5028487ea 100644 --- a/docs/fire-control-cut.md +++ b/docs/fire-control-cut.md @@ -2622,9 +2622,26 @@ Pins landed at `4629bc67` and `c7e20f08`. `Death` fires once per entity (`Entity which also fixed a direct hit on a corpse raising `Death` again. A three-kill blast test was added. 362 tests pass. -**Operator ruling needed:** a fused weapon fired with no target bursts at the shooter's own -position (`FireControl.cs:391`). This predates 12.4. Self recommends a max-range burst along the -aim line. Recorded unguarded: if a `Death` handler removes another ship mid-blast, that ship +**Ruled (operator, 2026-09-30): "fused weapons without a lock explode at max range."** This is no longer +open. Landed at `8c002637` (fix) and `3e8ee022` (tests), and Soul confirmed it. That Soul pass raised four +follow-up rulings, all given on 2026-09-30: +- **The aim is `LookDirection`, restricted by the mount's arc.** The operator notes this was already the case; + the fix had used the hull-fixed mount. +- **An invalid target counts as no lock.** A target that is selected but out of range, out of arc, not + visible, or not locked takes the no-lock path. Nothing bursts beyond max range. +- **A no-lock contact fuse stops on the first hull on its aim line**, computed at commit. Mining adds chunks + to this through a named seam. +- **A round outlives its shooter,** for targeted and no-lock shots alike. Credit and loot with no shooter go + nowhere. + +Soul also found two more defects: +- A fused weapon with `Range <= BlastRadius` bursts on its shooter. The fix is to refuse it when the catalog + opens. +- A burst without a target reads as MISS in the debug HUD. + +Fix batch 2 covers all six, in Hands on `hands/fuse-fix2`. The stale lines about "`BurstPosition` is read +only for a proximity fuse" and the Apply rules below it are superseded by these rulings. +Recorded unguarded: if a `Death` handler removes another ship mid-blast, that ship still takes blast damage. Revised 2026-09-25, after 12.3 closed. The first draft predates 12.3's proportional-absorption and one-path From e3c9cbb2821ce37b00b7e07ed734548b7b2e5bf4 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 13:27:42 +0200 Subject: [PATCH 13/49] Refuse a fused weapon whose Range does not exceed its BlastRadius Soul: a no-lock round bursts at Range, so Range <= BlastRadius (or a zero Range) lands the blast on the shooter. Made structurally impossible at the catalog rather than policed at runtime: StatValidation.ValidateFuse joins the checks CultRecordRefs.Validate runs on every Upsert, at the weakest value the Range stat can take. Only a weapon that will detonate (a Fuse and a BlastRadius above zero) is held to it. AetheriaStores.Open now calls that same Validate for every equippable and consumable document instead of carrying its own copy of the three checks, so Upsert and Open can no longer disagree. Co-Authored-By: Claude Sonnet 5 --- Assets/Scripts/ServerShared/AetheriaStores.cs | 21 ++------ Assets/Scripts/ServerShared/ItemData.cs | 15 ++++++ .../AetheriaStoresTests.cs | 54 +++++++++++++++++++ 3 files changed, 74 insertions(+), 16 deletions(-) diff --git a/Assets/Scripts/ServerShared/AetheriaStores.cs b/Assets/Scripts/ServerShared/AetheriaStores.cs index cd2589dc0..b16a33af8 100644 --- a/Assets/Scripts/ServerShared/AetheriaStores.cs +++ b/Assets/Scripts/ServerShared/AetheriaStores.cs @@ -29,22 +29,10 @@ public static class AetheriaStores cache.AllStoredDocuments.All(stored => stored.Descriptor != descriptor)); if (missing != null) throw new InvalidOperationException($"Catalog {catalogPath} has no {missing.SchemaName} record; catalog globals are authored, never invented."); - // R-heat (docs/stats-and-power-cut.md): every equippable design's heat response must describe a - // coherent range before anything reads it. Fails loudly, naming the item. - foreach (var data in cache.GetAll()) - { - StatValidation.ValidateHeatResponse(data); - StatValidation.ValidateStatModifiers(data.Name, data.Behaviors); - // Cut 7 (docs/stats-and-power-cut.md): a stat naming a role its design lacks is refused. - StatValidation.ValidateRoleUsage(data.Name, data.Roles, data.Behaviors); - } - // Cut 2 (docs/stats-and-power-cut.md): a consumable's own StatModifierData needs the same check; - // consumables carry no heat response, so this is not folded into the loop above. - foreach (var data in cache.GetAll()) - { - StatValidation.ValidateStatModifiers(data.Name, data.Behaviors); - StatValidation.ValidateRoleUsage(data.Name, data.Roles, data.Behaviors); - } + // Every catalog document is held to the one check Upsert applies on write: R-heat (docs/stats-and-power- + // cut.md), stat modifiers, role usage (Cut 7), and the fuse range rule. Fails loudly, naming the item. + foreach (var data in cache.GetAll()) CultRecordRefs.Validate(data); + foreach (var data in cache.GetAll()) CultRecordRefs.Validate(data); return cache; } catch @@ -85,6 +73,7 @@ public static class CultRecordRefs StatValidation.ValidateHeatResponse(data); StatValidation.ValidateStatModifiers(data.Name, data.Behaviors); StatValidation.ValidateRoleUsage(data.Name, data.Roles, data.Behaviors); + StatValidation.ValidateFuse(data); } if (document is ConsumableItemData consumable) { diff --git a/Assets/Scripts/ServerShared/ItemData.cs b/Assets/Scripts/ServerShared/ItemData.cs index 59d93c1ba..40e2beb83 100644 --- a/Assets/Scripts/ServerShared/ItemData.cs +++ b/Assets/Scripts/ServerShared/ItemData.cs @@ -752,6 +752,21 @@ public static class StatValidation "not exceed them"); } + // Operator ruling 2026-09-30 (Soul): a round with a blast that could burst on its own shooter is refused at + // the catalog rather than policed at runtime. A weapon that detonates (a fuse and a blast radius above zero, + // the two FireControl.Fire freezes together) must reach farther than its blast, at the weakest value its Range + // stat can take: a no-lock round bursts at Range, so Range <= BlastRadius would put the shooter inside the + // burst, and Range <= 0 is a burst on the muzzle. A fuse without a radius is inert and needs no range. + public static void ValidateFuse(EquippableItemData data) + { + if (!(data is WeaponItemData weapon) || weapon.Fuse == null || !(weapon.BlastRadius > 0f)) return; + foreach (var behavior in weapon.Behaviors) + if (behavior is WeaponData weaponData && weaponData.Range.Min <= weapon.BlastRadius.Value) + throw new InvalidOperationException( + $"{weapon.Name}: fused weapon's Range {weaponData.Range.Min} does not exceed its BlastRadius " + + $"{weapon.BlastRadius.Value} -- a burst at max range would land on the shooter"); + } + // Cut 2 (docs/stats-and-power-cut.md): a StatReference names its target by (type name, field name) so a // modifier can point at any design or behaviour without a hard type reference -- but before this cut an // unresolvable reference failed silently: StatModifier.Initialize left `_stats` null and the first diff --git a/tests/Aetheria.Shared.Tests/AetheriaStoresTests.cs b/tests/Aetheria.Shared.Tests/AetheriaStoresTests.cs index e0fec193d..e23953192 100644 --- a/tests/Aetheria.Shared.Tests/AetheriaStoresTests.cs +++ b/tests/Aetheria.Shared.Tests/AetheriaStoresTests.cs @@ -190,6 +190,60 @@ public sealed class AetheriaStoresTests : IDisposable Assert.Null(record); } + // Operator ruling 2026-09-30 (Soul): a weapon that detonates must reach farther than its blast, at the + // weakest value its Range stat can take, or a max-range burst lands on the shooter. Refused where every + // catalog write and every open already checks, not policed at runtime. + private static WeaponItemData FusedWeapon(string name, float rangeMin, float rangeMax, float? blastRadius, WeaponFuse? fuse) => new WeaponItemData + { + Name = name, Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Fuse = fuse, BlastRadius = blastRadius, + Behaviors = { new InstantWeaponData { Range = new PerformanceStat { Min = rangeMin, Max = rangeMax } } } + }; + + // Kills: a comparison that admits equality (Range == BlastRadius bursts on the shooter's own edge); reading + // Range.Max instead of Min (the second row: Max 100 clears the blast, the weakest value does not); a + // zero range with a positive blast. + [Theory] + [InlineData(3f, 3f, 3f)] + [InlineData(2f, 2f, 3f)] + [InlineData(2f, 100f, 3f)] + [InlineData(0f, 0f, .5f)] + public void ValidateRefusesAFusedWeaponWhoseRangeDoesNotExceedItsBlast(float rangeMin, float rangeMax, float radius) + { + var error = Assert.Throws(() => + CultRecordRefs.Validate(FusedWeapon("ShortFuse", rangeMin, rangeMax, radius, WeaponFuse.Proximity))); + Assert.Contains("ShortFuse", error.Message); + } + + // A fuse without a radius, a radius without a fuse, and a range that clears the blast are all fine: only a + // weapon that will actually detonate is held to the rule. + [Theory] + [InlineData(1f, 1f, null, WeaponFuse.Contact)] + [InlineData(1f, 1f, 0f, WeaponFuse.Contact)] + [InlineData(1f, 1f, 30f, null)] + [InlineData(60f, 60f, 4f, WeaponFuse.Delayed)] + public void ValidateAcceptsAWeaponThatCannotBurstOnItsShooter(float rangeMin, float rangeMax, float? radius, WeaponFuse? fuse) + { + var record = Record.Exception(() => CultRecordRefs.Validate(FusedWeapon("Fine", rangeMin, rangeMax, radius, fuse))); + Assert.Null(record); + } + + // Open holds a catalog already on disk to the same rule Upsert applies on write. The bad record is written + // past Upsert, straight through the cache, the way a hand-edited or migrated catalog would carry one. + [Fact] + public void OpenRefusesAFusedWeaponWhoseRangeDoesNotExceedItsBlast() + { + using (var cache = AetheriaStores.Open(Catalog, catalogWritable: true)) + { + cache.UpsertAsync(FusedWeapon("OnDisk", 2f, 2f, 3f, WeaponFuse.Contact)).Wait(); + cache.FlushAsync().Wait(); + } + + var error = Assert.Throws(() => AetheriaStores.Open(Catalog)); + Assert.Contains("OnDisk", error.Message); + } + // A document type Validate has no opinion about (neither EquippableItemData nor ConsumableItemData) must be // a pure no-op, not a crash -- the three tools' change lists carry `object`, and not every migration touches // an equippable design (e.g. a future repair over FactionProductData). From f5c6729e7a4cde321c4ac865883aac0a0c29a0e9 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 13:27:43 +0200 Subject: [PATCH 14/49] No-lock fused rounds: aim, invalid targets, contact stops, outlived shooters Operator rulings 2026-09-30 after Soul's pass on 8c002637: - A no-lock round flies along Entity.LookDirection restricted by the mount's arc (FireControl.AimDirection: inside InArc's cone as is, outside it turned to the cone's edge; no aim falls back to the mount). The simulation had no aim clamp before; the old code used the hull-fixed mount direction. - An invalid target (out of range, out of arc, not visible, not locked) counts as no lock for a fused round. PFire's gate is the one decider and now also reports `bears`; Fire reads it. The round carries no target afterwards. - A no-lock Contact round bursts at the first hull its aim line crosses before max range, read through Lane's own cell admission (CellsOnLine, split out of Lane). Proximity and Delayed keep the max-range burst. Such a round commits at Fire, since it has no target facing to read late, and arrives when it has flown to the burst point. The seam for mining chunks is FireControl.ContactDistance. - A round outlives its shooter: Step no longer cancels a shot whose source left the zone. Resolution reads the zone and the target; the shooter is an identity a shot carries. PendingShot.FireOrigin freezes where it left from. - ShotOutcome.Result (Miss, Hit, Burst) replaces the stored Hit bool, which is now derived. A burst without a target is no longer reported as a miss, and the debug HUD labels it BURST. Co-Authored-By: Claude Sonnet 5 --- Assets/Scripts/Gameplay/ActionGameManager.cs | 11 +- Assets/Scripts/ServerShared/FireControl.cs | 262 +++++++++++++---- .../FireControlCut124Tests.cs | 278 +++++++++++++++++- 3 files changed, 483 insertions(+), 68 deletions(-) diff --git a/Assets/Scripts/Gameplay/ActionGameManager.cs b/Assets/Scripts/Gameplay/ActionGameManager.cs index 6d3c0491c..bc9bce8a2 100644 --- a/Assets/Scripts/Gameplay/ActionGameManager.cs +++ b/Assets/Scripts/Gameplay/ActionGameManager.cs @@ -1282,6 +1282,13 @@ public class ActionGameManager : MonoBehaviour } } + private static string ResultLabel(ShotResult result) => result switch + { + ShotResult.Hit => "HIT", + ShotResult.Burst => "BURST", + _ => "MISS" + }; + private void UpdateFireControlDebug(Entity target) { if (DebugInfoText == null) return; @@ -1310,7 +1317,7 @@ public class ActionGameManager : MonoBehaviour var shot = CurrentEntity.Zone.PendingShots[i]; if (shot.Source != CurrentEntity || shot.Weapon != selectedItem) continue; if (shot.Committed) - pendingLine = $"shot {shot.ShotId}: committed {(shot.Outcome.Hit ? "HIT" : "MISS")}"; + pendingLine = $"shot {shot.ShotId}: committed {ResultLabel(shot.Outcome.Result)}"; else { var pDeviation = FireControl.DeviationProbability(shot, CurrentEntity.Zone.Time, out var deviation); @@ -1324,7 +1331,7 @@ public class ActionGameManager : MonoBehaviour } var lastLine = _debugLastShots.TryGetValue(selectedItem, out var lastShot) - ? $"last {lastShot.ShotId}: {(lastShot.Hit ? "HIT" : "MISS")} cell {lastShot.Cell.x},{lastShot.Cell.y}" + ? $"last {lastShot.ShotId}: {ResultLabel(lastShot.Result)} cell {lastShot.Cell.x},{lastShot.Cell.y}" : "last: none"; var gates = target == null ? "target: none" diff --git a/Assets/Scripts/ServerShared/FireControl.cs b/Assets/Scripts/ServerShared/FireControl.cs index f3fcf6b8c..3d08172d8 100644 --- a/Assets/Scripts/ServerShared/FireControl.cs +++ b/Assets/Scripts/ServerShared/FireControl.cs @@ -57,6 +57,26 @@ public static class FireControl return dot(MountDirection(weapon), planarTarget) >= cos(radians(arc / 2f)); } + // Operator ruling 2026-09-30: a shot with nothing to bear on flies along where its shooter is aiming + // (Entity.LookDirection -- the player's mouse or a turret's tracking), restricted by the mount's arc. The + // arc is InArc's own cone: an aim inside it is used as is, one outside it is turned to the nearest edge of + // the same cone. There is no second bearing test here; the edge is where InArc stops passing. A shooter + // with no aim set (LookDirection zero) aims down the mount, the same fallback InArc takes for a zero bearing. + // The simulation had no aim clamp before this: the Unity barrel's ArticulationPoint clamps the picture, + // and a target outside the arc merely priced the shot at zero. + public static float2 AimDirection(EquippedItem item, Entity shooter) + { + var mount = MountDirection(item).xz; + var look = shooter.LookDirection.xz; + if (lengthsq(look) < 1e-6f) return mount; + look = normalize(look); + if (InArc(item, float3(look.x, 0, look.y))) return look; + + var half = radians(ArcFor(item) / 2f); + var turn = mount.x * look.y - mount.y * look.x >= 0f ? half : -half; + return float2(mount.x * cos(turn) - mount.y * sin(turn), mount.x * sin(turn) + mount.y * cos(turn)); + } + // Cut 2, R5: whether `observer` has resolved `item` (which must belong to some entity in the zone) well // enough to aim at it. Derived fresh from EntityInfoGathered on every call -- nothing caches a reveal // result, so decay drops it the moment info falls back below tier, with no clearing loop anywhere. @@ -152,15 +172,23 @@ public static class FireControl // freezes (R10), gated exactly as HitProbability gates. Nothing about the target's facing or silhouette // enters here; that is Silhouette's and PSpread's job, priced fresh by HitProbability below and again, // live, by Commit at the commit tick. 0 the moment any gate closes. - private static float PFire(Weapon weapon, Entity source, Entity target) => PFire(weapon, source, target, out _); + private static float PFire(Weapon weapon, Entity source, Entity target) => PFire(weapon, source, target, out _, out _); // The `out range` overload is HitProbability's own gate call -- one computation of `target.Position - // source.Position`, not two. HitProbability used to recompute its own copy for PSpread, which is exactly // the kind of split authority Cut 3 named as the risk (two places computing the same vector, free to // drift or to have their subtraction order flipped in only one of them). - private static float PFire(Weapon weapon, Entity source, Entity target, out float range) + private static float PFire(Weapon weapon, Entity source, Entity target, out float range) => + PFire(weapon, source, target, out range, out _); + + // `bears` is the one answer to "is this target one the weapon can fire at": in range, visible, locked (for a + // LockWeapon) and in arc. PFire prices a shot at zero when it is false; Fire reads it to decide that a + // fused round has no target at all (operator ruling 2026-09-30: an invalid target counts as no lock). + // Inspect recomputes the four gates one by one, to show the HUD which is closed; it decides nothing. + private static float PFire(Weapon weapon, Entity source, Entity target, out float range, out bool bears) { range = 0f; + bears = false; if (target == null) return 0f; // F8 correction (Soul's fix batch, 2026-09-24): range is computed for any non-null target, before any @@ -174,6 +202,7 @@ public static class FireControl if (range < weapon.MinRange || range > weapon.Range) return 0f; if (weapon is LockWeapon lockWeapon && !lockWeapon.IsLocked) return 0f; if (!InArc(weapon.Item, toTarget)) return 0f; + bears = true; var settings = source.ItemManager.GameplaySettings; var info = source.EntityInfoGathered.TryGetValue(target, out var gathered) ? gathered : 0f; @@ -364,20 +393,7 @@ public static class FireControl // F8 (Soul's fix batch, 2026-09-24): the `out range` overload is the one range HitProbability now // reads too -- Fire used to recompute its own second copy of `target.Position - source.Position` for // fireRange, right beside the copy PFire's gate already computed. - var pFire = PFire(weapon, source, target, out var fireRange); - var travelDirection = target != null ? TravelDirection(weapon, source, target) : float2(0, 1); - - var targetVelocity = float3.zero; - var targetPosition = source.Position; - var flightTime = 0f; - if (target != null) - { - targetVelocity = float3(target.Velocity.x, 0, target.Velocity.y); - targetPosition = target.Position; - flightTime = weapon.Velocity > .01f ? fireRange / weapon.Velocity : 0f; - } - - var commitHorizon = source.ItemManager.GameplaySettings.CommitHorizon; + var pFire = PFire(weapon, source, target, out var fireRange, out var bears); // Cut 12.4(a) (Q12-6, "WeaponModifiers are labels, not behaviour"): a blast is a property of the // weapon's data fields alone, resolved and frozen here like every other payload field -- never decided @@ -388,14 +404,33 @@ public static class FireControl var weaponItemData = item.Data as WeaponItemData; var blastRadius = weaponItemData?.BlastRadius > 0f ? weaponItemData.BlastRadius.Value : 0f; var fuse = blastRadius > 0f ? weaponItemData.Fuse : null; - var burstPosition = blastRadius > 0f && target != null ? PredictedIntercept(weapon, source, target) : targetPosition; - // Operator ruling 2026-09-30 ("fused weapons without a lock explode at max range"): a fused shot with no - // target bursts where its own aim line ends -- the mount direction at the weapon's Range stat, the same - // stat PFire's range gate reads -- and flies there at the weapon's Velocity, like a targeted shot flies - // to its intercept. Only the burst point and flight time differ; Apply detonates it through Detonate. - if (fuse != null && target == null) + + // Operator rulings 2026-09-30: an invalid target counts as no lock -- a fused round whose selected + // target is out of range, out of arc, not visible or not locked (PFire's `bears`) is fired at nothing. + // A fused shot with no target bursts where its own aim line ends: the shooter's aim (AimDirection) at + // the weapon's Range stat, the same stat PFire's range gate reads, flown at the weapon's Velocity like + // a targeted shot flies to its intercept. The round carries no target from here on, so nothing at + // resolution asks whether one is still around. A shot without a fuse keeps its target as before. + var engaged = fuse != null && !bears ? null : target; + var origin = source.Position; + var travelDirection = engaged != null ? TravelDirection(weapon, source, engaged) : AimDirection(item, source); + + var targetVelocity = float3.zero; + var targetPosition = origin; + var flightTime = 0f; + if (engaged != null) { - burstPosition = source.Position + MountDirection(item) * weapon.Range; + targetVelocity = float3(engaged.Velocity.x, 0, engaged.Velocity.y); + targetPosition = engaged.Position; + flightTime = weapon.Velocity > .01f ? fireRange / weapon.Velocity : 0f; + } + + var commitHorizon = source.ItemManager.GameplaySettings.CommitHorizon; + + var burstPosition = blastRadius > 0f && engaged != null ? PredictedIntercept(weapon, source, engaged) : targetPosition; + if (fuse != null && engaged == null) + { + burstPosition = origin + float3(travelDirection.x, 0, travelDirection.y) * weapon.Range; flightTime = weapon.Velocity > .01f ? weapon.Range / weapon.Velocity : 0f; } @@ -403,7 +438,7 @@ public static class FireControl { ShotId = zone.NextShotId(), Source = source, - Target = target, + Target = engaged, Weapon = item, Aimed = source.ResolvedTargetItem, Damage = damageOverride ?? weapon.Damage, @@ -417,10 +452,14 @@ public static class FireControl Spread = weapon.Spread, FireRange = fireRange, FireTime = now, + FireOrigin = origin, FireTargetPosition = targetPosition, FireTargetVelocity = targetVelocity, ArrivalTime = now + flightTime, - CommitTime = now + max(0f, flightTime - commitHorizon), + // The commit horizon exists so the target's facing can be read late (Bearing timing). A round fired at + // nothing has no facing to read, so it commits at Fire: its burst point is decided from the poses of + // the moment it leaves, and a Contact round short of max range can arrive as early as it truly does. + CommitTime = fuse != null && engaged == null ? now : now + max(0f, flightTime - commitHorizon), BurstPosition = burstPosition, Fuse = fuse, BlastRadius = blastRadius, @@ -432,8 +471,11 @@ public static class FireControl } // Cut 3, R4: ages every shot in the zone, commits the ones that have reached their horizon and resolves - // the ones that have arrived. Called from Zone.Update after the entity loop. A shot whose source or target - // has left the zone resolves as a miss and is removed outright (0b table), whichever stage it is at. + // the ones that have arrived. Called from Zone.Update after the entity loop. A shot whose target has left + // the zone resolves as a miss and is removed outright (0b table), whichever stage it is at. A round outlives + // its shooter (operator ruling 2026-09-30): the shooter is only an identity a shot carries, never asked + // for at resolution, so a shooter that died or left the zone in flight changes nothing about the shot. + // Resolution reads the zone and the target; there is no credit or loot for a shooter to receive. public static void Step(Zone zone, float dt) { var shots = zone.PendingShots; @@ -444,16 +486,15 @@ public static class FireControl { var shot = shots[i]; - var sourceGone = !zone.Entities.Contains(shot.Source); var targetGone = shot.Target != null && !zone.Entities.Contains(shot.Target); - if (sourceGone || targetGone) + if (targetGone) { - // Cut 11 (Soul C4): a shot whose source or target has left the zone resolves as a miss whichever + // Cut 11 (Soul C4): a shot whose target has left the zone resolves as a miss whichever // stage it is at (0b table). The resolution is published as a fresh miss rather than by // rewriting shot.Outcome: a committed outcome is immutable (R4) and stays a true record of what // the commit decided, while ShotResolved reports what actually happened -- nothing, because the // target is gone. Republishing the committed outcome here used to put a hit marker on a corpse. - var miss = MakeOutcome(shot, false, false, false, int2.zero, float2.zero, 0f, now); + var miss = MakeOutcome(shot, ShotResult.Miss, false, false, int2.zero, float2.zero, 0f, now); if (!shot.Committed) zone.ShotCommitted.OnNext(miss); zone.ShotResolved.OnNext(miss); shots.RemoveAt(i); @@ -464,6 +505,9 @@ public static class FireControl { shot.Outcome = Commit(zone, shot, now); shot.Committed = true; + // A contact burst short of max range arrives when the round has flown that far, not at the frozen + // max-range time (CommitBurst). Never later than the frozen time. + if (shot.Outcome.Result == ShotResult.Burst) shot.ArrivalTime = min(shot.ArrivalTime, now + shot.Outcome.ArrivalIn); zone.ShotCommitted.OnNext(shot.Outcome); shots[i] = shot; } @@ -474,7 +518,7 @@ public static class FireControl // which model a shot uses, through its own switch on the frozen Fuse -- Step has no damage // branch left. A direct hit, a proximity burst and a contact or delayed blast all arrive here // the same way; only Apply decides what that means. - Apply(shot); + Apply(zone, shot); zone.ShotResolved.OnNext(shot.Outcome); shots.RemoveAt(i); } @@ -522,14 +566,18 @@ public static class FireControl if (p <= 0f) return 0f; var bearing = Bearing(shot.Target, shot.TravelDirection); - var hullData = shot.Source.ItemManager.GetData(shot.Target.Hull) as HullData; + var hullData = shot.Target.ItemManager.GetData(shot.Target.Hull) as HullData; sil = Silhouette(shot.Target, hullData, shot.Aimed, bearing, shot.Precision); - var settings = shot.Source.ItemManager.GameplaySettings; + var settings = shot.Target.ItemManager.GameplaySettings; return p * PSpread(shot.Spread, sil.Span, shot.FireRange, settings.SchematicCellSize) * sil.POnHull; } private static ShotOutcome Commit(Zone zone, PendingShot shot, float now) { + // A fused round with no target rolls nothing: it bursts. Where is decided here, at the commit, from the + // poses of this tick (R4); Apply performs it. + if (shot.Fuse != null && shot.Target == null) return CommitBurst(zone, shot, now); + // Cut 6b, 6.1 (Soul finding 6): a shot's dice belong to the shot, not to whatever else happened to // draw from the engine-wide shared generator first. A pure function of (zone identity, shot id) -- // the `| 1u` guards @@ -567,7 +615,7 @@ public static class FireControl bearing = Bearing(shot.Target, shot.TravelDirection); lateral = LateralDraw(sil, random.NextFloat()); - var hullData = shot.Source.ItemManager.GetData(shot.Target.Hull) as HullData; + var hullData = shot.Target.ItemManager.GetData(shot.Target.Hull) as HullData; var buffer = ArrayPool.Shared.Rent(hullData.Shape.Coordinates.Length); try { @@ -601,7 +649,70 @@ public static class FireControl } } - return MakeOutcome(shot, hit, shielded, shieldBroken, cell, bearing, lateral, now); + return MakeOutcome(shot, hit ? ShotResult.Hit : ShotResult.Miss, shielded, shieldBroken, cell, bearing, lateral, now); + } + + // Operator rulings 2026-09-30. A fused round with no target bursts at the max-range point Fire froze along + // the aim line, whatever its fuse -- except a Contact round, which bursts at the first hull that line crosses + // before that point (a Proximity or Delayed fuse does not trip on a hull with nothing locked). The point is + // read from this tick's poses and frozen into the outcome (R4, the same timing Commit reads facing at). + // Nothing is rolled or drawn, so determinism needs nothing beyond the frozen inputs. + private static ShotOutcome CommitBurst(Zone zone, PendingShot shot, float now) + { + var outcome = MakeOutcome(shot, ShotResult.Burst, false, false, int2.zero, float2.zero, 0f, now); + var reach = length(shot.BurstPosition.xz - shot.FireOrigin.xz); + var travelled = shot.Fuse == WeaponFuse.Contact ? ContactDistance(zone, shot, reach) : reach; + if (travelled < reach) + { + outcome.BurstPoint = shot.FireOrigin.xz + shot.TravelDirection * travelled; + // The round arrives once it has flown that far, which is sooner than the frozen max-range arrival. + outcome.ArrivalIn = max(0f, shot.FireTime + (shot.ArrivalTime - shot.FireTime) * travelled / reach - now); + } + else outcome.BurstPoint = shot.BurstPosition.xz; + return outcome; + } + + // The distance along a no-lock round's aim line to the first thing a contact fuse trips on, or `reach` (the + // max-range point) when it meets nothing first. The shooter's own hull is never a candidate. + // SEAM FOR THE MINING CAMPAIGN: mining chunks are not Entities and are not on this branch. When they land, + // whatever they are contributes its own entry distance to this minimum, and nothing else in the file changes. + private static float ContactDistance(Zone zone, PendingShot shot, float reach) + { + var nearest = reach; + foreach (var entity in zone.Entities) + { + if (entity == shot.Source) continue; + nearest = min(nearest, HullEntryDistance(entity, shot.FireOrigin.xz, shot.TravelDirection)); + } + return nearest; + } + + // World distance from `origin` along the unit planar `direction` to the first metal cell of `entity`'s hull, + // or +infinity when the line meets none ahead of it. Read through Lane's own cell admission (CellsOnLine), in + // the entity's schematic frame: the line's lateral offset is its own, and cells wholly behind the origin + // are skipped. An origin already inside the hull is a distance of zero. + private static float HullEntryDistance(Entity entity, float2 origin, float2 direction) + { + var hullData = entity.ItemManager.GetData(entity.Hull) as HullData; + var cellSize = entity.ItemManager.GameplaySettings.SchematicCellSize; + var start = entity.ToSchematicPoint(origin); + var bearing = Bearing(entity, direction); + var lateral = dot(start, Lateral(bearing)); + var along = dot(start, bearing); + + var buffer = ArrayPool.Shared.Rent(hullData.Shape.Coordinates.Length); + try + { + var count = CellsOnLine(hullData, bearing, lateral, buffer); + for (var i = 0; i < count; i++) + if (buffer[i].Exit > along) + return max(buffer[i].Entry - along, 0f) * cellSize; + return float.PositiveInfinity; + } + finally + { + ArrayPool.Shared.Return(buffer); + } } // R4: the commit is authoritative and immutable from here on -- this only performs what Commit already @@ -657,13 +768,13 @@ public static class FireControl // contact or delayed fuse detonates only on a committed hit, at a point 12.3's own Lane/Reach machinery // locates. This is a default, not a ruling: a blast shot's DamageSpread is not read, and P sits on the // centre lane (see the fuse-point comment on the Contact/Delayed branch below). - private static void Apply(PendingShot shot) + private static void Apply(Zone zone, PendingShot shot) { - // A fused shot with no target has nothing to hit or miss: whatever its fuse, it bursts at the point Fire - // froze (max range along the aim line). Detonate is still the one blast path. - if (shot.Fuse != null && shot.Target == null) + // A fused shot with no target has nothing to hit or miss: its committed outcome is a Burst, and it + // detonates at the point Commit decided (Burst outcome's BurstPoint). Detonate is still the one blast path. + if (shot.Outcome.Result == ShotResult.Burst) { - Detonate(shot.Source.Zone, shot.BurstPosition.xz, shot.BlastRadius, shot.Damage, shot.DamageType); + Detonate(zone, shot.Outcome.BurstPoint, shot.BlastRadius, shot.Damage, shot.DamageType); return; } @@ -675,7 +786,7 @@ public static class FireControl case WeaponFuse.Proximity: // Detonates at shot.BurstPosition.xz, which PredictedIntercept froze at Fire (today's airburst // rule, unchanged) -- no roll, and no gate on shot.Outcome.Hit. - Detonate(shot.Source.Zone, shot.BurstPosition.xz, shot.BlastRadius, shot.Damage, shot.DamageType); + Detonate(zone, shot.BurstPosition.xz, shot.BlastRadius, shot.Damage, shot.DamageType); return; case WeaponFuse.Contact: case WeaponFuse.Delayed: @@ -1042,7 +1153,7 @@ public static class FireControl return walked; } - private static ShotOutcome MakeOutcome(PendingShot shot, bool hit, bool shielded, bool shieldBroken, int2 cell, float2 bearing, float lateral, float now) + private static ShotOutcome MakeOutcome(PendingShot shot, ShotResult result, bool shielded, bool shieldBroken, int2 cell, float2 bearing, float lateral, float now) { return new ShotOutcome { @@ -1050,7 +1161,7 @@ public static class FireControl Source = shot.Source, Target = shot.Target, Weapon = shot.Weapon, - Hit = hit, + Result = result, Shielded = shielded, ShieldBroken = shieldBroken, Cell = cell, @@ -1381,24 +1492,10 @@ public static class FireControl return sil.A; // sil.Count == 0: no metal in the shadow at all -- unreachable while POnHull > 0 gated Commit's roll } - // Cut 12.2 (docs/fire-control-cut.md, "the rules every sub-cut reads"): exact slab traversal along the - // bearing at a fixed lateral offset s, replacing the old 0.5-step sampling march. Collects the occupied - // cells whose shadow interval contains s, each with its own entry/exit parameter along b, ordered by - // entry (ties broken by dot(cell, b), then cell index for full determinism), then walks forward while - // consecutive cells are contiguous (`next.entry <= current.exit + 1e-4`) -- the first gap ends the walk, - // the same rule Entity.cs's old march kept. A direct hit starts outside the hull (t -> -infinity), so the - // walk's first element is the impact cell. Only that first element is read in 12.2; the rest is 12.3's - // armour-first absorption march. Writes into the caller-supplied pooled buffer and returns the walked - // count; never allocates on its own. - // Cut 12.2 fix batch (S1, Soul's second pass): admission is now decided ONCE, by the exact same shadow - // interval Silhouette sums over (Extent(c, ell), the shared function above) -- not by a re-derivation and - // not by a second, independent geometric test. The old code ran two tests: this shadow prefilter (a - // "re-derivation" of Silhouette's own centre/h, inline) AND SlabAlongB, an independent 2-axis ray-box - // intersection that could reject a cell the shadow test had just admitted, purely on float rounding at an - // interval edge -- an empty lane for a shot that had already passed its roll (R3 violation, the crash Soul - // reproduced through Zone.Update). Deleting the second gate removes the split authority: a cell the shadow - // sum counted metal for can no longer be un-counted here. - public static int Lane(HullData hull, float2 b, float s, LaneCell[] buffer) + // Every occupied cell the line meets, sorted by entry, in the pooled buffer; returns how many. Lane below + // walks the contiguous run from the first; a caller that needs the whole line (a contact fuse looking for + // the first cell ahead of its origin) reads all of them. One admission test either way. + private static int CellsOnLine(HullData hull, float2 b, float s, LaneCell[] buffer) { var ell = Lateral(b); var coords = hull.Shape.Coordinates; @@ -1420,7 +1517,29 @@ public static class FireControl } Array.Sort(buffer, 0, n); + return n; + } + // Cut 12.2 (docs/fire-control-cut.md, "the rules every sub-cut reads"): exact slab traversal along the + // bearing at a fixed lateral offset s, replacing the old 0.5-step sampling march. Collects the occupied + // cells whose shadow interval contains s, each with its own entry/exit parameter along b, ordered by + // entry (ties broken by dot(cell, b), then cell index for full determinism), then walks forward while + // consecutive cells are contiguous (`next.entry <= current.exit + 1e-4`) -- the first gap ends the walk, + // the same rule Entity.cs's old march kept. A direct hit starts outside the hull (t -> -infinity), so the + // walk's first element is the impact cell. Only that first element is read in 12.2; the rest is 12.3's + // armour-first absorption march. Writes into the caller-supplied pooled buffer and returns the walked + // count; never allocates on its own. + // Cut 12.2 fix batch (S1, Soul's second pass): admission is now decided ONCE, by the exact same shadow + // interval Silhouette sums over (Extent(c, ell), the shared function above) -- not by a re-derivation and + // not by a second, independent geometric test. The old code ran two tests: this shadow prefilter (a + // "re-derivation" of Silhouette's own centre/h, inline) AND SlabAlongB, an independent 2-axis ray-box + // intersection that could reject a cell the shadow test had just admitted, purely on float rounding at an + // interval edge -- an empty lane for a shot that had already passed its roll (R3 violation, the crash Soul + // reproduced through Zone.Update). Deleting the second gate removes the split authority: a cell the shadow + // sum counted metal for can no longer be un-counted here. + public static int Lane(HullData hull, float2 b, float s, LaneCell[] buffer) + { + var n = CellsOnLine(hull, b, s, buffer); var walked = n > 0 ? 1 : 0; for (var i = 1; i < n; i++) { @@ -1515,6 +1634,9 @@ public struct PendingShot public WeaponFuse? Fuse; public float BlastRadius; + // Frozen with the rest of the shooter's decision: where the round left from. A round outlives its shooter, so + // nothing at resolution reads the shooter's position. + public float3 FireOrigin; public float3 FireTargetPosition; public float3 FireTargetVelocity; public float FireTime; @@ -1614,7 +1736,12 @@ public sealed class ShotOutcome public Entity Source; public Entity Target; public EquippedItem Weapon; - public bool Hit; + // What the commit decided, typed: a shot rolled and missed, rolled and hit, or was a fused round with no + // target and so burst without rolling (Burst, at BurstPoint). Hit is derived from it, not stored beside it. + public ShotResult Result; + public bool Hit => Result == ShotResult.Hit; + // World planar point a Burst detonates at, frozen by the commit. Meaningless for any other Result. + public float2 BurstPoint; public bool Shielded; // Cut 5, 5.2 (Soul finding 4): frozen alongside the rest of the outcome (R4) -- a shield present, active, // and unable to CanTakeHit this shot is decided broken right here, so Apply performs Break() rather than @@ -1629,3 +1756,12 @@ public sealed class ShotOutcome public float ArrivalIn; public DamageType DamageType; } + +// What a committed shot came to. A Burst is a fused round that had no valid target: it rolled nothing, so it is +// neither a hit nor a miss, and the presentation must not call it one. +public enum ShotResult +{ + Miss, + Hit, + Burst +} diff --git a/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs index 3f5e28e97..d25718137 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs @@ -1127,13 +1127,14 @@ public sealed class FireControlCut124Tests : IDisposable Assert.NotNull(outcome); Assert.False(outcome.Hit); // fixture: a guaranteed miss + Assert.Equal(ShotResult.Miss, outcome.Result); Assert.Empty(e.Zone.PendingShots); Assert.Equal(0, events); Assert.Equal(before, e.Target.Hull.Durability); } // Operator ruling 2026-09-30: "fused weapons without a lock explode at max range." A fused shot with no - // target bursts at the weapon's Range along its aim (the mount direction), whatever its fuse. The fixture + // target bursts at the weapon's Range along its aim (Entity.LookDirection), whatever its fuse. The fixture // aims the shooter along (2,1), a non-axis facing; witnesses sit at the burst point and beside it. Radius 4 // with the witness hull 5x4 cells of 2 world units: a witness centred on the burst point is covered, and // one 30 units off the aim line is not. @@ -1153,7 +1154,7 @@ public sealed class FireControlCut124Tests : IDisposable { var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: fuse, blastRadius: 4f, penetration: 1f, damage: 100f, weaponRange: NoLockRange); - e.Shooter.Direction = normalize(float2(2, 1)); + Aim(e, float2(2, 1)); e.Shooter.Target.Value = null; var burst = AimLine(e); var onLine = AddShip(e, burst, 1f, 300); @@ -1178,7 +1179,7 @@ public sealed class FireControlCut124Tests : IDisposable { var e = Build(TestSettings(), SolidShape(5, 4), velocity: 20f, fuse: WeaponFuse.Proximity, blastRadius: 4f, damage: 100f, weaponRange: NoLockRange); - e.Shooter.Direction = normalize(float2(2, 1)); + Aim(e, float2(2, 1)); e.Shooter.Target.Value = null; FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); @@ -1210,6 +1211,276 @@ public sealed class FireControlCut124Tests : IDisposable Assert.Equal(5.65884256f, damage, 3); } + // ==== Operator rulings 2026-09-30 (Soul's pass on the no-lock fix): aim, invalid target, contact, shooter ==== + + private static void Aim(Engagement e, float2 direction) => e.Shooter.LookDirection = float3(direction.x, 0, direction.y); + + private static float3 PointAlong(Engagement e, float2 direction, float distance) => + e.Shooter.Position + float3(direction.x, 0, direction.y) * distance; + + private static bool Alive(Engagement e, Entity entity) => e.Zone.Entities.Contains(entity); + + // Ruling 1: an aim beyond the mount's arc flies along the nearest edge of that arc. The mount is (0,1), the + // arc 170 degrees (edge 85), and the aim (+-1,-1) sits 135 degrees off it; the witness on the edge dies and + // the one under the unclamped aim does not (they are 50 units apart). Kills: no clamp; the turn taken the + // wrong way round (the mirrored edge holds no witness). + [Theory] + [InlineData(1f)] + [InlineData(-1f)] + public void ANoLockShotOutsideTheMountArcFliesAlongTheNearestEdgeOfIt(float side) + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: 4f, damage: 100f, + weaponRange: NoLockRange); + e.Shooter.Target.Value = null; + Aim(e, float2(side, -1)); + var edge = float2(side * sin(radians(85f)), cos(radians(85f))); + var onEdge = AddShip(e, PointAlong(e, edge, NoLockRange), 1f, 300); + var underAim = AddShip(e, PointAlong(e, normalize(float2(side, -1)), NoLockRange), 1f, 302); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + + Assert.False(Alive(e, onEdge), "the blast must land on the arc's edge"); + Assert.True(Alive(e, underAim), "the blast must not follow an aim the arc does not allow"); + } + + // Ruling 1: an aim inside the arc is used as it is (unnormalised here), and a shooter aiming at nothing + // (LookDirection zero) fires down its mount. Kills: reading MountDirection instead of the aim; a missing + // zero-aim fallback, which would normalise to NaN and burst nowhere. + [Fact] + public void ANoLockShotFliesAlongTheAimAndDownTheMountWhenNothingIsAimed() + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: 4f, damage: 100f, + weaponRange: NoLockRange); + e.Shooter.Target.Value = null; + var mount = float2(0, 1); + var aim = normalize(float2(2, 1)); + var onAim = AddShip(e, PointAlong(e, aim, NoLockRange), 1f, 300); + var onMount = AddShip(e, PointAlong(e, mount, NoLockRange), 1f, 302); + + Aim(e, float2(6, 3)); + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + Assert.False(Alive(e, onAim), "the blast must follow the aim"); + Assert.True(Alive(e, onMount), "the mount direction is not the aim"); + + Aim(e, float2(0, 0)); + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + Assert.False(Alive(e, onMount), "with nothing aimed the blast follows the mount"); + } + + public enum Invalid { OutOfRange, NotVisible, OutOfArc } + + // Ruling 2: a selected target that is out of range, not visible or out of arc counts as no lock -- the round + // bursts at max range along the aim, and the target is left alone. The aim is (2,1) except when the target is + // behind the mount, where the mount is flipped and the aim follows it. Kills: reading "no lock" as + // Target == null alone (the round would burst at the target's intercept, 100 / 40 / 100 units out). + // (An unlocked LockWeapon shares the same predicate; the fixture has no LockWeapon.) + [Theory] + [InlineData(Invalid.OutOfRange)] + [InlineData(Invalid.NotVisible)] + [InlineData(Invalid.OutOfArc)] + public void AnInvalidTargetCountsAsNoLock(Invalid reason) + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: 4f, damage: 100f, + weaponRange: NoLockRange, targetRange: reason == Invalid.NotVisible ? 40f : 100f); + var aim = normalize(float2(2, 1)); + if (reason == Invalid.NotVisible) e.Shooter.VisibleEntities.Remove(e.Target); + if (reason == Invalid.OutOfArc) + { + e.Shooter.Direction = float2(0, -1); + aim = float2(0, -1); + } + Aim(e, aim); + var burst = PointAlong(e, aim, NoLockRange); + var onAim = AddShip(e, burst, 1f, 300); + var targetBefore = e.Target.Hull.Durability; + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + var shot = Assert.Single(e.Zone.PendingShots); + Assert.Null(shot.Target); + Assert.Equal(burst.x, shot.BurstPosition.x, 3); + Assert.Equal(burst.z, shot.BurstPosition.z, 3); + e.Zone.Update(.01f); + + Assert.False(Alive(e, onAim), "the blast must land at max range along the aim"); + Assert.Equal(targetBefore, e.Target.Hull.Durability); + } + + // Ruling 3: a Contact round with no lock stops on the first hull its aim line crosses before max range; a + // Proximity or Delayed round keeps the max-range burst. The blocker's near face sits 21 units out (a 5x4-cell + // hull of 2-unit cells centred 25 out), the witness at max range (60). Kills: a Contact fuse that ignores + // hulls; a Proximity or Delayed fuse that trips on one. + [Theory] + [InlineData(WeaponFuse.Contact, true)] + [InlineData(WeaponFuse.Proximity, false)] + [InlineData(WeaponFuse.Delayed, false)] + public void OnlyAContactRoundWithNoLockStopsOnTheFirstHullItCrosses(WeaponFuse fuse, bool stopsOnTheBlocker) + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: fuse, blastRadius: 4f, penetration: 1f, damage: 100f, + weaponRange: NoLockRange); + e.Shooter.Target.Value = null; + Aim(e, float2(0, 1)); + var blocker = AddShip(e, PointAlong(e, float2(0, 1), 25f), 1f, 300); + var atMaxRange = AddShip(e, PointAlong(e, float2(0, 1), NoLockRange), 1f, 302); + ShotOutcome outcome = null; + using var c = e.Zone.ShotCommitted.Subscribe(o => outcome = o); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + + Assert.NotNull(outcome); + var expectedReach = stopsOnTheBlocker ? 21f : NoLockRange; + Assert.Equal(e.Shooter.Position.z + expectedReach, outcome.BurstPoint.y, 2); + Assert.Equal(e.Shooter.Position.x, outcome.BurstPoint.x, 2); + Assert.Equal(!stopsOnTheBlocker, Alive(e, blocker)); + Assert.Equal(stopsOnTheBlocker, Alive(e, atMaxRange)); + } + + // Ruling 3: a hull beyond max range is not on the round's way, and the shooter's own hull, which the line + // starts inside, is never a candidate. Here the target (100 out) lies on the line of a 60-range Contact + // round. Kills: a burst point beyond the max-range point; a Contact search that admits the shooter. + [Fact] + public void AContactRoundWithNoLockBurstsAtMaxRangeWhenNoHullIsInReach() + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Contact, blastRadius: 4f, damage: 100f, + weaponRange: NoLockRange); + e.Shooter.Target.Value = null; + Aim(e, float2(0, 1)); + var targetBefore = e.Target.Hull.Durability; + var shooterHit = 0f; + using var a = e.Shooter.ArmorDamage.Subscribe(x => shooterHit += x.damage); + using var h = e.Shooter.HullDamage.Subscribe(x => shooterHit += x); + using var i = e.Shooter.ItemDamage.Subscribe(x => shooterHit += x.damage); + ShotOutcome outcome = null; + using var c = e.Zone.ShotCommitted.Subscribe(o => outcome = o); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + + Assert.Equal(e.Shooter.Position.z + NoLockRange, outcome.BurstPoint.y, 2); + Assert.Equal(targetBefore, e.Target.Hull.Durability); + Assert.Equal(0f, shooterHit); + } + + // Ruling 3: a Contact round bursting short of max range arrives when it has flown that far -- 21 of 60 units + // at 20 units/s is 1.05 s into a 3 s flight -- and the commit says so through ArrivalIn. Kills: an arrival + // left at the max-range time (the round would still be pending at 1.2 s, and ArrivalIn would read ~3). + [Fact] + public void AContactBurstShortOfMaxRangeArrivesWhenTheRoundHasFlownThatFar() + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 20f, fuse: WeaponFuse.Contact, blastRadius: 4f, damage: 100f, + weaponRange: NoLockRange); + e.Shooter.Target.Value = null; + Aim(e, float2(0, 1)); + var blocker = AddShip(e, PointAlong(e, float2(0, 1), 25f), 1f, 300); + ShotOutcome outcome = null; + using var c = e.Zone.ShotCommitted.Subscribe(o => outcome = o); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + Assert.NotNull(outcome); + Assert.Equal(1.05f - .01f, outcome.ArrivalIn, 2); + Assert.True(Alive(e, blocker)); + + e.Zone.Update(1.1f); + Assert.Empty(e.Zone.PendingShots); + Assert.False(Alive(e, blocker)); + } + + // Ruling 4: a round outlives its shooter. A no-lock proximity round whose shooter leaves the zone in flight + // still detonates at max range, whether the shooter goes before or after the commit; a targeted proximity + // round does the same at its intercept, for the same damage a shooter who stayed would have dealt (the value + // measured for ATargetedFusedShotIsUnchangedByTheNoLockRule at b66ba524, 5.65884256). Kills: Step + // cancelling a shot whose source has left the zone. + [Theory] + [InlineData(0)] + [InlineData(1)] + public void ANoLockRoundStillBurstsAfterItsShooterLeavesTheZone(int updatesBeforeLeaving) + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 20f, fuse: WeaponFuse.Proximity, blastRadius: 4f, damage: 100f, + weaponRange: NoLockRange); + e.Shooter.Target.Value = null; + Aim(e, float2(0, 1)); + var witness = AddShip(e, PointAlong(e, float2(0, 1), NoLockRange), 1f, 300); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + for (var i = 0; i < updatesBeforeLeaving; i++) e.Zone.Update(.01f); + e.Zone.Entities.Remove(e.Shooter); + e.Zone.Update(4f); + + Assert.Empty(e.Zone.PendingShots); + Assert.False(Alive(e, witness), "the round must still burst at max range"); + } + + [Fact] + public void ATargetedFusedRoundStillBurstsAfterItsShooterLeavesTheZone() + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 20f, fuse: WeaponFuse.Proximity, blastRadius: 30f, damage: 100f); + var damage = 0f; + using var a = e.Target.ArmorDamage.Subscribe(x => damage += x.damage); + using var h = e.Target.HullDamage.Subscribe(x => damage += x); + using var i = e.Target.ItemDamage.Subscribe(x => damage += x.damage); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + e.Zone.Entities.Remove(e.Shooter); + e.Zone.Update(6f); + + Assert.Empty(e.Zone.PendingShots); + Assert.Equal(5.65884256f, damage, 3); + } + + // Ruling 4, direct hits: rounds fired at a target and in flight when their shooter leaves still hit it. A dozen + // shots each roll their own dice (seeded by shot id), and with a stationary target and a full-accuracy + // targeting system nearly all land; the test needs only that all resolve and some hit, and that each hit + // still names the departed shooter (identity carried, never asked anything). + [Fact] + public void ADirectRoundStillHitsAfterItsShooterLeavesTheZone() + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 20f, damage: 5f); + var resolved = new List(); + using var r = e.Zone.ShotResolved.Subscribe(o => resolved.Add(o)); + var struck = 0; + using var s = e.Target.IncomingHit.Subscribe(src => { if (src == e.Shooter) struck++; }); + + for (var i = 0; i < 12; i++) FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + e.Zone.Entities.Remove(e.Shooter); + e.Zone.Update(6f); + + Assert.Empty(e.Zone.PendingShots); + Assert.Equal(12, resolved.Count); + Assert.True(resolved.Any(o => o.Hit), "at least one round must land on the stationary target"); + Assert.Equal(resolved.Count(o => o.Hit), struck); + } + + // Ruling 6: what a commit came to is typed. A no-lock burst is neither a hit nor a miss, and reads as neither. + // Kills: MakeOutcome collapsing a burst back to a miss (the debug HUD read MISS for a real explosion). + [Fact] + public void ANoLockBurstCommitsAsABurstNotAMiss() + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: 4f, damage: 100f, + weaponRange: NoLockRange); + e.Shooter.Target.Value = null; + Aim(e, float2(2, 1)); + ShotOutcome committed = null, resolved = null; + using var c = e.Zone.ShotCommitted.Subscribe(o => committed = o); + using var r = e.Zone.ShotResolved.Subscribe(o => resolved = o); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + + Assert.NotNull(committed); + Assert.Equal(ShotResult.Burst, committed.Result); + Assert.False(committed.Hit); + var burst = PointAlong(e, normalize(float2(2, 1)), NoLockRange); + Assert.Equal(burst.x, committed.BurstPoint.x, 3); + Assert.Equal(burst.z, committed.BurstPoint.y, 3); + Assert.Same(committed, resolved); + } + // ToWorldPoint and ToSchematicPoint are inverses, and ToWorldPoint puts one cell to starboard of the centre // of mass two world units along the starboard axis (right = (forward.y, -forward.x), spelled out here). At // facing (0,1) a flipped right axis is still masked by a centre-lane point; (2,1) is not. @@ -1352,6 +1623,7 @@ public sealed class FireControlCut124Tests : IDisposable } Assert.NotNull(outcome); Assert.True(outcome.Hit); + Assert.Equal(ShotResult.Hit, outcome.Result); Assert.True(armorEvents > 0); var shot = e.Zone.PendingShots.Count == 0 ? null : (PendingShot?) e.Zone.PendingShots[0]; Assert.Null(shot); // resolved and removed -- confirms it went through Apply's null-fuse path to completion From 91ed4ef91b6674dc501bbfd571ab7b550bb70105 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 13:50:41 +0200 Subject: [PATCH 15/49] Assert on the shot target and outcome identity without formatting entities Assert.Null(shot.Target) and Assert.Same(committed, resolved) format the entity graph when they fail, which overflows the xunit stack and crashes the host instead of reporting a red. Use Assert.True with a message. Co-Authored-By: Claude Sonnet 5 --- tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs index d25718137..943475789 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs @@ -1299,7 +1299,7 @@ public sealed class FireControlCut124Tests : IDisposable FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); var shot = Assert.Single(e.Zone.PendingShots); - Assert.Null(shot.Target); + Assert.True(shot.Target == null, "an invalid target must not ride with the round"); // not Assert.Null: a failure would format the entity graph and overflow the stack Assert.Equal(burst.x, shot.BurstPosition.x, 3); Assert.Equal(burst.z, shot.BurstPosition.z, 3); e.Zone.Update(.01f); @@ -1478,7 +1478,7 @@ public sealed class FireControlCut124Tests : IDisposable var burst = PointAlong(e, normalize(float2(2, 1)), NoLockRange); Assert.Equal(burst.x, committed.BurstPoint.x, 3); Assert.Equal(burst.z, committed.BurstPoint.y, 3); - Assert.Same(committed, resolved); + Assert.True(ReferenceEquals(committed, resolved), "the resolution republishes the commit unchanged"); } // ToWorldPoint and ToSchematicPoint are inverses, and ToWorldPoint puts one cell to starboard of the centre From e7bb1e14f0e071e8265464491ddf789238bab4f9 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 14:02:42 +0200 Subject: [PATCH 16/49] Record the airburst intent ruling Co-Authored-By: Claude Opus 5.5 --- docs/fire-control-cut.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/fire-control-cut.md b/docs/fire-control-cut.md index 5028487ea..5c4f7794c 100644 --- a/docs/fire-control-cut.md +++ b/docs/fire-control-cut.md @@ -2631,6 +2631,13 @@ follow-up rulings, all given on 2026-09-30: visible, or not locked takes the no-lock path. Nothing bursts beyond max range. - **A no-lock contact fuse stops on the first hull on its aim line**, computed at commit. Mining adds chunks to this through a named seam. +- **Airburst follows player intent (operator, 2026-09-30):** "If I don't have targeting data but decide + to shoot anyway, my intent is likely to be bomb fishing. If I fire with a target but that target warps + away, I likely to not want to explode whatever was behind it, and the projectile shouldn't burst." + - A round fired without valid targeting data bursts at max range. + - A round fired at a valid target that later leaves or becomes invalid does not burst. It resolves + with no detonation. + - Validity is judged on the targeting data at fire time. - **A round outlives its shooter,** for targeted and no-lock shots alike. Credit and loot with no shooter go nowhere. From 640fa96787a5fea3ad1dcb7e7a3cdfc7d7c2e700 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 16:27:46 +0200 Subject: [PATCH 17/49] Fused rounds fire out of arc; safe fuse; proximity misses type as Burst Operator ruling 2026-09-30: a fused weapon with a selected but out-of-arc target fires. Its round flies along the arc-clamped aim and bursts at the target's range. Non-fused weapons still refuse. - FireControl.ArcPermitsFire is the one trigger arc gate (fused weapons are exempt); Fire reads the same InArc through PFire's new designated/inArc outs, which replace `bears`. No valid targeting data still bursts at Range. - F3: a fused weapon whose effective Range does not exceed its BlastRadius stays safed at the trigger (FuseCanArm, read from the runtime Range). The catalog check on Range.Min is deleted; it read the authored floor, which Evaluate can scale below. - F4: a targeted proximity burst is pulled back along its line to Range. - F8: a proximity round that detonates commits as Burst (or Hit); Apply's Proximity case collapses into the Burst path via Outcome.BurstPoint. - Tests: arc-only and LockWeapon gates, out-of-arc burst, trigger gate, runtime-Range refusal, Range clamp, Burst typing, commit horizon, and Soul's adopted probes (aim clamp edges, contact point, target leaves, shooter destroyed). Co-Authored-By: Claude Sonnet 5 --- Assets/Scripts/ServerShared/AetheriaStores.cs | 3 +- .../ServerShared/Behaviors/ConstantWeapon.cs | 4 +- .../ServerShared/Behaviors/InstantWeapon.cs | 2 + .../Scripts/ServerShared/Behaviors/Weapon.cs | 15 +- Assets/Scripts/ServerShared/FireControl.cs | 123 ++-- Assets/Scripts/ServerShared/ItemData.cs | 15 - .../AetheriaStoresTests.cs | 54 -- .../FireControlCut124Tests.cs | 534 +++++++++++++++++- 8 files changed, 607 insertions(+), 143 deletions(-) diff --git a/Assets/Scripts/ServerShared/AetheriaStores.cs b/Assets/Scripts/ServerShared/AetheriaStores.cs index b16a33af8..bee8a4c9f 100644 --- a/Assets/Scripts/ServerShared/AetheriaStores.cs +++ b/Assets/Scripts/ServerShared/AetheriaStores.cs @@ -30,7 +30,7 @@ public static class AetheriaStores if (missing != null) throw new InvalidOperationException($"Catalog {catalogPath} has no {missing.SchemaName} record; catalog globals are authored, never invented."); // Every catalog document is held to the one check Upsert applies on write: R-heat (docs/stats-and-power- - // cut.md), stat modifiers, role usage (Cut 7), and the fuse range rule. Fails loudly, naming the item. + // cut.md), stat modifiers, role usage (Cut 7). Fails loudly, naming the item. foreach (var data in cache.GetAll()) CultRecordRefs.Validate(data); foreach (var data in cache.GetAll()) CultRecordRefs.Validate(data); return cache; @@ -73,7 +73,6 @@ public static class CultRecordRefs StatValidation.ValidateHeatResponse(data); StatValidation.ValidateStatModifiers(data.Name, data.Behaviors); StatValidation.ValidateRoleUsage(data.Name, data.Roles, data.Behaviors); - StatValidation.ValidateFuse(data); } if (document is ConsumableItemData consumable) { diff --git a/Assets/Scripts/ServerShared/Behaviors/ConstantWeapon.cs b/Assets/Scripts/ServerShared/Behaviors/ConstantWeapon.cs index 37da4fe06..8a93c2a82 100644 --- a/Assets/Scripts/ServerShared/Behaviors/ConstantWeapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/ConstantWeapon.cs @@ -91,7 +91,7 @@ public class ConstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerC // Cut 5 (docs/fire-control-cut.md, 5.3, Soul finding 9): StanceAllowsFire alone let a side-mounted beam // fire forward -- ArcAllowsFire joins it here, matching InstantWeapon.cs's player arc gate (Q2: manual and // programmatic are one truth, and a continuous weapon is no exception). - public float PowerRequest(float dt) => _firing && StanceAllowsFire && ArcAllowsFire ? EvaluateNominalPower(_data.Energy) * dt : 0f; + public float PowerRequest(float dt) => _firing && StanceAllowsFire && ArcAllowsFire && FuseAllowsFire ? EvaluateNominalPower(_data.Energy) * dt : 0f; // Cut 5 (docs/stats-and-power-cut.md §1.3, PowerTiers.cs): Low -- offense, same as InstantWeapon. public int DefaultPowerTier => PowerTiers.Low; @@ -99,7 +99,7 @@ public class ConstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerC public override bool Execute(float dt) { base.Execute(dt); - if (_firing && !(StanceAllowsFire && ArcAllowsFire)) + if (_firing && !(StanceAllowsFire && ArcAllowsFire && FuseAllowsFire)) { // Safed: shooter has a target and hasn't declared hostility toward it, or the target no longer // bears (5.3: a beam obeys its arc exactly as InstantWeapon's trigger does). diff --git a/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs b/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs index 6b9b14fc8..1cf398539 100644 --- a/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs @@ -103,6 +103,8 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo // through -- a player's action-bar Activate() reaches this exactly the same way Combat.cs's and // TurretController.cs's Activate() calls do. if (!ArcAllowsFire) return; + // A fused weapon whose Range no longer clears its blast would burst on its shooter. + if (!FuseAllowsFire) return; // If 1 ammo is consumed per burst, perform ammo and energy consumption here // UseAmmo returns false when triggering reload; cancel firing if that is the case diff --git a/Assets/Scripts/ServerShared/Behaviors/Weapon.cs b/Assets/Scripts/ServerShared/Behaviors/Weapon.cs index 350d63500..57340f8d9 100644 --- a/Assets/Scripts/ServerShared/Behaviors/Weapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/Weapon.cs @@ -107,16 +107,11 @@ public abstract class Weapon : Behavior, IActivatedBehavior // gate, not the gate itself. With no target set, behaviour is unchanged (nothing to bear on). // Cut 5, 5.4 (Soul finding 11): the point-blank special case this used to carry moved into InArc itself -- // one bearing test, one owner, so every caller (not just this one) gets a point-blank shot that bears. - public bool ArcAllowsFire - { - get - { - var target = Entity.Target.Value; - if (target == null) return true; - var toTarget = target.Position - Entity.Position; - return FireControl.InArc(Item, toTarget); - } - } + // FireControl owns the gate, including its one exemption: a fused weapon fires out of arc. + public bool ArcAllowsFire => FireControl.ArcPermitsFire(this, Entity); + + // A fused weapon whose current Range does not exceed its blast stays safed (FireControl.FuseCanArm). + public bool FuseAllowsFire => FireControl.FuseCanArm(this); public Weapon(WeaponData data, EquippedItem item) : base(data, item) { diff --git a/Assets/Scripts/ServerShared/FireControl.cs b/Assets/Scripts/ServerShared/FireControl.cs index 3d08172d8..51fab98af 100644 --- a/Assets/Scripts/ServerShared/FireControl.cs +++ b/Assets/Scripts/ServerShared/FireControl.cs @@ -57,6 +57,33 @@ public static class FireControl return dot(MountDirection(weapon), planarTarget) >= cos(radians(arc / 2f)); } + // The one place a weapon's payload is read: its blast radius, and the fuse it detonates with. A weapon + // detonates only with both a fuse and a radius above zero; either alone is inert and resolves as a direct + // hit (nothing polices that), so a null return means "not fused". + public static WeaponFuse? FuseOf(EquippedItem item, out float blastRadius) + { + var data = item?.Data as WeaponItemData; + blastRadius = data?.BlastRadius > 0f ? data.BlastRadius.Value : 0f; + return blastRadius > 0f ? data.Fuse : null; + } + + // The one arc gate on the trigger. A fused weapon fires whatever the arc says: a target it cannot bear on is + // no reason to keep it silent, the round flies along the arc-clamped aim and bursts at the target's range + // (Fire; operator ruling 2026-09-30, "the user is hoping that the arc gets the weapon close enough to still + // splash some damage"). A weapon that only hits what it fires at still refuses. Fire reads InArc itself to + // choose that flight, so the two share the one bearing test and differ only in this exemption. + public static bool ArcPermitsFire(Weapon weapon, Entity shooter) + { + var target = shooter.Target.Value; + if (target == null) return true; + return FuseOf(weapon.Item, out _) != null || InArc(weapon.Item, target.Position - shooter.Position); + } + + // A fused weapon whose Range, as it stands now (power supply and modifiers included), does not exceed its + // blast would burst on its own shooter at max range, so its trigger stays safed. Judged on the effective + // Range rather than the catalog's authored floor, which Evaluate can scale below. + public static bool FuseCanArm(Weapon weapon) => FuseOf(weapon.Item, out var blastRadius) == null || weapon.Range > blastRadius; + // Operator ruling 2026-09-30: a shot with nothing to bear on flies along where its shooter is aiming // (Entity.LookDirection -- the player's mouse or a turret's tracking), restricted by the mount's arc. The // arc is InArc's own cone: an aim inside it is used as is, one outside it is turned to the nearest edge of @@ -172,23 +199,26 @@ public static class FireControl // freezes (R10), gated exactly as HitProbability gates. Nothing about the target's facing or silhouette // enters here; that is Silhouette's and PSpread's job, priced fresh by HitProbability below and again, // live, by Commit at the commit tick. 0 the moment any gate closes. - private static float PFire(Weapon weapon, Entity source, Entity target) => PFire(weapon, source, target, out _, out _); + private static float PFire(Weapon weapon, Entity source, Entity target) => PFire(weapon, source, target, out _, out _, out _); // The `out range` overload is HitProbability's own gate call -- one computation of `target.Position - // source.Position`, not two. HitProbability used to recompute its own copy for PSpread, which is exactly // the kind of split authority Cut 3 named as the risk (two places computing the same vector, free to // drift or to have their subtraction order flipped in only one of them). private static float PFire(Weapon weapon, Entity source, Entity target, out float range) => - PFire(weapon, source, target, out range, out _); + PFire(weapon, source, target, out range, out _, out _); - // `bears` is the one answer to "is this target one the weapon can fire at": in range, visible, locked (for a - // LockWeapon) and in arc. PFire prices a shot at zero when it is false; Fire reads it to decide that a - // fused round has no target at all (operator ruling 2026-09-30: an invalid target counts as no lock). - // Inspect recomputes the four gates one by one, to show the HUD which is closed; it decides nothing. - private static float PFire(Weapon weapon, Entity source, Entity target, out float range, out bool bears) + // `designated` is the one answer to "does the shooter hold valid targeting data on this target": visible, in + // range and locked (for a LockWeapon). `inArc` is the bearing test, asked only once a target is designated. + // PFire prices a shot at zero unless both hold. Fire reads them to decide what a fused round flies at + // (operator rulings 2026-09-30): without valid data it has no target and bursts at max range; with data but + // out of arc it bursts at the target's range, along the aim. Inspect recomputes the four gates one by one, + // to show the HUD which is closed; it decides nothing. + private static float PFire(Weapon weapon, Entity source, Entity target, out float range, out bool designated, out bool inArc) { range = 0f; - bears = false; + designated = false; + inArc = false; if (target == null) return 0f; // F8 correction (Soul's fix batch, 2026-09-24): range is computed for any non-null target, before any @@ -201,8 +231,9 @@ public static class FireControl if (!source.VisibleEntities.Contains(target)) return 0f; if (range < weapon.MinRange || range > weapon.Range) return 0f; if (weapon is LockWeapon lockWeapon && !lockWeapon.IsLocked) return 0f; - if (!InArc(weapon.Item, toTarget)) return 0f; - bears = true; + designated = true; + inArc = InArc(weapon.Item, toTarget); + if (!inArc) return 0f; var settings = source.ItemManager.GameplaySettings; var info = source.EntityInfoGathered.TryGetValue(target, out var gathered) ? gathered : 0f; @@ -393,7 +424,7 @@ public static class FireControl // F8 (Soul's fix batch, 2026-09-24): the `out range` overload is the one range HitProbability now // reads too -- Fire used to recompute its own second copy of `target.Position - source.Position` for // fireRange, right beside the copy PFire's gate already computed. - var pFire = PFire(weapon, source, target, out var fireRange, out var bears); + var pFire = PFire(weapon, source, target, out var fireRange, out var designated, out var inArc); // Cut 12.4(a) (Q12-6, "WeaponModifiers are labels, not behaviour"): a blast is a property of the // weapon's data fields alone, resolved and frozen here like every other payload field -- never decided @@ -401,17 +432,18 @@ public static class FireControl // only when BlastRadius > 0, and null otherwise, so a radius without a fuse or a fuse without a radius // is inert and resolves as a direct hit (nothing polices that). A zero-radius freeze is what Step below // still reads as "resolve this shot with Apply." - var weaponItemData = item.Data as WeaponItemData; - var blastRadius = weaponItemData?.BlastRadius > 0f ? weaponItemData.BlastRadius.Value : 0f; - var fuse = blastRadius > 0f ? weaponItemData.Fuse : null; + var fuse = FuseOf(item, out var blastRadius); - // Operator rulings 2026-09-30: an invalid target counts as no lock -- a fused round whose selected - // target is out of range, out of arc, not visible or not locked (PFire's `bears`) is fired at nothing. - // A fused shot with no target bursts where its own aim line ends: the shooter's aim (AimDirection) at - // the weapon's Range stat, the same stat PFire's range gate reads, flown at the weapon's Velocity like - // a targeted shot flies to its intercept. The round carries no target from here on, so nothing at - // resolution asks whether one is still around. A shot without a fuse keeps its target as before. - var engaged = fuse != null && !bears ? null : target; + // Operator rulings 2026-09-30. A fused round flies along the shooter's aim (AimDirection, clamped to the + // mount's arc) and carries no target -- so nothing at resolution asks whether one is still around -- + // unless its target is designated (visible, in range, locked) and in arc: + // * no valid targeting data (no target, or one out of range, not visible or not locked) counts as no + // lock and bursts at the weapon's Range, the stat PFire's range gate reads: bomb fishing; + // * valid data but out of arc bursts at the target's range, which the range gate has already held to + // Range, along that same clamped aim: the arc is hoped to land the blast near enough to splash. + // Either way the round flies at the weapon's Velocity like a targeted shot flies to its intercept. A shot + // without a fuse keeps its target as before. + var engaged = fuse != null && !(designated && inArc) ? null : target; var origin = source.Position; var travelDirection = engaged != null ? TravelDirection(weapon, source, engaged) : AimDirection(item, source); @@ -427,11 +459,20 @@ public static class FireControl var commitHorizon = source.ItemManager.GameplaySettings.CommitHorizon; - var burstPosition = blastRadius > 0f && engaged != null ? PredictedIntercept(weapon, source, engaged) : targetPosition; - if (fuse != null && engaged == null) + var burstPosition = targetPosition; + if (fuse != null && engaged != null) { - burstPosition = origin + float3(travelDirection.x, 0, travelDirection.y) * weapon.Range; - flightTime = weapon.Velocity > .01f ? weapon.Range / weapon.Velocity : 0f; + // Nothing bursts beyond max range: an intercept a receding target leads the round past Range is + // pulled back along its own line to Range. + var intercept = PredictedIntercept(weapon, source, engaged); + var reach = length((intercept - origin).xz); + burstPosition = reach > weapon.Range ? origin + (intercept - origin) * (weapon.Range / reach) : intercept; + } + else if (fuse != null) + { + var reach = designated ? fireRange : weapon.Range; + burstPosition = origin + float3(travelDirection.x, 0, travelDirection.y) * reach; + flightTime = weapon.Velocity > .01f ? reach / weapon.Velocity : 0f; } var shot = new PendingShot @@ -649,7 +690,14 @@ public static class FireControl } } - return MakeOutcome(shot, hit ? ShotResult.Hit : ShotResult.Miss, shielded, shieldBroken, cell, bearing, lateral, now); + // A proximity round detonates at its burst point whether or not the roll hit, so a roll that missed is a + // Burst, not a Miss: the outcome types what happened, and the HUD must not call a real explosion a miss. A + // contact or delayed round detonates only on a hit, so its miss stays a Miss. + var proximity = shot.Fuse == WeaponFuse.Proximity; + var outcome = MakeOutcome(shot, hit ? ShotResult.Hit : proximity ? ShotResult.Burst : ShotResult.Miss, + shielded, shieldBroken, cell, bearing, lateral, now); + if (proximity) outcome.BurstPoint = shot.BurstPosition.xz; + return outcome; } // Operator rulings 2026-09-30. A fused round with no target bursts at the max-range point Fire froze along @@ -770,9 +818,10 @@ public static class FireControl // centre lane (see the fuse-point comment on the Contact/Delayed branch below). private static void Apply(Zone zone, PendingShot shot) { - // A fused shot with no target has nothing to hit or miss: its committed outcome is a Burst, and it - // detonates at the point Commit decided (Burst outcome's BurstPoint). Detonate is still the one blast path. - if (shot.Outcome.Result == ShotResult.Burst) + // A Burst outcome detonates at the point Commit decided (its BurstPoint): a fused round with no target, + // which has nothing to hit or miss, and a proximity round, which detonates whether its roll hit or not. + // Detonate is still the one blast path. + if (shot.Outcome.Result == ShotResult.Burst || shot.Fuse == WeaponFuse.Proximity) { Detonate(zone, shot.Outcome.BurstPoint, shot.BlastRadius, shot.Damage, shot.DamageType); return; @@ -783,11 +832,6 @@ public static class FireControl case null: ApplyDirectHit(shot); return; - case WeaponFuse.Proximity: - // Detonates at shot.BurstPosition.xz, which PredictedIntercept froze at Fire (today's airburst - // rule, unchanged) -- no roll, and no gate on shot.Outcome.Hit. - Detonate(zone, shot.BurstPosition.xz, shot.BlastRadius, shot.Damage, shot.DamageType); - return; case WeaponFuse.Contact: case WeaponFuse.Delayed: ApplyBlastHit(shot); @@ -1737,10 +1781,12 @@ public sealed class ShotOutcome public Entity Target; public EquippedItem Weapon; // What the commit decided, typed: a shot rolled and missed, rolled and hit, or was a fused round with no - // target and so burst without rolling (Burst, at BurstPoint). Hit is derived from it, not stored beside it. + // target and so burst without rolling, or a proximity round whose roll missed but which detonates anyway + // (Burst, at BurstPoint). Hit is derived from it, not stored beside it. public ShotResult Result; public bool Hit => Result == ShotResult.Hit; - // World planar point a Burst detonates at, frozen by the commit. Meaningless for any other Result. + // World planar point a proximity or no-target round detonates at, frozen by the commit. Meaningless for any + // other round. public float2 BurstPoint; public bool Shielded; // Cut 5, 5.2 (Soul finding 4): frozen alongside the rest of the outcome (R4) -- a shield present, active, @@ -1757,8 +1803,9 @@ public sealed class ShotOutcome public DamageType DamageType; } -// What a committed shot came to. A Burst is a fused round that had no valid target: it rolled nothing, so it is -// neither a hit nor a miss, and the presentation must not call it one. +// What a committed shot came to. A Burst is a detonation that is not a hit on a target: a fused round that had no +// valid target and rolled nothing, or a proximity round whose roll missed but which bursts anyway. It is neither +// a hit nor a miss, and the presentation must not call it one. public enum ShotResult { Miss, diff --git a/Assets/Scripts/ServerShared/ItemData.cs b/Assets/Scripts/ServerShared/ItemData.cs index 40e2beb83..59d93c1ba 100644 --- a/Assets/Scripts/ServerShared/ItemData.cs +++ b/Assets/Scripts/ServerShared/ItemData.cs @@ -752,21 +752,6 @@ public static class StatValidation "not exceed them"); } - // Operator ruling 2026-09-30 (Soul): a round with a blast that could burst on its own shooter is refused at - // the catalog rather than policed at runtime. A weapon that detonates (a fuse and a blast radius above zero, - // the two FireControl.Fire freezes together) must reach farther than its blast, at the weakest value its Range - // stat can take: a no-lock round bursts at Range, so Range <= BlastRadius would put the shooter inside the - // burst, and Range <= 0 is a burst on the muzzle. A fuse without a radius is inert and needs no range. - public static void ValidateFuse(EquippableItemData data) - { - if (!(data is WeaponItemData weapon) || weapon.Fuse == null || !(weapon.BlastRadius > 0f)) return; - foreach (var behavior in weapon.Behaviors) - if (behavior is WeaponData weaponData && weaponData.Range.Min <= weapon.BlastRadius.Value) - throw new InvalidOperationException( - $"{weapon.Name}: fused weapon's Range {weaponData.Range.Min} does not exceed its BlastRadius " + - $"{weapon.BlastRadius.Value} -- a burst at max range would land on the shooter"); - } - // Cut 2 (docs/stats-and-power-cut.md): a StatReference names its target by (type name, field name) so a // modifier can point at any design or behaviour without a hard type reference -- but before this cut an // unresolvable reference failed silently: StatModifier.Initialize left `_stats` null and the first diff --git a/tests/Aetheria.Shared.Tests/AetheriaStoresTests.cs b/tests/Aetheria.Shared.Tests/AetheriaStoresTests.cs index e23953192..e0fec193d 100644 --- a/tests/Aetheria.Shared.Tests/AetheriaStoresTests.cs +++ b/tests/Aetheria.Shared.Tests/AetheriaStoresTests.cs @@ -190,60 +190,6 @@ public sealed class AetheriaStoresTests : IDisposable Assert.Null(record); } - // Operator ruling 2026-09-30 (Soul): a weapon that detonates must reach farther than its blast, at the - // weakest value its Range stat can take, or a max-range burst lands on the shooter. Refused where every - // catalog write and every open already checks, not policed at runtime. - private static WeaponItemData FusedWeapon(string name, float rangeMin, float rangeMax, float? blastRadius, WeaponFuse? fuse) => new WeaponItemData - { - Name = name, Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1, - MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, - Fuse = fuse, BlastRadius = blastRadius, - Behaviors = { new InstantWeaponData { Range = new PerformanceStat { Min = rangeMin, Max = rangeMax } } } - }; - - // Kills: a comparison that admits equality (Range == BlastRadius bursts on the shooter's own edge); reading - // Range.Max instead of Min (the second row: Max 100 clears the blast, the weakest value does not); a - // zero range with a positive blast. - [Theory] - [InlineData(3f, 3f, 3f)] - [InlineData(2f, 2f, 3f)] - [InlineData(2f, 100f, 3f)] - [InlineData(0f, 0f, .5f)] - public void ValidateRefusesAFusedWeaponWhoseRangeDoesNotExceedItsBlast(float rangeMin, float rangeMax, float radius) - { - var error = Assert.Throws(() => - CultRecordRefs.Validate(FusedWeapon("ShortFuse", rangeMin, rangeMax, radius, WeaponFuse.Proximity))); - Assert.Contains("ShortFuse", error.Message); - } - - // A fuse without a radius, a radius without a fuse, and a range that clears the blast are all fine: only a - // weapon that will actually detonate is held to the rule. - [Theory] - [InlineData(1f, 1f, null, WeaponFuse.Contact)] - [InlineData(1f, 1f, 0f, WeaponFuse.Contact)] - [InlineData(1f, 1f, 30f, null)] - [InlineData(60f, 60f, 4f, WeaponFuse.Delayed)] - public void ValidateAcceptsAWeaponThatCannotBurstOnItsShooter(float rangeMin, float rangeMax, float? radius, WeaponFuse? fuse) - { - var record = Record.Exception(() => CultRecordRefs.Validate(FusedWeapon("Fine", rangeMin, rangeMax, radius, fuse))); - Assert.Null(record); - } - - // Open holds a catalog already on disk to the same rule Upsert applies on write. The bad record is written - // past Upsert, straight through the cache, the way a hand-edited or migrated catalog would carry one. - [Fact] - public void OpenRefusesAFusedWeaponWhoseRangeDoesNotExceedItsBlast() - { - using (var cache = AetheriaStores.Open(Catalog, catalogWritable: true)) - { - cache.UpsertAsync(FusedWeapon("OnDisk", 2f, 2f, 3f, WeaponFuse.Contact)).Wait(); - cache.FlushAsync().Wait(); - } - - var error = Assert.Throws(() => AetheriaStores.Open(Catalog)); - Assert.Contains("OnDisk", error.Message); - } - // A document type Validate has no opinion about (neither EquippableItemData nor ConsumableItemData) must be // a pure no-op, not a crash -- the three tools' change lists carry `object`, and not every migration touches // an equippable design (e.g. a future repair over FactionProductData). diff --git a/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs index 943475789..3c8f38db3 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs @@ -83,6 +83,32 @@ public sealed class FireControlCut124Tests : IDisposable public Dictionary Custom = new Dictionary(); } + // The gun's behaviour data: a plain InstantWeaponData, or a LockWeaponData whose lock either grows to full + // (LockAngle 180 admits any bearing) or never grows (LockAngle 0 admits none, so it only decays). A stat + // term on Range lets a test scale it below its authored value through the power supply. + private static InstantWeaponData WeaponBehavior(float damage, float range, StatSource? rangeTerm, float velocity, float penetration, + bool lockWeapon, bool lockAcquires) + { + var data = lockWeapon ? new LockWeaponData + { + LockSpeed = Constant(1000), SensorImpact = Constant(1), LockAngle = Constant(lockAcquires ? 180 : 0), + DirectionImpact = Constant(1), Decay = Constant(1) + } : new InstantWeaponData(); + data.Damage = Constant(damage); + data.Range = rangeTerm == null ? Constant(range) + : new PerformanceStat { Min = range, Max = range, Terms = { new StatTerm { Source = rangeTerm.Value, Exponent = 1f } } }; + data.MinRange = Constant(0); + data.Velocity = Constant(velocity); + data.Spread = Constant(0); + data.DamageSpread = Constant(0); + data.Penetration = Constant(penetration); + data.Count = Constant(1); + data.BurstTime = Constant(0); + data.Cooldown = Constant(1000); + data.DamageCurve = new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 1, 0, 0) } }; + return data; + } + private Engagement Build( GameplaySettings settings, Shape hullShape, float2? targetFacing = null, float targetRange = 100f, float velocity = 0f, @@ -91,7 +117,8 @@ public sealed class FireControlCut124Tests : IDisposable bool equipShield = false, float shieldCapacity = 1000f, bool shieldActive = true, int2? cockpitCell = null, float cockpitDurability = 1000f, (string Name, Shape Shape, int2 Cell, float Durability)[] custom = null, - float accuracy = 1f, int2? gunCell = null, float weaponRange = 100000f) + float accuracy = 1f, int2? gunCell = null, float weaponRange = 100000f, + float hardpointArc = 0f, StatSource? rangeTerm = null, bool lockWeapon = false, bool lockAcquires = true) { var hullData = new HullData { @@ -101,7 +128,7 @@ public sealed class FireControlCut124Tests : IDisposable var shooterHullData = new HullData { Name = "ShooterHull", HullType = HullType.Ship, Shape = SolidShape(5, 5), Durability = 1000000, Mass = 1000, - Hardpoints = { new HardpointData { Type = HardpointType.Sensors, Position = new int2(0, 0), Shape = new Shape() } } + Hardpoints = { new HardpointData { Type = HardpointType.Sensors, Position = new int2(0, 0), Shape = new Shape(), FiringArc = hardpointArc } } }; var cache = AetheriaStores.Open(Catalog + Guid.NewGuid().ToString("N"), catalogWritable: true); @@ -114,13 +141,7 @@ public sealed class FireControlCut124Tests : IDisposable Name = "Gun", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1, MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, Fuse = fuse, BlastRadius = blastRadius, WeaponModifiers = modifiers, - Behaviors = { new InstantWeaponData - { - Damage = Constant(damage), Range = Constant(weaponRange), MinRange = Constant(0), - Velocity = Constant(velocity), Spread = Constant(0), DamageSpread = Constant(0), - Penetration = Constant(penetration), Count = Constant(1), BurstTime = Constant(0), Cooldown = Constant(1000), - DamageCurve = new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 1, 0, 0) } } - } } + Behaviors = { WeaponBehavior(damage, weaponRange, rangeTerm, velocity, penetration, lockWeapon, lockAcquires) } }); cache.Upsert(new GearData { @@ -1270,28 +1291,25 @@ public sealed class FireControlCut124Tests : IDisposable Assert.False(Alive(e, onMount), "with nothing aimed the blast follows the mount"); } - public enum Invalid { OutOfRange, NotVisible, OutOfArc } + public enum Invalid { OutOfRange, NotVisible, Unlocked } - // Ruling 2: a selected target that is out of range, not visible or out of arc counts as no lock -- the round - // bursts at max range along the aim, and the target is left alone. The aim is (2,1) except when the target is - // behind the mount, where the mount is flipped and the aim follows it. Kills: reading "no lock" as - // Target == null alone (the round would burst at the target's intercept, 100 / 40 / 100 units out). - // (An unlocked LockWeapon shares the same predicate; the fixture has no LockWeapon.) + // Ruling 2: a selected target that is out of range, not visible or not locked is not valid targeting data, + // and counts as no lock -- the round bursts at max range along the aim, and the target is left alone. Kills: + // reading "no lock" as Target == null alone (the round would burst at the target's intercept, 100 / 40 / 40 + // units out); dropping the range, visibility or lock gate from PFire's `designated`. The lock row needs a + // LockWeapon whose lock never grows; its control, below, is the same gun with the lock acquired. (A closed + // arc is not an invalid target: see AFusedShotAtATargetOutsideTheArc...) [Theory] [InlineData(Invalid.OutOfRange)] [InlineData(Invalid.NotVisible)] - [InlineData(Invalid.OutOfArc)] + [InlineData(Invalid.Unlocked)] public void AnInvalidTargetCountsAsNoLock(Invalid reason) { var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: 4f, damage: 100f, - weaponRange: NoLockRange, targetRange: reason == Invalid.NotVisible ? 40f : 100f); + weaponRange: NoLockRange, targetRange: reason == Invalid.OutOfRange ? 100f : 40f, + lockWeapon: reason == Invalid.Unlocked, lockAcquires: false); var aim = normalize(float2(2, 1)); if (reason == Invalid.NotVisible) e.Shooter.VisibleEntities.Remove(e.Target); - if (reason == Invalid.OutOfArc) - { - e.Shooter.Direction = float2(0, -1); - aim = float2(0, -1); - } Aim(e, aim); var burst = PointAlong(e, aim, NoLockRange); var onAim = AddShip(e, burst, 1f, 300); @@ -1308,6 +1326,228 @@ public sealed class FireControlCut124Tests : IDisposable Assert.Equal(targetBefore, e.Target.Hull.Durability); } + // The lock row's control: the same LockWeapon once its lock has grown carries its target to the intercept. + // Kills: a fixture whose lock could never grow (which would make the unlocked row above pass for nothing). + [Fact] + public void ALockedTargetRidesWithTheRound() + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: 4f, damage: 100f, + weaponRange: NoLockRange, targetRange: 40f, lockWeapon: true, lockAcquires: true); + Aim(e, float2(0, 1)); + e.Zone.Update(.1f); + Assert.True(((LockWeapon) e.Weapon).IsLocked, "fixture: the lock must have grown"); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + + var shot = Assert.Single(e.Zone.PendingShots); + Assert.True(shot.Target == e.Target, "a locked, visible, in-range, in-arc target rides with the round"); + Assert.Equal(e.Target.Position.z, shot.BurstPosition.z, 3); + } + + // The out-of-arc ruling (operator, 2026-09-30): a fused round at a target it holds valid data on but cannot + // bear on flies along the aim clamped to the mount's arc and bursts at the TARGET'S range, not at max range. + // The mount is flipped to (0,-1) so the target 40 ahead of the shooter is 180 degrees off it, in range, + // visible and unlocked by nothing: the arc is the only closed gate. The aim (1,1) is 135 degrees off the + // mount, so it clamps to the 85-degree edge on its own side. Three witnesses: on the edge at 40 (dies), on + // the edge at max range 60 (lives: the burst is at target range), under the unclamped aim at 40 (lives: the + // clamp holds). Kills: dropping `inArc` from PFire (the target would ride and burst at 40 along the target + // line); bursting at Range instead of the target's range; skipping the clamp. Contact is left out: its round + // stops on the first hull it meets, and these witnesses are hulls. + [Theory] + [InlineData(WeaponFuse.Proximity)] + [InlineData(WeaponFuse.Delayed)] + public void AFusedShotAtATargetOutsideTheArcBurstsAtTheTargetsRangeAlongTheClampedAim(WeaponFuse fuse) + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: fuse, blastRadius: 4f, penetration: 1f, damage: 100f, + weaponRange: NoLockRange, targetRange: 40f); + e.Shooter.Direction = float2(0, -1); + var look = normalize(float2(1, 1)); + Aim(e, look); + var mount = float2(0, -1); + var edge = float2(mount.x * cos(radians(85f)) - mount.y * sin(radians(85f)), mount.x * sin(radians(85f)) + mount.y * cos(radians(85f))); + var atTargetRange = AddShip(e, PointAlong(e, edge, 40f), 1f, 300); + var atMaxRange = AddShip(e, PointAlong(e, edge, NoLockRange), 1f, 302); + var underAim = AddShip(e, PointAlong(e, look, 40f), 1f, 304); + var targetBefore = e.Target.Hull.Durability; + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + var shot = Assert.Single(e.Zone.PendingShots); + Assert.True(shot.Target == null, "a target the weapon cannot bear on must not ride with the round"); + var burst = PointAlong(e, edge, 40f); + Assert.Equal(burst.x, shot.BurstPosition.x, 3); + Assert.Equal(burst.z, shot.BurstPosition.z, 3); + e.Zone.Update(.01f); + + Assert.False(Alive(e, atTargetRange), "the blast must land at the target's range along the clamped aim"); + Assert.True(Alive(e, atMaxRange), "the round bursts at the target's range, not at max range"); + Assert.True(Alive(e, underAim), "the aim is clamped to the arc's edge"); + Assert.Equal(targetBefore, e.Target.Hull.Durability); + } + + // The trigger and Fire share one bearing test (InArc) and differ only in the fused weapon's exemption, so + // this pins the pair at the layer a player uses: Activate. With the target out of arc and the weapon + // otherwise clear, a fused weapon fires a round; the same weapon without a fuse stays silent. Kills: + // removing the exemption from FireControl.ArcPermitsFire (the fused row goes silent); removing the arc gate + // itself (the plain row fires); widening the exemption to a weapon with a blast radius but no fuse. + [Theory] + [InlineData(WeaponFuse.Proximity, true)] + [InlineData(null, false)] + public void OnlyAFusedWeaponFiresAtATargetOutsideItsArc(WeaponFuse? fuse, bool fires) + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: fuse, blastRadius: 4f, damage: 100f, + weaponRange: NoLockRange, targetRange: 40f); + e.Shooter.Direction = float2(0, -1); + Aim(e, float2(0, -1)); + Assert.Equal(fires, e.Weapon.ArcAllowsFire); + var committed = 0; + using var c = e.Zone.ShotCommitted.Subscribe(_ => committed++); + + e.Weapon.Activate(); + for (var i = 0; i < 5; i++) e.Zone.Update(.01f); + + Assert.Equal(fires ? 1 : 0, committed); + } + + // A fused weapon safes itself while its effective Range does not exceed its blast, because a round bursting at + // max range would land on its own shooter. The gate reads the Range the weapon has NOW: the third row is + // Soul's probe (authored Range 100 clears a blast of 30, but at a power supply of 0.1 Range is 10), and the + // first two pin the boundary itself (equal refuses). Kills: reading the authored figure instead of the + // runtime Range; `>=` for `>`; dropping the gate from InstantWeapon.Trigger. + [Theory] + [InlineData(30f, 30f, false, false)] + [InlineData(31f, 30f, false, true)] + [InlineData(100f, 30f, true, false)] + public void AFusedWeaponWhoseRangeDoesNotExceedItsBlastStaysSafed(float range, float blast, bool starvedOfPower, bool fires) + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: blast, damage: 100f, + weaponRange: range, rangeTerm: starvedOfPower ? StatSource.PowerSupply : (StatSource?) null); + e.Shooter.Target.Value = null; + Aim(e, float2(0, 1)); + if (starvedOfPower) + { + typeof(EquippedItem).GetProperty("PowerSupply").GetSetMethod(true).Invoke(e.WeaponItem, new object[] { .1f }); + e.Shooter.Resolver.InvalidateSource(e.WeaponItem, StatSource.PowerSupply); + e.Weapon.GetType().GetMethod("UpdateStats", BindingFlags.Instance | BindingFlags.NonPublic).Invoke(e.Weapon, null); + Assert.True(e.Weapon.Range < blast, "fixture: the runtime Range must have fallen below the blast"); + } + var committed = 0; + using var c = e.Zone.ShotCommitted.Subscribe(_ => committed++); + + Assert.Equal(fires, e.Weapon.FuseAllowsFire); + e.Weapon.Activate(); + // The starved row must not run a full Update first: the power bus would restore the supply we forced. + // Activate queues the burst inside Trigger, so one short tick is enough for a commit if it fires. + for (var i = 0; i < 5; i++) e.Zone.Update(.01f); + + Assert.Equal(fires ? 1 : 0, committed); + } + + // Nothing bursts beyond max range. A target receding faster than the round would be led past Range by the + // intercept; the burst is pulled back along the line to the intercept, to exactly Range. The target moves + // (6,8), so the direction is off-axis and a clamp that moved the point along the wrong line shows. + // Kills: no clamp (the probe burst 180 units out on Range 100); a clamp on one axis only. + [Fact] + public void ATargetedProximityBurstIsPulledBackToMaxRange() + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 20f, fuse: WeaponFuse.Proximity, blastRadius: 4f, damage: 100f, + weaponRange: 100f, targetRange: 90f); + e.Target.Velocity = float2(6f, 8f); + var intercept = FireControl.PredictedIntercept(e.Weapon, e.Shooter, e.Target); + var origin = e.Shooter.Position; + var toIntercept = normalize((intercept - origin).xz); + Assert.True(length((intercept - origin).xz) > 100f, "fixture: the intercept must lie beyond Range"); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + + var shot = Assert.Single(e.Zone.PendingShots); + Assert.Equal(origin.x + toIntercept.x * 100f, shot.BurstPosition.x, 2); + Assert.Equal(origin.z + toIntercept.y * 100f, shot.BurstPosition.z, 2); + } + + // What a proximity round's commit says. A round whose roll missed still detonates, so it commits as a Burst + // at the intercept, and only a roll that landed is a Hit; a contact or delayed round detonates only on a hit, + // so its guaranteed miss (accuracy 0) stays a Miss and explodes nowhere. Kills: typing a proximity miss as a + // Miss (the HUD said MISS over a real explosion); typing every proximity round a Burst (no Hit among the + // twelve); dropping the burst point from a Hit (it would detonate at the origin). + [Fact] + public void AProximityRoundThatMissesCommitsAsABurstAtTheIntercept() + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: 30f, damage: 100f, accuracy: 0f); + var damage = 0f; + using var a = e.Target.ArmorDamage.Subscribe(x => damage += x.damage); + using var h = e.Target.HullDamage.Subscribe(x => damage += x); + using var i = e.Target.ItemDamage.Subscribe(x => damage += x.damage); + ShotOutcome committed = null; + using var c = e.Zone.ShotCommitted.Subscribe(o => committed = o); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + + Assert.NotNull(committed); + Assert.Equal(ShotResult.Burst, committed.Result); + Assert.Equal(e.Target.Position.x, committed.BurstPoint.x, 3); + Assert.Equal(e.Target.Position.z, committed.BurstPoint.y, 3); + Assert.Equal(5.65884256f, damage, 3); + } + + [Fact] + public void AProximityRoundThatLandsCommitsAsAHitAndStillDetonates() + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: 30f, damage: 100f); + var results = new List(); + using var r = e.Zone.ShotResolved.Subscribe(o => results.Add(o)); + var damage = 0f; + using var a = e.Target.ArmorDamage.Subscribe(x => damage += x.damage); + using var h = e.Target.HullDamage.Subscribe(x => damage += x); + using var i = e.Target.ItemDamage.Subscribe(x => damage += x.damage); + + for (var n = 0; n < 12; n++) FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + + Assert.Equal(12, results.Count); + Assert.True(results.All(o => o.Result != ShotResult.Miss), "a proximity round always detonates, so it is never a Miss"); + Assert.True(results.Any(o => o.Result == ShotResult.Hit), "the stationary, fully-accurate target must be hit at least once"); + Assert.True(results.All(o => abs(o.BurstPoint.x - e.Target.Position.x) < 1e-3f && abs(o.BurstPoint.y - e.Target.Position.z) < 1e-3f), + "a hit detonates at the intercept too, not at the origin"); + Assert.True(damage > 0f, "the blasts must have landed"); + } + + [Theory] + [InlineData(WeaponFuse.Contact)] + [InlineData(WeaponFuse.Delayed)] + public void AContactOrDelayedRoundThatMissesIsAMissAndDoesNotDetonate(WeaponFuse fuse) + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: fuse, blastRadius: 30f, penetration: 1f, damage: 100f, accuracy: 0f); + var bystander = AddShip(e, e.Target.Position + float3(8, 0, 0), 1f, 300); + ShotOutcome resolved = null; + using var r = e.Zone.ShotResolved.Subscribe(o => resolved = o); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + + Assert.NotNull(resolved); + Assert.Equal(ShotResult.Miss, resolved.Result); + Assert.True(Alive(e, bystander), "a missed contact or delayed round detonates nowhere"); + } + + // A targeted fused round commits at its horizon, not at Fire: the target's facing is read late (Bearing + // timing), and only a round with no target commits at once. 100 units at 20/s is a 5 s flight, so with the + // 0.5 s horizon it commits at 4.5 s. Kills: CommitTime = now for every fused shot (the round would be + // committed after the first tick). + [Fact] + public void ATargetedFusedShotCommitsAtItsHorizonNotAtFire() + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 20f, fuse: WeaponFuse.Proximity, blastRadius: 4f, damage: 100f); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + var shot = Assert.Single(e.Zone.PendingShots); + Assert.Equal(shot.FireTime + 4.5f, shot.CommitTime, 3); + e.Zone.Update(.01f); + Assert.False(e.Zone.PendingShots[0].Committed, "a targeted round reads the target's facing late"); + e.Zone.Update(4.5f); + Assert.True(e.Zone.PendingShots[0].Committed, "and commits once its horizon is reached, with the round still in flight"); + } + // Ruling 3: a Contact round with no lock stops on the first hull its aim line crosses before max range; a // Proximity or Delayed round keeps the max-range burst. The blocker's near face sits 21 units out (a 5x4-cell // hull of 2-unit cells centred 25 out), the witness at max range (60). Kills: a Contact fuse that ignores @@ -1481,6 +1721,256 @@ public sealed class FireControlCut124Tests : IDisposable Assert.True(ReferenceEquals(committed, resolved), "the resolution republishes the commit unchanged"); } + // ==== Soul's probes on hands/fuse-fix2 (2026-09-30), adopted as tests ==== + + private static float2 Dir(float degreesFromMount) => float2(sin(radians(degreesFromMount)), cos(radians(degreesFromMount))); + + private static float2 Clamped(Engagement e, float2 look) + { + Aim(e, look); + return FireControl.AimDirection(e.WeaponItem, e.Shooter); + } + + private static void Near(float2 expected, float2 actual, int digits = 3) + { + Assert.Equal(expected.x, actual.x, digits); + Assert.Equal(expected.y, actual.y, digits); + } + + // AimDirection at the arc's edges: the mount is (0,1) and the arc 170 degrees, so the edge is 85. On the edge + // and inside are untouched, just outside turns to the edge on its own side, straight behind turns to the + // positive edge, a zero aim (and one too short to normalise) is the mount, and a NaN aim never poisons the + // result. Kills: a clamp that turns the wrong way; an edge off by a degree; a missing zero-aim fallback. + [Fact] + public void TheAimClampHoldsAtTheEdgesOfTheArc() + { + var e = Build(TestSettings(), SolidShape(5, 4), fuse: WeaponFuse.Proximity, blastRadius: 4f, weaponRange: 60f); + Near(Dir(85), Clamped(e, Dir(85))); + Near(Dir(85), Clamped(e, Dir(85.5f))); + Near(Dir(-85), Clamped(e, Dir(-86))); + Near(Dir(84), Clamped(e, Dir(84))); + Near(Dir(0), Clamped(e, Dir(0))); + Near(Dir(85), Clamped(e, Dir(180 - 1e-3f)), 2); + Near(float2(0, 1), Clamped(e, float2(0, 0))); + Near(float2(0, 1), Clamped(e, float2(5e-4f, 0))); + Near(Dir(85), Clamped(e, float2(2e-3f, 0))); + e.Shooter.LookDirection = float3(0, 1, 0); + Near(float2(0, 1), FireControl.AimDirection(e.WeaponItem, e.Shooter)); + e.Shooter.LookDirection = float3(float.NaN, 0, float.NaN); + var nan = FireControl.AimDirection(e.WeaponItem, e.Shooter); + Assert.False(float.IsNaN(nan.x) || float.IsNaN(nan.y), "a NaN aim must not produce a NaN flight direction"); + } + + [Fact] + public void AFullCircleMountPassesEveryAim() + { + var settings = TestSettings(); + settings.FiringArc = 360; + var e = Build(settings, SolidShape(5, 4), fuse: WeaponFuse.Proximity, blastRadius: 4f, weaponRange: 60f); + Near(float2(0, -1), Clamped(e, float2(0, -3))); + Near(normalize(float2(-1, -1)), Clamped(e, float2(-5, -5))); + } + + [Fact] + public void AZeroWidthArcIsTheMount() + { + var settings = TestSettings(); + settings.FiringArc = 0; + var e = Build(settings, SolidShape(5, 4), fuse: WeaponFuse.Proximity, blastRadius: 4f, weaponRange: 60f); + foreach (var d in new[] { 0f, 1e-3f, 30f, 90f, 179f, -120f }) + Near(float2(0, 1), Clamped(e, Dir(d)), 4); + } + + // The hardpoint's authored arc overrides the default (90 here, so the edge is 45), and the arc turns with the + // mount: a shooter facing (1,1) has its edge 45 degrees counter-clockwise of that when aimed along -x. + [Fact] + public void TheHardpointsOwnArcAndTheRotatedMountSetTheEdge() + { + var e = Build(TestSettings(), SolidShape(5, 4), fuse: WeaponFuse.Proximity, blastRadius: 4f, weaponRange: 60f, hardpointArc: 90f); + Near(Dir(45), Clamped(e, float2(1, 0))); + Near(Dir(-45), Clamped(e, Dir(-100))); + e.Shooter.Direction = normalize(float2(1, 1)); + var mount = normalize(float2(1, 1)); + var edgeCcw = float2(mount.x * cos(radians(45)) - mount.y * sin(radians(45)), mount.x * sin(radians(45)) + mount.y * cos(radians(45))); + Near(edgeCcw, Clamped(e, float2(-1, 0))); + } + + // A contact round with no lock commits at Fire, against the poses of that tick: a hull that sails onto the + // aim line afterwards is not in its way, and one that leaves it afterwards does not un-stop it. Kills: + // committing later than Fire (the blocker that arrives would stop the round); re-deciding the burst point + // from live poses. + [Fact] + public void AContactRoundIgnoresAHullThatCrossesItsLineAfterFire() + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 20f, fuse: WeaponFuse.Contact, blastRadius: 4f, damage: 100f, weaponRange: 60f); + e.Shooter.Target.Value = null; + Aim(e, float2(0, 1)); + var blocker = AddShip(e, PointAlong(e, float2(0, 1), 25f) + float3(300, 0, 0), 1f, 300); + ShotOutcome committed = null; + using var c = e.Zone.ShotCommitted.Subscribe(o => committed = o); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + Assert.Equal(ShotResult.Burst, committed.Result); + Assert.Equal(e.Shooter.Position.z + 60f, committed.BurstPoint.y, 2); + blocker.Position = PointAlong(e, float2(0, 1), 25f); + e.Zone.Update(4f); + + Assert.Empty(e.Zone.PendingShots); + Assert.True(Alive(e, blocker), "the round flew through the hull that arrived after Fire and burst at max range"); + } + + [Fact] + public void AContactRoundStillBurstsAtAHullThatLeavesItsLineAfterFire() + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 20f, fuse: WeaponFuse.Contact, blastRadius: 4f, damage: 100f, weaponRange: 60f); + e.Shooter.Target.Value = null; + Aim(e, float2(0, 1)); + var blocker = AddShip(e, PointAlong(e, float2(0, 1), 25f), 1f, 300); + var atMaxRange = AddShip(e, PointAlong(e, float2(0, 1), 60f), 1f, 302); + ShotOutcome committed = null; + using var c = e.Zone.ShotCommitted.Subscribe(o => committed = o); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + Assert.Equal(e.Shooter.Position.z + 21f, committed.BurstPoint.y, 2); + blocker.Position += float3(300, 0, 0); + e.Zone.Update(4f); + + Assert.True(Alive(e, blocker), "the hull moved away; the round still burst at its old face"); + Assert.True(Alive(e, atMaxRange), "and did not continue to max range"); + } + + // The contact point is measured from where the round left (FireOrigin), not from where its shooter is when + // the commit tick runs. Unblocked, a max-range burst point is frozen at Fire either way; with a blocker 21 + // units out the origin matters. Kills: Commit reading the shooter's live position (Soul's M6). + [Theory] + [InlineData(false)] + [InlineData(true)] + public void AShooterMovingAfterFireDoesNotMoveTheContactPoint(bool withBlocker) + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 20f, fuse: WeaponFuse.Contact, blastRadius: 4f, damage: 100f, weaponRange: 60f); + e.Shooter.Target.Value = null; + Aim(e, float2(0, 1)); + var origin = e.Shooter.Position; + if (withBlocker) AddShip(e, PointAlong(e, float2(0, 1), 25f), 1f, 300); + ShotOutcome committed = null; + using var c = e.Zone.ShotCommitted.Subscribe(o => committed = o); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Shooter.Position += float3(25f, 0, 10f); + e.Zone.Update(.01f); + + Assert.Equal(origin.x, committed.BurstPoint.x, 2); + Assert.Equal(origin.z + (withBlocker ? 21f : 60f), committed.BurstPoint.y, 2); + } + + // A round fired from inside another hull bursts at the muzzle: the origin is already in metal, a distance of + // zero. Kills: reading a cell's Entry for its Exit (Soul's M7: a cell the origin sits in is skipped and the + // round flies on); dropping the clamp at zero (Soul's M8: the negative distance puts the burst behind the + // muzzle). + [Fact] + public void AContactRoundFiredFromInsideAnotherHullBurstsAtTheMuzzle() + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 20f, fuse: WeaponFuse.Contact, blastRadius: 4f, damage: 100f, weaponRange: 60f); + e.Shooter.Target.Value = null; + Aim(e, float2(0, 1)); + AddShip(e, e.Shooter.Position + float3(0.5f, 0, 0.5f), 1f, 300); + ShotOutcome committed = null; + using var c = e.Zone.ShotCommitted.Subscribe(o => committed = o); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + + Assert.Equal(e.Shooter.Position.x, committed.BurstPoint.x, 2); + Assert.Equal(e.Shooter.Position.z, committed.BurstPoint.y, 2); + } + + // A hull wholly behind the muzzle is not on the round's way. Kills: admitting cells whose exit lies behind + // the origin (the round would burst at the hull behind it, at distance zero). + [Fact] + public void AContactRoundIgnoresAHullBehindTheMuzzle() + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 20f, fuse: WeaponFuse.Contact, blastRadius: 4f, damage: 100f, weaponRange: 60f); + e.Shooter.Target.Value = null; + Aim(e, float2(0, 1)); + AddShip(e, PointAlong(e, float2(0, -1), 25f), 1f, 300); + ShotOutcome committed = null; + using var c = e.Zone.ShotCommitted.Subscribe(o => committed = o); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + + Assert.Equal(e.Shooter.Position.z + 60f, committed.BurstPoint.y, 2); + } + + // The intent ruling: a round fired at a valid target that has left the zone does not burst, at either stage + // (before or after its commit), whatever its fuse -- it resolves as a Miss, and nothing standing beside where + // the target was, nor the shooter, takes blast damage. Kills: a round that detonates at its frozen burst point + // after its target is gone. + [Theory] + [InlineData(WeaponFuse.Proximity, false)] + [InlineData(WeaponFuse.Contact, false)] + [InlineData(WeaponFuse.Delayed, false)] + [InlineData(WeaponFuse.Proximity, true)] + [InlineData(WeaponFuse.Contact, true)] + [InlineData(WeaponFuse.Delayed, true)] + public void ARoundWhoseTargetLeavesTheZoneDoesNotBurst(WeaponFuse fuse, bool committedFirst) + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 20f, fuse: fuse, blastRadius: 30f, penetration: 1f, damage: 500f); + var bystanders = new Entity[] + { + AddShip(e, e.Target.Position + float3(8, 0, 0), 1f, 300), + AddShip(e, e.Target.Position + float3(-8, 0, 12), 1f, 302) + }; + var damage = 0f; + var subscriptions = new List(); + foreach (var o in bystanders.Append(e.Shooter)) + { + subscriptions.Add(o.ArmorDamage.Subscribe(x => damage += x.damage)); + subscriptions.Add(o.HullDamage.Subscribe(x => damage += x)); + subscriptions.Add(o.ItemDamage.Subscribe(x => damage += x.damage)); + } + var resolved = new List(); + subscriptions.Add(e.Zone.ShotResolved.Subscribe(o => resolved.Add(o))); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(committedFirst ? 4.6f : .01f); + Assert.Equal(committedFirst, e.Zone.PendingShots[0].Committed); + e.Zone.Entities.Remove(e.Target); + e.Zone.Update(6f); + + foreach (var s in subscriptions) s.Dispose(); + Assert.Empty(e.Zone.PendingShots); + Assert.True(bystanders.All(b => Alive(e, b)), "no bystander may take blast damage"); + Assert.Equal(0f, damage); + Assert.Equal(ShotResult.Miss, Assert.Single(resolved).Result); + } + + // A no-lock round outlives a shooter destroyed outright in flight, for every fuse: Death removes the shooter + // and the round still bursts at max range and resolves once as a Burst. + [Theory] + [InlineData(WeaponFuse.Proximity)] + [InlineData(WeaponFuse.Contact)] + [InlineData(WeaponFuse.Delayed)] + public void ANoLockRoundStillBurstsAfterItsShooterIsDestroyed(WeaponFuse fuse) + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 20f, fuse: fuse, blastRadius: 4f, damage: 100f, weaponRange: 60f); + e.Shooter.Target.Value = null; + Aim(e, float2(0, 1)); + var witness = AddShip(e, PointAlong(e, float2(0, 1), 60f), 1f, 300); + var resolved = new List(); + using var r = e.Zone.ShotResolved.Subscribe(o => resolved.Add(o)); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Shooter.DamageHull(1e9f); + e.Zone.Update(4f); + + Assert.False(Alive(e, e.Shooter)); + Assert.False(Alive(e, witness), "the round must still burst at max range"); + Assert.Equal(ShotResult.Burst, Assert.Single(resolved).Result); + } + // ToWorldPoint and ToSchematicPoint are inverses, and ToWorldPoint puts one cell to starboard of the centre // of mass two world units along the starboard axis (right = (forward.y, -forward.x), spelled out here). At // facing (0,1) a flipped right axis is still masked by a centre-lane point; (2,1) is not. From 9c0029931a715ebdce5e194c83eb5b82fda12d29 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 16:27:54 +0200 Subject: [PATCH 18/49] Presentation follows the committed shot, not the selection (F7) EntityInstance handed effect managers the shooter's selected target whatever FireControl decided. A fused round whose target was invalid or out of arc flies at none, and GuidedProjectileManager dropped a launcher shot with no target entirely, so a real burst had no missile. - Zone.TryGetShot: a presentation reads the shot the simulation committed (its target, its burst point) by the ShotId OnFire hands it. - EntityInstance passes the shot's own target; a launcher round with none flies to the shot's burst point. Unity-only; compile-checked by reading. Co-Authored-By: Claude Sonnet 5 --- Assets/Scripts/Gameplay/EntityInstance.cs | 9 ++++++++- .../Gameplay/Weapons/GuidedProjectileManager.cs | 9 +++++++-- Assets/Scripts/ServerShared/Zone.cs | 15 +++++++++++++++ 3 files changed, 30 insertions(+), 3 deletions(-) diff --git a/Assets/Scripts/Gameplay/EntityInstance.cs b/Assets/Scripts/Gameplay/EntityInstance.cs index 4034c2e55..f3d977c8c 100644 --- a/Assets/Scripts/Gameplay/EntityInstance.cs +++ b/Assets/Scripts/Gameplay/EntityInstance.cs @@ -203,8 +203,11 @@ public class EntityInstance : MonoBehaviour // Cut 3 (docs/fire-control-cut.md): carries the ShotId FireControl.Fire assigned so the // effect manager (and whatever it spawns) can bind its presentation to Zone.ShotCommitted / // ShotResolved instead of deciding or applying anything itself. + // The target handed to the effect is the shot's own, not the shooter's selection: a fused round + // whose selected target was invalid, or out of arc, flies at none (FireControl.Fire) and the + // effect must not chase one. instantWeapon.OnFire += shotId => - _instantWeaponManagers[data].Fire(instantWeapon, item, this, entity.Target.Value != null && ZoneRenderer.EntityInstances.ContainsKey(entity.Target.Value) ? ZoneRenderer.EntityInstances[entity.Target.Value] : null, shotId); + _instantWeaponManagers[data].Fire(instantWeapon, item, this, ShotTarget(shotId), shotId); if (behavior is ChargedWeapon chargedWeapon) { @@ -336,6 +339,10 @@ public class EntityInstance : MonoBehaviour } } + private EntityInstance ShotTarget(int shotId) => + Entity.Zone.TryGetShot(shotId, out var shot) && shot.Target != null && + ZoneRenderer.EntityInstances.TryGetValue(shot.Target, out var instance) ? instance : null; + private void OnSensorPingEnd() { Destroy(_currentPing.transform.gameObject); diff --git a/Assets/Scripts/Gameplay/Weapons/GuidedProjectileManager.cs b/Assets/Scripts/Gameplay/Weapons/GuidedProjectileManager.cs index 51b62969e..748503967 100644 --- a/Assets/Scripts/Gameplay/Weapons/GuidedProjectileManager.cs +++ b/Assets/Scripts/Gameplay/Weapons/GuidedProjectileManager.cs @@ -17,12 +17,17 @@ public class GuidedProjectileManager : InstantWeaponEffectManager { if(weapon.Data is LauncherData launcher) { - if (target == null) return; + // A round the simulation fired at no target (FireControl.Fire: a fused round with no valid targeting + // data, or out of arc) flies to the point it bursts at; one with no target and no burst is not a shot. + PendingShot shot = default; + if (target == null && !(source.Entity.Zone.TryGetShot(shotId, out shot) && shot.Fuse != null)) return; + var burst = shot.BurstPosition.ToUnity(); var p = ProjectilePrototype.Instantiate(); p.ShotId = shotId; p.Source = source.transform; p.SourceEntity = source.Entity; - p.Target = target.transform; + if (target != null) p.Target = target.transform; + else p.TargetPosition = () => burst; p.Frequency = launcher.DodgeFrequency; var hp = source.Entity.Hardpoints[item.Position.x, item.Position.y]; var barrel = source.GetBarrel(hp); diff --git a/Assets/Scripts/ServerShared/Zone.cs b/Assets/Scripts/ServerShared/Zone.cs index 14ee6b6c0..9b34e6687 100644 --- a/Assets/Scripts/ServerShared/Zone.cs +++ b/Assets/Scripts/ServerShared/Zone.cs @@ -43,6 +43,21 @@ public class Zone private int _nextShotId; public int NextShotId() => ++_nextShotId; + // The committed shot a presentation was handed the id of by OnFire. It carries what the simulation decided + // -- the target it flies at (null for a round that carries none) and where it bursts -- so a presentation + // reads that rather than the shooter's current selection. + public bool TryGetShot(int shotId, out PendingShot shot) + { + foreach (var pending in PendingShots) + if (pending.ShotId == shotId) + { + shot = pending; + return true; + } + shot = default; + return false; + } + private List BeltUpdates = new List(); public float Time From 2d4474f859df6d09f78e2bd55c76a6d32a9842c6 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 16:49:36 +0200 Subject: [PATCH 19/49] Record out-of-arc, arming-distance and pivot-arc rulings Co-Authored-By: Claude Opus 5.5 --- docs/fire-control-cut.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/fire-control-cut.md b/docs/fire-control-cut.md index 5c4f7794c..755a5c707 100644 --- a/docs/fire-control-cut.md +++ b/docs/fire-control-cut.md @@ -2638,6 +2638,16 @@ follow-up rulings, all given on 2026-09-30: - A round fired at a valid target that later leaves or becomes invalid does not burst. It resolves with no detonation. - Validity is judged on the targeting data at fire time. +- **Out of arc (operator, 2026-09-30):** a fused weapon whose selected target is out of arc still fires + (bomb fishing). It bursts at the target's range, clamped to max range, along the arc-clamped aim. + Non-fused weapons refuse. +- **Arming distance (operator, 2026-09-30):** a round never bursts closer than its blast radius to its fire + origin, and close bursts are pushed out to that distance. A pilot who flies into their own distant burst + takes the damage. Fire is refused when the live Range cannot clear BlastRadius. +- **Turret arc ownership (operator, 2026-09-30):** the simulation's arc is authoritative, and Unity barrels + derive their aim from it. The true owner of an arc is the **pivot**, not the hardpoint. The arc stays on + the hardpoint until the moddable-ships campaign adds pivots to `HullData`. Until then, a pivot carrying + several hardpoints turns within the **intersection** of their arcs. - **A round outlives its shooter,** for targeted and no-lock shots alike. Credit and loot with no shooter go nowhere. From 24b4071b694bf9f5a1c9a16204c224b858cba88b Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 17:05:14 +0200 Subject: [PATCH 20/49] Arming distance: no round bursts nearer than its blast radius past the shooter's hull Operator ruling 2026-09-30. A fused round's burst is at least BlastRadius beyond the far edge of the shooter's own hull, measured along the flight line against the hull as it stood at fire (HullAhead's exit). A close burst -- a target inside that distance, in or out of arc -- is pushed out along the aim and carries no target; a contact round is floored to it too. - The F3 trigger gate is deleted. Fire refuses a round (returns 0) whose arming distance exceeds Range, per round, where Range is read after Execute refreshed it; InstantWeapon and ConstantWeapon skip a refused round. - PFire's range, Inspect's range and the range clamp are planar (R7). - HullEntryDistance becomes HullAhead (entry and exit); the max(...,0) clamp on entry is gone, the arming floor covers it. - ShotOutcome.HasBurstPoint names the rounds that have a frozen burst point. - SafeAssert: the tests no longer hand xunit an Entity, PendingShot or ShotOutcome to format; a failing assertion had been overflowing the test host's stack. Co-Authored-By: Claude Sonnet 5 --- .../ServerShared/Behaviors/ConstantWeapon.cs | 6 +- .../ServerShared/Behaviors/InstantWeapon.cs | 5 +- .../Scripts/ServerShared/Behaviors/Weapon.cs | 3 - Assets/Scripts/ServerShared/FireControl.cs | 106 ++++--- .../FireAuthorityTests.cs | 4 +- .../FireControlCut11Tests.cs | 6 +- .../FireControlCut124Tests.cs | 277 ++++++++++++++---- .../FireControlCut5Tests.cs | 4 +- .../FireControlCut8Tests.cs | 4 +- tests/Aetheria.Shared.Tests/SafeAssert.cs | 37 +++ 10 files changed, 344 insertions(+), 108 deletions(-) create mode 100644 tests/Aetheria.Shared.Tests/SafeAssert.cs diff --git a/Assets/Scripts/ServerShared/Behaviors/ConstantWeapon.cs b/Assets/Scripts/ServerShared/Behaviors/ConstantWeapon.cs index 8a93c2a82..14d6b4820 100644 --- a/Assets/Scripts/ServerShared/Behaviors/ConstantWeapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/ConstantWeapon.cs @@ -91,7 +91,7 @@ public class ConstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerC // Cut 5 (docs/fire-control-cut.md, 5.3, Soul finding 9): StanceAllowsFire alone let a side-mounted beam // fire forward -- ArcAllowsFire joins it here, matching InstantWeapon.cs's player arc gate (Q2: manual and // programmatic are one truth, and a continuous weapon is no exception). - public float PowerRequest(float dt) => _firing && StanceAllowsFire && ArcAllowsFire && FuseAllowsFire ? EvaluateNominalPower(_data.Energy) * dt : 0f; + public float PowerRequest(float dt) => _firing && StanceAllowsFire && ArcAllowsFire ? EvaluateNominalPower(_data.Energy) * dt : 0f; // Cut 5 (docs/stats-and-power-cut.md §1.3, PowerTiers.cs): Low -- offense, same as InstantWeapon. public int DefaultPowerTier => PowerTiers.Low; @@ -99,7 +99,7 @@ public class ConstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerC public override bool Execute(float dt) { base.Execute(dt); - if (_firing && !(StanceAllowsFire && ArcAllowsFire && FuseAllowsFire)) + if (_firing && !(StanceAllowsFire && ArcAllowsFire)) { // Safed: shooter has a target and hasn't declared hostility toward it, or the target no longer // bears (5.3: a beam obeys its arc exactly as InstantWeapon's trigger does). @@ -179,7 +179,7 @@ public class ConstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerC // argument included, when OnBeamShot has no subscriber (true today, R9's "no design uses it // yet"), so the roll would silently never happen. Evaluate it into a local first. var shotId = FireControl.Fire(this, Item, Entity, Damage * interval); - OnBeamShot?.Invoke(shotId); + if (shotId != 0) OnBeamShot?.Invoke(shotId); } } return true; diff --git a/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs b/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs index 1cf398539..6de087ba0 100644 --- a/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs @@ -103,8 +103,6 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo // through -- a player's action-bar Activate() reaches this exactly the same way Combat.cs's and // TurretController.cs's Activate() calls do. if (!ArcAllowsFire) return; - // A fused weapon whose Range no longer clears its blast would burst on its shooter. - if (!FuseAllowsFire) return; // If 1 ammo is consumed per burst, perform ammo and energy consumption here // UseAmmo returns false when triggering reload; cancel firing if that is the case @@ -248,6 +246,9 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo // Unity EntityInstance wiring, docs/headless-playground-cut.md), so a shot silently never fired. // Evaluate it into a local first. var shotId = FireControl.Fire(this, Item, Entity); + // A fused round whose arming distance Range cannot reach is refused (FireControl.Fire returns 0): + // nothing was fired, so nothing sounds, wears or heats. + if (shotId == 0) continue; OnFire?.Invoke(shotId); if(!firedThisFrame) { diff --git a/Assets/Scripts/ServerShared/Behaviors/Weapon.cs b/Assets/Scripts/ServerShared/Behaviors/Weapon.cs index 57340f8d9..47f2acf11 100644 --- a/Assets/Scripts/ServerShared/Behaviors/Weapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/Weapon.cs @@ -110,9 +110,6 @@ public abstract class Weapon : Behavior, IActivatedBehavior // FireControl owns the gate, including its one exemption: a fused weapon fires out of arc. public bool ArcAllowsFire => FireControl.ArcPermitsFire(this, Entity); - // A fused weapon whose current Range does not exceed its blast stays safed (FireControl.FuseCanArm). - public bool FuseAllowsFire => FireControl.FuseCanArm(this); - public Weapon(WeaponData data, EquippedItem item) : base(data, item) { _data = data; diff --git a/Assets/Scripts/ServerShared/FireControl.cs b/Assets/Scripts/ServerShared/FireControl.cs index 51fab98af..adf1721e0 100644 --- a/Assets/Scripts/ServerShared/FireControl.cs +++ b/Assets/Scripts/ServerShared/FireControl.cs @@ -79,10 +79,15 @@ public static class FireControl return FuseOf(weapon.Item, out _) != null || InArc(weapon.Item, target.Position - shooter.Position); } - // A fused weapon whose Range, as it stands now (power supply and modifiers included), does not exceed its - // blast would burst on its own shooter at max range, so its trigger stays safed. Judged on the effective - // Range rather than the catalog's authored floor, which Evaluate can scale below. - public static bool FuseCanArm(Weapon weapon) => FuseOf(weapon.Item, out var blastRadius) == null || weapon.Range > blastRadius; + // Operator ruling 2026-09-30 (arming distance): a round never bursts closer than its blast radius to where + // it was fired, and the disc must clear the shooter's own hull as it stood at fire. So the burst is at least + // BlastRadius beyond the far edge of the shooter's hull along the flight line. Close bursts are pushed out + // along the aim to this distance; a pilot who flies into their own distant burst takes the damage. + private static float ArmingDistance(Entity shooter, float2 direction, float blastRadius) + { + HullAhead(shooter, shooter.Position.xz, direction, out _, out var exit); + return exit + blastRadius; + } // Operator ruling 2026-09-30: a shot with nothing to bear on flies along where its shooter is aiming // (Entity.LookDirection -- the player's mouse or a turret's tracking), restricted by the mount's arc. The @@ -225,8 +230,10 @@ public static class FireControl // gate -- Fire reads it through this same overload for flightTime, and flightTime must reflect the // real distance even when a gate (not visible, out of arc) already prices the shot at PFire 0. The // gates below still short-circuit the rest of the shooter-side probability in the same order as before. + // Planar (R7: the simulation is 2D): the range gate, the burst reach and the range clamp all measure the + // same distance, so a target's height never moves one of them without the others. var toTarget = target.Position - source.Position; - range = length(toTarget); + range = length(toTarget.xz); if (!source.VisibleEntities.Contains(target)) return 0f; if (range < weapon.MinRange || range > weapon.Range) return 0f; @@ -316,7 +323,7 @@ public static class FireControl if (target == null) return diagnostic; var toTarget = target.Position - source.Position; - diagnostic.Range = length(toTarget); + diagnostic.Range = length(toTarget.xz); diagnostic.Visible = source.VisibleEntities.Contains(target); diagnostic.InRange = diagnostic.Range >= weapon.MinRange && diagnostic.Range <= weapon.Range; diagnostic.Locked = !(weapon is LockWeapon lockWeapon) || lockWeapon.IsLocked; @@ -443,10 +450,36 @@ public static class FireControl // Range, along that same clamped aim: the arc is hoped to land the blast near enough to splash. // Either way the round flies at the weapon's Velocity like a targeted shot flies to its intercept. A shot // without a fuse keeps its target as before. + // Arming distance (operator ruling 2026-09-30): a round never bursts closer than its blast radius past the + // shooter's hull, so a burst that would land nearer -- a target close in, or out of arc and inside the + // radius -- is pushed out along the aim to that distance, and the round carries no target. A round whose + // arming distance exceeds the weapon's Range cannot be fired at all: it is refused here, per round, where + // Range is read after Execute refreshed it (a burst's later rounds and a power-starved Range included). var engaged = fuse != null && !(designated && inArc) ? null : target; var origin = source.Position; var travelDirection = engaged != null ? TravelDirection(weapon, source, engaged) : AimDirection(item, source); + var arming = 0f; + var burstPosition = engaged != null ? engaged.Position : origin; + if (fuse != null && engaged != null) + { + // Nothing bursts beyond max range: an intercept a receding target leads the round past Range is + // pulled back along its own line to Range. + var intercept = PredictedIntercept(weapon, source, engaged); + var interceptReach = length((intercept - origin).xz); + arming = ArmingDistance(source, travelDirection, blastRadius); + if (min(interceptReach, weapon.Range) < arming) engaged = null; + else burstPosition = interceptReach > weapon.Range ? origin + (intercept - origin) * (weapon.Range / interceptReach) : intercept; + } + if (fuse != null && engaged == null) + { + travelDirection = AimDirection(item, source); + arming = ArmingDistance(source, travelDirection, blastRadius); + var reach = max(designated ? fireRange : weapon.Range, arming); + burstPosition = origin + float3(travelDirection.x, 0, travelDirection.y) * reach; + } + if (arming > weapon.Range) return 0; + var targetVelocity = float3.zero; var targetPosition = origin; var flightTime = 0f; @@ -456,25 +489,14 @@ public static class FireControl targetPosition = engaged.Position; flightTime = weapon.Velocity > .01f ? fireRange / weapon.Velocity : 0f; } - - var commitHorizon = source.ItemManager.GameplaySettings.CommitHorizon; - - var burstPosition = targetPosition; - if (fuse != null && engaged != null) - { - // Nothing bursts beyond max range: an intercept a receding target leads the round past Range is - // pulled back along its own line to Range. - var intercept = PredictedIntercept(weapon, source, engaged); - var reach = length((intercept - origin).xz); - burstPosition = reach > weapon.Range ? origin + (intercept - origin) * (weapon.Range / reach) : intercept; - } else if (fuse != null) { - var reach = designated ? fireRange : weapon.Range; - burstPosition = origin + float3(travelDirection.x, 0, travelDirection.y) * reach; - flightTime = weapon.Velocity > .01f ? reach / weapon.Velocity : 0f; + var flown = length((burstPosition - origin).xz); + flightTime = weapon.Velocity > .01f ? flown / weapon.Velocity : 0f; } + var commitHorizon = source.ItemManager.GameplaySettings.CommitHorizon; + var shot = new PendingShot { ShotId = zone.NextShotId(), @@ -504,6 +526,7 @@ public static class FireControl BurstPosition = burstPosition, Fuse = fuse, BlastRadius = blastRadius, + ArmingDistance = arming, Committed = false }; @@ -696,7 +719,11 @@ public static class FireControl var proximity = shot.Fuse == WeaponFuse.Proximity; var outcome = MakeOutcome(shot, hit ? ShotResult.Hit : proximity ? ShotResult.Burst : ShotResult.Miss, shielded, shieldBroken, cell, bearing, lateral, now); - if (proximity) outcome.BurstPoint = shot.BurstPosition.xz; + if (proximity) + { + outcome.BurstPoint = shot.BurstPosition.xz; + outcome.HasBurstPoint = true; + } return outcome; } @@ -708,8 +735,10 @@ public static class FireControl private static ShotOutcome CommitBurst(Zone zone, PendingShot shot, float now) { var outcome = MakeOutcome(shot, ShotResult.Burst, false, false, int2.zero, float2.zero, 0f, now); + outcome.HasBurstPoint = true; var reach = length(shot.BurstPosition.xz - shot.FireOrigin.xz); var travelled = shot.Fuse == WeaponFuse.Contact ? ContactDistance(zone, shot, reach) : reach; + travelled = min(reach, max(travelled, shot.ArmingDistance)); if (travelled < reach) { outcome.BurstPoint = shot.FireOrigin.xz + shot.TravelDirection * travelled; @@ -730,16 +759,18 @@ public static class FireControl foreach (var entity in zone.Entities) { if (entity == shot.Source) continue; - nearest = min(nearest, HullEntryDistance(entity, shot.FireOrigin.xz, shot.TravelDirection)); + if (HullAhead(entity, shot.FireOrigin.xz, shot.TravelDirection, out var entry, out _)) nearest = min(nearest, entry); } return nearest; } - // World distance from `origin` along the unit planar `direction` to the first metal cell of `entity`'s hull, - // or +infinity when the line meets none ahead of it. Read through Lane's own cell admission (CellsOnLine), in - // the entity's schematic frame: the line's lateral offset is its own, and cells wholly behind the origin - // are skipped. An origin already inside the hull is a distance of zero. - private static float HullEntryDistance(Entity entity, float2 origin, float2 direction) + // Where the line from `origin` along the unit planar `direction` meets `entity`'s hull, in world distance + // from `origin`: `entry` to the first metal cell ahead (+infinity when the line meets none, and negative when the + // origin is already inside the hull: the arming distance floors every caller's use of it) and `exit` to the far edge of the last one (zero when none). Read + // through Lane's own cell admission (CellsOnLine), in the entity's schematic frame: the line's lateral offset + // is its own, and cells wholly behind the origin are skipped. Contact fuses read `entry`; a round's arming + // distance reads `exit` of its own shooter. + private static bool HullAhead(Entity entity, float2 origin, float2 direction, out float entry, out float exit) { var hullData = entity.ItemManager.GetData(entity.Hull) as HullData; var cellSize = entity.ItemManager.GameplaySettings.SchematicCellSize; @@ -748,14 +779,19 @@ public static class FireControl var lateral = dot(start, Lateral(bearing)); var along = dot(start, bearing); + entry = float.PositiveInfinity; + exit = 0f; var buffer = ArrayPool.Shared.Rent(hullData.Shape.Coordinates.Length); try { var count = CellsOnLine(hullData, bearing, lateral, buffer); for (var i = 0; i < count; i++) - if (buffer[i].Exit > along) - return max(buffer[i].Entry - along, 0f) * cellSize; - return float.PositiveInfinity; + { + if (!(buffer[i].Exit > along)) continue; + if (float.IsPositiveInfinity(entry)) entry = (buffer[i].Entry - along) * cellSize; + exit = max(exit, (buffer[i].Exit - along) * cellSize); + } + return !float.IsPositiveInfinity(entry); } finally { @@ -1677,6 +1713,9 @@ public struct PendingShot public float3 BurstPosition; public WeaponFuse? Fuse; public float BlastRadius; + // How far along its flight line a fused round may first burst: BlastRadius past the far edge of the + // shooter's hull, frozen at Fire (a round outlives its shooter's pose, too). + public float ArmingDistance; // Frozen with the rest of the shooter's decision: where the round left from. A round outlives its shooter, so // nothing at resolution reads the shooter's position. @@ -1785,9 +1824,10 @@ public sealed class ShotOutcome // (Burst, at BurstPoint). Hit is derived from it, not stored beside it. public ShotResult Result; public bool Hit => Result == ShotResult.Hit; - // World planar point a proximity or no-target round detonates at, frozen by the commit. Meaningless for any - // other round. + // World planar point a proximity or no-target round detonates at, frozen by the commit; HasBurstPoint says + // whether this round has one (a contact or delayed round detonates where it hits, not at a frozen point). public float2 BurstPoint; + public bool HasBurstPoint; public bool Shielded; // Cut 5, 5.2 (Soul finding 4): frozen alongside the rest of the outcome (R4) -- a shield present, active, // and unable to CanTakeHit this shot is decided broken right here, so Apply performs Break() rather than diff --git a/tests/Aetheria.Shared.Tests/FireAuthorityTests.cs b/tests/Aetheria.Shared.Tests/FireAuthorityTests.cs index 3d3568b93..921961b15 100644 --- a/tests/Aetheria.Shared.Tests/FireAuthorityTests.cs +++ b/tests/Aetheria.Shared.Tests/FireAuthorityTests.cs @@ -331,7 +331,7 @@ public sealed class FireAuthorityTests : IDisposable e.Zone.Update(10f); // well past arrival Assert.Equal(before, e.Target.Hull.Durability); - Assert.Empty(e.Zone.PendingShots); + SafeAssert.NoShots(e.Zone); } // R4: ShotCommitted fires exactly CommitHorizon before ShotResolved, once per shot. Mutation: publish only @@ -420,7 +420,7 @@ public sealed class FireAuthorityTests : IDisposable e.Zone.Update(.016f); // one ordinary tick Assert.Equal(new[] { "committed", "resolved" }, order); - Assert.Empty(e.Zone.PendingShots); + SafeAssert.NoShots(e.Zone); } // R10/Q6: a shot's parameters freeze when the trigger is pulled. Changing the weapon's authored Damage diff --git a/tests/Aetheria.Shared.Tests/FireControlCut11Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut11Tests.cs index 2a65fda64..ab2966025 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut11Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut11Tests.cs @@ -305,19 +305,19 @@ public sealed class FireControlCut11Tests : IDisposable var shotId = FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); e.Zone.Update(1f); - Assert.Empty(committed); + SafeAssert.Empty(committed); e.Zone.Entities.Remove(e.Target); e.Zone.Update(1f); - foreach (var outcome in new[] { Assert.Single(committed), Assert.Single(resolved) }) + foreach (var outcome in new[] { SafeAssert.Only(committed), SafeAssert.Only(resolved) }) { Assert.Equal(shotId, outcome.ShotId); Assert.False(outcome.Hit); Assert.False(outcome.Shielded, "a shot at nothing did not hit a shield"); Assert.False(outcome.ShieldBroken, "a shot at nothing did not break a shield"); } - Assert.Empty(e.Zone.PendingShots); + SafeAssert.NoShots(e.Zone); } // ---- A targeting system's Tracking is what forgives a target that moves off its predicted line. ---- diff --git a/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs index 3c8f38db3..f6b91f97e 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs @@ -543,10 +543,10 @@ public sealed class FireControlCut124Tests : IDisposable FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); e.Zone.Update(.01f); - Assert.Empty(e.Zone.PendingShots); + SafeAssert.NoShots(e.Zone); Assert.Equal(1, resolved); - Assert.DoesNotContain(near, e.Zone.Entities); // fixture: the blast really was lethal to a ship mid-pass - Assert.Contains(far, e.Zone.Entities); + SafeAssert.NotIn(e.Zone, near); // fixture: the blast really was lethal to a ship mid-pass + SafeAssert.In(e.Zone, far); Assert.True(targetDamage > 0f, "fixture: the target must be inside the blast"); var afterFirstTick = targetDamage; @@ -588,7 +588,7 @@ public sealed class FireControlCut124Tests : IDisposable Assert.Equal(new[] { 1, 1, 1 }, hullHits); Assert.Equal(new[] { 1, 1, 1 }, deaths); foreach (var s in ships) Assert.False(e.Zone.Entities.Contains(s), "a killed ship must have left the zone"); - Assert.Empty(e.Zone.PendingShots); + SafeAssert.NoShots(e.Zone); Assert.Equal(1, resolved); } @@ -1149,7 +1149,7 @@ public sealed class FireControlCut124Tests : IDisposable Assert.NotNull(outcome); Assert.False(outcome.Hit); // fixture: a guaranteed miss Assert.Equal(ShotResult.Miss, outcome.Result); - Assert.Empty(e.Zone.PendingShots); + SafeAssert.NoShots(e.Zone); Assert.Equal(0, events); Assert.Equal(before, e.Target.Hull.Durability); } @@ -1189,7 +1189,7 @@ public sealed class FireControlCut124Tests : IDisposable e.Zone.Update(.01f); Assert.Equal(0f, shooterHit); - Assert.Empty(e.Zone.PendingShots); + SafeAssert.NoShots(e.Zone); Assert.False(e.Zone.Entities.Contains(onLine), "the blast must reach the burst point (fixture: 1 durability, so any damage kills it)"); Assert.True(e.Zone.Entities.Contains(offLine), "the blast must not reach 42 units off the aim line"); } @@ -1205,7 +1205,7 @@ public sealed class FireControlCut124Tests : IDisposable FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); - var shot = Assert.Single(e.Zone.PendingShots); + var shot = SafeAssert.OnlyShot(e.Zone); Assert.Equal(NoLockRange / 20f, shot.ArrivalTime - shot.FireTime, 4); var burst = AimLine(e); Assert.Equal(burst.x, shot.BurstPosition.x, 3); @@ -1224,7 +1224,7 @@ public sealed class FireControlCut124Tests : IDisposable using var i = e.Target.ItemDamage.Subscribe(x => damage += x.damage); FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); - var shot = Assert.Single(e.Zone.PendingShots); + var shot = SafeAssert.OnlyShot(e.Zone); Assert.Equal(e.Target.Position.x, shot.BurstPosition.x, 3); Assert.Equal(e.Target.Position.z, shot.BurstPosition.z, 3); e.Zone.Update(.01f); @@ -1316,7 +1316,7 @@ public sealed class FireControlCut124Tests : IDisposable var targetBefore = e.Target.Hull.Durability; FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); - var shot = Assert.Single(e.Zone.PendingShots); + var shot = SafeAssert.OnlyShot(e.Zone); Assert.True(shot.Target == null, "an invalid target must not ride with the round"); // not Assert.Null: a failure would format the entity graph and overflow the stack Assert.Equal(burst.x, shot.BurstPosition.x, 3); Assert.Equal(burst.z, shot.BurstPosition.z, 3); @@ -1339,7 +1339,7 @@ public sealed class FireControlCut124Tests : IDisposable FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); - var shot = Assert.Single(e.Zone.PendingShots); + var shot = SafeAssert.OnlyShot(e.Zone); Assert.True(shot.Target == e.Target, "a locked, visible, in-range, in-arc target rides with the round"); Assert.Equal(e.Target.Position.z, shot.BurstPosition.z, 3); } @@ -1371,7 +1371,7 @@ public sealed class FireControlCut124Tests : IDisposable var targetBefore = e.Target.Hull.Durability; FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); - var shot = Assert.Single(e.Zone.PendingShots); + var shot = SafeAssert.OnlyShot(e.Zone); Assert.True(shot.Target == null, "a target the weapon cannot bear on must not ride with the round"); var burst = PointAlong(e, edge, 40f); Assert.Equal(burst.x, shot.BurstPosition.x, 3); @@ -1408,18 +1408,140 @@ public sealed class FireControlCut124Tests : IDisposable Assert.Equal(fires ? 1 : 0, committed); } - // A fused weapon safes itself while its effective Range does not exceed its blast, because a round bursting at - // max range would land on its own shooter. The gate reads the Range the weapon has NOW: the third row is - // Soul's probe (authored Range 100 clears a blast of 30, but at a power supply of 0.1 Range is 10), and the - // first two pin the boundary itself (equal refuses). Kills: reading the authored figure instead of the - // runtime Range; `>=` for `>`; dropping the gate from InstantWeapon.Trigger. + // ==== Operator ruling 2026-09-30, batch 4: the arming distance ==== + // + // A round never bursts closer than its blast radius past the shooter's own hull. The fixture's shooter hull is + // a 5x5 block of 2-unit cells centred on the shooter, so its far edge is 5 units out along the axis and + // 5 * sqrt(2) along a diagonal; with a blast of 30 the arming distance is 35 (or 30 + 7.07). + + private const float ShooterHullReach = 5f; + + private static float Planar(float3 from, float3 to) => length((to - from).xz); + + // A target inside the arming distance is not detonated on: the round flies along the aim and bursts at the + // arming distance, carrying no target, and the shooter that stood beside it is untouched (the probes measured + // 7.07 of self-damage for a target at 5, before this). The out-of-arc row is a target 10 away behind the + // mount, inside the blast radius, with the mount flipped so the arc is closed. Kills: no arming distance; a + // burst that still rides the target; an arming distance measured from the shooter's centre instead of the far + // edge of its hull (the burst would land 5 units short and the shooter would still be inside the disc). [Theory] - [InlineData(30f, 30f, false, false)] - [InlineData(31f, 30f, false, true)] - [InlineData(100f, 30f, true, false)] - public void AFusedWeaponWhoseRangeDoesNotExceedItsBlastStaysSafed(float range, float blast, bool starvedOfPower, bool fires) + [InlineData(5f, false)] + [InlineData(10f, true)] + public void ACloseBurstIsPushedOutAlongTheAimToTheArmingDistance(float targetRange, bool outOfArc) { - var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: blast, damage: 100f, + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: 30f, damage: 100f, + weaponRange: 60f, targetRange: targetRange); + var aim = float2(0, 1); + if (outOfArc) + { + e.Shooter.Direction = float2(0, -1); + aim = float2(0, -1); + } + Aim(e, aim); + var burst = PointAlong(e, aim, 30f + ShooterHullReach); + var hurt = 0f; + using var a = e.Shooter.ArmorDamage.Subscribe(x => hurt += x.damage); + using var h = e.Shooter.HullDamage.Subscribe(x => hurt += x); + using var i = e.Shooter.ItemDamage.Subscribe(x => hurt += x.damage); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + var shot = SafeAssert.OnlyShot(e.Zone); + Assert.True(shot.Target == null, "a burst pushed out to the arming distance carries no target"); + Assert.Equal(burst.x, shot.BurstPosition.x, 2); + Assert.Equal(burst.z, shot.BurstPosition.z, 2); + Assert.Equal(35f, shot.ArmingDistance, 2); + e.Zone.Update(.01f); + + Assert.Equal(0f, hurt); + } + + // The far edge is read along the flight line, not along an axis: aimed down a diagonal the 5x5 hull reaches + // 5 * sqrt(2) = 7.07 units, so the burst sits at 30 + 7.07. A close in-arc target makes the arming distance + // the burst reach. Kills: an extent read along the mount or the hull's own axes. + [Fact] + public void TheArmingDistanceIsMeasuredAlongTheFlightLine() + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: 30f, damage: 100f, + weaponRange: 60f, targetRange: 5f); + Aim(e, normalize(float2(1, 1))); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + + var shot = SafeAssert.OnlyShot(e.Zone); + Assert.Equal(30f + ShooterHullReach * sqrt(2f), Planar(e.Shooter.Position, shot.BurstPosition), 1); + } + + // A target beyond the arming distance is not pushed anywhere: the round still rides it and bursts at its + // intercept. Kills: demoting every fused round to the aim. + [Fact] + public void ATargetBeyondTheArmingDistanceStillRidesWithTheRound() + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: 30f, damage: 100f, + weaponRange: 60f, targetRange: 40f); + Aim(e, float2(0, 1)); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + + var shot = SafeAssert.OnlyShot(e.Zone); + Assert.True(shot.Target == e.Target, "a target the disc clears the shooter to reach is still the round's target"); + Assert.Equal(40f, Planar(e.Shooter.Position, shot.BurstPosition), 2); + } + + // A contact round stops on the first hull it crosses, but never nearer than its arming distance: a blocker + // whose near face is 6 units out (centred 10) is passed, and the round bursts at 5 + 4 = 9. Kills: a contact + // point taken as the hull face regardless of arming. + [Fact] + public void AContactRoundNeverStopsNearerThanItsArmingDistance() + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Contact, blastRadius: 4f, damage: 100f, weaponRange: NoLockRange); + e.Shooter.Target.Value = null; + Aim(e, float2(0, 1)); + AddShip(e, PointAlong(e, float2(0, 1), 10f), 1f, 300); + ShotOutcome outcome = null; + using var c = e.Zone.ShotCommitted.Subscribe(o => outcome = o); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + + Assert.NotNull(outcome); + Assert.Equal(e.Shooter.Position.z + ShooterHullReach + 4f, outcome.BurstPoint.y, 2); + } + + // A round fired from inside another hull is already in metal: its contact point is its arming distance. The + // host is a column with a 30-unit gap after the cell the muzzle sits in (cells 0-2 and 18-21, the muzzle in + // cell 2), so a contact search that skips the cell the origin is inside finds the far block instead of the + // muzzle's own. Kills: reading a cell's Entry for its Exit (Soul's M7). + [Fact] + public void AContactRoundFiredFromInsideAnotherHullBurstsAtTheArmingDistance() + { + var column = new Shape(1, 22); + foreach (var y in new[] { 0, 1, 2, 18, 19, 20, 21 }) column[new int2(0, y)] = true; + var e = Build(TestSettings(), column, velocity: 20f, fuse: WeaponFuse.Contact, blastRadius: 4f, damage: 100f, weaponRange: NoLockRange); + e.Shooter.Target.Value = null; + Aim(e, float2(0, 1)); + var offset = e.Target.ToWorldPoint(float2(0, 2)) - e.Target.Position.xz; + e.Target.Position = float3(e.Shooter.Position.x - offset.x, 0, e.Shooter.Position.z - offset.y); + ShotOutcome committed = null; + using var c = e.Zone.ShotCommitted.Subscribe(o => committed = o); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + + Assert.NotNull(committed); + Assert.Equal(e.Shooter.Position.z + ShooterHullReach + 4f, committed.BurstPoint.y, 2); + } + + // A fused round is refused, per round and at the moment Fire reads Range, when its arming distance exceeds + // that Range: 34.5 against 35 refuses, 35.01 fires, and a power-starved Range (authored 100, forced to about + // 10 the way Soul's probe did after the trigger has passed) refuses although the authored figure clears + // it. Fire returns 0 and queues nothing. Kills: a gate that reads the authored Range; `>=` for `>`. + [Theory] + [InlineData(34.5f, false, false)] + [InlineData(35.01f, false, true)] + [InlineData(100f, true, false)] + public void AFusedRoundWhoseArmingDistanceExceedsRangeIsRefusedByFire(float range, bool starvedOfPower, bool fires) + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: 30f, damage: 100f, weaponRange: range, rangeTerm: starvedOfPower ? StatSource.PowerSupply : (StatSource?) null); e.Shooter.Target.Value = null; Aim(e, float2(0, 1)); @@ -1428,18 +1550,75 @@ public sealed class FireControlCut124Tests : IDisposable typeof(EquippedItem).GetProperty("PowerSupply").GetSetMethod(true).Invoke(e.WeaponItem, new object[] { .1f }); e.Shooter.Resolver.InvalidateSource(e.WeaponItem, StatSource.PowerSupply); e.Weapon.GetType().GetMethod("UpdateStats", BindingFlags.Instance | BindingFlags.NonPublic).Invoke(e.Weapon, null); - Assert.True(e.Weapon.Range < blast, "fixture: the runtime Range must have fallen below the blast"); + Assert.True(e.Weapon.Range < 35f, "fixture: the runtime Range must have fallen below the arming distance"); } + + var shotId = FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + + Assert.Equal(fires, shotId != 0); + Assert.Equal(fires ? 1 : 0, e.Zone.PendingShots.Count); + } + + // The same refusal through the weapon a player uses: a refused round sounds nothing (OnFire never runs) and + // queues nothing, and the same weapon with Range to spare fires once. Kills: Execute still announcing a + // refused round (Fire's 0 handed to the presentation as a shot id). + [Theory] + [InlineData(34.5f, 0)] + [InlineData(40f, 1)] + public void ARefusedRoundIsNotAnnounced(float range, int fired) + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: 30f, damage: 100f, weaponRange: range); + e.Shooter.Target.Value = null; + Aim(e, float2(0, 1)); + var announced = 0; var committed = 0; + e.Weapon.OnFire += _ => announced++; using var c = e.Zone.ShotCommitted.Subscribe(_ => committed++); - Assert.Equal(fires, e.Weapon.FuseAllowsFire); e.Weapon.Activate(); - // The starved row must not run a full Update first: the power bus would restore the supply we forced. - // Activate queues the burst inside Trigger, so one short tick is enough for a commit if it fires. for (var i = 0; i < 5; i++) e.Zone.Update(.01f); - Assert.Equal(fires ? 1 : 0, committed); + Assert.Equal(fired, announced); + Assert.Equal(fired, committed); + } + + // Planar, everywhere the gate, the clamp and the burst meet (R7). A target 50 units above the plane at a + // planar range of 40 is 64 units away in three dimensions: inside a Range of 60 by the plane, outside it by + // the straight line. It is designated, rides with the round, and the HUD's diagnostic agrees. Kills: a 3D + // length in PFire's range or Inspect's. + [Fact] + public void ATargetsHeightDoesNotMoveTheRangeGate() + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: 4f, damage: 100f, + weaponRange: 60f, targetRange: 40f); + e.Target.Position += float3(0, 50, 0); + Aim(e, float2(0, 1)); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + + var shot = SafeAssert.OnlyShot(e.Zone); + Assert.True(shot.Target == e.Target, "the target is inside Range in the plane, so it is valid data"); + Assert.Equal(40f, Planar(e.Shooter.Position, shot.BurstPosition), 2); + var diagnostic = FireControl.Inspect(e.Weapon, e.Shooter, e.Target); + Assert.True(diagnostic.InRange, "the HUD's range gate is planar too"); + Assert.Equal(40f, diagnostic.Range, 2); + } + + // The F4 clamp is planar as well: the target's height does not change how far the burst is pulled back. + [Fact] + public void TheRangeClampIgnoresTheTargetsHeight() + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 20f, fuse: WeaponFuse.Proximity, blastRadius: 4f, damage: 100f, + weaponRange: 100f, targetRange: 90f); + e.Target.Position += float3(0, 200, 0); + e.Target.Velocity = float2(6f, 8f); + var intercept = FireControl.PredictedIntercept(e.Weapon, e.Shooter, e.Target); + Assert.True(Planar(e.Shooter.Position, intercept) > 100f, "fixture: the intercept must lie beyond Range in the plane"); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + + var shot = SafeAssert.OnlyShot(e.Zone); + Assert.Equal(100f, Planar(e.Shooter.Position, shot.BurstPosition), 2); } // Nothing bursts beyond max range. A target receding faster than the round would be led past Range by the @@ -1459,7 +1638,7 @@ public sealed class FireControlCut124Tests : IDisposable FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); - var shot = Assert.Single(e.Zone.PendingShots); + var shot = SafeAssert.OnlyShot(e.Zone); Assert.Equal(origin.x + toIntercept.x * 100f, shot.BurstPosition.x, 2); Assert.Equal(origin.z + toIntercept.y * 100f, shot.BurstPosition.z, 2); } @@ -1509,6 +1688,7 @@ public sealed class FireControlCut124Tests : IDisposable Assert.True(results.Any(o => o.Result == ShotResult.Hit), "the stationary, fully-accurate target must be hit at least once"); Assert.True(results.All(o => abs(o.BurstPoint.x - e.Target.Position.x) < 1e-3f && abs(o.BurstPoint.y - e.Target.Position.z) < 1e-3f), "a hit detonates at the intercept too, not at the origin"); + Assert.True(results.All(o => o.HasBurstPoint), "a proximity outcome names its burst point, hit or not: presentation retargets to it"); Assert.True(damage > 0f, "the blasts must have landed"); } @@ -1527,6 +1707,7 @@ public sealed class FireControlCut124Tests : IDisposable Assert.NotNull(resolved); Assert.Equal(ShotResult.Miss, resolved.Result); + Assert.False(resolved.HasBurstPoint, "a contact or delayed round has no frozen burst point"); Assert.True(Alive(e, bystander), "a missed contact or delayed round detonates nowhere"); } @@ -1540,7 +1721,7 @@ public sealed class FireControlCut124Tests : IDisposable var e = Build(TestSettings(), SolidShape(5, 4), velocity: 20f, fuse: WeaponFuse.Proximity, blastRadius: 4f, damage: 100f); FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); - var shot = Assert.Single(e.Zone.PendingShots); + var shot = SafeAssert.OnlyShot(e.Zone); Assert.Equal(shot.FireTime + 4.5f, shot.CommitTime, 3); e.Zone.Update(.01f); Assert.False(e.Zone.PendingShots[0].Committed, "a targeted round reads the target's facing late"); @@ -1625,7 +1806,7 @@ public sealed class FireControlCut124Tests : IDisposable Assert.True(Alive(e, blocker)); e.Zone.Update(1.1f); - Assert.Empty(e.Zone.PendingShots); + SafeAssert.NoShots(e.Zone); Assert.False(Alive(e, blocker)); } @@ -1650,7 +1831,7 @@ public sealed class FireControlCut124Tests : IDisposable e.Zone.Entities.Remove(e.Shooter); e.Zone.Update(4f); - Assert.Empty(e.Zone.PendingShots); + SafeAssert.NoShots(e.Zone); Assert.False(Alive(e, witness), "the round must still burst at max range"); } @@ -1668,7 +1849,7 @@ public sealed class FireControlCut124Tests : IDisposable e.Zone.Entities.Remove(e.Shooter); e.Zone.Update(6f); - Assert.Empty(e.Zone.PendingShots); + SafeAssert.NoShots(e.Zone); Assert.Equal(5.65884256f, damage, 3); } @@ -1690,7 +1871,7 @@ public sealed class FireControlCut124Tests : IDisposable e.Zone.Entities.Remove(e.Shooter); e.Zone.Update(6f); - Assert.Empty(e.Zone.PendingShots); + SafeAssert.NoShots(e.Zone); Assert.Equal(12, resolved.Count); Assert.True(resolved.Any(o => o.Hit), "at least one round must land on the stationary target"); Assert.Equal(resolved.Count(o => o.Hit), struck); @@ -1714,6 +1895,7 @@ public sealed class FireControlCut124Tests : IDisposable Assert.NotNull(committed); Assert.Equal(ShotResult.Burst, committed.Result); + Assert.True(committed.HasBurstPoint); Assert.False(committed.Hit); var burst = PointAlong(e, normalize(float2(2, 1)), NoLockRange); Assert.Equal(burst.x, committed.BurstPoint.x, 3); @@ -1816,7 +1998,7 @@ public sealed class FireControlCut124Tests : IDisposable blocker.Position = PointAlong(e, float2(0, 1), 25f); e.Zone.Update(4f); - Assert.Empty(e.Zone.PendingShots); + SafeAssert.NoShots(e.Zone); Assert.True(Alive(e, blocker), "the round flew through the hull that arrived after Fire and burst at max range"); } @@ -1865,27 +2047,6 @@ public sealed class FireControlCut124Tests : IDisposable Assert.Equal(origin.z + (withBlocker ? 21f : 60f), committed.BurstPoint.y, 2); } - // A round fired from inside another hull bursts at the muzzle: the origin is already in metal, a distance of - // zero. Kills: reading a cell's Entry for its Exit (Soul's M7: a cell the origin sits in is skipped and the - // round flies on); dropping the clamp at zero (Soul's M8: the negative distance puts the burst behind the - // muzzle). - [Fact] - public void AContactRoundFiredFromInsideAnotherHullBurstsAtTheMuzzle() - { - var e = Build(TestSettings(), SolidShape(5, 4), velocity: 20f, fuse: WeaponFuse.Contact, blastRadius: 4f, damage: 100f, weaponRange: 60f); - e.Shooter.Target.Value = null; - Aim(e, float2(0, 1)); - AddShip(e, e.Shooter.Position + float3(0.5f, 0, 0.5f), 1f, 300); - ShotOutcome committed = null; - using var c = e.Zone.ShotCommitted.Subscribe(o => committed = o); - - FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); - e.Zone.Update(.01f); - - Assert.Equal(e.Shooter.Position.x, committed.BurstPoint.x, 2); - Assert.Equal(e.Shooter.Position.z, committed.BurstPoint.y, 2); - } - // A hull wholly behind the muzzle is not on the round's way. Kills: admitting cells whose exit lies behind // the origin (the round would burst at the hull behind it, at distance zero). [Fact] @@ -1941,10 +2102,10 @@ public sealed class FireControlCut124Tests : IDisposable e.Zone.Update(6f); foreach (var s in subscriptions) s.Dispose(); - Assert.Empty(e.Zone.PendingShots); + SafeAssert.NoShots(e.Zone); Assert.True(bystanders.All(b => Alive(e, b)), "no bystander may take blast damage"); Assert.Equal(0f, damage); - Assert.Equal(ShotResult.Miss, Assert.Single(resolved).Result); + Assert.Equal(ShotResult.Miss, SafeAssert.Only(resolved).Result); } // A no-lock round outlives a shooter destroyed outright in flight, for every fuse: Death removes the shooter @@ -1968,7 +2129,7 @@ public sealed class FireControlCut124Tests : IDisposable Assert.False(Alive(e, e.Shooter)); Assert.False(Alive(e, witness), "the round must still burst at max range"); - Assert.Equal(ShotResult.Burst, Assert.Single(resolved).Result); + Assert.Equal(ShotResult.Burst, SafeAssert.Only(resolved).Result); } // ToWorldPoint and ToSchematicPoint are inverses, and ToWorldPoint puts one cell to starboard of the centre @@ -2116,7 +2277,7 @@ public sealed class FireControlCut124Tests : IDisposable Assert.Equal(ShotResult.Hit, outcome.Result); Assert.True(armorEvents > 0); var shot = e.Zone.PendingShots.Count == 0 ? null : (PendingShot?) e.Zone.PendingShots[0]; - Assert.Null(shot); // resolved and removed -- confirms it went through Apply's null-fuse path to completion + Assert.True(!shot.HasValue, "the shot must be resolved and removed"); // resolved and removed -- confirms it went through Apply's null-fuse path to completion } // ARadiusWithoutAFuseIsADirectHit (Soul F1): the symmetric inert half -- BlastRadius > 0 but the catalog diff --git a/tests/Aetheria.Shared.Tests/FireControlCut5Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut5Tests.cs index 525a24ce6..e1366721e 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut5Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut5Tests.cs @@ -621,12 +621,12 @@ public sealed class FireControlCut5Tests : IDisposable var e = Build(TestSettings(), damage: 10000, velocity: 0, accuracy: 1, resolution: 1, spread: 0, hullDurability: 5, armor: 0); // one hit is lethal - Assert.Contains(e.Target, e.Zone.Entities); + SafeAssert.In(e.Zone, e.Target); FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); e.Zone.Update(.1f); Assert.True(e.Target.Hull.Durability < .01f); - Assert.DoesNotContain(e.Target, e.Zone.Entities); + SafeAssert.NotIn(e.Zone, e.Target); } // ---- 5.7: delete the two carried-and-unread fields (Soul finding 12). ---- diff --git a/tests/Aetheria.Shared.Tests/FireControlCut8Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut8Tests.cs index 81256f3ec..96fb785d1 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut8Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut8Tests.cs @@ -293,10 +293,10 @@ public sealed class FireControlCut8Tests : IDisposable e.Zone.Update(.1f); Assert.True(e.Target.Hull.Durability < .01f); - Assert.DoesNotContain(e.Target, e.Zone.Entities); + SafeAssert.NotIn(e.Zone, e.Target); Assert.False(e.Target.Active); Assert.Empty(e.Target.EntityInfoGathered); - Assert.Empty(e.Target.VisibleEntities); + Assert.True(e.Target.VisibleEntities.Count == 0, "the target's visible set must be empty"); } // ---- Ordering risk (8.1's own callout): EntityInstance's loot-drop subscription (EntityInstance.cs:292-325) diff --git a/tests/Aetheria.Shared.Tests/SafeAssert.cs b/tests/Aetheria.Shared.Tests/SafeAssert.cs new file mode 100644 index 000000000..2ab52d6bd --- /dev/null +++ b/tests/Aetheria.Shared.Tests/SafeAssert.cs @@ -0,0 +1,37 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +using System.Collections.Generic; +using Xunit; + +// Assertions over collections of entities and shots. xunit formats a collection's contents into its failure +// message, and an Entity, a PendingShot or a ShotOutcome drags the whole zone graph in with it: the formatter +// recurses until the test host dies of a stack overflow, which a mutation tool scores as a kill of every test +// that was running. A failure here must be an ordinary red naming the test, so these compare counts and +// identities and never hand xunit an entity. +internal static class SafeAssert +{ + public static void NoShots(Zone zone) => + Assert.True(zone.PendingShots.Count == 0, $"{zone.PendingShots.Count} shot(s) still pending"); + + public static PendingShot OnlyShot(Zone zone) + { + Assert.True(zone.PendingShots.Count == 1, $"expected exactly one pending shot, found {zone.PendingShots.Count}"); + return zone.PendingShots[0]; + } + + public static T Only(List list) + { + Assert.True(list.Count == 1, $"expected exactly one {typeof(T).Name}, found {list.Count}"); + return list[0]; + } + + public static void Empty(List list) => Assert.True(list.Count == 0, $"expected no {typeof(T).Name}, found {list.Count}"); + + public static void In(Zone zone, Entity entity, string why = "entity should be in the zone") => + Assert.True(zone.Entities.Contains(entity), why); + + public static void NotIn(Zone zone, Entity entity, string why = "entity should have left the zone") => + Assert.False(zone.Entities.Contains(entity), why); +} From 543edee35834fc67caa3f4365cd5423999c1ba05 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 17:05:15 +0200 Subject: [PATCH 21/49] Guided rounds follow the committed burst and resolution; no-target launcher no longer throws - GuidedProjectileManager passed target.transform to OnFireGuided for a no-target fused round: a NullReferenceException inside InstantWeapon.Execute after the shot was queued, which skipped audio, wear, heat and visibility and unwound Zone.Update. The tuple's transform is null-safe now, and the trackmissile console command falls back to the missile. - Presentation follows the commit rather than predicting it. The manager binds each guided round to Zone.ShotCommitted (retarget to the burst point the commit names, so a Contact round bursts at its hull and a Range-clamped burst is not overshot) and ShotResolved (burst there, then). The binding is disposed when the projectile dies, so a pooled round does not accumulate them. - Beams and barrel-fired projectiles stay with F5. Unity-only; compile-checked by reading. Co-Authored-By: Claude Sonnet 5 --- Assets/Scripts/Gameplay/ActionGameManager.cs | 2 +- .../Gameplay/Weapons/GuidedProjectile.cs | 27 +++++++++++++++++++ .../Weapons/GuidedProjectileManager.cs | 16 ++++++++++- 3 files changed, 43 insertions(+), 2 deletions(-) diff --git a/Assets/Scripts/Gameplay/ActionGameManager.cs b/Assets/Scripts/Gameplay/ActionGameManager.cs index bc9bce8a2..3f69563ed 100644 --- a/Assets/Scripts/Gameplay/ActionGameManager.cs +++ b/Assets/Scripts/Gameplay/ActionGameManager.cs @@ -535,7 +535,7 @@ public class ActionGameManager : MonoBehaviour missileManager.OnFireGuided.Where(x => x.source == _currentEntity).Take(1).Subscribe(x => { FollowCamera.Follow = x.missile.transform; - FollowCamera.LookAt = x.target; + FollowCamera.LookAt = x.target ? x.target : x.missile.transform; x.missile.OnKill += () => { FollowCamera.LookAt = ZoneRenderer.EntityInstances[CurrentEntity].LookAtPoint; diff --git a/Assets/Scripts/Gameplay/Weapons/GuidedProjectile.cs b/Assets/Scripts/Gameplay/Weapons/GuidedProjectile.cs index 9d9b02070..b5fdc47da 100644 --- a/Assets/Scripts/Gameplay/Weapons/GuidedProjectile.cs +++ b/Assets/Scripts/Gameplay/Weapons/GuidedProjectile.cs @@ -48,6 +48,31 @@ public class GuidedProjectile : MonoBehaviour public event Action OnKill; + // The simulation's verdict on this round, bound by GuidedProjectileManager to Zone.ShotCommitted and + // ShotResolved for its ShotId: this object flies and looks, and neither decides where the round bursts nor when. + public IDisposable Binding { get; set; } + + // The commit named a burst point: fly to it, whatever this round was homing on (a target it would have + // passed, or a Range clamp it would have overshot). + public void BurstAt(Vector3 point) + { + Target = null; + TargetPosition = () => point; + } + + // The resolution: the round bursts now, at the simulation's burst point when it has one. + public void Resolve(Vector3? point) + { + if (!_alive || !_active) return; + if (point.HasValue) transform.position = point.Value; + if (HitEffect != null) + { + var ht = HitEffect.Instantiate(); + ht.position = transform.position; + } + StartCoroutine(Kill()); + } + void OnEnable() { _active = _alive = true; @@ -170,6 +195,8 @@ public class GuidedProjectile : MonoBehaviour IEnumerator Kill() { + Binding?.Dispose(); + Binding = null; _active = false; _alive = false; Particles.Stop(true, ParticleSystemStopBehavior.StopEmitting); diff --git a/Assets/Scripts/Gameplay/Weapons/GuidedProjectileManager.cs b/Assets/Scripts/Gameplay/Weapons/GuidedProjectileManager.cs index 748503967..2e41570ef 100644 --- a/Assets/Scripts/Gameplay/Weapons/GuidedProjectileManager.cs +++ b/Assets/Scripts/Gameplay/Weapons/GuidedProjectileManager.cs @@ -9,6 +9,17 @@ using static CultMath.math; public class GuidedProjectileManager : InstantWeaponEffectManager { + // Presentation follows the committed shot (FireControl): the round retargets to the burst point the commit + // names and bursts when the shot resolves, rather than predicting either from its own homing. + private static void Bind(GuidedProjectile p, Zone zone, int shotId) + { + Vector3 At(ShotOutcome o) => new Vector3(o.BurstPoint.x, p.transform.position.y, o.BurstPoint.y); + var bindings = new CompositeDisposable(); + zone.ShotCommitted.Where(o => o.ShotId == shotId && o.HasBurstPoint).Subscribe(o => p.BurstAt(At(o))).AddTo(bindings); + zone.ShotResolved.Where(o => o.ShotId == shotId).Subscribe(o => p.Resolve(o.HasBurstPoint ? At(o) : (Vector3?) null)).AddTo(bindings); + p.Binding = bindings; + } + public Prototype ProjectilePrototype; public Subject<(Entity source, Transform target, GuidedProjectile missile)> OnFireGuided = new Subject<(Entity source, Transform target, GuidedProjectile missile)>(); @@ -39,7 +50,9 @@ public class GuidedProjectileManager : InstantWeaponEffectManager p.Velocity = barrel.forward * weapon.Velocity; p.Thrust = item.Evaluate(launcher.Thrust); p.TopSpeed = item.Evaluate(launcher.MissileVelocity); - OnFireGuided.OnNext((source.Entity, target.transform, p)); + Bind(p, source.Entity.Zone, shotId); + // A round with no target (a fused round fired at none) has no transform to hand a listener. + OnFireGuided.OnNext((source.Entity, target != null ? target.transform : null, p)); } else if(weapon.Data is GuidedWeaponData guidance) { @@ -59,6 +72,7 @@ public class GuidedProjectileManager : InstantWeaponEffectManager p.Thrust = item.Evaluate(guidance.Thrust); p.TopSpeed = item.Evaluate(guidance.MissileVelocity); p.TargetPosition = () => (source.Entity.Position + length(source.LookAtPoint.position.ToCultMath() - source.Entity.Position) * source.Entity.LookDirection).ToUnity(); + Bind(p, source.Entity.Zone, shotId); } else Debug.LogError($"Weapon {item.Data.Name} linked to {name} effect, but is not a Launcher!"); } From 14f666347c34a599db6aebd23daedb7baa92b924 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 17:27:47 +0200 Subject: [PATCH 22/49] Pin the arming-distance boundary: a Range equal to it fires The clean mutation run left one survivor, `>=` for `>` in Fire's refusal. Co-Authored-By: Claude Sonnet 5 --- .../FireControlCut124Tests.cs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs index f6b91f97e..cb50782d7 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs @@ -1559,6 +1559,25 @@ public sealed class FireControlCut124Tests : IDisposable Assert.Equal(fires ? 1 : 0, e.Zone.PendingShots.Count); } + // The boundary itself: a Range exactly equal to the arming distance fires (the disc is tangent to the hull, + // which delivers nothing). The arming distance is read from a probe round on the same fixture, so the + // equality is exact rather than a decimal that float arithmetic could tip. Kills: `>=` for `>`. + [Fact] + public void ARangeExactlyEqualToTheArmingDistanceFires() + { + var probe = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: 30f, damage: 100f, weaponRange: 60f); + probe.Shooter.Target.Value = null; + Aim(probe, float2(0, 1)); + FireControl.Fire(probe.Weapon, probe.WeaponItem, probe.Shooter); + var arming = SafeAssert.OnlyShot(probe.Zone).ArmingDistance; + + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: 30f, damage: 100f, weaponRange: arming); + e.Shooter.Target.Value = null; + Aim(e, float2(0, 1)); + + Assert.True(FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter) != 0, "a Range equal to the arming distance must fire"); + } + // The same refusal through the weapon a player uses: a refused round sounds nothing (OnFire never runs) and // queues nothing, and the same weapon with Range to spare fires once. Kills: Execute still announcing a // refused round (Fire's 0 handed to the presentation as a shot id). From d6f1e192516194d15c9a9300c9b1752e412edc04 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 17:42:49 +0200 Subject: [PATCH 23/49] Record AI per-weapon firing, HUD outcome forecast, and free refusal rulings Co-Authored-By: Claude Opus 5.5 --- docs/fire-control-cut.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/fire-control-cut.md b/docs/fire-control-cut.md index 755a5c707..66f0b68e7 100644 --- a/docs/fire-control-cut.md +++ b/docs/fire-control-cut.md @@ -2648,6 +2648,14 @@ follow-up rulings, all given on 2026-09-30: derive their aim from it. The true owner of an arc is the **pivot**, not the hardpoint. The arc stays on the hardpoint until the moddable-ships campaign adds pivots to `HullData`. Until then, a pivot carrying several hardpoints turns within the **intersection** of their arcs. +- **AI and turrets (operator, 2026-09-30):** each weapon decides for itself, not the group's first weapon. + A fused weapon fires whenever its target is designated (visible, in range, locked), whatever the arc. A + non-fused weapon keeps the hit-chance threshold. The AI never fires at nothing, so AI does not bomb-fish. +- **HUD forecast (operator, 2026-09-30):** for a fused weapon, the forecast shows the outcome (Direct, Burst + at N m, Refused). `Fire` and `Inspect` compute it with the same helper. Direct-hit weapons keep the + percentage. +- **A refused round is free (operator, 2026-09-30):** it spends no ammo or energy, and makes no sound, heat, + wear or visibility. - **A round outlives its shooter,** for targeted and no-lock shots alike. Credit and loot with no shooter go nowhere. From 96c2f6ba8d0c2b229c774135510f07b6a85343cf Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 18:28:06 +0200 Subject: [PATCH 24/49] Record the literal arming distance and demoted-round rulings Co-Authored-By: Claude Opus 5.5 --- docs/fire-control-cut.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/docs/fire-control-cut.md b/docs/fire-control-cut.md index 66f0b68e7..0823120ee 100644 --- a/docs/fire-control-cut.md +++ b/docs/fire-control-cut.md @@ -2641,8 +2641,13 @@ follow-up rulings, all given on 2026-09-30: - **Out of arc (operator, 2026-09-30):** a fused weapon whose selected target is out of arc still fires (bomb fishing). It bursts at the target's range, clamped to max range, along the arc-clamped aim. Non-fused weapons refuse. -- **Arming distance (operator, 2026-09-30):** a round never bursts closer than its blast radius to its fire - origin, and close bursts are pushed out to that distance. A pilot who flies into their own distant burst +- **Arming distance (operator, 2026-09-30; refined the same day):** it is literal. A round never bursts + closer than `BlastRadius` to the ship's centre at fire time (`FireOrigin`), whatever the hull size or the + aim. Close bursts are pushed out to that distance, and fire is refused when `Range < BlastRadius`. A large + hull can take splash from its own close bursts, and that is accepted. +- **Demoted rounds keep their target (operator, 2026-09-30):** a round fired at a valid target closer than + the blast radius flies to the arming point but stays targeted. If the target leaves before resolution, + the round does not burst. A pilot who flies into their own distant burst takes the damage. Fire is refused when the live Range cannot clear BlastRadius. - **Turret arc ownership (operator, 2026-09-30):** the simulation's arc is authoritative, and Unity barrels derive their aim from it. The true owner of an arc is the **pivot**, not the hardpoint. The arc stays on From a193853d34d07bf9152e454bf8351bcbb070f947 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 18:36:59 +0200 Subject: [PATCH 25/49] Fuse batch 5: agents fire per weapon, Solve owns the burst, refusal is free, arming is the blast radius Operator rulings 2026-09-30. * Combat and TurretController ask FireControl.AgentFires for each weapon. A fused weapon fires whenever its target is designated (Designated, the one visible / in-range / locked predicate PFire already used), whatever the arc; an agent never fires one at nothing. A direct-hit weapon keeps the AgentMinHitProbability threshold. The group's first weapon no longer speaks for the group, and the turret's own range test is gone. * FireControl.Solve is the one decision about what a round does, extracted from Fire: Direct, Burst at a reach, or Refused. Fire, Inspect and the weapons' refusal check all read it. Inspect gains Outcome and BurstReach and the debug HUD prints them for fused weapons. * A refused round costs nothing. InstantWeapon asks before its ammo and energy spend (at the trigger and at each round), ConstantWeapon folds the refusal into the gate its power request and Execute already share, so a refused beam draws no power, wear, heat or visibility and stops. * The arming distance is BlastRadius, measured from the shooter's position and blind to hull size. HullAhead's exit, ArmingDistance and PendingShot.ArmingDistance are deleted; HullAhead survives as HullEntry for the contact fuse. A round fired at a valid target inside the radius is pushed out along its line but keeps its target, so a departed target cancels it. * Step settles a shot's queue state before publishing about it, so a throwing presentation observer costs its own notification and cannot re-commit or re-resolve the shot on every later tick. Tests: FireControlPerWeaponTests (new; includes a fused-beam fixture), the arming tests in FireControlCut124Tests rewritten to the literal distance, the hull-inclusive probe premises deleted. Co-Authored-By: Claude Sonnet 5 --- Assets/Scripts/Gameplay/ActionGameManager.cs | 7 +- .../ServerShared/Agents/States/Combat.cs | 9 +- .../ServerShared/Behaviors/ConstantWeapon.cs | 15 +- .../ServerShared/Behaviors/InstantWeapon.cs | 15 +- .../Behaviors/TurretController.cs | 10 +- Assets/Scripts/ServerShared/FireControl.cs | 278 ++++++---- .../FireControlCut124Tests.cs | 142 +++-- .../FireControlPerWeaponTests.cs | 503 ++++++++++++++++++ 8 files changed, 803 insertions(+), 176 deletions(-) create mode 100644 tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs diff --git a/Assets/Scripts/Gameplay/ActionGameManager.cs b/Assets/Scripts/Gameplay/ActionGameManager.cs index 3f69563ed..040bd4644 100644 --- a/Assets/Scripts/Gameplay/ActionGameManager.cs +++ b/Assets/Scripts/Gameplay/ActionGameManager.cs @@ -1337,6 +1337,11 @@ public class ActionGameManager : MonoBehaviour ? "target: none" : $"gates vis {d.Visible} range {d.InRange} arc {d.InArc} lock {d.Locked}"; + // A fused weapon's forecast is its outcome, not a hit chance: the burst point or the refusal. + var outcomeLine = d.Outcome == FireOutcome.Burst ? $"burst at {d.BurstReach:F0}" + : d.Outcome == FireOutcome.Refused ? "refused: arming distance exceeds range" + : $"base {d.PBase:P1}"; + DebugInfoText.text = $"FIRE CONTROL - {selectedItem.Data.Name}\n" + $"{gates}\n" + @@ -1344,7 +1349,7 @@ public class ActionGameManager : MonoBehaviour $"info {d.Info:F3}/{d.InfoDemandCeiling:F3} sensor {d.PSensor:F3}\n" + $"accuracy {d.Accuracy:F3} spread {d.PSpread:F3} hull {d.POnHull:F3}\n" + $"precision {d.Precision:F3} tracking {d.Tracking:F1}\n" + - $"base {d.PBase:P1}\n" + + $"{outcomeLine}\n" + $"{pendingLine}\n" + lastLine; } diff --git a/Assets/Scripts/ServerShared/Agents/States/Combat.cs b/Assets/Scripts/ServerShared/Agents/States/Combat.cs index 019198f36..41e2511da 100644 --- a/Assets/Scripts/ServerShared/Agents/States/Combat.cs +++ b/Assets/Scripts/ServerShared/Agents/States/Combat.cs @@ -111,13 +111,12 @@ public class CombatState : BaseState toTarget = normalize(predictedPosition - _agent.Ship.Position); } - // Cut 3: replaces Cut 1's bare InArc, which HitProbability already subsumes (it returns zero out - // of arc) -- this is the AI's own "worth it" heuristic layered on top of the shared gate, not a - // second gate. - var shouldFire = FireControl.HitProbability(testWeapon, _agent.Ship, target) >= _agent.Settings.AgentMinHitProbability; + // Each weapon decides for itself (operator ruling 2026-09-30): a group shares a trigger, not a + // verdict. FireControl.AgentFires is the AI's "worth it" heuristic for a weapon that hits what it + // fires at, and "the target is designated" for a fused one. foreach (var weapon in _agent.Ship.WeaponGroups[selectedGroup].weapons) { - if (shouldFire) + if (FireControl.AgentFires(weapon, _agent.Ship, target)) weapon.Activate(); else if (weapon.Firing) weapon.Deactivate(); diff --git a/Assets/Scripts/ServerShared/Behaviors/ConstantWeapon.cs b/Assets/Scripts/ServerShared/Behaviors/ConstantWeapon.cs index 14d6b4820..704635678 100644 --- a/Assets/Scripts/ServerShared/Behaviors/ConstantWeapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/ConstantWeapon.cs @@ -91,7 +91,13 @@ public class ConstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerC // Cut 5 (docs/fire-control-cut.md, 5.3, Soul finding 9): StanceAllowsFire alone let a side-mounted beam // fire forward -- ArcAllowsFire joins it here, matching InstantWeapon.cs's player arc gate (Q2: manual and // programmatic are one truth, and a continuous weapon is no exception). - public float PowerRequest(float dt) => _firing && StanceAllowsFire && ArcAllowsFire ? EvaluateNominalPower(_data.Energy) * dt : 0f; + // Operator ruling 2026-09-30: a refused round is free, and a beam is a stream of rounds, so a fused beam whose + // arming distance exceeds its Range fires nothing and draws nothing -- no power, ammo, wear, heat or + // visibility. FireControl.Refuses is the one arming test; it reads the Range Execute last refreshed, the same + // one-tick lag every PowerRequest reads its stats with. + private bool MayFire => StanceAllowsFire && ArcAllowsFire && !FireControl.Refuses(this, Entity); + + public float PowerRequest(float dt) => _firing && MayFire ? EvaluateNominalPower(_data.Energy) * dt : 0f; // Cut 5 (docs/stats-and-power-cut.md §1.3, PowerTiers.cs): Low -- offense, same as InstantWeapon. public int DefaultPowerTier => PowerTiers.Low; @@ -99,10 +105,11 @@ public class ConstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerC public override bool Execute(float dt) { base.Execute(dt); - if (_firing && !(StanceAllowsFire && ArcAllowsFire)) + if (_firing && !MayFire) { // Safed: shooter has a target and hasn't declared hostility toward it, or the target no longer - // bears (5.3: a beam obeys its arc exactly as InstantWeapon's trigger does). + // bears (5.3: a beam obeys its arc exactly as InstantWeapon's trigger does), or a fused round is + // refused. _firing = false; OnStopFiring?.Invoke(); return false; @@ -179,7 +186,7 @@ public class ConstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerC // argument included, when OnBeamShot has no subscriber (true today, R9's "no design uses it // yet"), so the roll would silently never happen. Evaluate it into a local first. var shotId = FireControl.Fire(this, Item, Entity, Damage * interval); - if (shotId != 0) OnBeamShot?.Invoke(shotId); + OnBeamShot?.Invoke(shotId); } } return true; diff --git a/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs b/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs index 6de087ba0..e4f4618be 100644 --- a/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs @@ -103,6 +103,9 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo // through -- a player's action-bar Activate() reaches this exactly the same way Combat.cs's and // TurretController.cs's Activate() calls do. if (!ArcAllowsFire) return; + // A refused round is free (operator ruling 2026-09-30): no ammo, energy or cooldown is spent on a burst + // whose first round FireControl would refuse. + if (FireControl.Refuses(this, Entity)) return; // If 1 ammo is consumed per burst, perform ammo and energy consumption here // UseAmmo returns false when triggering reload; cancel firing if that is the case @@ -227,16 +230,21 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo _burstTimer += dt; while (_burstRemaining > 0 && _burstTimer > 0) { + // A fused round whose arming distance Range cannot reach is refused, per round, before anything is + // spent (operator ruling 2026-09-30): it costs no ammo, energy, sound, heat, wear or visibility. + // The decision is FireControl's; Fire makes the same one. + var refused = FireControl.Refuses(this, Entity); // If multiple ammo is consumed per burst, perform ammo and energy consumption here // UseAmmo returns false when triggering reload; cancel firing if that is the case - if (!_data.SingleAmmoBurst && (!TrySpendActivationEnergy() || !UseAmmo())) + if (!refused && !_data.SingleAmmoBurst && (!TrySpendActivationEnergy() || !UseAmmo())) { _burstRemaining = 0; return false; } - + _burstRemaining--; _burstTimer -= _burstInterval; + if (refused) continue; // Cut 3: this is fire authority's one entry point. FireControl.Fire freezes the payload snapshot // (Q6 -- the gun's own stats at this exact instant, base.Execute(dt) above already refreshed them // this tick) and queues a PendingShot; nothing downstream re-evaluates a stat. @@ -246,9 +254,6 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo // Unity EntityInstance wiring, docs/headless-playground-cut.md), so a shot silently never fired. // Evaluate it into a local first. var shotId = FireControl.Fire(this, Item, Entity); - // A fused round whose arming distance Range cannot reach is refused (FireControl.Fire returns 0): - // nothing was fired, so nothing sounds, wears or heats. - if (shotId == 0) continue; OnFire?.Invoke(shotId); if(!firedThisFrame) { diff --git a/Assets/Scripts/ServerShared/Behaviors/TurretController.cs b/Assets/Scripts/ServerShared/Behaviors/TurretController.cs index 4a2fdab63..48b4c6c4e 100644 --- a/Assets/Scripts/ServerShared/Behaviors/TurretController.cs +++ b/Assets/Scripts/ServerShared/Behaviors/TurretController.cs @@ -73,16 +73,12 @@ public class TurretController : Behavior, IInitializableBehavior } else Entity.LookDirection = normalize(diff); - var dist = length(diff); foreach (var x in _weapons) { - var data = x.Data as WeaponData; - // Cut 3 (docs/fire-control-cut.md): the same gate and threshold Combat.cs uses -- HitProbability - // already subsumes the bare arc test (zero out of arc), so this is the "worth it" heuristic, not - // a parallel arc check. - var fire = FireControl.HitProbability(x, Entity, Entity.Target.Value) >= Entity.ItemManager.GameplaySettings.AgentMinHitProbability; - if (x.Evaluate(data.Range) > dist && fire) + // The same per-weapon decision Combat.cs makes; the range test is part of it (a shot out of range + // is not designated and prices at zero). + if (FireControl.AgentFires(x, Entity, Entity.Target.Value)) { x.Activate(); } diff --git a/Assets/Scripts/ServerShared/FireControl.cs b/Assets/Scripts/ServerShared/FireControl.cs index adf1721e0..4a6afb5e5 100644 --- a/Assets/Scripts/ServerShared/FireControl.cs +++ b/Assets/Scripts/ServerShared/FireControl.cs @@ -79,15 +79,20 @@ public static class FireControl return FuseOf(weapon.Item, out _) != null || InArc(weapon.Item, target.Position - shooter.Position); } - // Operator ruling 2026-09-30 (arming distance): a round never bursts closer than its blast radius to where - // it was fired, and the disc must clear the shooter's own hull as it stood at fire. So the burst is at least - // BlastRadius beyond the far edge of the shooter's hull along the flight line. Close bursts are pushed out - // along the aim to this distance; a pilot who flies into their own distant burst takes the damage. - private static float ArmingDistance(Entity shooter, float2 direction, float blastRadius) - { - HullAhead(shooter, shooter.Position.xz, direction, out _, out var exit); - return exit + blastRadius; - } + // Whether the shooter's next round from this weapon is refused: the weapons ask before they spend anything, so + // a refused round costs no ammo, energy, sound, heat, wear or visibility (operator ruling 2026-09-30). The + // answer is Solve's own -- there is no second copy of the arming test. + public static bool Refuses(Weapon weapon, Entity shooter) => + Solve(weapon, weapon.Item, shooter, shooter.Target.Value).Outcome == FireOutcome.Refused; + + // The agents' trigger decision (Combat, TurretController), one weapon at a time (operator ruling 2026-09-30). + // A weapon that fires at what it hits fires when the shot is worth taking. A fused weapon fires whenever its + // target is designated, whatever the arc: the round is sent out of arc on purpose (Solve). An agent never + // fires a fused weapon at nothing -- no target, or one it holds no valid data on, is no reason to fish. + public static bool AgentFires(Weapon weapon, Entity shooter, Entity target) => + FuseOf(weapon.Item, out _) != null + ? Designated(weapon, shooter, target, out _) + : HitProbability(weapon, shooter, target) >= shooter.ItemManager.GameplaySettings.AgentMinHitProbability; // Operator ruling 2026-09-30: a shot with nothing to bear on flies along where its shooter is aiming // (Entity.LookDirection -- the player's mouse or a turret's tracking), restricted by the mount's arc. The @@ -213,8 +218,24 @@ public static class FireControl private static float PFire(Weapon weapon, Entity source, Entity target, out float range) => PFire(weapon, source, target, out range, out _, out _); - // `designated` is the one answer to "does the shooter hold valid targeting data on this target": visible, in - // range and locked (for a LockWeapon). `inArc` is the bearing test, asked only once a target is designated. + // The one answer to "does the shooter hold valid targeting data on this target": visible, in range and locked + // (for a LockWeapon). PFire, Solve and the agents' trigger decision all read it here, and nowhere else. + // F8 correction (Soul's fix batch, 2026-09-24): `range` is computed for any non-null target, before any + // gate -- Fire reads it for flightTime, and flightTime must reflect the real distance even when a gate (not + // visible, out of range) already denies the target. Planar (R7: the simulation is 2D): the range gate, the + // burst reach and the range clamp all measure the same distance, so a target's height never moves one of + // them without the others. + public static bool Designated(Weapon weapon, Entity source, Entity target, out float range) + { + range = 0f; + if (target == null) return false; + range = length((target.Position - source.Position).xz); + return source.VisibleEntities.Contains(target) + && range >= weapon.MinRange && range <= weapon.Range + && !(weapon is LockWeapon lockWeapon && !lockWeapon.IsLocked); + } + + // `designated` is the answer above. `inArc` is the bearing test, asked only once a target is designated. // PFire prices a shot at zero unless both hold. Fire reads them to decide what a fused round flies at // (operator rulings 2026-09-30): without valid data it has no target and bursts at max range; with data but // out of arc it bursts at the target's range, along the aim. Inspect recomputes the four gates one by one, @@ -226,20 +247,9 @@ public static class FireControl inArc = false; if (target == null) return 0f; - // F8 correction (Soul's fix batch, 2026-09-24): range is computed for any non-null target, before any - // gate -- Fire reads it through this same overload for flightTime, and flightTime must reflect the - // real distance even when a gate (not visible, out of arc) already prices the shot at PFire 0. The - // gates below still short-circuit the rest of the shooter-side probability in the same order as before. - // Planar (R7: the simulation is 2D): the range gate, the burst reach and the range clamp all measure the - // same distance, so a target's height never moves one of them without the others. - var toTarget = target.Position - source.Position; - range = length(toTarget.xz); - - if (!source.VisibleEntities.Contains(target)) return 0f; - if (range < weapon.MinRange || range > weapon.Range) return 0f; - if (weapon is LockWeapon lockWeapon && !lockWeapon.IsLocked) return 0f; - designated = true; - inArc = InArc(weapon.Item, toTarget); + designated = Designated(weapon, source, target, out range); + if (!designated) return 0f; + inArc = InArc(weapon.Item, target.Position - source.Position); if (!inArc) return 0f; var settings = source.ItemManager.GameplaySettings; @@ -320,6 +330,9 @@ public static class FireControl MinRange = weapon.MinRange, MaxRange = weapon.Range }; + var solution = Solve(weapon, weapon.Item, source, target); + diagnostic.Outcome = solution.Outcome; + diagnostic.BurstReach = solution.BurstReach; if (target == null) return diagnostic; var toTarget = target.Position - source.Position; @@ -408,6 +421,82 @@ public static class FireControl return shot.Target == null ? 1f : saturate(1f - deviation / shot.Tracking); } + // The one decision about what a round does, made from the shooter's state and a target and nothing else: + // no draw, no side effect. Fire freezes it into a PendingShot; Inspect shows it to the HUD; the weapons ask + // it whether a round is refused before they spend anything -- so a refusal is decided once, by the same + // code that would have flown the round, and the HUD cannot forecast a burst the trigger will not fire. + // Direct: a round that resolves against its target (a weapon without a fuse). Burst: a fused round, with + // BurstReach the planar distance from the shooter at which it bursts. Refused: a fused round whose blast + // radius (its arming distance) exceeds the weapon's Range; it cannot be fired. + public static FireSolution Solve(Weapon weapon, EquippedItem item, Entity source, Entity target) + { + var pFire = PFire(weapon, source, target, out var fireRange, out var designated, out var inArc); + + // Cut 12.4(a) (Q12-6, "WeaponModifiers are labels, not behaviour"): a blast is a property of the + // weapon's data fields alone, resolved and frozen here like every other payload field -- never decided + // later by a Unity projectile, and never by a label. Solve decides "detonates" once: a fuse is frozen + // only when BlastRadius > 0, and null otherwise, so a radius without a fuse or a fuse without a radius + // is inert and resolves as a direct hit (nothing polices that). A zero-radius freeze is what Step below + // still reads as "resolve this shot with Apply." + var fuse = FuseOf(item, out var blastRadius); + + // Operator rulings 2026-09-30. A fused round flies along the shooter's aim (AimDirection, clamped to the + // mount's arc) and carries no target -- so nothing at resolution asks whether one is still around -- + // unless its target is designated (visible, in range, locked) and in arc: + // * no valid targeting data (no target, or one out of range, not visible or not locked) counts as no + // lock and bursts at the weapon's Range, the stat PFire's range gate reads: bomb fishing; + // * valid data but out of arc bursts at the target's range, which the range gate has already held to + // Range, along that same clamped aim: the arc is hoped to land the blast near enough to splash. + // Either way the round flies at the weapon's Velocity like a targeted shot flies to its intercept. A shot + // without a fuse keeps its target as before. + // Arming distance (operator ruling 2026-09-30): the blast radius, measured from the shooter's position and + // blind to hull size -- a large hull can take splash from its own close burst. A round never bursts nearer + // than that, so a burst that would land nearer is pushed out along its flight line to it. A round fired at a + // valid target inside the radius is pushed out but stays a targeted round: it keeps its target, so a target + // that leaves the zone cancels it like any other. A round whose Range is shorter than the arming distance + // cannot be fired at all: it is refused, per round, where Range is read after Execute refreshed it (a + // burst's later rounds and a power-starved Range included). + var engaged = fuse != null && !(designated && inArc) ? null : target; + var origin = source.Position; + var travelDirection = engaged != null ? TravelDirection(weapon, source, engaged) : AimDirection(item, source); + + var arming = fuse != null ? blastRadius : 0f; + var burstPosition = engaged != null ? engaged.Position : origin; + var flightDistance = 0f; + if (fuse != null && engaged != null) + { + // Nothing bursts beyond max range: an intercept a receding target leads the round past Range is + // pulled back along its own line to Range. + var intercept = PredictedIntercept(weapon, source, engaged); + var interceptReach = length((intercept - origin).xz); + if (min(interceptReach, weapon.Range) < arming) burstPosition = origin + float3(travelDirection.x, 0, travelDirection.y) * arming; + else burstPosition = interceptReach > weapon.Range ? origin + (intercept - origin) * (weapon.Range / interceptReach) : intercept; + flightDistance = max(fireRange, arming); + } + else if (fuse != null) + { + var reach = max(designated ? fireRange : weapon.Range, arming); + burstPosition = origin + float3(travelDirection.x, 0, travelDirection.y) * reach; + flightDistance = reach; + } + else if (engaged != null) flightDistance = fireRange; + + var solution = new FireSolution + { + Outcome = weapon.Range < arming ? FireOutcome.Refused : fuse != null ? FireOutcome.Burst : FireOutcome.Direct, + PFire = pFire, + FireRange = fireRange, + Engaged = engaged, + TravelDirection = travelDirection, + BurstPosition = burstPosition, + BurstReach = length((burstPosition - origin).xz), + FlightDistance = flightDistance, + Fuse = fuse, + BlastRadius = blastRadius + }; + return solution; + } + // Cut 3, R1: called once per burst step from InstantWeapon.Execute. Computes flight time, freezes the // payload snapshot (R10, Q6 -- the gun that fired it, not a re-read later), and queues a PendingShot for // Zone.Step to age and eventually resolve. Returns the ShotId so the caller's OnFire event can carry it to @@ -428,72 +517,26 @@ public static class FireControl // R1's engage gate and the shooter-side probability, evaluated now and frozen: nothing at commit time // re-reads a stat, an info level or a range. Cut 12.2: PFire no longer carries the spread/hull share -- // those are priced live at Commit, against the target's facing then, not now (Bearing timing, R10). - // F8 (Soul's fix batch, 2026-09-24): the `out range` overload is the one range HitProbability now - // reads too -- Fire used to recompute its own second copy of `target.Position - source.Position` for - // fireRange, right beside the copy PFire's gate already computed. - var pFire = PFire(weapon, source, target, out var fireRange, out var designated, out var inArc); - - // Cut 12.4(a) (Q12-6, "WeaponModifiers are labels, not behaviour"): a blast is a property of the - // weapon's data fields alone, resolved and frozen here like every other payload field -- never decided - // later by a Unity projectile, and never by a label. Fire decides "detonates" once: a fuse is frozen - // only when BlastRadius > 0, and null otherwise, so a radius without a fuse or a fuse without a radius - // is inert and resolves as a direct hit (nothing polices that). A zero-radius freeze is what Step below - // still reads as "resolve this shot with Apply." - var fuse = FuseOf(item, out var blastRadius); - - // Operator rulings 2026-09-30. A fused round flies along the shooter's aim (AimDirection, clamped to the - // mount's arc) and carries no target -- so nothing at resolution asks whether one is still around -- - // unless its target is designated (visible, in range, locked) and in arc: - // * no valid targeting data (no target, or one out of range, not visible or not locked) counts as no - // lock and bursts at the weapon's Range, the stat PFire's range gate reads: bomb fishing; - // * valid data but out of arc bursts at the target's range, which the range gate has already held to - // Range, along that same clamped aim: the arc is hoped to land the blast near enough to splash. - // Either way the round flies at the weapon's Velocity like a targeted shot flies to its intercept. A shot - // without a fuse keeps its target as before. - // Arming distance (operator ruling 2026-09-30): a round never bursts closer than its blast radius past the - // shooter's hull, so a burst that would land nearer -- a target close in, or out of arc and inside the - // radius -- is pushed out along the aim to that distance, and the round carries no target. A round whose - // arming distance exceeds the weapon's Range cannot be fired at all: it is refused here, per round, where - // Range is read after Execute refreshed it (a burst's later rounds and a power-starved Range included). - var engaged = fuse != null && !(designated && inArc) ? null : target; + // Solve is the one range and the one burst decision Inspect and the weapons' refusal check read too. + var solution = Solve(weapon, item, source, target); + if (solution.Outcome == FireOutcome.Refused) return 0; + var pFire = solution.PFire; + var fireRange = solution.FireRange; + var engaged = solution.Engaged; + var travelDirection = solution.TravelDirection; + var burstPosition = solution.BurstPosition; + var fuse = solution.Fuse; + var blastRadius = solution.BlastRadius; var origin = source.Position; - var travelDirection = engaged != null ? TravelDirection(weapon, source, engaged) : AimDirection(item, source); - - var arming = 0f; - var burstPosition = engaged != null ? engaged.Position : origin; - if (fuse != null && engaged != null) - { - // Nothing bursts beyond max range: an intercept a receding target leads the round past Range is - // pulled back along its own line to Range. - var intercept = PredictedIntercept(weapon, source, engaged); - var interceptReach = length((intercept - origin).xz); - arming = ArmingDistance(source, travelDirection, blastRadius); - if (min(interceptReach, weapon.Range) < arming) engaged = null; - else burstPosition = interceptReach > weapon.Range ? origin + (intercept - origin) * (weapon.Range / interceptReach) : intercept; - } - if (fuse != null && engaged == null) - { - travelDirection = AimDirection(item, source); - arming = ArmingDistance(source, travelDirection, blastRadius); - var reach = max(designated ? fireRange : weapon.Range, arming); - burstPosition = origin + float3(travelDirection.x, 0, travelDirection.y) * reach; - } - if (arming > weapon.Range) return 0; var targetVelocity = float3.zero; var targetPosition = origin; - var flightTime = 0f; if (engaged != null) { targetVelocity = float3(engaged.Velocity.x, 0, engaged.Velocity.y); targetPosition = engaged.Position; - flightTime = weapon.Velocity > .01f ? fireRange / weapon.Velocity : 0f; - } - else if (fuse != null) - { - var flown = length((burstPosition - origin).xz); - flightTime = weapon.Velocity > .01f ? flown / weapon.Velocity : 0f; } + var flightTime = weapon.Velocity > .01f ? solution.FlightDistance / weapon.Velocity : 0f; var commitHorizon = source.ItemManager.GameplaySettings.CommitHorizon; @@ -526,7 +569,6 @@ public static class FireControl BurstPosition = burstPosition, Fuse = fuse, BlastRadius = blastRadius, - ArmingDistance = arming, Committed = false }; @@ -540,6 +582,9 @@ public static class FireControl // its shooter (operator ruling 2026-09-30): the shooter is only an identity a shot carries, never asked // for at resolution, so a shooter that died or left the zone in flight changes nothing about the shot. // Resolution reads the zone and the target; there is no credit or loot for a shooter to receive. + // A shot's queue state is settled before anything is published about it: it is committed in the list before + // ShotCommitted fires and removed before ShotResolved fires, so a presentation observer that throws costs + // that one notification and cannot leave the shot to be committed or resolved again on every later tick. public static void Step(Zone zone, float dt) { var shots = zone.PendingShots; @@ -559,9 +604,9 @@ public static class FireControl // the commit decided, while ShotResolved reports what actually happened -- nothing, because the // target is gone. Republishing the committed outcome here used to put a hit marker on a corpse. var miss = MakeOutcome(shot, ShotResult.Miss, false, false, int2.zero, float2.zero, 0f, now); + shots.RemoveAt(i); if (!shot.Committed) zone.ShotCommitted.OnNext(miss); zone.ShotResolved.OnNext(miss); - shots.RemoveAt(i); continue; } @@ -572,8 +617,8 @@ public static class FireControl // A contact burst short of max range arrives when the round has flown that far, not at the frozen // max-range time (CommitBurst). Never later than the frozen time. if (shot.Outcome.Result == ShotResult.Burst) shot.ArrivalTime = min(shot.ArrivalTime, now + shot.Outcome.ArrivalIn); - zone.ShotCommitted.OnNext(shot.Outcome); shots[i] = shot; + zone.ShotCommitted.OnNext(shot.Outcome); } if (shot.Committed && now >= shot.ArrivalTime) @@ -583,8 +628,8 @@ public static class FireControl // branch left. A direct hit, a proximity burst and a contact or delayed blast all arrive here // the same way; only Apply decides what that means. Apply(zone, shot); - zone.ShotResolved.OnNext(shot.Outcome); shots.RemoveAt(i); + zone.ShotResolved.OnNext(shot.Outcome); } } } @@ -738,7 +783,7 @@ public static class FireControl outcome.HasBurstPoint = true; var reach = length(shot.BurstPosition.xz - shot.FireOrigin.xz); var travelled = shot.Fuse == WeaponFuse.Contact ? ContactDistance(zone, shot, reach) : reach; - travelled = min(reach, max(travelled, shot.ArmingDistance)); + travelled = min(reach, max(travelled, shot.BlastRadius)); if (travelled < reach) { outcome.BurstPoint = shot.FireOrigin.xz + shot.TravelDirection * travelled; @@ -759,18 +804,17 @@ public static class FireControl foreach (var entity in zone.Entities) { if (entity == shot.Source) continue; - if (HullAhead(entity, shot.FireOrigin.xz, shot.TravelDirection, out var entry, out _)) nearest = min(nearest, entry); + nearest = min(nearest, HullEntry(entity, shot.FireOrigin.xz, shot.TravelDirection)); } return nearest; } - // Where the line from `origin` along the unit planar `direction` meets `entity`'s hull, in world distance - // from `origin`: `entry` to the first metal cell ahead (+infinity when the line meets none, and negative when the - // origin is already inside the hull: the arming distance floors every caller's use of it) and `exit` to the far edge of the last one (zero when none). Read - // through Lane's own cell admission (CellsOnLine), in the entity's schematic frame: the line's lateral offset - // is its own, and cells wholly behind the origin are skipped. Contact fuses read `entry`; a round's arming - // distance reads `exit` of its own shooter. - private static bool HullAhead(Entity entity, float2 origin, float2 direction, out float entry, out float exit) + // Where the line from `origin` along the unit planar `direction` meets `entity`'s hull: the world distance from + // `origin` to the first metal cell ahead, +infinity when the line meets none, and negative when the origin is + // already inside the hull (the blast radius floors every caller's use of it). Read through Lane's own cell + // admission (CellsOnLine), in the entity's schematic frame: the line's lateral offset is its own, and cells + // wholly behind the origin are skipped. Only a contact fuse asks. + private static float HullEntry(Entity entity, float2 origin, float2 direction) { var hullData = entity.ItemManager.GetData(entity.Hull) as HullData; var cellSize = entity.ItemManager.GameplaySettings.SchematicCellSize; @@ -779,8 +823,7 @@ public static class FireControl var lateral = dot(start, Lateral(bearing)); var along = dot(start, bearing); - entry = float.PositiveInfinity; - exit = 0f; + var entry = float.PositiveInfinity; var buffer = ArrayPool.Shared.Rent(hullData.Shape.Coordinates.Length); try { @@ -788,10 +831,10 @@ public static class FireControl for (var i = 0; i < count; i++) { if (!(buffer[i].Exit > along)) continue; - if (float.IsPositiveInfinity(entry)) entry = (buffer[i].Entry - along) * cellSize; - exit = max(exit, (buffer[i].Exit - along) * cellSize); + entry = (buffer[i].Entry - along) * cellSize; + break; } - return !float.IsPositiveInfinity(entry); + return entry; } finally { @@ -1708,14 +1751,11 @@ public struct PendingShot // Cut 12.4(a): the blast payload, frozen at Fire alongside everything else above. BlastRadius is zero for // a weapon whose data does not carry one -- Step reads that zero as "resolve with Apply," not a separate - // bool. Fuse is frozen only when BlastRadius > 0 (Fire decides "detonates" once); it is unread until + // bool. Fuse is frozen only when BlastRadius > 0 (Solve decides "detonates" once); it is unread until // Apply's fuse switch (12.4(b)). public float3 BurstPosition; public WeaponFuse? Fuse; public float BlastRadius; - // How far along its flight line a fused round may first burst: BlastRadius past the far edge of the - // shooter's hull, frozen at Fire (a round outlives its shooter's pose, too). - public float ArmingDistance; // Frozen with the rest of the shooter's decision: where the round left from. A round outlives its shooter, so // nothing at resolution reads the shooter's position. @@ -1730,6 +1770,32 @@ public struct PendingShot public ShotOutcome Outcome; } +// What a round does when fired (FireControl.Solve): Direct resolves against its target, Burst detonates at a +// point, Refused cannot be fired -- its blast radius exceeds the weapon's Range. +public enum FireOutcome +{ + Direct, + Burst, + Refused +} + +// Solve's answer. Outcome and BurstReach (planar distance from the shooter to the burst point, read for Burst +// only) are what the HUD shows; the rest is what Fire freezes into the PendingShot. +public struct FireSolution +{ + public FireOutcome Outcome; + public float BurstReach; + // How far the round flies: to its burst point, or for a direct round to its target. + public float FlightDistance; + public float PFire; + public float FireRange; + public Entity Engaged; + public float2 TravelDirection; + public float3 BurstPosition; + public WeaponFuse? Fuse; + public float BlastRadius; +} + public struct FireControlDiagnostic { public bool HasTarget; @@ -1753,6 +1819,10 @@ public struct FireControlDiagnostic public float PSpread; public float POnHull; public float PBase; + // What a round from this weapon does (Solve): the forecast for a fused weapon, whose percentages above do + // not describe it. BurstReach is read only when Outcome is Burst. + public FireOutcome Outcome; + public float BurstReach; } // Cut 12.2 (docs/fire-control-cut.md): a merged interval of a hull's lateral shadow, in the schematic frame's diff --git a/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs index cb50782d7..ca50e475c 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs @@ -1410,24 +1410,22 @@ public sealed class FireControlCut124Tests : IDisposable // ==== Operator ruling 2026-09-30, batch 4: the arming distance ==== // - // A round never bursts closer than its blast radius past the shooter's own hull. The fixture's shooter hull is - // a 5x5 block of 2-unit cells centred on the shooter, so its far edge is 5 units out along the axis and - // 5 * sqrt(2) along a diagonal; with a blast of 30 the arming distance is 35 (or 30 + 7.07). - - private const float ShooterHullReach = 5f; + // A round never bursts closer than its blast radius (its arming distance), measured from the shooter's + // position and blind to hull size: the fixture's shooter hull is a 5x5 block of 2-unit cells, and a blast of 30 + // arms at 30, not 30 plus whatever the hull reaches. A shooter close to its own burst takes the splash. private static float Planar(float3 from, float3 to) => length((to - from).xz); - // A target inside the arming distance is not detonated on: the round flies along the aim and bursts at the - // arming distance, carrying no target, and the shooter that stood beside it is untouched (the probes measured - // 7.07 of self-damage for a target at 5, before this). The out-of-arc row is a target 10 away behind the - // mount, inside the blast radius, with the mount flipped so the arc is closed. Kills: no arming distance; a - // burst that still rides the target; an arming distance measured from the shooter's centre instead of the far - // edge of its hull (the burst would land 5 units short and the shooter would still be inside the disc). + // A target inside the arming distance is not detonated on: the round flies on to the arming distance, exactly + // the blast radius from the shooter (35 would be the hull-inclusive figure this replaced). An in-arc target + // stays the round's target and the burst is pushed out along the line to it; the out-of-arc row is a target 10 + // away behind the mount, inside the blast radius, with the mount flipped so the arc is closed: no target rides + // and the burst is on the aim. Kills: no arming distance; a hull term in it; an in-arc round demoted to no + // target; an out-of-arc round that keeps one. [Theory] [InlineData(5f, false)] [InlineData(10f, true)] - public void ACloseBurstIsPushedOutAlongTheAimToTheArmingDistance(float targetRange, bool outOfArc) + public void ACloseBurstIsPushedOutToTheBlastRadius(float targetRange, bool outOfArc) { var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: 30f, damage: 100f, weaponRange: 60f, targetRange: targetRange); @@ -1438,37 +1436,88 @@ public sealed class FireControlCut124Tests : IDisposable aim = float2(0, -1); } Aim(e, aim); - var burst = PointAlong(e, aim, 30f + ShooterHullReach); + var burst = PointAlong(e, aim, 30f); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + var shot = SafeAssert.OnlyShot(e.Zone); + Assert.True((shot.Target == e.Target) == !outOfArc, "only a round with a target it can bear on keeps it"); + Assert.Equal(burst.x, shot.BurstPosition.x, 2); + Assert.Equal(burst.z, shot.BurstPosition.z, 2); + } + + // A round pushed out to its arming distance is still a targeted round (operator ruling 2026-09-30): if its target + // leaves the zone before it resolves, it does not burst. The target is 5 units away, inside a blast of 30, and + // the round flies 1.5 seconds; the target is gone after the first tick. The round resolves as a miss with no + // burst point, and the shooter standing inside the would-be disc is untouched. Kills: demoting a close round + // to no target (the round then bursts on schedule and hurts the shooter). + [Fact] + public void APushedOutRoundWhoseTargetLeavesTheZoneDoesNotBurst() + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 20f, fuse: WeaponFuse.Proximity, blastRadius: 30f, damage: 100f, + weaponRange: 60f, targetRange: 5f); + Aim(e, float2(0, 1)); var hurt = 0f; using var a = e.Shooter.ArmorDamage.Subscribe(x => hurt += x.damage); using var h = e.Shooter.HullDamage.Subscribe(x => hurt += x); using var i = e.Shooter.ItemDamage.Subscribe(x => hurt += x.damage); + ShotOutcome resolved = null; + using var r = e.Zone.ShotResolved.Subscribe(o => resolved = o); FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); - var shot = SafeAssert.OnlyShot(e.Zone); - Assert.True(shot.Target == null, "a burst pushed out to the arming distance carries no target"); - Assert.Equal(burst.x, shot.BurstPosition.x, 2); - Assert.Equal(burst.z, shot.BurstPosition.z, 2); - Assert.Equal(35f, shot.ArmingDistance, 2); + Assert.True(SafeAssert.OnlyShot(e.Zone).Target == e.Target, "fixture: the pushed-out round keeps its target"); e.Zone.Update(.01f); + e.Zone.Entities.Remove(e.Target); + e.Zone.Update(3f); + SafeAssert.NoShots(e.Zone); + Assert.NotNull(resolved); + Assert.Equal(ShotResult.Miss, resolved.Result); + Assert.False(resolved.HasBurstPoint, "a round whose target is gone does not burst"); Assert.Equal(0f, hurt); } - // The far edge is read along the flight line, not along an axis: aimed down a diagonal the 5x5 hull reaches - // 5 * sqrt(2) = 7.07 units, so the burst sits at 30 + 7.07. A close in-arc target makes the arming distance - // the burst reach. Kills: an extent read along the mount or the hull's own axes. - [Fact] - public void TheArmingDistanceIsMeasuredAlongTheFlightLine() - { - var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: 30f, damage: 100f, - weaponRange: 60f, targetRange: 5f); - Aim(e, normalize(float2(1, 1))); + public enum Publication { Committed, Resolved } + // A presentation observer that throws costs its own notification and nothing more: the shot's queue state is + // settled before anything is published, so it is not committed or resolved a second time on every later tick + // (the queue used to wedge, re-throwing forever). The first tick throws; the second does not, the shot is gone + // or committed exactly once. Kills: publishing before the shot is committed in the list or removed from it. + [Theory] + [InlineData(Publication.Committed)] + [InlineData(Publication.Resolved)] + public void AThrowingObserverCannotWedgeTheShotQueue(Publication throwsOn) + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: 4f, damage: 100f); + var commits = 0; + var resolutions = 0; + using var c = e.Zone.ShotCommitted.Subscribe(_ => { commits++; if (throwsOn == Publication.Committed) throw new InvalidOperationException("observer"); }); + using var r = e.Zone.ShotResolved.Subscribe(_ => { resolutions++; if (throwsOn == Publication.Resolved) throw new InvalidOperationException("observer"); }); FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); - var shot = SafeAssert.OnlyShot(e.Zone); - Assert.Equal(30f + ShooterHullReach * sqrt(2f), Planar(e.Shooter.Position, shot.BurstPosition), 1); + Assert.ThrowsAny(() => e.Zone.Update(.01f)); + var second = Record.Exception(() => e.Zone.Update(.01f)); + + Assert.Null(second); + Assert.Equal(1, commits); + Assert.True(resolutions <= 1, $"resolved {resolutions} times"); + } + + // The same for the miss a departed target publishes: the shot is removed before that notification, so an + // observer that throws on it cannot keep the shot in the queue to be cancelled, and thrown at, on every tick. + // Kills: publishing the miss before removing the shot. + [Fact] + public void AThrowingObserverOfADepartedTargetsMissCannotWedgeTheShotQueue() + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 20f, fuse: WeaponFuse.Proximity, blastRadius: 4f, damage: 100f); + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + using var c = e.Zone.ShotCommitted.Subscribe(_ => throw new InvalidOperationException("observer")); + e.Zone.Entities.Remove(e.Target); + + Assert.ThrowsAny(() => e.Zone.Update(.01f)); + var second = Record.Exception(() => e.Zone.Update(.01f)); + + Assert.Null(second); + SafeAssert.NoShots(e.Zone); } // A target beyond the arming distance is not pushed anywhere: the round still rides it and bursts at its @@ -1488,15 +1537,15 @@ public sealed class FireControlCut124Tests : IDisposable } // A contact round stops on the first hull it crosses, but never nearer than its arming distance: a blocker - // whose near face is 6 units out (centred 10) is passed, and the round bursts at 5 + 4 = 9. Kills: a contact - // point taken as the hull face regardless of arming. + // whose near face is 2 units out (centred 6) is passed, and the round bursts at its blast radius, 4. Kills: a + // contact point taken as the hull face regardless of arming. [Fact] public void AContactRoundNeverStopsNearerThanItsArmingDistance() { var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Contact, blastRadius: 4f, damage: 100f, weaponRange: NoLockRange); e.Shooter.Target.Value = null; Aim(e, float2(0, 1)); - AddShip(e, PointAlong(e, float2(0, 1), 10f), 1f, 300); + AddShip(e, PointAlong(e, float2(0, 1), 6f), 1f, 300); ShotOutcome outcome = null; using var c = e.Zone.ShotCommitted.Subscribe(o => outcome = o); @@ -1504,7 +1553,7 @@ public sealed class FireControlCut124Tests : IDisposable e.Zone.Update(.01f); Assert.NotNull(outcome); - Assert.Equal(e.Shooter.Position.z + ShooterHullReach + 4f, outcome.BurstPoint.y, 2); + Assert.Equal(e.Shooter.Position.z + 4f, outcome.BurstPoint.y, 2); } // A round fired from inside another hull is already in metal: its contact point is its arming distance. The @@ -1528,16 +1577,16 @@ public sealed class FireControlCut124Tests : IDisposable e.Zone.Update(.01f); Assert.NotNull(committed); - Assert.Equal(e.Shooter.Position.z + ShooterHullReach + 4f, committed.BurstPoint.y, 2); + Assert.Equal(e.Shooter.Position.z + 4f, committed.BurstPoint.y, 2); } - // A fused round is refused, per round and at the moment Fire reads Range, when its arming distance exceeds - // that Range: 34.5 against 35 refuses, 35.01 fires, and a power-starved Range (authored 100, forced to about + // A fused round is refused, per round and at the moment Fire reads Range, when its arming distance (the blast + // radius, 30) exceeds that Range: 29.5 refuses, 30.01 fires, and a power-starved Range (authored 100, forced to about // 10 the way Soul's probe did after the trigger has passed) refuses although the authored figure clears // it. Fire returns 0 and queues nothing. Kills: a gate that reads the authored Range; `>=` for `>`. [Theory] - [InlineData(34.5f, false, false)] - [InlineData(35.01f, false, true)] + [InlineData(29.5f, false, false)] + [InlineData(30.01f, false, true)] [InlineData(100f, true, false)] public void AFusedRoundWhoseArmingDistanceExceedsRangeIsRefusedByFire(float range, bool starvedOfPower, bool fires) { @@ -1550,7 +1599,7 @@ public sealed class FireControlCut124Tests : IDisposable typeof(EquippedItem).GetProperty("PowerSupply").GetSetMethod(true).Invoke(e.WeaponItem, new object[] { .1f }); e.Shooter.Resolver.InvalidateSource(e.WeaponItem, StatSource.PowerSupply); e.Weapon.GetType().GetMethod("UpdateStats", BindingFlags.Instance | BindingFlags.NonPublic).Invoke(e.Weapon, null); - Assert.True(e.Weapon.Range < 35f, "fixture: the runtime Range must have fallen below the arming distance"); + Assert.True(e.Weapon.Range < 30f, "fixture: the runtime Range must have fallen below the arming distance"); } var shotId = FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); @@ -1559,19 +1608,12 @@ public sealed class FireControlCut124Tests : IDisposable Assert.Equal(fires ? 1 : 0, e.Zone.PendingShots.Count); } - // The boundary itself: a Range exactly equal to the arming distance fires (the disc is tangent to the hull, - // which delivers nothing). The arming distance is read from a probe round on the same fixture, so the - // equality is exact rather than a decimal that float arithmetic could tip. Kills: `>=` for `>`. + // The boundary itself: a Range exactly equal to the arming distance (the blast radius) fires. Kills: `>=` for + // `>`. [Fact] public void ARangeExactlyEqualToTheArmingDistanceFires() { - var probe = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: 30f, damage: 100f, weaponRange: 60f); - probe.Shooter.Target.Value = null; - Aim(probe, float2(0, 1)); - FireControl.Fire(probe.Weapon, probe.WeaponItem, probe.Shooter); - var arming = SafeAssert.OnlyShot(probe.Zone).ArmingDistance; - - var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: 30f, damage: 100f, weaponRange: arming); + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: 30f, damage: 100f, weaponRange: 30f); e.Shooter.Target.Value = null; Aim(e, float2(0, 1)); @@ -1582,7 +1624,7 @@ public sealed class FireControlCut124Tests : IDisposable // queues nothing, and the same weapon with Range to spare fires once. Kills: Execute still announcing a // refused round (Fire's 0 handed to the presentation as a shot id). [Theory] - [InlineData(34.5f, 0)] + [InlineData(29.5f, 0)] [InlineData(40f, 1)] public void ARefusedRoundIsNotAnnounced(float range, int fired) { diff --git a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs new file mode 100644 index 000000000..c1276745e --- /dev/null +++ b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs @@ -0,0 +1,503 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using CultMath; +using GameCult.Caching; +using UniRx; +using Xunit; +using static CultMath.math; +using float2 = CultMath.float2; +using float3 = CultMath.float3; +using int2 = CultMath.int2; + +// Operator rulings 2026-09-30 (fuse batch 5): AI and turrets decide per weapon and a fused weapon needs a +// designated target; the HUD forecast shows a fused weapon's outcome and reads the same decision Fire flies; a +// refused round is free, for a discrete weapon and for a beam. Builds its own fixture, the convention every +// fire-control test file follows: its shooter carries several mounts and can hold either kind of weapon. +public sealed class FireControlPerWeaponTests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-firecontrolperweapon-" + Guid.NewGuid().ToString("N")); + private string Catalog => Path.Combine(_root, "Aetheria.cc"); + + public FireControlPerWeaponTests() => Directory.CreateDirectory(_root); + + private readonly List _openCaches = new List(); + + public void Dispose() + { + foreach (var c in _openCaches) c.Dispose(); + Directory.Delete(_root, true); + } + + private static GameplaySettings TestSettings(float minHit) => new GameplaySettings + { + DefaultEntitySettings = new EntitySettings(), + Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Rarity = 0, Color = new float3(1, 1, 1) } }, + QualityPriceModifier = new ExponentialLerp(), + TargetDetectionInfoThreshold = .1f, + TargetArmorInfoThreshold = .2f, + TargetGearInfoThreshold = .8f, + FiringArc = 170, + CommitHorizon = .5f, + SchematicCellSize = 2f, + UnaidedAccuracy = .05f, + UnaidedTracking = 10f, + UnaidedPrecision = 1000f, + AgentMinHitProbability = minHit, + BeamResolveInterval = .1f + }; + + private static PerformanceStat Constant(float v) => new PerformanceStat { Min = v, Max = v }; + + private static Shape SolidShape(int w, int h) + { + var shape = new Shape(w, h); + foreach (var cell in shape.AllCoordinates) shape[cell] = true; + return shape; + } + + private sealed class Gun + { + public EquippedItem Item; + public Weapon Weapon; + } + + private sealed class Rig + { + public Zone Zone; + public Ship Shooter; + public Ship Target; + public List Guns = new List(); + public Gun Gun => Guns[0]; + } + + // Mount 0 (None, arc 0) bears on a target dead ahead; (Clockwise, arc 0) is a side mount the default 170 + // degree arc cannot bring to bear on it. Fused: Proximity with `blast`, which is also its arming distance: blast 30 + // arms at 30, so a Range of 29.5 refuses and 40 fires. + private Rig Build( + (ItemRotation Rotation, float Arc)[] mounts = null, bool beam = false, WeaponFuse? fuse = null, float blast = 0f, + float range = 100f, float targetRange = 60f, float accuracy = 1f, float minHit = .2f, bool lockWeapon = false, + bool turret = false, float energy = 0f, float heat = 0f, float visibility = 0f, int magazine = 0, bool singleAmmoBurst = false) + { + mounts ??= new[] { (ItemRotation.None, 0f) }; + var hullData = new HullData + { + Name = "Hull", HullType = HullType.Ship, Shape = SolidShape(5, 4), Durability = 1000000, Mass = 1000, Armor = 0, + Hardpoints = { new HardpointData { Type = HardpointType.Sensors, Position = new int2(0, 0), Shape = new Shape() } } + }; + var shooterHullData = new HullData + { + Name = "ShooterHull", HullType = HullType.Ship, Shape = SolidShape(5, 5), Durability = 1000000, Mass = 1000 + }; + for (var i = 0; i < mounts.Length; i++) + shooterHullData.Hardpoints.Add(new HardpointData + { + Type = HardpointType.Sensors, Position = new int2(i, 0), Shape = new Shape(), + Rotation = mounts[i].Rotation, FiringArc = mounts[i].Arc + }); + + WeaponData behavior; + if (beam) + behavior = new ConstantWeaponData(); + else if (lockWeapon) + behavior = new LockWeaponData + { + LockSpeed = Constant(1000), SensorImpact = Constant(1), LockAngle = Constant(0), DirectionImpact = Constant(1), Decay = Constant(1), + Count = Constant(1), BurstTime = Constant(0), Cooldown = Constant(1) + }; + else + behavior = new InstantWeaponData { Count = Constant(1), BurstTime = Constant(0), Cooldown = Constant(1), SingleAmmoBurst = singleAmmoBurst }; + behavior.Damage = Constant(100); + behavior.Range = Constant(range); + behavior.MinRange = Constant(0); + behavior.Velocity = Constant(0); + behavior.Spread = Constant(0); + behavior.DamageSpread = Constant(0); + behavior.Penetration = Constant(0); + behavior.Energy = Constant(energy); + behavior.Heat = Constant(heat); + behavior.Visibility = Constant(visibility); + behavior.MagazineSize = magazine; + behavior.DamageCurve = new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 1, 0, 0) } }; + + var cache = AetheriaStores.Open(Catalog + Guid.NewGuid().ToString("N"), catalogWritable: true); + _openCaches.Add(cache); + cache.Upsert(new TestCatalogGlobal { Name = "Temperament" }); + cache.Upsert(hullData); + cache.Upsert(shooterHullData); + cache.Upsert(new WeaponItemData + { + Name = "Gun", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Fuse = fuse, BlastRadius = blast, + Behaviors = { behavior } + }); + cache.Upsert(new GearData + { + Name = "Targeting", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 1, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Behaviors = { new TargetingSystemData { Accuracy = Constant(accuracy), Resolution = Constant(1000f), Precision = Constant(1000f), Tracking = Constant(1000000f) } } + }); + cache.Upsert(new GearData + { + Name = "Reactor", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 10, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Behaviors = { new ReactorData { Charge = Constant(1000), Efficiency = Constant(1), OverloadEfficiency = Constant(1), ThrottlingFactor = Constant(2) } } + }); + cache.Upsert(new GearData + { + Name = "Turret", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 1, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Behaviors = { new TurretControllerData() } + }); + cache.FlushAsync().Wait(); + + var ledger = new ProvenanceLedger(); + for (var i = 1; i <= 100; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = 1 }; + var items = new ItemManager(cache, ledger, TestSettings(minHit), _ => { }); + var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "PerWeapon", Owner = null }, null); + + EquippableItem Make(string name, int lot, float durability) => new EquippableItem + { + Data = items.ItemData.RefOf(items.ItemData.GetByName(name)), Durability = durability, Lot = lot + }; + + var lot = 1; + var shooterHullRef = items.ItemData.RefOf(items.ItemData.GetByName("ShooterHull")); + var hullRef = items.ItemData.RefOf(items.ItemData.GetByName("Hull")); + var shooter = new Ship(items, zone, new EquippableItem { Data = shooterHullRef, Durability = 1000000, Lot = lot++ }, new EntitySettings()); + var rig = new Rig(); + for (var i = 0; i < mounts.Length; i++) + { + var gun = Make("Gun", lot++, 1); + Assert.True(shooter.TryEquip(gun, new int2(i, 0))); + var item = shooter.Equipment.Single(x => x.EquippableItem == gun); + rig.Guns.Add(new Gun { Item = item, Weapon = (Weapon) item.Behaviors.Single(b => b is Weapon) }); + } + var targeting = Make("Targeting", lot++, 1); + Assert.True(shooter.TryFindSpace(targeting, out var tpos)); + Assert.True(shooter.TryEquip(targeting, tpos)); + if (true) + { + var reactor = Make("Reactor", lot++, 10); + Assert.True(shooter.TryFindSpace(reactor, out var rpos)); + Assert.True(shooter.TryEquip(reactor, rpos)); + } + if (turret) + { + var t = Make("Turret", lot++, 1); + Assert.True(shooter.TryFindSpace(t, out var upos)); + Assert.True(shooter.TryEquip(t, upos)); + } + + var target = new Ship(items, zone, new EquippableItem { Data = hullRef, Durability = 1000000, Lot = lot++ }, new EntitySettings()); + Assert.True(target.TryEquip(Make("Gun", lot++, 1), new int2(0, 0))); + + zone.Entities.Add(shooter); + zone.Entities.Add(target); + shooter.Activate(); + target.Activate(); + + shooter.Position = float3(-53, 0, -101); + target.Position = shooter.Position + float3(0, 0, targetRange); + shooter.Target.Value = target; + shooter.EntityInfoGathered[target] = 1f; + shooter.SetIff(target, true); + + zone.Update(1f); + + rig.Zone = zone; + rig.Shooter = shooter; + rig.Target = target; + return rig; + } + + private static void Aim(Rig r, float2 direction) => r.Shooter.LookDirection = float3(direction.x, 0, direction.y); + + private static readonly (ItemRotation, float)[] SideThenForward = { (ItemRotation.Clockwise, 0f), (ItemRotation.None, 0f) }; + private static readonly (ItemRotation, float)[] SideMount = { (ItemRotation.Clockwise, 0f) }; + + // ==== Ruling 1: per weapon, and a fused weapon needs a designated target ==== + + // A weapon group shares a trigger, not a verdict: two guns of one type, the first on a side mount that cannot + // bear on the target and the second dead ahead. Only the second fires. Kills: the group's verdict read off its + // first weapon (Combat's old `testWeapon`), which silences the group. + [Fact] + public void CombatDecidesEachWeaponOfAGroupForItself() + { + var r = Build(SideThenForward); + var group = r.Shooter.WeaponGroups.Single(g => g.weapons.Count == 2).weapons; + Assert.True(ReferenceEquals(group[0], r.Guns[0].Weapon), "fixture: the side mount leads the group"); + r.Zone.Agents.Add(new Minion(r.Shooter)); + + r.Zone.Update(1f); + r.Zone.Update(1f); + + Assert.False(r.Guns[0].Weapon.Firing, "the side-mounted gun cannot bear"); + Assert.True(r.Guns[1].Weapon.Firing, "the forward gun bears and is worth firing"); + } + + // A fused weapon fires whenever its target is designated, whatever the arc: the same group, now fused, fires + // both guns, the side mount included. Kills: Combat reading HitProbability (zero out of arc) for a fused weapon. + [Fact] + public void CombatFiresAFusedWeaponAtADesignatedTargetOutOfArc() + { + var r = Build(SideThenForward, fuse: WeaponFuse.Proximity, blast: 4f); + r.Zone.Agents.Add(new Minion(r.Shooter)); + + r.Zone.Update(1f); + r.Zone.Update(1f); + + Assert.True(r.Guns[0].Weapon.Firing, "a designated target is enough, arc or no"); + Assert.True(r.Guns[1].Weapon.Firing); + } + + // The turret makes the same per-weapon decision. Kills: TurretController reading HitProbability, or its own + // range test, for a fused weapon. + [Fact] + public void ATurretFiresAFusedWeaponAtADesignatedTargetOutOfArc() + { + var r = Build(SideMount, fuse: WeaponFuse.Proximity, blast: 4f, turret: true); + + r.Zone.Update(1f); + r.Zone.Update(1f); + + Assert.True(r.Gun.Weapon.Firing, "the turret must fire a fused weapon at a designated target out of arc"); + } + + public enum Withheld { NoTarget, OutOfRange, NotVisible, Unlocked } + + // The AI never fires a fused weapon at nothing: a missing target, one out of range, one not visible and one + // an unlocked LockWeapon holds no lock on are each no reason to fish. The direct-hit control fires (true) on + // the same rig once the target is valid. Kills: `designated` dropped from the fused branch (null included). + [Theory] + [InlineData(Withheld.NoTarget)] + [InlineData(Withheld.OutOfRange)] + [InlineData(Withheld.NotVisible)] + [InlineData(Withheld.Unlocked)] + public void AnAgentNeverFiresAFusedWeaponAtNothing(Withheld reason) + { + var r = Build(fuse: WeaponFuse.Proximity, blast: 4f, range: 100f, + targetRange: reason == Withheld.OutOfRange ? 150f : 60f, lockWeapon: reason == Withheld.Unlocked); + var target = r.Target; + if (reason == Withheld.NoTarget) target = null; + if (reason == Withheld.NotVisible) r.Shooter.VisibleEntities.Remove(r.Target); + + Assert.False(FireControl.AgentFires(r.Gun.Weapon, r.Shooter, target)); + } + + [Fact] + public void AnAgentFiresAFusedWeaponAtAValidTargetWhateverItsHitChance() + { + // Accuracy 0 prices every direct hit at zero; the fused weapon is unmoved. + var r = Build(SideMount, fuse: WeaponFuse.Proximity, blast: 4f, accuracy: 0f); + + Assert.True(FireControl.AgentFires(r.Gun.Weapon, r.Shooter, r.Target)); + } + + // A weapon that fires at what it hits keeps the AgentMinHitProbability threshold, per weapon. + // Kills: the threshold dropped for a direct-hit weapon, or applied to a fused one. + [Theory] + [InlineData(.5f, .2f, true)] + [InlineData(.5f, .9f, false)] + public void AnAgentKeepsTheHitProbabilityThresholdForADirectHitWeapon(float accuracy, float minHit, bool fires) + { + var r = Build(accuracy: accuracy, minHit: minHit); + var p = FireControl.HitProbability(r.Gun.Weapon, r.Shooter, r.Target); + Assert.True(p > .3f && p < .7f, $"fixture: the shot must price near the accuracy, priced {p}"); + + Assert.Equal(fires, FireControl.AgentFires(r.Gun.Weapon, r.Shooter, r.Target)); + } + + // ==== Ruling 2: the HUD forecast is Fire's own decision ==== + + // Inspect's Outcome and BurstReach against what Fire then does with the same rig: Refused iff Fire returns 0, + // and a Burst's reach is the planar distance to the burst point Fire froze. Rows: a target beyond the arming + // distance (burst at its range), one inside it (pushed out to 30), no target (max range), out of arc, a + // Range that cannot reach the arming distance, and a weapon without a fuse. + // Kills: an Inspect that computes its own reach (ignoring the arming push or the Range clamp). + [Theory] + [InlineData(60f, 100f, false, false, FireOutcome.Burst, 60f)] + [InlineData(20f, 100f, false, false, FireOutcome.Burst, 30f)] + [InlineData(60f, 40f, true, false, FireOutcome.Burst, 40f)] + [InlineData(60f, 100f, false, true, FireOutcome.Burst, 60f)] + [InlineData(60f, 29.5f, false, false, FireOutcome.Refused, 0f)] + [InlineData(60f, 29.5f, true, false, FireOutcome.Refused, 0f)] + public void TheForecastIsTheOutcomeFireDelivers(float targetRange, float range, bool noTarget, bool outOfArc, FireOutcome outcome, float reach) + { + var r = Build(outOfArc ? SideMount : null, fuse: WeaponFuse.Proximity, blast: 30f, range: range, targetRange: targetRange); + if (noTarget) r.Shooter.Target.Value = null; + Aim(r, float2(0, 1)); + + var d = FireControl.Inspect(r.Gun.Weapon, r.Shooter, r.Shooter.Target.Value); + var shotId = FireControl.Fire(r.Gun.Weapon, r.Gun.Item, r.Shooter); + + Assert.Equal(outcome, d.Outcome); + Assert.Equal(outcome != FireOutcome.Refused, shotId != 0); + if (outcome == FireOutcome.Refused) return; + Assert.Equal(reach, d.BurstReach, 2); + var shot = SafeAssert.OnlyShot(r.Zone); + Assert.Equal(d.BurstReach, length((shot.BurstPosition - shot.FireOrigin).xz), 2); + } + + // A weapon without a fuse forecasts a direct hit and keeps its percentages. Kills: Solve calling everything a burst. + [Fact] + public void AWeaponWithoutAFuseForecastsADirectHit() + { + var r = Build(); + + var d = FireControl.Inspect(r.Gun.Weapon, r.Shooter, r.Target); + + Assert.Equal(FireOutcome.Direct, d.Outcome); + Assert.True(d.PBase > 0f); + } + + // ==== Ruling 3: a refused round is free ==== + + private sealed class Costs + { + public int Ammo; + public float ChargeSpent; + public int Sounds; + public int Wears; + public float Heat; + public bool Visible; + public int Announced; + public int Shots; + } + + // One discrete weapon, triggered once at no target, with every cost observable: a magazine, an energy charge, + // a sound bank, wear events, heat and visibility. Range 29.5 refuses (arming 30); Range 40 fires. + private Costs Pull(float range, bool single) + { + var r = Build(fuse: WeaponFuse.Proximity, blast: 30f, range: range, energy: 50f, heat: 5000f, visibility: 100f, magazine: 5, singleAmmoBurst: single); + r.Shooter.Target.Value = null; + Aim(r, float2(0, 1)); + var weapon = (InstantWeapon) r.Gun.Weapon; + var capacitor = (InputCapacitor) typeof(InstantWeapon).GetField("_capacitor", BindingFlags.Instance | BindingFlags.NonPublic).GetValue(weapon); + r.Gun.Item.SoundBank = new WwiseMetaSoundBank { IncludedEvents = new[] { new WwiseMetaObject { Id = 7, Name = "gun_fire" } } }; + var costs = new Costs(); + r.Gun.Item.AudioEvents.Subscribe(_ => costs.Sounds++); + r.Zone.ShotCommitted.Subscribe(_ => costs.Shots++); // a fused round with no flight commits and resolves in its own tick + r.Shooter.ItemDamage.Subscribe(t => { if (t.Item1 == r.Gun.Item) costs.Wears++; }); + weapon.OnFire += _ => costs.Announced++; + capacitor.AddCharge(1000f); // a full buffer, so the first round can be paid for + var chargeBefore = capacitor.Charge; + var heatBefore = r.Gun.Item.Temperature; + var ammoBefore = weapon.Ammo; + Assert.True(chargeBefore > 0f && ammoBefore == 5, "fixture: a full buffer and magazine"); + + weapon.Activate(); + r.Zone.Update(.01f); + + costs.Ammo = ammoBefore - weapon.Ammo; + costs.ChargeSpent = chargeBefore - capacitor.Charge; + costs.Heat = r.Gun.Item.Temperature - heatBefore; + costs.Visible = r.Shooter.VisibilitySources.TryGetValue(weapon, out var v) && v > 1f; + return costs; + } + + // Control: the same weapon with Range to spare pays every cost, so a free refusal below is not a fixture that + // never spends. Then the refusal costs nothing: no ammo, no energy, no sound, no wear, no heat, no + // visibility, no shot. Kills, one per row of the assertions: a spend that precedes the refusal (Execute or + // Trigger), an announcement, a sound, wear, heat or visibility left ahead of the `continue`. + // `single` is a weapon that pays once per burst, at the trigger rather than at the round. + [Theory] + [InlineData(false)] + [InlineData(true)] + public void ARefusedDiscreteRoundCostsNothing(bool single) + { + var fired = Pull(40f, single); + Assert.True(fired.Shots == 1, $"shots {fired.Shots}"); + Assert.True(fired.Ammo == 1, $"ammo spent {fired.Ammo}"); + Assert.True(fired.ChargeSpent > 1f, "a fired round spends its energy"); + Assert.Equal(1, fired.Sounds); + Assert.True(fired.Wears > 0); + Assert.True(fired.Heat > 0f); + Assert.True(fired.Visible); + Assert.Equal(1, fired.Announced); + + var refused = Pull(29.5f, single); + Assert.Equal(0, refused.Shots); + Assert.Equal(0, refused.Ammo); + Assert.Equal(0f, refused.ChargeSpent); + Assert.Equal(0, refused.Sounds); + Assert.Equal(0, refused.Wears); + Assert.True(fired.Heat - refused.Heat > 5f, $"a refused round adds no heat ({refused.Heat} vs {fired.Heat})"); + Assert.False(refused.Visible); + Assert.Equal(0, refused.Announced); + } + + // The trigger and the round are separate decisions: a burst authorised at the trigger runs its rounds later, + // at whatever Range Execute has refreshed by then, and a round that Range no longer reaches is refused before + // its spend. The authorisation is set directly (the way a trigger that passed leaves it) on a weapon whose + // Range is short of its blast radius, so the Trigger check cannot be what stops it. Kills: Execute spending + // before it asks. + [Fact] + public void ARoundRefusedAfterTheTriggerPassedIsRefusedBeforeItsSpend() + { + var r = Build(fuse: WeaponFuse.Proximity, blast: 30f, range: 29.5f, magazine: 5); + r.Shooter.Target.Value = null; + Aim(r, float2(0, 1)); + var weapon = (InstantWeapon) r.Gun.Weapon; + typeof(InstantWeapon).GetField("_burstRemaining", BindingFlags.Instance | BindingFlags.NonPublic).SetValue(weapon, 1); + + r.Zone.Update(.01f); + + Assert.Equal(5, weapon.Ammo); + SafeAssert.NoShots(r.Zone); + Assert.Equal(0, (int) typeof(InstantWeapon).GetField("_burstRemaining", BindingFlags.Instance | BindingFlags.NonPublic).GetValue(weapon)); + } + + // The same rule for a beam: a fused beam whose arming distance exceeds Range fires nothing and draws + // nothing. The control (Range 40) rolls shots, wears, draws power and shows; the refused beam does none of + // that, asks the bus for nothing, and stops itself the way an out-of-arc beam does. + // Kills: ConstantWeapon spending (power request, wear, heat, visibility) before the refusal, or not + // consulting FireControl at all. + private (int Shots, int Wears, float Request, bool Visible, float Heat, int Stops, bool Firing) Burn(float range) + { + var r = Build(beam: true, fuse: WeaponFuse.Proximity, blast: 30f, range: range, energy: 50f, heat: 5000f, visibility: 100f); + r.Shooter.Target.Value = null; + Aim(r, float2(0, 1)); + var weapon = (ConstantWeapon) r.Gun.Weapon; + var wears = 0; + var stops = 0; + var shots = 0; + r.Zone.ShotResolved.Subscribe(_ => shots++); + r.Shooter.ItemDamage.Subscribe(t => { if (t.Item1 == r.Gun.Item) wears++; }); + weapon.OnStopFiring += () => stops++; + var heatBefore = r.Gun.Item.Temperature; + + weapon.Activate(); + var request = weapon.PowerRequest(.1f); + for (var i = 0; i < 5; i++) r.Zone.Update(.1f); + + var visible = r.Shooter.VisibilitySources.TryGetValue(weapon, out var v) && v > 1f; + return (shots, wears, request, visible, r.Gun.Item.Temperature - heatBefore, stops, weapon.Firing); + } + + [Fact] + public void ARefusedBeamCostsNothing() + { + var fired = Burn(40f); + Assert.True(fired.Shots > 0, "control: the beam rolls shots"); + Assert.True(fired.Wears > 0); + Assert.True(fired.Request > 0f); + Assert.True(fired.Visible); + Assert.True(fired.Heat > 0f); + + var refused = Burn(29.5f); + Assert.Equal(0, refused.Shots); + Assert.Equal(0, refused.Wears); + Assert.Equal(0f, refused.Request); + Assert.False(refused.Visible); + Assert.True(fired.Heat - refused.Heat > 5f, $"a refused beam adds no heat ({refused.Heat} vs {fired.Heat})"); + Assert.True(refused.Stops > 0 && !refused.Firing, "a refused beam stops itself, as an out-of-arc beam does"); + } +} From 8c63fbe50e6c98ab8fc1df63618baf44481c0218 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 18:37:02 +0200 Subject: [PATCH 26/49] Guided projectile: binding lives and dies with the projectile; no explosion without a detonation Unity-only, compile-checked by reading. * F5: GuidedProjectile.Binding is one owner: a new binding disposes the one it replaces, Kill disposes it, and OnDestroy does too, so a projectile torn down with its scene cannot leave an observer on the zone's subjects to touch a destroyed transform. * F6: Resolve takes whether the simulation detonated. A miss (a contact or delayed round that missed, a target that left) fades out with no explosion. * F7: BurstAt resets the previous target position and target velocity, so the retarget jump does not feed the guidance a bogus target velocity. Co-Authored-By: Claude Sonnet 5 --- .../Gameplay/Weapons/GuidedProjectile.cs | 34 ++++++++++++++++--- .../Weapons/GuidedProjectileManager.cs | 6 ++-- 2 files changed, 33 insertions(+), 7 deletions(-) diff --git a/Assets/Scripts/Gameplay/Weapons/GuidedProjectile.cs b/Assets/Scripts/Gameplay/Weapons/GuidedProjectile.cs index b5fdc47da..db2a8674d 100644 --- a/Assets/Scripts/Gameplay/Weapons/GuidedProjectile.cs +++ b/Assets/Scripts/Gameplay/Weapons/GuidedProjectile.cs @@ -50,20 +50,45 @@ public class GuidedProjectile : MonoBehaviour // The simulation's verdict on this round, bound by GuidedProjectileManager to Zone.ShotCommitted and // ShotResolved for its ShotId: this object flies and looks, and neither decides where the round bursts nor when. - public IDisposable Binding { get; set; } + // The one owner of the subscription's lifetime: a new binding disposes the one it replaces, Kill disposes it, + // and so does destruction, so a projectile torn down with its scene never leaves an observer on the zone's + // subjects to touch a destroyed transform. + public IDisposable Binding + { + get => _binding; + set + { + _binding?.Dispose(); + _binding = value; + } + } + + private IDisposable _binding; + + void OnDestroy() => Binding = null; // The commit named a burst point: fly to it, whatever this round was homing on (a target it would have - // passed, or a Range clamp it would have overshot). + // passed, or a Range clamp it would have overshot). The point is a static target, so the old target's motion + // is dropped with it: the jump to the new point must not feed the guidance a target velocity. public void BurstAt(Vector3 point) { Target = null; TargetPosition = () => point; + _previousTargetPosition = point; + _targetVelocity = Vector3.zero; } - // The resolution: the round bursts now, at the simulation's burst point when it has one. - public void Resolve(Vector3? point) + // The resolution. A round that detonated (a hit, or a burst) bursts now, at the simulation's burst point when + // it has one; a round the simulation resolved without a detonation (a contact or delayed round that missed, + // one whose target is gone) just falls away, with no explosion to show. + public void Resolve(bool detonated, Vector3? point) { if (!_alive || !_active) return; + if (!detonated) + { + StartCoroutine(FadeOut()); + return; + } if (point.HasValue) transform.position = point.Value; if (HitEffect != null) { @@ -195,7 +220,6 @@ public class GuidedProjectile : MonoBehaviour IEnumerator Kill() { - Binding?.Dispose(); Binding = null; _active = false; _alive = false; diff --git a/Assets/Scripts/Gameplay/Weapons/GuidedProjectileManager.cs b/Assets/Scripts/Gameplay/Weapons/GuidedProjectileManager.cs index 2e41570ef..a611b1106 100644 --- a/Assets/Scripts/Gameplay/Weapons/GuidedProjectileManager.cs +++ b/Assets/Scripts/Gameplay/Weapons/GuidedProjectileManager.cs @@ -16,8 +16,10 @@ public class GuidedProjectileManager : InstantWeaponEffectManager Vector3 At(ShotOutcome o) => new Vector3(o.BurstPoint.x, p.transform.position.y, o.BurstPoint.y); var bindings = new CompositeDisposable(); zone.ShotCommitted.Where(o => o.ShotId == shotId && o.HasBurstPoint).Subscribe(o => p.BurstAt(At(o))).AddTo(bindings); - zone.ShotResolved.Where(o => o.ShotId == shotId).Subscribe(o => p.Resolve(o.HasBurstPoint ? At(o) : (Vector3?) null)).AddTo(bindings); - p.Binding = bindings; + zone.ShotResolved.Where(o => o.ShotId == shotId) + .Subscribe(o => p.Resolve(o.Result == ShotResult.Hit || o.Result == ShotResult.Burst, o.HasBurstPoint ? At(o) : (Vector3?) null)) + .AddTo(bindings); + p.Binding = bindings; // replaces, and so disposes, any binding the projectile already had } public Prototype ProjectilePrototype; From 7e620ff9f22013af7bf4b83db74c1659af6bda46 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 18:51:57 +0200 Subject: [PATCH 27/49] Run the refused-round cost test through Execute as well as the trigger The trigger's own refusal check stopped a short-Range round before Execute ever reached its refusal, so wear, heat, visibility and sound left ahead of Execute's `continue` survived every mutation. The cost theory now also authorises a burst directly, the way a trigger that passed leaves it, and the separate ammo-only test folds into it. Co-Authored-By: Claude Sonnet 5 --- .../FireControlPerWeaponTests.cs | 45 ++++++------------- 1 file changed, 14 insertions(+), 31 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs index c1276745e..8ee6e70a4 100644 --- a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs @@ -374,7 +374,7 @@ public sealed class FireControlPerWeaponTests : IDisposable // One discrete weapon, triggered once at no target, with every cost observable: a magazine, an energy charge, // a sound bank, wear events, heat and visibility. Range 29.5 refuses (arming 30); Range 40 fires. - private Costs Pull(float range, bool single) + private Costs Pull(float range, bool single, bool viaTrigger) { var r = Build(fuse: WeaponFuse.Proximity, blast: 30f, range: range, energy: 50f, heat: 5000f, visibility: 100f, magazine: 5, singleAmmoBurst: single); r.Shooter.Target.Value = null; @@ -393,7 +393,8 @@ public sealed class FireControlPerWeaponTests : IDisposable var ammoBefore = weapon.Ammo; Assert.True(chargeBefore > 0f && ammoBefore == 5, "fixture: a full buffer and magazine"); - weapon.Activate(); + if (viaTrigger) weapon.Activate(); + else typeof(InstantWeapon).GetField("_burstRemaining", BindingFlags.Instance | BindingFlags.NonPublic).SetValue(weapon, 1); r.Zone.Update(.01f); costs.Ammo = ammoBefore - weapon.Ammo; @@ -405,15 +406,18 @@ public sealed class FireControlPerWeaponTests : IDisposable // Control: the same weapon with Range to spare pays every cost, so a free refusal below is not a fixture that // never spends. Then the refusal costs nothing: no ammo, no energy, no sound, no wear, no heat, no - // visibility, no shot. Kills, one per row of the assertions: a spend that precedes the refusal (Execute or - // Trigger), an announcement, a sound, wear, heat or visibility left ahead of the `continue`. - // `single` is a weapon that pays once per burst, at the trigger rather than at the round. + // visibility, no shot. `single` is a weapon that pays once per burst, at the trigger rather than at the round; + // `viaTrigger` false authorises the burst directly (the way a trigger that passed leaves it) on a weapon whose + // Range is short, so the Trigger check cannot be what stops the round and Execute must refuse it itself. + // Kills, one per row of the assertions: a spend that precedes the refusal (Execute or Trigger), an + // announcement, a sound, wear, heat or visibility left ahead of the `continue`. [Theory] - [InlineData(false)] - [InlineData(true)] - public void ARefusedDiscreteRoundCostsNothing(bool single) + [InlineData(false, true)] + [InlineData(true, true)] + [InlineData(false, false)] + public void ARefusedDiscreteRoundCostsNothing(bool single, bool viaTrigger) { - var fired = Pull(40f, single); + var fired = Pull(40f, single, viaTrigger); Assert.True(fired.Shots == 1, $"shots {fired.Shots}"); Assert.True(fired.Ammo == 1, $"ammo spent {fired.Ammo}"); Assert.True(fired.ChargeSpent > 1f, "a fired round spends its energy"); @@ -423,7 +427,7 @@ public sealed class FireControlPerWeaponTests : IDisposable Assert.True(fired.Visible); Assert.Equal(1, fired.Announced); - var refused = Pull(29.5f, single); + var refused = Pull(29.5f, single, viaTrigger); Assert.Equal(0, refused.Shots); Assert.Equal(0, refused.Ammo); Assert.Equal(0f, refused.ChargeSpent); @@ -434,27 +438,6 @@ public sealed class FireControlPerWeaponTests : IDisposable Assert.Equal(0, refused.Announced); } - // The trigger and the round are separate decisions: a burst authorised at the trigger runs its rounds later, - // at whatever Range Execute has refreshed by then, and a round that Range no longer reaches is refused before - // its spend. The authorisation is set directly (the way a trigger that passed leaves it) on a weapon whose - // Range is short of its blast radius, so the Trigger check cannot be what stops it. Kills: Execute spending - // before it asks. - [Fact] - public void ARoundRefusedAfterTheTriggerPassedIsRefusedBeforeItsSpend() - { - var r = Build(fuse: WeaponFuse.Proximity, blast: 30f, range: 29.5f, magazine: 5); - r.Shooter.Target.Value = null; - Aim(r, float2(0, 1)); - var weapon = (InstantWeapon) r.Gun.Weapon; - typeof(InstantWeapon).GetField("_burstRemaining", BindingFlags.Instance | BindingFlags.NonPublic).SetValue(weapon, 1); - - r.Zone.Update(.01f); - - Assert.Equal(5, weapon.Ammo); - SafeAssert.NoShots(r.Zone); - Assert.Equal(0, (int) typeof(InstantWeapon).GetField("_burstRemaining", BindingFlags.Instance | BindingFlags.NonPublic).GetValue(weapon)); - } - // The same rule for a beam: a fused beam whose arming distance exceeds Range fires nothing and draws // nothing. The control (Range 40) rolls shots, wears, draws power and shows; the refused beam does none of // that, asks the bus for nothing, and stops itself the way an out-of-arc beam does. From d3be00c23a4ead2df469eb9c0606d6144db5f661 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 20:10:06 +0200 Subject: [PATCH 28/49] Record the all-fuses first-hull ruling and the batch 5 Soul pass Co-Authored-By: Claude Opus 5.5 --- docs/fire-control-cut.md | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/docs/fire-control-cut.md b/docs/fire-control-cut.md index 0823120ee..9235a666a 100644 --- a/docs/fire-control-cut.md +++ b/docs/fire-control-cut.md @@ -2629,8 +2629,10 @@ follow-up rulings, all given on 2026-09-30: the fix had used the hull-fixed mount. - **An invalid target counts as no lock.** A target that is selected but out of range, out of arc, not visible, or not locked takes the no-lock path. Nothing bursts beyond max range. -- **A no-lock contact fuse stops on the first hull on its aim line**, computed at commit. Mining adds chunks - to this through a named seam. +- **A no-lock round of any fuse stops on the first hull on its aim line**, computed at commit, and detonates + there. This covers contact, proximity and delayed fuses (operator, 2026-09-30, extending the contact-only + ruling). A round fired along a clear line still bursts at max range (bomb fishing). Mining adds chunks to + this through a named seam. - **Airburst follows player intent (operator, 2026-09-30):** "If I don't have targeting data but decide to shoot anyway, my intent is likely to be bomb fishing. If I fire with a target but that target warps away, I likely to not want to explode whatever was behind it, and the projectile shouldn't burst." @@ -2661,6 +2663,18 @@ follow-up rulings, all given on 2026-09-30: percentage. - **A refused round is free (operator, 2026-09-30):** it spends no ammo or energy, and makes no sound, heat, wear or visibility. +- **Batch 5 Soul pass, 2026-09-30: fix first.** `Solve` is the one decision, and `Fire`, `Inspect` and + `Refuses` all read it. Findings in fix batch 6 (Hands, `hands/fuse-fix2`): + - Contact and delayed rounds detonate on the hull and ignore the arming push-out. + - A refused beam blinks start and stop. + - A throwing damage observer re-applies a blast every tick. + - The AI selects refused fused weapons. + - A single-ammo burst pays, then refuses. + - The guided projectile's overshoot explosion ignores the simulation. + - Two copies of the range and designation predicates remain. + + An out-of-arc round with a selected target bursts after that target leaves. That is ruled behaviour: an + out-of-arc target is invalid, so the round takes the no-lock path. - **A round outlives its shooter,** for targeted and no-lock shots alike. Credit and loot with no shooter go nowhere. From 63a2fcdaa4a124ce80199c3cb79075ec7be9bc74 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 20:20:06 +0200 Subject: [PATCH 29/49] Guided projectile: only the simulation's detonation shows an explosion The overshoot branch of GuidedProjectile.Update (out of range, or flown past its target) instantiated HitEffect whatever the simulation had decided. A round the simulation never detonated could therefore explode on screen. The branch now fades the round out; Resolve stays the one place a HitEffect is shown for a detonation. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/Gameplay/Weapons/GuidedProjectile.cs | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/Assets/Scripts/Gameplay/Weapons/GuidedProjectile.cs b/Assets/Scripts/Gameplay/Weapons/GuidedProjectile.cs index db2a8674d..c66ede374 100644 --- a/Assets/Scripts/Gameplay/Weapons/GuidedProjectile.cs +++ b/Assets/Scripts/Gameplay/Weapons/GuidedProjectile.cs @@ -134,14 +134,11 @@ public class GuidedProjectile : MonoBehaviour var targetDist = diff.magnitude; var sourceDist = length(StartPosition.xz - position.xz); + // Out of range, or flown past its target: the round falls away. Only the simulation's detonation + // (Resolve) shows an explosion; a round that merely ran out of flight has none. if (sourceDist > Range || dot(diff.ToCultMath(), Velocity.ToCultMath()) < 0) { StartCoroutine(FadeOut()); - if (HitEffect != null) - { - var ht = HitEffect.Instantiate(); - ht.position = t.position; - } return; } _prevDist = targetDist; From 57c2560fc01fed4353ca2e5cd889ba5af6aeebb3 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 20:31:59 +0200 Subject: [PATCH 30/49] Fuse fixes: arming holds for every burst, no-lock rounds stop on the first hull, settle before user code Operator ruling 2026-09-30: a no-lock round of any fuse stops at the first hull on its aim line and detonates there (bomb fishing still bursts at max range on a clear line). CommitBurst no longer special-cases Contact. Soul's findings on 14f66634..7e620ff9: - Arming (HIGH): a contact or delayed blast on a hull nearer the muzzle than the blast radius detonated at the hull. Armed is now the one owner of the arming rule; Solve plans its burst point through it and ApplyBlastHit pushes the hull point through it. - A refused beam never starts (ConstantWeapon.Activate asks Refuses), so OnStartFiring no longer flashes visuals and audio for a beam that fires nothing. - Step removes a shot from the queue before Apply runs damage observers, so a throwing observer cannot make the blast repeat every tick. - AgentFires consults Solve for a fused weapon: a refused weapon is never fired and Combat's group pick no longer counts its damage. - SingleAmmoBurst pays at its first unrefused round in Execute, judged with the Range that round flies with, instead of at the trigger on the previous tick's. - Inspect reads Designated from Solve (FireSolution.Designated) instead of recomputing visible, in-range and locked; FireControlDiagnostic drops those three fields and the debug HUD shows the one. Combat's group range test is FireControl.InReach, the range half of Designated (a LockWeapon must be activated to acquire its lock, so Combat cannot ask Designated itself). Tests pin each rule, plus the two surviving gaps: a pushed-out targeted round's arrival at the arming distance, and the AgentMinHitProbability boundary. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/Gameplay/ActionGameManager.cs | 2 +- .../ServerShared/Agents/States/Combat.cs | 10 +- .../ServerShared/Behaviors/ConstantWeapon.cs | 4 +- .../ServerShared/Behaviors/InstantWeapon.cs | 16 +- Assets/Scripts/ServerShared/FireControl.cs | 91 ++++++----- .../FireControlCut124Tests.cs | 121 +++++++++++++-- .../FireControlCut9Tests.cs | 8 +- .../FireControlPerWeaponTests.cs | 142 +++++++++++++++++- 8 files changed, 325 insertions(+), 69 deletions(-) diff --git a/Assets/Scripts/Gameplay/ActionGameManager.cs b/Assets/Scripts/Gameplay/ActionGameManager.cs index 040bd4644..543e8f8ef 100644 --- a/Assets/Scripts/Gameplay/ActionGameManager.cs +++ b/Assets/Scripts/Gameplay/ActionGameManager.cs @@ -1335,7 +1335,7 @@ public class ActionGameManager : MonoBehaviour : "last: none"; var gates = target == null ? "target: none" - : $"gates vis {d.Visible} range {d.InRange} arc {d.InArc} lock {d.Locked}"; + : $"gates designated {d.Designated} arc {d.InArc}"; // A fused weapon's forecast is its outcome, not a hit chance: the burst point or the refusal. var outcomeLine = d.Outcome == FireOutcome.Burst ? $"burst at {d.BurstReach:F0}" diff --git a/Assets/Scripts/ServerShared/Agents/States/Combat.cs b/Assets/Scripts/ServerShared/Agents/States/Combat.cs index 41e2511da..d27a90ab1 100644 --- a/Assets/Scripts/ServerShared/Agents/States/Combat.cs +++ b/Assets/Scripts/ServerShared/Agents/States/Combat.cs @@ -57,10 +57,12 @@ public class CombatState : BaseState var group = _agent.Ship.WeaponGroups[i]; var dps = 0f; foreach (var weapon in group.weapons) - if (weapon.Item.Online.Value && - weapon.MinRange < targetDistance && - targetDistance < weapon.Range && - (weapon is ConstantWeapon || + // The range test is FireControl's (InReach, the part of Designated before the lock, which this + // loop is what acquires), and a weapon Solve refuses adds nothing to a group's damage. + if (weapon.Item.Online.Value && + FireControl.InReach(weapon, _agent.Ship, target, out _) && + !FireControl.Refuses(weapon, _agent.Ship) && + (weapon is ConstantWeapon || weapon is InstantWeapon instantWeapon && instantWeapon.CanFire)) { if(weapon is LockWeapon lockWeapon) diff --git a/Assets/Scripts/ServerShared/Behaviors/ConstantWeapon.cs b/Assets/Scripts/ServerShared/Behaviors/ConstantWeapon.cs index 704635678..e9dab4ce6 100644 --- a/Assets/Scripts/ServerShared/Behaviors/ConstantWeapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/ConstantWeapon.cs @@ -194,7 +194,9 @@ public class ConstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerC public override void Activate() { - if(!_firing && !_reloading) + // A refused beam never starts (operator ruling 2026-09-30): OnStartFiring drives the beam's visuals and + // audio, so starting it only for Execute to stop it a tick later would flash a beam that fires nothing. + if(!_firing && !_reloading && !FireControl.Refuses(this, Entity)) { _firing = true; OnStartFiring?.Invoke(); diff --git a/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs b/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs index e4f4618be..59b7f6344 100644 --- a/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs @@ -41,6 +41,7 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo protected readonly InputCapacitor _capacitor = new InputCapacitor(); protected int _burstRemaining; + private bool _burstPaid; private float _burstTimer; private float _burstInterval; protected float _cooldown; // Normalized @@ -107,10 +108,10 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo // whose first round FireControl would refuse. if (FireControl.Refuses(this, Entity)) return; - // If 1 ammo is consumed per burst, perform ammo and energy consumption here - // UseAmmo returns false when triggering reload; cancel firing if that is the case - if(_data.SingleAmmoBurst && (!TrySpendActivationEnergy() || !UseAmmo())) return; - + // A burst that pays once (SingleAmmoBurst) pays at its first round that is not refused, in Execute, judged + // with the Range that round flies with: paying here would judge with the Range of the previous tick, and a + // Range that fell in between would spend the ammo on rounds Fire then refuses. + _burstPaid = false; _burstRemaining = (int) BurstCount; _burstInterval = BurstTime / _burstRemaining; _burstTimer = 0; @@ -234,13 +235,14 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo // spent (operator ruling 2026-09-30): it costs no ammo, energy, sound, heat, wear or visibility. // The decision is FireControl's; Fire makes the same one. var refused = FireControl.Refuses(this, Entity); - // If multiple ammo is consumed per burst, perform ammo and energy consumption here - // UseAmmo returns false when triggering reload; cancel firing if that is the case - if (!refused && !_data.SingleAmmoBurst && (!TrySpendActivationEnergy() || !UseAmmo())) + // Ammo and energy are consumed here, per round, or once per burst at its first unrefused round + // (SingleAmmoBurst). UseAmmo returns false when triggering reload; cancel firing if that is the case + if (!refused && !_burstPaid && (!TrySpendActivationEnergy() || !UseAmmo())) { _burstRemaining = 0; return false; } + if (!refused) _burstPaid = _data.SingleAmmoBurst; _burstRemaining--; _burstTimer -= _burstInterval; diff --git a/Assets/Scripts/ServerShared/FireControl.cs b/Assets/Scripts/ServerShared/FireControl.cs index 4a6afb5e5..8bdada43b 100644 --- a/Assets/Scripts/ServerShared/FireControl.cs +++ b/Assets/Scripts/ServerShared/FireControl.cs @@ -88,11 +88,15 @@ public static class FireControl // The agents' trigger decision (Combat, TurretController), one weapon at a time (operator ruling 2026-09-30). // A weapon that fires at what it hits fires when the shot is worth taking. A fused weapon fires whenever its // target is designated, whatever the arc: the round is sent out of arc on purpose (Solve). An agent never - // fires a fused weapon at nothing -- no target, or one it holds no valid data on, is no reason to fish. - public static bool AgentFires(Weapon weapon, Entity shooter, Entity target) => - FuseOf(weapon.Item, out _) != null - ? Designated(weapon, shooter, target, out _) - : HitProbability(weapon, shooter, target) >= shooter.ItemManager.GameplaySettings.AgentMinHitProbability; + // fires a fused weapon at nothing -- no target, or one it holds no valid data on, is no reason to fish -- and + // never one Solve refuses: a refused weapon is not a weapon it can use. + public static bool AgentFires(Weapon weapon, Entity shooter, Entity target) + { + if (FuseOf(weapon.Item, out _) == null) + return HitProbability(weapon, shooter, target) >= shooter.ItemManager.GameplaySettings.AgentMinHitProbability; + var solution = Solve(weapon, weapon.Item, shooter, target); + return solution.Designated && solution.Outcome != FireOutcome.Refused; + } // Operator ruling 2026-09-30: a shot with nothing to bear on flies along where its shooter is aiming // (Entity.LookDirection -- the player's mouse or a turret's tracking), restricted by the mount's arc. The @@ -225,21 +229,25 @@ public static class FireControl // visible, out of range) already denies the target. Planar (R7: the simulation is 2D): the range gate, the // burst reach and the range clamp all measure the same distance, so a target's height never moves one of // them without the others. - public static bool Designated(Weapon weapon, Entity source, Entity target, out float range) + public static bool Designated(Weapon weapon, Entity source, Entity target, out float range) => + InReach(weapon, source, target, out range) && !(weapon is LockWeapon lockWeapon && !lockWeapon.IsLocked); + + // Designated before the lock: the target is seen and within the weapon's planar range. A LockWeapon acquires + // its lock only while its shooter holds the trigger, so an agent asks this to know when to start locking; + // every other reader wants Designated. The one range test in the file. + public static bool InReach(Weapon weapon, Entity source, Entity target, out float range) { range = 0f; if (target == null) return false; range = length((target.Position - source.Position).xz); - return source.VisibleEntities.Contains(target) - && range >= weapon.MinRange && range <= weapon.Range - && !(weapon is LockWeapon lockWeapon && !lockWeapon.IsLocked); + return source.VisibleEntities.Contains(target) && range >= weapon.MinRange && range <= weapon.Range; } // `designated` is the answer above. `inArc` is the bearing test, asked only once a target is designated. // PFire prices a shot at zero unless both hold. Fire reads them to decide what a fused round flies at // (operator rulings 2026-09-30): without valid data it has no target and bursts at max range; with data but - // out of arc it bursts at the target's range, along the aim. Inspect recomputes the four gates one by one, - // to show the HUD which is closed; it decides nothing. + // out of arc it bursts at the target's range, along the aim. The HUD reads Designated and the + // arc from Solve and Inspect and decides nothing. private static float PFire(Weapon weapon, Entity source, Entity target, out float range, out bool designated, out bool inArc) { range = 0f; @@ -337,9 +345,7 @@ public static class FireControl var toTarget = target.Position - source.Position; diagnostic.Range = length(toTarget.xz); - diagnostic.Visible = source.VisibleEntities.Contains(target); - diagnostic.InRange = diagnostic.Range >= weapon.MinRange && diagnostic.Range <= weapon.Range; - diagnostic.Locked = !(weapon is LockWeapon lockWeapon) || lockWeapon.IsLocked; + diagnostic.Designated = solution.Designated; diagnostic.InArc = InArc(weapon.Item, toTarget); var targetHull = source.ItemManager.GetData(target.Hull) as HullData; @@ -447,6 +453,9 @@ public static class FireControl // lock and bursts at the weapon's Range, the stat PFire's range gate reads: bomb fishing; // * valid data but out of arc bursts at the target's range, which the range gate has already held to // Range, along that same clamped aim: the arc is hoped to land the blast near enough to splash. + // Either way the point frozen here is the farthest the round can burst: whatever its fuse, it detonates at the + // first hull on its aim line short of it (CommitBurst; operator ruling 2026-09-30, "a no-lock round stops at + // the first hull"), and bomb fishing bursts at this point only when the line is clear. // Either way the round flies at the weapon's Velocity like a targeted shot flies to its intercept. A shot // without a fuse keeps its target as before. // Arming distance (operator ruling 2026-09-30): the blast radius, measured from the shooter's position and @@ -469,15 +478,15 @@ public static class FireControl // pulled back along its own line to Range. var intercept = PredictedIntercept(weapon, source, engaged); var interceptReach = length((intercept - origin).xz); - if (min(interceptReach, weapon.Range) < arming) burstPosition = origin + float3(travelDirection.x, 0, travelDirection.y) * arming; - else burstPosition = interceptReach > weapon.Range ? origin + (intercept - origin) * (weapon.Range / interceptReach) : intercept; + var point = interceptReach > weapon.Range ? origin + (intercept - origin) * (weapon.Range / interceptReach) : intercept; + burstPosition = Armed(origin, travelDirection, arming, point); flightDistance = max(fireRange, arming); } else if (fuse != null) { - var reach = max(designated ? fireRange : weapon.Range, arming); - burstPosition = origin + float3(travelDirection.x, 0, travelDirection.y) * reach; - flightDistance = reach; + var reach = designated ? fireRange : weapon.Range; + burstPosition = Armed(origin, travelDirection, arming, origin + float3(travelDirection.x, 0, travelDirection.y) * reach); + flightDistance = max(reach, arming); } else if (engaged != null) flightDistance = fireRange; @@ -486,6 +495,7 @@ public static class FireControl Outcome = weapon.Range < arming ? FireOutcome.Refused : fuse != null ? FireOutcome.Burst : FireOutcome.Direct, PFire = pFire, FireRange = fireRange, + Designated = designated, Engaged = engaged, TravelDirection = travelDirection, BurstPosition = burstPosition, @@ -497,6 +507,12 @@ public static class FireControl return solution; } + // The arming rule, once: a burst point nearer to `origin` than `arming` (planar, blind to hull size) is pushed + // out along the unit planar `direction` to it. Solve applies it to the point it plans; the detonation of a + // contact or delayed blast, whose point is found on the hull only at resolution, applies it to that point. + private static float3 Armed(float3 origin, float2 direction, float arming, float3 point) => + length((point - origin).xz) < arming ? origin + float3(direction.x, 0, direction.y) * arming : point; + // Cut 3, R1: called once per burst step from InstantWeapon.Execute. Computes flight time, freezes the // payload snapshot (R10, Q6 -- the gun that fired it, not a re-read later), and queues a PendingShot for // Zone.Step to age and eventually resolve. Returns the ShotId so the caller's OnFire event can carry it to @@ -582,9 +598,10 @@ public static class FireControl // its shooter (operator ruling 2026-09-30): the shooter is only an identity a shot carries, never asked // for at resolution, so a shooter that died or left the zone in flight changes nothing about the shot. // Resolution reads the zone and the target; there is no credit or loot for a shooter to receive. - // A shot's queue state is settled before anything is published about it: it is committed in the list before - // ShotCommitted fires and removed before ShotResolved fires, so a presentation observer that throws costs - // that one notification and cannot leave the shot to be committed or resolved again on every later tick. + // A shot's queue state is settled before anything that can run user code: it is committed in the list before + // ShotCommitted fires and removed before its damage is applied (damage observers) or ShotResolved fires, so an + // observer that throws costs that one notification, or the rest of that blast, and cannot leave the shot to be + // committed, resolved or applied again on every later tick. public static void Step(Zone zone, float dt) { var shots = zone.PendingShots; @@ -627,8 +644,10 @@ public static class FireControl // which model a shot uses, through its own switch on the frozen Fuse -- Step has no damage // branch left. A direct hit, a proximity burst and a contact or delayed blast all arrive here // the same way; only Apply decides what that means. - Apply(zone, shot); + // Removed first: Apply reaches damage observers (user code), and a shot left in the queue by one + // that throws would resolve, and hurt, again on every later tick. shots.RemoveAt(i); + Apply(zone, shot); zone.ShotResolved.OnNext(shot.Outcome); } } @@ -772,9 +791,9 @@ public static class FireControl return outcome; } - // Operator rulings 2026-09-30. A fused round with no target bursts at the max-range point Fire froze along - // the aim line, whatever its fuse -- except a Contact round, which bursts at the first hull that line crosses - // before that point (a Proximity or Delayed fuse does not trip on a hull with nothing locked). The point is + // Operator rulings 2026-09-30. A fused round with no target bursts at the first hull its aim line crosses + // before the max-range point Fire froze, whatever its fuse (contact, proximity and delayed alike: a round with + // nothing locked stops on what is in its way), and at the max-range point when the line is clear. The point is // read from this tick's poses and frozen into the outcome (R4, the same timing Commit reads facing at). // Nothing is rolled or drawn, so determinism needs nothing beyond the frozen inputs. private static ShotOutcome CommitBurst(Zone zone, PendingShot shot, float now) @@ -782,8 +801,7 @@ public static class FireControl var outcome = MakeOutcome(shot, ShotResult.Burst, false, false, int2.zero, float2.zero, 0f, now); outcome.HasBurstPoint = true; var reach = length(shot.BurstPosition.xz - shot.FireOrigin.xz); - var travelled = shot.Fuse == WeaponFuse.Contact ? ContactDistance(zone, shot, reach) : reach; - travelled = min(reach, max(travelled, shot.BlastRadius)); + var travelled = min(reach, max(HullDistance(zone, shot, reach), shot.BlastRadius)); if (travelled < reach) { outcome.BurstPoint = shot.FireOrigin.xz + shot.TravelDirection * travelled; @@ -794,11 +812,11 @@ public static class FireControl return outcome; } - // The distance along a no-lock round's aim line to the first thing a contact fuse trips on, or `reach` (the + // The distance along a no-lock round's aim line to the first hull it crosses, or `reach` (the // max-range point) when it meets nothing first. The shooter's own hull is never a candidate. // SEAM FOR THE MINING CAMPAIGN: mining chunks are not Entities and are not on this branch. When they land, // whatever they are contributes its own entry distance to this minimum, and nothing else in the file changes. - private static float ContactDistance(Zone zone, PendingShot shot, float reach) + private static float HullDistance(Zone zone, PendingShot shot, float reach) { var nearest = reach; foreach (var entity in zone.Entities) @@ -813,7 +831,7 @@ public static class FireControl // `origin` to the first metal cell ahead, +infinity when the line meets none, and negative when the origin is // already inside the hull (the blast radius floors every caller's use of it). Read through Lane's own cell // admission (CellsOnLine), in the entity's schematic frame: the line's lateral offset is its own, and cells - // wholly behind the origin are skipped. Only a contact fuse asks. + // wholly behind the origin are skipped. Only a no-lock round asks. private static float HullEntry(Entity entity, float2 origin, float2 direction) { var hullData = entity.ItemManager.GetData(entity.Hull) as HullData; @@ -1025,7 +1043,10 @@ public static class FireControl // cancels the host's pose, so the host's own damage is fixed by the commit (R4 holds for it), while // every bystander is judged against where it actually is at arrival -- the same live rule a proximity // burst already applies to everyone. - var worldPoint = target.ToWorldPoint(schematicPoint); + // The arming rule applies to this point as it does to every other burst (Solve's Armed): a hull met nearer + // the muzzle than the blast radius is detonated on at the radius, along the round's own flight line. + var hullPoint = target.ToWorldPoint(schematicPoint); + var worldPoint = Armed(shot.FireOrigin, shot.TravelDirection, shot.BlastRadius, float3(hullPoint.x, shot.FireOrigin.y, hullPoint.y)).xz; Detonate(target.Zone, worldPoint, shot.BlastRadius, shot.Damage, shot.DamageType); } @@ -1789,6 +1810,8 @@ public struct FireSolution public float FlightDistance; public float PFire; public float FireRange; + // Whether the shooter holds valid targeting data on the target (Designated); the HUD and the agents read it here. + public bool Designated; public Entity Engaged; public float2 TravelDirection; public float3 BurstPosition; @@ -1799,9 +1822,7 @@ public struct FireSolution public struct FireControlDiagnostic { public bool HasTarget; - public bool Visible; - public bool InRange; - public bool Locked; + public bool Designated; public bool InArc; public float Range; public float MinRange; diff --git a/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs index ca50e475c..16f663b58 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs @@ -1520,6 +1520,103 @@ public sealed class FireControlCut124Tests : IDisposable SafeAssert.NoShots(e.Zone); } + public enum Observer { HullDamage, ArmorDamage, IncomingHit } + + // Nothing the queue does is left to a user callback that may throw: a shot is out of the queue before its + // damage is applied, so a damage observer that throws costs the rest of that blast, once, and the shot is not + // applied again on every later tick (its blast, and the observer, used to repeat forever). The observer throws + // on its first call; the following tick is clean and the observer has been called exactly once. Contact for + // the hit report (a roll can miss, so it retries), Proximity for the damage events. + // Kills: applying the shot's damage before removing it from the queue. + [Theory] + [InlineData(Observer.HullDamage)] + [InlineData(Observer.ArmorDamage)] + [InlineData(Observer.IncomingHit)] + public void AThrowingDamageObserverIsCalledOnceAndCannotWedgeTheShotQueue(Observer observer) + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: observer == Observer.IncomingHit ? WeaponFuse.Contact : WeaponFuse.Proximity, + blastRadius: 4f, penetration: 1f, damage: 100f); + if (observer == Observer.ArmorDamage) ArmourEverything(e); + var calls = 0; + void Throw() { calls++; throw new InvalidOperationException("observer"); } + using var a = observer == Observer.ArmorDamage ? e.Target.ArmorDamage.Subscribe(_ => Throw()) : Disposable.Empty; + using var h = observer == Observer.HullDamage ? e.Target.HullDamage.Subscribe(_ => Throw()) : Disposable.Empty; + using var i = observer == Observer.IncomingHit ? e.Target.IncomingHit.Subscribe(_ => Throw()) : Disposable.Empty; + + for (var attempt = 0; attempt < 60 && calls == 0; attempt++) + { + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + Record.Exception(() => e.Zone.Update(.01f)); + } + Assert.Equal(1, calls); + var later = Record.Exception(() => e.Zone.Update(.01f)); + + Assert.Null(later); + Assert.Equal(1, calls); + SafeAssert.NoShots(e.Zone); + } + + // A round pushed out to its arming distance flies the arming distance: its arrival is that far off, not the + // target's own (5 units, a quarter second) range. 30 units at 20 units/s is 1.5 seconds. Kills: a flight + // distance of the target's range for a pushed-out round. + [Fact] + public void APushedOutRoundDoesNotArriveBeforeItHasFlownTheArmingDistance() + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 20f, fuse: WeaponFuse.Proximity, blastRadius: 30f, damage: 100f, + weaponRange: 60f, targetRange: 5f); + Aim(e, float2(0, 1)); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + + var shot = SafeAssert.OnlyShot(e.Zone); + Assert.True(shot.Target == e.Target, "fixture: the pushed-out round keeps its target"); + Assert.Equal(1.5f, shot.ArrivalTime - shot.FireTime, 3); + } + + // What the shooter's own hull takes from one round at `targetRange`, counting only rounds that hit when the + // fuse needs a hit (a roll can miss, so it retries). + private static float ShooterSplash(Engagement e, bool needsHit) + { + var taken = 0f; + using var a = e.Shooter.ArmorDamage.Subscribe(x => taken += x.damage); + using var h = e.Shooter.HullDamage.Subscribe(x => taken += x); + using var i = e.Shooter.ItemDamage.Subscribe(x => taken += x.damage); + ShotOutcome outcome = null; + using var r = e.Zone.ShotResolved.Subscribe(o => outcome = o); + for (var attempt = 0; attempt < 60; attempt++) + { + taken = 0f; + outcome = null; + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + if (!needsHit || outcome != null && outcome.Hit) return taken; + } + throw new InvalidOperationException("no round hit in 60 attempts"); + } + + // The arming ruling holds for every fuse, at the point a contact or delayed round detonates on a hull: no + // burst nearer to the shooter than the blast radius. A round that hits a target inside the radius (5 out), or + // one whose near face is inside it (a target 32 out has its face at 28), is detonated on at the radius (30), + // so the shooter takes what any burst 30 out on its line deals: the amount a proximity round at exactly 30 + // deals. Kills: a hull-point detonation that ignores the arming distance (the shooter then took 7.07 from a + // blast 30 at 5 out, more than the armed burst). + [Theory] + [InlineData(WeaponFuse.Contact, 5f)] + [InlineData(WeaponFuse.Delayed, 5f)] + [InlineData(WeaponFuse.Contact, 32f)] + public void AContactOrDelayedBlastNeverDetonatesNearerThanTheArmingDistance(WeaponFuse fuse, float targetRange) + { + var armed = ShooterSplash(Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: 30f, penetration: 1f, + damage: 100f, weaponRange: 60f, targetRange: 30f), needsHit: false); + Assert.True(armed > 0f, "fixture: the shooter's own hull takes splash from a burst at the radius"); + + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: fuse, blastRadius: 30f, penetration: 1f, + damage: 100f, weaponRange: 60f, targetRange: targetRange); + var splash = ShooterSplash(e, needsHit: true); + + Assert.Equal(armed, splash, 3); + } + // A target beyond the arming distance is not pushed anywhere: the round still rides it and bursts at its // intercept. Kills: demoting every fused round to the aim. [Fact] @@ -1661,7 +1758,7 @@ public sealed class FireControlCut124Tests : IDisposable Assert.True(shot.Target == e.Target, "the target is inside Range in the plane, so it is valid data"); Assert.Equal(40f, Planar(e.Shooter.Position, shot.BurstPosition), 2); var diagnostic = FireControl.Inspect(e.Weapon, e.Shooter, e.Target); - Assert.True(diagnostic.InRange, "the HUD's range gate is planar too"); + Assert.True(diagnostic.Designated, "the HUD's designation is planar too"); Assert.Equal(40f, diagnostic.Range, 2); } @@ -1790,15 +1887,15 @@ public sealed class FireControlCut124Tests : IDisposable Assert.True(e.Zone.PendingShots[0].Committed, "and commits once its horizon is reached, with the round still in flight"); } - // Ruling 3: a Contact round with no lock stops on the first hull its aim line crosses before max range; a - // Proximity or Delayed round keeps the max-range burst. The blocker's near face sits 21 units out (a 5x4-cell - // hull of 2-unit cells centred 25 out), the witness at max range (60). Kills: a Contact fuse that ignores - // hulls; a Proximity or Delayed fuse that trips on one. + // Ruling 3 (operator, 2026-09-30): a no-lock round of any fuse stops on the first hull its aim line crosses + // before max range and detonates there; with the line clear it bursts at max range. The blocker's near face + // sits 21 units out (a 5x4-cell hull of 2-unit cells centred 25 out), the witness at max range (60). Kills: a + // fuse that ignores hulls (a Proximity or Delayed round used to fly on to max range). [Theory] - [InlineData(WeaponFuse.Contact, true)] - [InlineData(WeaponFuse.Proximity, false)] - [InlineData(WeaponFuse.Delayed, false)] - public void OnlyAContactRoundWithNoLockStopsOnTheFirstHullItCrosses(WeaponFuse fuse, bool stopsOnTheBlocker) + [InlineData(WeaponFuse.Contact)] + [InlineData(WeaponFuse.Proximity)] + [InlineData(WeaponFuse.Delayed)] + public void ANoLockRoundOfAnyFuseStopsOnTheFirstHullItCrosses(WeaponFuse fuse) { var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: fuse, blastRadius: 4f, penetration: 1f, damage: 100f, weaponRange: NoLockRange); @@ -1813,11 +1910,11 @@ public sealed class FireControlCut124Tests : IDisposable e.Zone.Update(.01f); Assert.NotNull(outcome); - var expectedReach = stopsOnTheBlocker ? 21f : NoLockRange; + const float expectedReach = 21f; Assert.Equal(e.Shooter.Position.z + expectedReach, outcome.BurstPoint.y, 2); Assert.Equal(e.Shooter.Position.x, outcome.BurstPoint.x, 2); - Assert.Equal(!stopsOnTheBlocker, Alive(e, blocker)); - Assert.Equal(stopsOnTheBlocker, Alive(e, atMaxRange)); + Assert.False(Alive(e, blocker)); + Assert.True(Alive(e, atMaxRange), "the witness at max range is beyond the stop"); } // Ruling 3: a hull beyond max range is not on the round's way, and the shooter's own hull, which the line diff --git a/tests/Aetheria.Shared.Tests/FireControlCut9Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut9Tests.cs index 2300108fd..d824d4278 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut9Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut9Tests.cs @@ -428,7 +428,7 @@ public sealed class FireControlCut9Tests : IDisposable e.Shooter.EntityInfoGathered[e.Target] = info; var hud = FireControl.Inspect(e.Weapon, e.Shooter, e.Target); var hot = FireControl.HitProbability(e.Weapon, e.Shooter, e.Target); - Assert.True(hud.Visible && hud.InRange && hud.Locked && hud.InArc, $"info {info}: precondition, every gate open"); + Assert.True(hud.Designated && hud.InArc, $"info {info}: precondition, every gate open"); Assert.True(hot > 0f, $"info {info}, Precision {precision}: precondition, a shot that can land"); Assert.Equal(hot, hud.Accuracy * hud.PSensor * hud.PSpread * hud.POnHull, 5); sawPartialSpread |= hud.PSpread < .99f; @@ -445,7 +445,7 @@ public sealed class FireControlCut9Tests : IDisposable } e.Target.Position = origin + float3(0, 0, 5000); - Closed("out of range", d => d.InRange); + Closed("out of range", d => d.Designated); e.Target.Position = origin + float3(0, 0, -100); Closed("behind the mount", d => d.InArc); @@ -455,13 +455,13 @@ public sealed class FireControlCut9Tests : IDisposable e.Target.Position = float3(600, 0, 500); var far = FireControl.Inspect(e.Weapon, e.Shooter, e.Target); Assert.Equal(500f, far.Range, 3); - Assert.True(far.InRange && FireControl.HitProbability(e.Weapon, e.Shooter, e.Target) > 0f, "shooter far from the origin"); + Assert.True(far.Designated && FireControl.HitProbability(e.Weapon, e.Shooter, e.Target) > 0f, "shooter far from the origin"); checkedConfigurations++; e.Shooter.Position = origin; e.Target.Position = origin + float3(0, 0, 100); e.Shooter.VisibleEntities.Remove(e.Target); - Closed("not visible", d => d.Visible); + Closed("not visible", d => d.Designated); Assert.False(FireControl.Inspect(e.Weapon, e.Shooter, null).HasTarget); Assert.Equal(0f, FireControl.HitProbability(e.Weapon, e.Shooter, null)); diff --git a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs index 8ee6e70a4..4f9ffe282 100644 --- a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs @@ -75,6 +75,7 @@ public sealed class FireControlPerWeaponTests : IDisposable public Ship Target; public List Guns = new List(); public Gun Gun => Guns[0]; + public Gun Decoy; } // Mount 0 (None, arc 0) bears on a target dead ahead; (Clockwise, arc 0) is a side mount the default 170 @@ -83,7 +84,7 @@ public sealed class FireControlPerWeaponTests : IDisposable private Rig Build( (ItemRotation Rotation, float Arc)[] mounts = null, bool beam = false, WeaponFuse? fuse = null, float blast = 0f, float range = 100f, float targetRange = 60f, float accuracy = 1f, float minHit = .2f, bool lockWeapon = false, - bool turret = false, float energy = 0f, float heat = 0f, float visibility = 0f, int magazine = 0, bool singleAmmoBurst = false) + bool turret = false, float energy = 0f, float heat = 0f, float visibility = 0f, int magazine = 0, bool singleAmmoBurst = false, bool decoy = false) { mounts ??= new[] { (ItemRotation.None, 0f) }; var hullData = new HullData @@ -101,6 +102,14 @@ public sealed class FireControlPerWeaponTests : IDisposable Type = HardpointType.Sensors, Position = new int2(i, 0), Shape = new Shape(), Rotation = mounts[i].Rotation, FiringArc = mounts[i].Arc }); + // A second weapon type on its own mount (a group of its own, since groups are per item type), dead ahead + // and unfused, weaker than the gun: what a group pick falls back to when the gun's group is refused. + if (decoy) + shooterHullData.Hardpoints.Add(new HardpointData + { + Type = HardpointType.Sensors, Position = new int2(mounts.Length, 0), Shape = new Shape(), + Rotation = ItemRotation.None, FiringArc = 0f + }); WeaponData behavior; if (beam) @@ -138,6 +147,21 @@ public sealed class FireControlPerWeaponTests : IDisposable Fuse = fuse, BlastRadius = blast, Behaviors = { behavior } }); + cache.Upsert(new WeaponItemData + { + Name = "Decoy", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Behaviors = + { + new InstantWeaponData + { + Count = Constant(1), BurstTime = Constant(0), Cooldown = Constant(1), Damage = Constant(50), Range = Constant(100), + MinRange = Constant(0), Velocity = Constant(0), Spread = Constant(0), DamageSpread = Constant(0), Penetration = Constant(0), + Energy = Constant(0), Heat = Constant(0), Visibility = Constant(0), + DamageCurve = new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 1, 0, 0) } } + } + } + }); cache.Upsert(new GearData { Name = "Targeting", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 1, @@ -180,6 +204,13 @@ public sealed class FireControlPerWeaponTests : IDisposable var item = shooter.Equipment.Single(x => x.EquippableItem == gun); rig.Guns.Add(new Gun { Item = item, Weapon = (Weapon) item.Behaviors.Single(b => b is Weapon) }); } + if (decoy) + { + var d = Make("Decoy", lot++, 1); + Assert.True(shooter.TryEquip(d, new int2(mounts.Length, 0))); + var item = shooter.Equipment.Single(x => x.EquippableItem == d); + rig.Decoy = new Gun { Item = item, Weapon = (Weapon) item.Behaviors.Single(b => b is Weapon) }; + } var targeting = Make("Targeting", lot++, 1); Assert.True(shooter.TryFindSpace(targeting, out var tpos)); Assert.True(shooter.TryEquip(targeting, tpos)); @@ -374,7 +405,7 @@ public sealed class FireControlPerWeaponTests : IDisposable // One discrete weapon, triggered once at no target, with every cost observable: a magazine, an energy charge, // a sound bank, wear events, heat and visibility. Range 29.5 refuses (arming 30); Range 40 fires. - private Costs Pull(float range, bool single, bool viaTrigger) + private Costs Pull(float range, bool single, bool viaTrigger, bool staleRange = false) { var r = Build(fuse: WeaponFuse.Proximity, blast: 30f, range: range, energy: 50f, heat: 5000f, visibility: 100f, magazine: 5, singleAmmoBurst: single); r.Shooter.Target.Value = null; @@ -393,6 +424,9 @@ public sealed class FireControlPerWeaponTests : IDisposable var ammoBefore = weapon.Ammo; Assert.True(chargeBefore > 0f && ammoBefore == 5, "fixture: a full buffer and magazine"); + // The Range the previous tick's Execute left, before this tick refreshes it: a power-dependent Range that + // has since fallen. Set through the property's own setter, so the trigger sees a Range the round will not. + if (staleRange) typeof(Weapon).GetProperty("Range").GetSetMethod(true).Invoke(weapon, new object[] { 100f }); if (viaTrigger) weapon.Activate(); else typeof(InstantWeapon).GetField("_burstRemaining", BindingFlags.Instance | BindingFlags.NonPublic).SetValue(weapon, 1); r.Zone.Update(.01f); @@ -406,7 +440,7 @@ public sealed class FireControlPerWeaponTests : IDisposable // Control: the same weapon with Range to spare pays every cost, so a free refusal below is not a fixture that // never spends. Then the refusal costs nothing: no ammo, no energy, no sound, no wear, no heat, no - // visibility, no shot. `single` is a weapon that pays once per burst, at the trigger rather than at the round; + // visibility, no shot. `single` is a weapon that pays once per burst, at its first unrefused round rather than at each; // `viaTrigger` false authorises the burst directly (the way a trigger that passed leaves it) on a weapon whose // Range is short, so the Trigger check cannot be what stops the round and Execute must refuse it itself. // Kills, one per row of the assertions: a spend that precedes the refusal (Execute or Trigger), an @@ -440,7 +474,7 @@ public sealed class FireControlPerWeaponTests : IDisposable // The same rule for a beam: a fused beam whose arming distance exceeds Range fires nothing and draws // nothing. The control (Range 40) rolls shots, wears, draws power and shows; the refused beam does none of - // that, asks the bus for nothing, and stops itself the way an out-of-arc beam does. + // that, asks the bus for nothing, and never starts (ARefusedBeamNeverStarts). // Kills: ConstantWeapon spending (power request, wear, heat, visibility) before the refusal, or not // consulting FireControl at all. private (int Shots, int Wears, float Request, bool Visible, float Heat, int Stops, bool Firing) Burn(float range) @@ -481,6 +515,104 @@ public sealed class FireControlPerWeaponTests : IDisposable Assert.Equal(0f, refused.Request); Assert.False(refused.Visible); Assert.True(fired.Heat - refused.Heat > 5f, $"a refused beam adds no heat ({refused.Heat} vs {fired.Heat})"); - Assert.True(refused.Stops > 0 && !refused.Firing, "a refused beam stops itself, as an out-of-arc beam does"); + Assert.True(refused.Stops == 0 && !refused.Firing, "a refused beam never starts, so it never has to stop"); + } + + // A beam is a stream of rounds, so a refused beam never starts: OnStartFiring drives the beam's visuals and + // audio, and starting it for Execute to stop a tick later would flash a beam that fires nothing. The control + // (Range 40) starts once and keeps firing. Kills: Activate that starts a beam without asking FireControl. + [Theory] + [InlineData(40f, 1)] + [InlineData(29.5f, 0)] + public void ARefusedBeamNeverStarts(float range, int starts) + { + var r = Build(beam: true, fuse: WeaponFuse.Proximity, blast: 30f, range: range); + r.Shooter.Target.Value = null; + Aim(r, float2(0, 1)); + var weapon = (ConstantWeapon) r.Gun.Weapon; + var started = 0; + weapon.OnStartFiring += () => started++; + + weapon.Activate(); + for (var i = 0; i < 5; i++) r.Zone.Update(.1f); + + Assert.Equal(starts, started); + Assert.Equal(starts == 1, weapon.Firing); + } + + // A refused weapon is not one an agent can use: a fused weapon whose arming distance (30) exceeds its Range + // (29.5) is not fired at a target inside that Range, where the same weapon with Range to spare is. + // Kills: AgentFires answering "designated" without asking whether Solve refuses. + [Theory] + [InlineData(29.5f, false)] + [InlineData(40f, true)] + public void AnAgentNeverFiresARefusedFusedWeapon(float range, bool fires) + { + var r = Build(fuse: WeaponFuse.Proximity, blast: 30f, range: range, targetRange: 20f); + + Assert.Equal(fires, FireControl.AgentFires(r.Gun.Weapon, r.Shooter, r.Target)); + } + + // Combat's group pick counts only weapons that can fire: the refused gun's group out-damages the decoy's, so a + // pick that counted it would select a group whose weapons are then never fired, and the decoy would stay silent. + // Kills: Combat counting a refused weapon's damage toward its group. + [Fact] + public void CombatDoesNotPickAGroupWhoseWeaponIsRefused() + { + var r = Build(fuse: WeaponFuse.Proximity, blast: 30f, range: 29.5f, targetRange: 20f, decoy: true); + Assert.True(r.Gun.Weapon.RangeDamagePerSecond(20f) > r.Decoy.Weapon.RangeDamagePerSecond(20f), "fixture: the refused gun is the stronger group"); + r.Zone.Agents.Add(new Minion(r.Shooter)); + + r.Zone.Update(1f); + r.Zone.Update(1f); + + Assert.False(r.Gun.Weapon.Firing, "a refused weapon is never activated"); + Assert.True(r.Decoy.Weapon.Firing, "the group that can fire is the one picked"); + } + + // Combat's range test is Designated's, not a second one: a target exactly at max range is in reach (the old + // private test was strict). Kills: a range comparison of Combat's own. + [Fact] + public void CombatCountsATargetExactlyAtMaxRangeAsInReach() + { + var r = Build(range: 60f, targetRange: 60f); + // The agent is driven directly, not through the zone, so the shooter stays exactly at max range: the first + // update leaves the root state for combat, the second is combat deciding. + var agent = new Minion(r.Shooter); + agent.Update(.01f); + agent.Update(.01f); + + Assert.True(r.Gun.Weapon.Firing); + } + + // The trigger's threshold is inclusive: a shot priced exactly at AgentMinHitProbability is worth taking, and + // one priced a hair under it is not. Kills: `>` for `>=`. + [Fact] + public void AnAgentFiresAtExactlyTheHitProbabilityThreshold() + { + var probe = Build(accuracy: .5f); + var price = FireControl.HitProbability(probe.Gun.Weapon, probe.Shooter, probe.Target); + Assert.True(price > 0f); + + var atThreshold = Build(accuracy: .5f, minHit: price); + var overThreshold = Build(accuracy: .5f, minHit: MathF.BitIncrement(price)); + + Assert.True(FireControl.AgentFires(atThreshold.Gun.Weapon, atThreshold.Shooter, atThreshold.Target)); + Assert.False(FireControl.AgentFires(overThreshold.Gun.Weapon, overThreshold.Shooter, overThreshold.Target)); + } + + // A round refused on a Range that fell between the trigger and the round is still free: the burst is paid + // (SingleAmmoBurst) at its first round that is not refused, judged with the Range that round flies with. + // The trigger saw a stale Range of 100; the round's Range is 29.5, under the arming distance. + // Kills: paying for the burst at the trigger. + [Fact] + public void ARefusedBurstThatPassedTheTriggerOnAStaleRangeCostsNothing() + { + var refused = Pull(29.5f, single: true, viaTrigger: true, staleRange: true); + + Assert.Equal(0, refused.Shots); + Assert.Equal(0, refused.Ammo); + Assert.Equal(0f, refused.ChargeSpent); + Assert.Equal(0, refused.Announced); } } From d00d1580071f2844bfe613a2407d846ea4b67b0b Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 20:42:30 +0200 Subject: [PATCH 31/49] Pin the flight distance of a pushed-out no-lock round The mutation pass on the fuse fixes left one survivor: a no-lock round whose aim ends inside the blast radius (a designated target out of arc) flew only to its aimed point instead of the arming distance, and nothing noticed. Its arrival is now pinned to the arming distance, beside the targeted case. Co-Authored-By: Claude Opus 5.5 --- .../FireControlCut124Tests.cs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs index 16f663b58..d55bc6df6 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs @@ -1573,6 +1573,25 @@ public sealed class FireControlCut124Tests : IDisposable Assert.Equal(1.5f, shot.ArrivalTime - shot.FireTime, 3); } + // The same for a no-lock round: a target 10 out behind a mount whose arc is closed is designated but out of + // arc, so the round flies the aim to the target's range, inside the blast radius, and is pushed out to 30. It + // arrives after 30 units at 20 units/s, not after the 10 it was aimed at. Kills: a no-lock flight distance + // that ignores the arming distance. + [Fact] + public void APushedOutNoLockRoundDoesNotArriveBeforeItHasFlownTheArmingDistance() + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 20f, fuse: WeaponFuse.Proximity, blastRadius: 30f, damage: 100f, + weaponRange: 60f, targetRange: 10f); + e.Shooter.Direction = float2(0, -1); + Aim(e, float2(0, -1)); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + + var shot = SafeAssert.OnlyShot(e.Zone); + Assert.True(shot.Target == null, "fixture: out of arc, the round carries no target"); + Assert.Equal(1.5f, shot.ArrivalTime - shot.FireTime, 3); + } + // What the shooter's own hull takes from one round at `targetRange`, counting only rounds that hit when the // fuse needs a hit (a roll can miss, so it retries). private static float ShooterSplash(Engagement e, bool needsHit) From 76e78db4b2c36ca64cb867b6d6765393d47918a9 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 20:52:21 +0200 Subject: [PATCH 32/49] Record that a refused round starts no cooldown Co-Authored-By: Claude Opus 5.5 --- docs/fire-control-cut.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/fire-control-cut.md b/docs/fire-control-cut.md index 9235a666a..265452916 100644 --- a/docs/fire-control-cut.md +++ b/docs/fire-control-cut.md @@ -2662,7 +2662,8 @@ follow-up rulings, all given on 2026-09-30: at N m, Refused). `Fire` and `Inspect` compute it with the same helper. Direct-hit weapons keep the percentage. - **A refused round is free (operator, 2026-09-30):** it spends no ammo or energy, and makes no sound, heat, - wear or visibility. + wear or visibility. **It does not start the cooldown either** (operator, same day: "triggering cooldown on + refusal would be mostly neutral but punishing for long cooldown weapons, worth the code to fix"). - **Batch 5 Soul pass, 2026-09-30: fix first.** `Solve` is the one decision, and `Fire`, `Inspect` and `Refuses` all read it. Findings in fix batch 6 (Hands, `hands/fuse-fix2`): - Contact and delayed rounds detonate on the hull and ignore the arming push-out. From 136fddf15a0a43b9026f0167c752718e42b0c01d Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 21:16:52 +0200 Subject: [PATCH 33/49] Start the cooldown at a burst's first unrefused round; refused charged weapons never charge A burst whose every round is refused fires nothing and leaves no cooldown: the cooldown moves from Trigger to the first round Execute does not refuse, and CanFire holds a triggered burst that has not yet fired. ChargedWeapon.Activate asks FireControl before it starts charging, so a refused weapon pays no charge heat or audio. InReach is deleted: Designated carries the one range test and the lock gate, and Combat reads it. GuidedProjectile answers Resolve while alive and keeps its binding through the shot's flight time, so a faded round still shows the simulation's detonation. Co-Authored-By: Claude Opus 5.5 --- .../Gameplay/Weapons/GuidedProjectile.cs | 16 +++- .../Weapons/GuidedProjectileManager.cs | 3 + .../ServerShared/Agents/States/Combat.cs | 6 +- .../ServerShared/Behaviors/ChargedWeapon.cs | 3 +- .../ServerShared/Behaviors/InstantWeapon.cs | 20 ++++- Assets/Scripts/ServerShared/FireControl.cs | 11 +-- .../FireControlPerWeaponTests.cs | 81 ++++++++++++++++--- 7 files changed, 113 insertions(+), 27 deletions(-) diff --git a/Assets/Scripts/Gameplay/Weapons/GuidedProjectile.cs b/Assets/Scripts/Gameplay/Weapons/GuidedProjectile.cs index c66ede374..4eb75f1ad 100644 --- a/Assets/Scripts/Gameplay/Weapons/GuidedProjectile.cs +++ b/Assets/Scripts/Gameplay/Weapons/GuidedProjectile.cs @@ -35,6 +35,7 @@ public class GuidedProjectile : MonoBehaviour private float _prevDist; private bool _active; private bool _alive; + private float _spawnTime; private Vector3 _targetVelocity; private Vector3 _previousTargetPosition; @@ -46,6 +47,11 @@ public class GuidedProjectile : MonoBehaviour public Vector3 Velocity { get; set; } public Entity SourceEntity { get; set; } + // How long after spawn the round keeps its binding to the simulation's verdict: the shot's own flight time (the + // manager sets it from the committed shot's ArrivalTime), so a round that faded out early still lives to show + // the detonation the simulation resolves at arrival. 0 (the default) kills the round when its fade ends. + public float BindingLifetime { get; set; } + public event Action OnKill; // The simulation's verdict on this round, bound by GuidedProjectileManager to Zone.ShotCommitted and @@ -83,12 +89,15 @@ public class GuidedProjectile : MonoBehaviour // one whose target is gone) just falls away, with no explosion to show. public void Resolve(bool detonated, Vector3? point) { - if (!_alive || !_active) return; + // A round that already faded out (overshot, out of flight) still shows the simulation's detonation: only a + // round that has been killed is done. The fade is stopped so it cannot Kill the round a second time. + if (!_alive) return; if (!detonated) { - StartCoroutine(FadeOut()); + if (_active) StartCoroutine(FadeOut()); return; } + StopAllCoroutines(); if (point.HasValue) transform.position = point.Value; if (HitEffect != null) { @@ -101,6 +110,7 @@ public class GuidedProjectile : MonoBehaviour void OnEnable() { _active = _alive = true; + _spawnTime = Time.time; _phase = Random.value * 100; _prevDist = Single.MaxValue; Particles.startColor = Color.white; @@ -212,6 +222,8 @@ public class GuidedProjectile : MonoBehaviour yield return null; } + // Faded, but the simulation's verdict has not necessarily arrived: wait for it until the shot's flight is over. + while (Time.time - _spawnTime < BindingLifetime) yield return null; StartCoroutine(Kill()); } diff --git a/Assets/Scripts/Gameplay/Weapons/GuidedProjectileManager.cs b/Assets/Scripts/Gameplay/Weapons/GuidedProjectileManager.cs index a611b1106..e5eb74b79 100644 --- a/Assets/Scripts/Gameplay/Weapons/GuidedProjectileManager.cs +++ b/Assets/Scripts/Gameplay/Weapons/GuidedProjectileManager.cs @@ -19,6 +19,9 @@ public class GuidedProjectileManager : InstantWeaponEffectManager zone.ShotResolved.Where(o => o.ShotId == shotId) .Subscribe(o => p.Resolve(o.Result == ShotResult.Hit || o.Result == ShotResult.Burst, o.HasBurstPoint ? At(o) : (Vector3?) null)) .AddTo(bindings); + // The binding outlives a fade-out until the shot's own arrival (the moment the simulation resolves it), plus a + // second of margin; a shot no longer pending has nothing left to resolve. + p.BindingLifetime = zone.TryGetShot(shotId, out var shot) ? max(0f, shot.ArrivalTime - zone.Time) + 1f : 0f; p.Binding = bindings; // replaces, and so disposes, any binding the projectile already had } diff --git a/Assets/Scripts/ServerShared/Agents/States/Combat.cs b/Assets/Scripts/ServerShared/Agents/States/Combat.cs index d27a90ab1..d605ff8a5 100644 --- a/Assets/Scripts/ServerShared/Agents/States/Combat.cs +++ b/Assets/Scripts/ServerShared/Agents/States/Combat.cs @@ -57,10 +57,10 @@ public class CombatState : BaseState var group = _agent.Ship.WeaponGroups[i]; var dps = 0f; foreach (var weapon in group.weapons) - // The range test is FireControl's (InReach, the part of Designated before the lock, which this - // loop is what acquires), and a weapon Solve refuses adds nothing to a group's damage. + // The range test is FireControl's (Designated), and a weapon Solve refuses adds nothing to a group's + // damage. if (weapon.Item.Online.Value && - FireControl.InReach(weapon, _agent.Ship, target, out _) && + FireControl.Designated(weapon, _agent.Ship, target, out _) && !FireControl.Refuses(weapon, _agent.Ship) && (weapon is ConstantWeapon || weapon is InstantWeapon instantWeapon && instantWeapon.CanFire)) diff --git a/Assets/Scripts/ServerShared/Behaviors/ChargedWeapon.cs b/Assets/Scripts/ServerShared/Behaviors/ChargedWeapon.cs index f3363ac23..ea9134fff 100644 --- a/Assets/Scripts/ServerShared/Behaviors/ChargedWeapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/ChargedWeapon.cs @@ -160,7 +160,8 @@ public class ChargedWeapon : InstantWeapon public override void Activate() { - if(!_charging && !_coolingDown) + // A refused weapon never starts charging: it would pay charge heat and audio for a shot Trigger drops. + if(!_charging && !_coolingDown && !FireControl.Refuses(this, Entity)) { OnStartCharging?.Invoke(); Item.FireAudioEvent(ChargedWeaponAudioEvent.Start); diff --git a/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs b/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs index 59b7f6344..4d6cb80d0 100644 --- a/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs @@ -42,6 +42,7 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo protected int _burstRemaining; private bool _burstPaid; + private bool _burstStarted; private float _burstTimer; private float _burstInterval; protected float _cooldown; // Normalized @@ -53,7 +54,8 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo public float Cooldown { get; protected set; } public virtual bool CanFire { - get => !_coolingDown; + // A triggered burst that has not yet fired a round is not ready either: its cooldown has not started. + get => !_coolingDown && (_burstStarted || _burstRemaining == 0); } public override float DamagePerSecond => Damage / Cooldown; @@ -115,8 +117,9 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo _burstRemaining = (int) BurstCount; _burstInterval = BurstTime / _burstRemaining; _burstTimer = 0; - _cooldown = 1; - _coolingDown = true; + // The cooldown starts with the burst's first round that is not refused (Execute), not here: a burst whose + // every round is refused fires nothing and leaves no cooldown behind. + _burstStarted = false; } protected override void UpdateStats() @@ -242,7 +245,16 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo _burstRemaining = 0; return false; } - if (!refused) _burstPaid = _data.SingleAmmoBurst; + if (!refused) + { + _burstPaid = _data.SingleAmmoBurst; + if (!_burstStarted) + { + _burstStarted = true; + _cooldown = 1; + _coolingDown = true; + } + } _burstRemaining--; _burstTimer -= _burstInterval; diff --git a/Assets/Scripts/ServerShared/FireControl.cs b/Assets/Scripts/ServerShared/FireControl.cs index 8bdada43b..06c9d3ca5 100644 --- a/Assets/Scripts/ServerShared/FireControl.cs +++ b/Assets/Scripts/ServerShared/FireControl.cs @@ -229,18 +229,13 @@ public static class FireControl // visible, out of range) already denies the target. Planar (R7: the simulation is 2D): the range gate, the // burst reach and the range clamp all measure the same distance, so a target's height never moves one of // them without the others. - public static bool Designated(Weapon weapon, Entity source, Entity target, out float range) => - InReach(weapon, source, target, out range) && !(weapon is LockWeapon lockWeapon && !lockWeapon.IsLocked); - - // Designated before the lock: the target is seen and within the weapon's planar range. A LockWeapon acquires - // its lock only while its shooter holds the trigger, so an agent asks this to know when to start locking; - // every other reader wants Designated. The one range test in the file. - public static bool InReach(Weapon weapon, Entity source, Entity target, out float range) + public static bool Designated(Weapon weapon, Entity source, Entity target, out float range) { range = 0f; if (target == null) return false; range = length((target.Position - source.Position).xz); - return source.VisibleEntities.Contains(target) && range >= weapon.MinRange && range <= weapon.Range; + return source.VisibleEntities.Contains(target) && range >= weapon.MinRange && range <= weapon.Range && + !(weapon is LockWeapon lockWeapon && !lockWeapon.IsLocked); } // `designated` is the answer above. `inArc` is the bearing test, asked only once a target is designated. diff --git a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs index 4f9ffe282..70d083e27 100644 --- a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs @@ -84,7 +84,7 @@ public sealed class FireControlPerWeaponTests : IDisposable private Rig Build( (ItemRotation Rotation, float Arc)[] mounts = null, bool beam = false, WeaponFuse? fuse = null, float blast = 0f, float range = 100f, float targetRange = 60f, float accuracy = 1f, float minHit = .2f, bool lockWeapon = false, - bool turret = false, float energy = 0f, float heat = 0f, float visibility = 0f, int magazine = 0, bool singleAmmoBurst = false, bool decoy = false) + bool turret = false, float energy = 0f, float heat = 0f, float visibility = 0f, int magazine = 0, bool singleAmmoBurst = false, bool decoy = false, bool charged = false, bool auto = false) { mounts ??= new[] { (ItemRotation.None, 0f) }; var hullData = new HullData @@ -120,6 +120,14 @@ public sealed class FireControlPerWeaponTests : IDisposable LockSpeed = Constant(1000), SensorImpact = Constant(1), LockAngle = Constant(0), DirectionImpact = Constant(1), Decay = Constant(1), Count = Constant(1), BurstTime = Constant(0), Cooldown = Constant(1) }; + else if (charged) + behavior = new ChargedWeaponData + { + Count = Constant(1), BurstTime = Constant(0), Cooldown = Constant(1), SingleAmmoBurst = singleAmmoBurst, + ChargeTime = Constant(1), ChargeEnergy = Constant(0), ChargeHeat = Constant(5000), CanFireEarly = true + }; + else if (auto) + behavior = new AutoWeaponData { Count = Constant(1), BurstTime = Constant(0), Cooldown = Constant(1), SingleAmmoBurst = singleAmmoBurst }; else behavior = new InstantWeaponData { Count = Constant(1), BurstTime = Constant(0), Cooldown = Constant(1), SingleAmmoBurst = singleAmmoBurst }; behavior.Damage = Constant(100); @@ -401,13 +409,14 @@ public sealed class FireControlPerWeaponTests : IDisposable public bool Visible; public int Announced; public int Shots; + public bool CanFire; } // One discrete weapon, triggered once at no target, with every cost observable: a magazine, an energy charge, // a sound bank, wear events, heat and visibility. Range 29.5 refuses (arming 30); Range 40 fires. - private Costs Pull(float range, bool single, bool viaTrigger, bool staleRange = false) + private Costs Pull(float range, bool single, bool viaTrigger, bool staleRange = false, bool auto = false) { - var r = Build(fuse: WeaponFuse.Proximity, blast: 30f, range: range, energy: 50f, heat: 5000f, visibility: 100f, magazine: 5, singleAmmoBurst: single); + var r = Build(fuse: WeaponFuse.Proximity, blast: 30f, range: range, energy: 50f, heat: 5000f, visibility: 100f, magazine: 5, singleAmmoBurst: single, auto: auto); r.Shooter.Target.Value = null; Aim(r, float2(0, 1)); var weapon = (InstantWeapon) r.Gun.Weapon; @@ -427,7 +436,13 @@ public sealed class FireControlPerWeaponTests : IDisposable // The Range the previous tick's Execute left, before this tick refreshes it: a power-dependent Range that // has since fallen. Set through the property's own setter, so the trigger sees a Range the round will not. if (staleRange) typeof(Weapon).GetProperty("Range").GetSetMethod(true).Invoke(weapon, new object[] { 100f }); - if (viaTrigger) weapon.Activate(); + if (auto) + { + // AutoWeapon's own trigger: a held weapon whose cooldown has run out retriggers inside Execute. + typeof(Weapon).GetField("_firing", BindingFlags.Instance | BindingFlags.NonPublic).SetValue(weapon, true); + typeof(InstantWeapon).GetField("_cooldown", BindingFlags.Instance | BindingFlags.NonPublic).SetValue(weapon, -1f); + } + else if (viaTrigger) weapon.Activate(); else typeof(InstantWeapon).GetField("_burstRemaining", BindingFlags.Instance | BindingFlags.NonPublic).SetValue(weapon, 1); r.Zone.Update(.01f); @@ -435,6 +450,7 @@ public sealed class FireControlPerWeaponTests : IDisposable costs.ChargeSpent = chargeBefore - capacitor.Charge; costs.Heat = r.Gun.Item.Temperature - heatBefore; costs.Visible = r.Shooter.VisibilitySources.TryGetValue(weapon, out var v) && v > 1f; + costs.CanFire = weapon.CanFire; return costs; } @@ -603,16 +619,63 @@ public sealed class FireControlPerWeaponTests : IDisposable // A round refused on a Range that fell between the trigger and the round is still free: the burst is paid // (SingleAmmoBurst) at its first round that is not refused, judged with the Range that round flies with. - // The trigger saw a stale Range of 100; the round's Range is 29.5, under the arming distance. - // Kills: paying for the burst at the trigger. - [Fact] - public void ARefusedBurstThatPassedTheTriggerOnAStaleRangeCostsNothing() + // The trigger saw a stale Range of 100; the round's Range is 29.5, under the arming distance. The refusal + // starts no cooldown either (operator ruling 2026-09-30): the weapon can fire again at once, while the + // control (Range 40) fired and is cooling down. Covers the player's trigger and AutoWeapon's own. + // Kills: paying for the burst at the trigger; starting the cooldown at the trigger (Trigger or AutoWeapon). + [Theory] + [InlineData(true, false)] + [InlineData(false, false)] + [InlineData(true, true)] + [InlineData(false, true)] + public void ARefusedBurstThatPassedTheTriggerOnAStaleRangeCostsNothing(bool single, bool auto) { - var refused = Pull(29.5f, single: true, viaTrigger: true, staleRange: true); + var fired = Pull(40f, single, viaTrigger: true, staleRange: true, auto: auto); + Assert.Equal(1, fired.Shots); + Assert.False(fired.CanFire, "control: a fired burst starts the cooldown"); + + var refused = Pull(29.5f, single, viaTrigger: true, staleRange: true, auto: auto); Assert.Equal(0, refused.Shots); Assert.Equal(0, refused.Ammo); Assert.Equal(0f, refused.ChargeSpent); Assert.Equal(0, refused.Announced); + Assert.True(refused.CanFire, "a burst that fired nothing leaves no cooldown"); + } + + // A refused charged weapon never starts charging: no charge heat, no audio, no start event, and it can be + // triggered again at once. The control (Range 40) charges, sounds and heats. + // Kills: ChargedWeapon.Activate that does not ask FireControl. + private (int Starts, int Sounds, float Heat, bool Charging) Charge(float range) + { + var r = Build(charged: true, fuse: WeaponFuse.Proximity, blast: 30f, range: range); + r.Shooter.Target.Value = null; + Aim(r, float2(0, 1)); + var weapon = (ChargedWeapon) r.Gun.Weapon; + r.Gun.Item.SoundBank = new WwiseMetaSoundBank { IncludedEvents = new[] { new WwiseMetaObject { Id = 7, Name = "gun_fire" } } }; + var starts = 0; + var sounds = 0; + weapon.OnStartCharging += () => starts++; + r.Gun.Item.AudioEvents.Subscribe(_ => sounds++); + var heatBefore = r.Gun.Item.Temperature; + + weapon.Activate(); + for (var i = 0; i < 3; i++) r.Zone.Update(.1f); + + return (starts, sounds, r.Gun.Item.Temperature - heatBefore, weapon.Progress > 0f); + } + + [Fact] + public void ARefusedChargedWeaponNeverStartsCharging() + { + var fired = Charge(40f); + Assert.Equal(1, fired.Starts); + Assert.True(fired.Sounds > 0, "control: charging sounds"); + Assert.True(fired.Heat > 0f, "control: charging heats"); + + var refused = Charge(29.5f); + Assert.Equal(0, refused.Starts); + Assert.Equal(0, refused.Sounds); + Assert.True(fired.Heat - refused.Heat > 5f, $"a refused weapon adds no charge heat ({refused.Heat} vs {fired.Heat})"); } } From 0e73bbac728daab465d92bf17ff116e7c9aec0d8 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 21:23:04 +0200 Subject: [PATCH 34/49] Read the pending burst, not Progress, as the mark that Trigger ran; sound the charged control by its own event The cooldown now starts at a burst's first fired round, so Progress no longer moves at the trigger. IffAndCombatTests read CanFire (false while a burst is pending) instead, and the charged-weapon control uses the charge-start event name the sound bank resolves. Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs | 2 +- tests/Aetheria.Shared.Tests/IffAndCombatTests.cs | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs index 70d083e27..f61c75fe9 100644 --- a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs @@ -652,7 +652,7 @@ public sealed class FireControlPerWeaponTests : IDisposable r.Shooter.Target.Value = null; Aim(r, float2(0, 1)); var weapon = (ChargedWeapon) r.Gun.Weapon; - r.Gun.Item.SoundBank = new WwiseMetaSoundBank { IncludedEvents = new[] { new WwiseMetaObject { Id = 7, Name = "gun_fire" } } }; + r.Gun.Item.SoundBank = new WwiseMetaSoundBank { IncludedEvents = new[] { new WwiseMetaObject { Id = 7, Name = "gun_charge_play" } } }; var starts = 0; var sounds = 0; weapon.OnStartCharging += () => starts++; diff --git a/tests/Aetheria.Shared.Tests/IffAndCombatTests.cs b/tests/Aetheria.Shared.Tests/IffAndCombatTests.cs index 6c01aafc9..1d3863b50 100644 --- a/tests/Aetheria.Shared.Tests/IffAndCombatTests.cs +++ b/tests/Aetheria.Shared.Tests/IffAndCombatTests.cs @@ -250,7 +250,7 @@ public sealed class IffAndCombatTests : IDisposable weapon.Activate(); - Assert.Equal(0f, weapon.Progress); // Trigger() was safed: no cooldown/burst was started + Assert.True(weapon.CanFire); // Trigger() was safed: no burst was started } [Fact] @@ -266,7 +266,7 @@ public sealed class IffAndCombatTests : IDisposable weapon.Activate(); - Assert.Equal(1f, weapon.Progress); // Trigger() ran: cooldown/burst started + Assert.False(weapon.CanFire); // Trigger() ran: a burst is pending } [Fact] @@ -279,7 +279,7 @@ public sealed class IffAndCombatTests : IDisposable weapon.Activate(); - Assert.Equal(1f, weapon.Progress); + Assert.False(weapon.CanFire); // Trigger() ran: a burst is pending } [Fact] From 7c1b1cd3d8bd87fd62b33bb48f3b80d5089f8039 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 21:27:28 +0200 Subject: [PATCH 35/49] Tick the zone before reading the cooldown in the stance tests BurstCount is set by the first Execute, so a trigger pulled with no tick has no burst pending. The tests now run one tick and read whether the cooldown started. Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/IffAndCombatTests.cs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/IffAndCombatTests.cs b/tests/Aetheria.Shared.Tests/IffAndCombatTests.cs index 1d3863b50..814ab0176 100644 --- a/tests/Aetheria.Shared.Tests/IffAndCombatTests.cs +++ b/tests/Aetheria.Shared.Tests/IffAndCombatTests.cs @@ -250,7 +250,9 @@ public sealed class IffAndCombatTests : IDisposable weapon.Activate(); - Assert.True(weapon.CanFire); // Trigger() was safed: no burst was started + zone.Update(.01f); + + Assert.True(weapon.CanFire); // Trigger() was safed: no burst fired, so no cooldown } [Fact] @@ -265,8 +267,9 @@ public sealed class IffAndCombatTests : IDisposable shooter.SetIff(target, true); weapon.Activate(); + zone.Update(.01f); - Assert.False(weapon.CanFire); // Trigger() ran: a burst is pending + Assert.False(weapon.CanFire); // Trigger() ran: the burst fired and the cooldown runs } [Fact] @@ -278,8 +281,9 @@ public sealed class IffAndCombatTests : IDisposable var weapon = (InstantWeapon) shooter.Equipment.Single(e => e.Behaviors.Any(b => b is Weapon)).Behaviors.Single(b => b is Weapon); weapon.Activate(); + zone.Update(.01f); - Assert.False(weapon.CanFire); // Trigger() ran: a burst is pending + Assert.False(weapon.CanFire); // Trigger() ran: the burst fired and the cooldown runs } [Fact] From 020a42fcb6e37fa58f37eed4228fdb24dc2b467f Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 21:31:38 +0200 Subject: [PATCH 36/49] Execute the weapon directly in the stance tests The stance fixture has a shooter the zone cannot tick; the weapon runs on its own. Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/IffAndCombatTests.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/IffAndCombatTests.cs b/tests/Aetheria.Shared.Tests/IffAndCombatTests.cs index 814ab0176..9eed9d17e 100644 --- a/tests/Aetheria.Shared.Tests/IffAndCombatTests.cs +++ b/tests/Aetheria.Shared.Tests/IffAndCombatTests.cs @@ -250,7 +250,7 @@ public sealed class IffAndCombatTests : IDisposable weapon.Activate(); - zone.Update(.01f); + weapon.Execute(.01f); Assert.True(weapon.CanFire); // Trigger() was safed: no burst fired, so no cooldown } @@ -267,7 +267,7 @@ public sealed class IffAndCombatTests : IDisposable shooter.SetIff(target, true); weapon.Activate(); - zone.Update(.01f); + weapon.Execute(.01f); Assert.False(weapon.CanFire); // Trigger() ran: the burst fired and the cooldown runs } @@ -281,7 +281,7 @@ public sealed class IffAndCombatTests : IDisposable var weapon = (InstantWeapon) shooter.Equipment.Single(e => e.Behaviors.Any(b => b is Weapon)).Behaviors.Single(b => b is Weapon); weapon.Activate(); - zone.Update(.01f); + weapon.Execute(.01f); Assert.False(weapon.CanFire); // Trigger() ran: the burst fired and the cooldown runs } From c4b628fad7dd9d76f1797e47ac8627a3734d9872 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 21:32:31 +0200 Subject: [PATCH 37/49] Resolve the stance-test gun before triggering it A burst is sized from stats the first Execute resolves, so the untouched fixture gun fired nothing. Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/IffAndCombatTests.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/Aetheria.Shared.Tests/IffAndCombatTests.cs b/tests/Aetheria.Shared.Tests/IffAndCombatTests.cs index 9eed9d17e..bd8ad8513 100644 --- a/tests/Aetheria.Shared.Tests/IffAndCombatTests.cs +++ b/tests/Aetheria.Shared.Tests/IffAndCombatTests.cs @@ -248,8 +248,8 @@ public sealed class IffAndCombatTests : IDisposable shooter.Target.Value = target; shooter.SetIff(target, false); // explicitly neutral + weapon.Execute(.01f); // resolves the gun's stats: a burst has no rounds before the first tick weapon.Activate(); - weapon.Execute(.01f); Assert.True(weapon.CanFire); // Trigger() was safed: no burst fired, so no cooldown @@ -266,6 +266,7 @@ public sealed class IffAndCombatTests : IDisposable shooter.Target.Value = target; shooter.SetIff(target, true); + weapon.Execute(.01f); // resolves the gun's stats: a burst has no rounds before the first tick weapon.Activate(); weapon.Execute(.01f); @@ -280,6 +281,7 @@ public sealed class IffAndCombatTests : IDisposable var shooter = NewShip(items, zone, new Faction { Name = "Shooter" }, equipGun: true); var weapon = (InstantWeapon) shooter.Equipment.Single(e => e.Behaviors.Any(b => b is Weapon)).Behaviors.Single(b => b is Weapon); + weapon.Execute(.01f); // resolves the gun's stats: a burst has no rounds before the first tick weapon.Activate(); weapon.Execute(.01f); From 1c5872e7a899c4fa8cd4f94b7351aceef939e6d7 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 21:35:53 +0200 Subject: [PATCH 38/49] Give the stance-test gun a burst and a cooldown The default InstantWeaponData has Count 0, so the fixture gun never had a round to fire. Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/IffAndCombatTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Aetheria.Shared.Tests/IffAndCombatTests.cs b/tests/Aetheria.Shared.Tests/IffAndCombatTests.cs index bd8ad8513..31e390abe 100644 --- a/tests/Aetheria.Shared.Tests/IffAndCombatTests.cs +++ b/tests/Aetheria.Shared.Tests/IffAndCombatTests.cs @@ -32,7 +32,7 @@ public sealed class IffAndCombatTests : IDisposable cache.Upsert(new GearData { Name = "Gun", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1, - Behaviors = { new InstantWeaponData() } + Behaviors = { new InstantWeaponData { Count = new PerformanceStat { Min = 1, Max = 1 }, Cooldown = new PerformanceStat { Min = 1, Max = 1 } } } }); cache.FlushAsync().Wait(); } From c0fb26fca117d4f71fd9c438cdee98d527524ace Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 21:39:18 +0200 Subject: [PATCH 39/49] Pin that a triggered burst is not ready before its first round Co-Authored-By: Claude Opus 5.5 --- .../FireControlPerWeaponTests.cs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs index f61c75fe9..5a3578987 100644 --- a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs @@ -678,4 +678,20 @@ public sealed class FireControlPerWeaponTests : IDisposable Assert.Equal(0, refused.Sounds); Assert.True(fired.Heat - refused.Heat > 5f, $"a refused weapon adds no charge heat ({refused.Heat} vs {fired.Heat})"); } + // A triggered burst has not started its cooldown, but it is not ready either: pulled again before its first round + // flies it would restart the burst. Kills: CanFire that reads the cooldown alone. + [Fact] + public void ATriggeredBurstIsNotReadyBeforeItsFirstRound() + { + var r = Build(range: 100f); + r.Shooter.Target.Value = null; + Aim(r, float2(0, 1)); + var weapon = (InstantWeapon) r.Gun.Weapon; + r.Zone.Update(.01f); // resolves the gun's stats, which size the burst + Assert.True(weapon.CanFire); + + weapon.Activate(); + + Assert.False(weapon.CanFire); + } } From 819b28234662c937abbd32cdcd90508e3a9c38fc Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 21:54:36 +0200 Subject: [PATCH 40/49] Pin that Combat picks no group whose weapon cannot reach the target Co-Authored-By: Claude Opus 5.5 --- .../FireControlPerWeaponTests.cs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs index 5a3578987..d188fea46 100644 --- a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs @@ -586,6 +586,23 @@ public sealed class FireControlPerWeaponTests : IDisposable Assert.True(r.Decoy.Weapon.Firing, "the group that can fire is the one picked"); } + // Combat's group pick counts only weapons whose target is designated: the stronger gun's Range (10) does not reach the + // target (20), so its group is not picked and the decoy's, which does reach, is. Kills: Combat asking + // Designated nothing (a group of weapons that cannot reach the target selected). + [Fact] + public void CombatDoesNotPickAGroupWhoseWeaponCannotReachTheTarget() + { + var r = Build(range: 10f, targetRange: 20f, decoy: true); + Assert.True(r.Gun.Weapon.RangeDamagePerSecond(20f) > r.Decoy.Weapon.RangeDamagePerSecond(20f), "fixture: the short gun is the stronger group"); + r.Zone.Agents.Add(new Minion(r.Shooter)); + + r.Zone.Update(1f); + r.Zone.Update(1f); + + Assert.False(r.Gun.Weapon.Firing, "a weapon that cannot reach the target is never activated"); + Assert.True(r.Decoy.Weapon.Firing, "the group that reaches is the one picked"); + } + // Combat's range test is Designated's, not a second one: a target exactly at max range is in reach (the old // private test was strict). Kills: a range comparison of Combat's own. [Fact] From 742cb23f5b030fbebc6e9613d3a82689f9721a32 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 22:15:51 +0200 Subject: [PATCH 41/49] Gate a charged weapon's Activate on CanFire, and start a burst's cooldown with the tick it flew in A released charge has a burst pending and no cooldown until its first round, so a second Activate in that window started a second charge; Activate now asks CanFire. The round that starts a burst's cooldown does so after the tick's decrement, which made every cooldown one tick long; it now charges that tick to the cooldown. Pins both, and that a burst triggered after a fired one is not ready before its first round. Co-Authored-By: Claude Opus 5.5 --- .../ServerShared/Behaviors/ChargedWeapon.cs | 4 +- .../ServerShared/Behaviors/InstantWeapon.cs | 3 +- .../FireControlPerWeaponTests.cs | 75 +++++++++++++++++++ 3 files changed, 80 insertions(+), 2 deletions(-) diff --git a/Assets/Scripts/ServerShared/Behaviors/ChargedWeapon.cs b/Assets/Scripts/ServerShared/Behaviors/ChargedWeapon.cs index ea9134fff..bc80bc1e5 100644 --- a/Assets/Scripts/ServerShared/Behaviors/ChargedWeapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/ChargedWeapon.cs @@ -160,8 +160,10 @@ public class ChargedWeapon : InstantWeapon public override void Activate() { + // CanFire also holds a weapon whose released charge has a burst pending: its cooldown starts at its first round, + // so a charge started before then would overlap it. // A refused weapon never starts charging: it would pay charge heat and audio for a shot Trigger drops. - if(!_charging && !_coolingDown && !FireControl.Refuses(this, Entity)) + if(!_charging && CanFire && !FireControl.Refuses(this, Entity)) { OnStartCharging?.Invoke(); Item.FireAudioEvent(ChargedWeaponAudioEvent.Start); diff --git a/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs b/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs index 4d6cb80d0..2801110c9 100644 --- a/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs @@ -251,7 +251,8 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo if (!_burstStarted) { _burstStarted = true; - _cooldown = 1; + _cooldown = 1 - dt / Cooldown; + // The round starts the cooldown after this tick's decrement above: charge the tick to it, so the cadence stays Cooldown _coolingDown = true; } } diff --git a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs index d188fea46..7d79a99b1 100644 --- a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs @@ -711,4 +711,79 @@ public sealed class FireControlPerWeaponTests : IDisposable Assert.False(weapon.CanFire); } + + // A charge released with a burst pending has not started its cooldown, but it is not ready either: pressed + // again before that burst's first round it would start a second charge over it. Kills: ChargedWeapon.Activate + // that reads the cooldown alone instead of CanFire. + [Fact] + public void AChargedWeaponPressedAgainBeforeItsReleasedRoundFliesStartsNoSecondCharge() + { + var r = Build(charged: true, range: 100f); + r.Shooter.Target.Value = null; + Aim(r, float2(0, 1)); + var weapon = (ChargedWeapon) r.Gun.Weapon; + var starts = 0; + weapon.OnStartCharging += () => starts++; + r.Zone.Update(.01f); // resolves the stats + weapon.Activate(); + for (var i = 0; i < 15; i++) r.Zone.Update(.1f); + Assert.Equal(1, starts); + + weapon.Deactivate(); + weapon.Activate(); + r.Zone.Update(.1f); + + Assert.Equal(1, starts); + Assert.False((bool) typeof(ChargedWeapon).GetField("_charging", BindingFlags.Instance | BindingFlags.NonPublic).GetValue(weapon)); + } + + // Holding the trigger fires once per Cooldown: the round that starts the cooldown pays for the tick it flew in. + // Cooldown 1 at .25 s ticks is a shot every 5 ticks, 12 in 15 s. And a burst whose every round is refused + // leaves no cooldown: the same held trigger is ready every tick. Kills: a first round that starts the cooldown + // at 1 after the tick's decrement (a shot every 6 ticks, 10 in 15 s); a cooldown left by a refused burst. + [Theory] + [InlineData(100f, 12)] + [InlineData(29.5f, 0)] + public void AHeldTriggerFiresOncePerCooldownAndARefusedBurstLeavesNone(float range, int shots) + { + var r = Build(fuse: WeaponFuse.Proximity, blast: 30f, range: range); + r.Shooter.Target.Value = null; + Aim(r, float2(0, 1)); + var weapon = (InstantWeapon) r.Gun.Weapon; + var fired = 0; + weapon.OnFire += _ => fired++; + r.Zone.Update(.25f); // resolves the stats + var readyTicks = 0; + + for (var i = 0; i < 60; i++) + { + weapon.Activate(); + r.Zone.Update(.25f); + if (weapon.CanFire) readyTicks++; + } + + Assert.Equal(shots, fired); + if (shots == 0) Assert.Equal(60, readyTicks); + } + + // Kills: Trigger that leaves _burstStarted true from the previous burst, so the next burst reads as started + // (and ready) before its first round has flown. + [Fact] + public void ABurstTriggeredAfterAFiredOneIsNotReadyBeforeItsFirstRound() + { + var r = Build(range: 100f); + r.Shooter.Target.Value = null; + Aim(r, float2(0, 1)); + var weapon = (InstantWeapon) r.Gun.Weapon; + r.Zone.Update(.25f); + weapon.Activate(); + r.Zone.Update(.25f); + Assert.False(weapon.CanFire, "control: the first burst fired and is cooling"); + for (var i = 0; i < 10 && !weapon.CanFire; i++) r.Zone.Update(.25f); + Assert.True(weapon.CanFire, "fixture: the cooldown expired"); + + weapon.Activate(); + + Assert.False(weapon.CanFire); + } } From 7f317bd33274a0cd8d496e4100ca49c80af40985 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 22:15:53 +0200 Subject: [PATCH 42/49] Freeze a guided round that waits on its verdict and place a positionless hit at its target After its fade a round kept integrating Velocity through the BindingLifetime wait, then a Hit (which carries no burst point) played HitEffect at the drifted position. The wait now holds still; a hit without a point plays the effect at the round's target position, and is skipped when the round never knew one. OnEnable resets BindingLifetime so a pooled split child, which is never bound, does not inherit the last round's. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/Gameplay/Weapons/GuidedProjectile.cs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/Assets/Scripts/Gameplay/Weapons/GuidedProjectile.cs b/Assets/Scripts/Gameplay/Weapons/GuidedProjectile.cs index 4eb75f1ad..26a6db650 100644 --- a/Assets/Scripts/Gameplay/Weapons/GuidedProjectile.cs +++ b/Assets/Scripts/Gameplay/Weapons/GuidedProjectile.cs @@ -98,11 +98,14 @@ public class GuidedProjectile : MonoBehaviour return; } StopAllCoroutines(); + // A hit carries no burst point: the effect plays where the round was homing (a round that faded out has stopped + // short of it), and is skipped when the round never knew where its target was. + Vector3? effectAt = point ?? (TargetPosition != null ? TargetPosition() : Target ? Target.position : (Vector3?) null); if (point.HasValue) transform.position = point.Value; - if (HitEffect != null) + if (HitEffect != null && effectAt.HasValue) { var ht = HitEffect.Instantiate(); - ht.position = transform.position; + ht.position = effectAt.Value; } StartCoroutine(Kill()); } @@ -111,6 +114,7 @@ public class GuidedProjectile : MonoBehaviour { _active = _alive = true; _spawnTime = Time.time; + BindingLifetime = 0f; // a pooled round starts unbound; the manager binds it after enabling _phase = Random.value * 100; _prevDist = Single.MaxValue; Particles.startColor = Color.white; @@ -222,6 +226,8 @@ public class GuidedProjectile : MonoBehaviour yield return null; } + // The wait holds still: a round waiting on the verdict must not drift off its last position. + Velocity = Vector3.zero; // Faded, but the simulation's verdict has not necessarily arrived: wait for it until the shot's flight is over. while (Time.time - _spawnTime < BindingLifetime) yield return null; StartCoroutine(Kill()); From 113164fadb9e671ed452a71ad6fcc7110c30890e Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 22:24:39 +0200 Subject: [PATCH 43/49] Record fire control batches 5-8 landing and their follow-ups Co-Authored-By: Claude Opus 5.5 --- docs/fire-control-cut.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/fire-control-cut.md b/docs/fire-control-cut.md index 265452916..00d783dc6 100644 --- a/docs/fire-control-cut.md +++ b/docs/fire-control-cut.md @@ -2676,6 +2676,14 @@ follow-up rulings, all given on 2026-09-30: An out-of-arc round with a selected target bursts after that target leaves. That is ruled behaviour: an out-of-arc target is invalid, so the round takes the no-lock path. +- **Landed, 2026-09-29: batches 5-8 merged at `06001b57`.** 476 tests pass on the merged tree, after Soul + passes 5-7. Recorded follow-ups: + - The push-out follows the flight line, not the ray to the hull point. + - The HUD's burst range is an upper bound when a no-lock round stops at an earlier hull. + - The first-hull stop reads poses at fire time. + - A burst whose first round fails its energy or ammo spend retries every tick, bounded by capacitor refill. + - The first-round cooldown divides by `Cooldown` even when the magazine empties (a reload quirk that + predates this work). - **A round outlives its shooter,** for targeted and no-lock shots alike. Credit and loot with no shooter go nowhere. From e0a802084e9735e49cd56570bf5f350c23a74cbd Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 23:38:48 +0200 Subject: [PATCH 44/49] Use the generated accessor for Cycle Target Item Regenerate AetheriaInput.cs from the committed Aetheria.inputactions so the wrapper carries Cycle Target Item, and bind it through Input.Player like the other target actions. The by-name FindAction lookup was a stopgap from when the generator could not be run outside the editor. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/AetheriaInput.cs | 40 ++++++++++++++++++++ Assets/Scripts/Gameplay/ActionGameManager.cs | 7 +--- 2 files changed, 42 insertions(+), 5 deletions(-) diff --git a/Assets/Scripts/AetheriaInput.cs b/Assets/Scripts/AetheriaInput.cs index c6eb618c2..938444fef 100644 --- a/Assets/Scripts/AetheriaInput.cs +++ b/Assets/Scripts/AetheriaInput.cs @@ -162,6 +162,16 @@ public partial class @AetheriaInput: IInputActionCollection2, IDisposable ""initialStateCheck"": false, ""priority"": 0 }, + { + ""name"": ""Cycle Target Item"", + ""type"": ""Button"", + ""id"": ""3f9a2f0a-6e3f-4a53-9f5e-1a9c1e6d9f7b"", + ""expectedControlType"": ""Button"", + ""processors"": """", + ""interactions"": """", + ""initialStateCheck"": false, + ""priority"": 0 + }, { ""name"": ""Toggle Heatsinks"", ""type"": ""Button"", @@ -409,6 +419,17 @@ public partial class @AetheriaInput: IInputActionCollection2, IDisposable ""isComposite"": false, ""isPartOfComposite"": false }, + { + ""name"": """", + ""id"": ""8c2d5e77-4b18-4f0b-9a2e-6d3f7c1a5e90"", + ""path"": ""/j"", + ""interactions"": """", + ""processors"": """", + ""groups"": ""Keyboard&Mouse"", + ""action"": ""Cycle Target Item"", + ""isComposite"": false, + ""isPartOfComposite"": false + }, { ""name"": """", ""id"": ""71ac6088-1e18-4810-86be-1179918dd4a2"", @@ -1178,6 +1199,7 @@ public partial class @AetheriaInput: IInputActionCollection2, IDisposable m_Player_TargetPrevious = m_Player.FindAction("Target Previous", throwIfNotFound: true); m_Player_TargetNext = m_Player.FindAction("Target Next", throwIfNotFound: true); m_Player_TargetNearest = m_Player.FindAction("Target Nearest", throwIfNotFound: true); + m_Player_CycleTargetItem = m_Player.FindAction("Cycle Target Item", throwIfNotFound: true); m_Player_ToggleHeatsinks = m_Player.FindAction("Toggle Heatsinks", throwIfNotFound: true); m_Player_ToggleShield = m_Player.FindAction("Toggle Shield", throwIfNotFound: true); m_Player_ToggleStance = m_Player.FindAction("Toggle Stance", throwIfNotFound: true); @@ -1294,6 +1316,7 @@ public partial class @AetheriaInput: IInputActionCollection2, IDisposable private readonly InputAction m_Player_TargetPrevious; private readonly InputAction m_Player_TargetNext; private readonly InputAction m_Player_TargetNearest; + private readonly InputAction m_Player_CycleTargetItem; private readonly InputAction m_Player_ToggleHeatsinks; private readonly InputAction m_Player_ToggleShield; private readonly InputAction m_Player_ToggleStance; @@ -1342,6 +1365,10 @@ public partial class @AetheriaInput: IInputActionCollection2, IDisposable /// public InputAction @TargetNearest => m_Wrapper.m_Player_TargetNearest; /// + /// Provides access to the underlying input action "Player/CycleTargetItem". + /// + public InputAction @CycleTargetItem => m_Wrapper.m_Player_CycleTargetItem; + /// /// Provides access to the underlying input action "Player/ToggleHeatsinks". /// public InputAction @ToggleHeatsinks => m_Wrapper.m_Player_ToggleHeatsinks; @@ -1420,6 +1447,9 @@ public partial class @AetheriaInput: IInputActionCollection2, IDisposable @TargetNearest.started += instance.OnTargetNearest; @TargetNearest.performed += instance.OnTargetNearest; @TargetNearest.canceled += instance.OnTargetNearest; + @CycleTargetItem.started += instance.OnCycleTargetItem; + @CycleTargetItem.performed += instance.OnCycleTargetItem; + @CycleTargetItem.canceled += instance.OnCycleTargetItem; @ToggleHeatsinks.started += instance.OnToggleHeatsinks; @ToggleHeatsinks.performed += instance.OnToggleHeatsinks; @ToggleHeatsinks.canceled += instance.OnToggleHeatsinks; @@ -1476,6 +1506,9 @@ public partial class @AetheriaInput: IInputActionCollection2, IDisposable @TargetNearest.started -= instance.OnTargetNearest; @TargetNearest.performed -= instance.OnTargetNearest; @TargetNearest.canceled -= instance.OnTargetNearest; + @CycleTargetItem.started -= instance.OnCycleTargetItem; + @CycleTargetItem.performed -= instance.OnCycleTargetItem; + @CycleTargetItem.canceled -= instance.OnCycleTargetItem; @ToggleHeatsinks.started -= instance.OnToggleHeatsinks; @ToggleHeatsinks.performed -= instance.OnToggleHeatsinks; @ToggleHeatsinks.canceled -= instance.OnToggleHeatsinks; @@ -1964,6 +1997,13 @@ public partial class @AetheriaInput: IInputActionCollection2, IDisposable /// void OnTargetNearest(InputAction.CallbackContext context); /// + /// Method invoked when associated input action "Cycle Target Item" is either , or . + /// + /// + /// + /// + void OnCycleTargetItem(InputAction.CallbackContext context); + /// /// Method invoked when associated input action "Toggle Heatsinks" is either , or . /// /// diff --git a/Assets/Scripts/Gameplay/ActionGameManager.cs b/Assets/Scripts/Gameplay/ActionGameManager.cs index 543e8f8ef..50f2ff2f5 100644 --- a/Assets/Scripts/Gameplay/ActionGameManager.cs +++ b/Assets/Scripts/Gameplay/ActionGameManager.cs @@ -405,11 +405,8 @@ public class ActionGameManager : MonoBehaviour // Cut 2 (docs/fire-control-cut.md): cycles the aim point among the current target's revealed // subsystems -- decides nothing itself, only calls the one writer (TrySelectTargetItem), same - // predicate (FireControl.IsRevealed) the AI path uses. No generated typed accessor exists for this - // action yet (it was hand-authored into Aetheria.inputactions rather than regenerated through the - // Unity Input Actions editor, unavailable here), so it is looked up by name instead of through - // Input.Player. - Input.asset.FindAction("Player/Cycle Target Item").performed += context => + // predicate (FireControl.IsRevealed) the AI path uses. + Input.Player.CycleTargetItem.performed += context => { var target = CurrentEntity.Target.Value; if (target == null) return; From 97edf48cec622963209bfdce802b4153702f50ca Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 23:38:54 +0200 Subject: [PATCH 45/49] Add the debug info panel and the shield panel test rig ARPG.unity gains Debug Info Panel and Debug Info Text, which ActionGameManager.DebugInfoText drives as the fire-control debug HUD. FieldShieldTest.unity gains the ShieldInterceptor, ShieldEnvelope and Panel prototype rig from shield-panel Cut 4. Co-Authored-By: Claude Opus 5.5 --- Assets/Scenes/ARPG.unity | 374 +++++++++++++++++++++++++++- Assets/Scenes/FieldShieldTest.unity | 263 ++++++++++++++++--- 2 files changed, 600 insertions(+), 37 deletions(-) diff --git a/Assets/Scenes/ARPG.unity b/Assets/Scenes/ARPG.unity index c4bd71fcf..1ef50e0ea 100644 --- a/Assets/Scenes/ARPG.unity +++ b/Assets/Scenes/ARPG.unity @@ -436,6 +436,8 @@ MeshRenderer: m_RayTracingAccelStructBuildFlagsOverride: 0 m_RayTracingAccelStructBuildFlags: 1 m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 m_RenderingLayerMask: 1 m_RendererPriority: 0 m_Materials: @@ -457,9 +459,11 @@ MeshRenderer: m_AutoUVMaxDistance: 0.5 m_AutoUVMaxAngle: 89 m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 m_SortingLayerID: 0 m_SortingLayer: 0 m_SortingOrder: 0 + m_MaskInteraction: 0 m_AdditionalVertexStreams: {fileID: 0} --- !u!33 &23420360 MeshFilter: @@ -701,6 +705,8 @@ MeshRenderer: m_RayTracingAccelStructBuildFlagsOverride: 0 m_RayTracingAccelStructBuildFlags: 1 m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 m_RenderingLayerMask: 1 m_RendererPriority: 0 m_Materials: @@ -722,9 +728,11 @@ MeshRenderer: m_AutoUVMaxDistance: 0.5 m_AutoUVMaxAngle: 89 m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 m_SortingLayerID: 0 m_SortingLayer: 0 m_SortingOrder: 0 + m_MaskInteraction: 0 m_AdditionalVertexStreams: {fileID: 0} --- !u!33 &45636168 MeshFilter: @@ -1137,6 +1145,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -1597,6 +1606,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -1821,6 +1831,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -2497,6 +2508,8 @@ MeshRenderer: m_RayTracingAccelStructBuildFlagsOverride: 0 m_RayTracingAccelStructBuildFlags: 1 m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 m_RenderingLayerMask: 1 m_RendererPriority: 0 m_Materials: @@ -2518,9 +2531,11 @@ MeshRenderer: m_AutoUVMaxDistance: 0.5 m_AutoUVMaxAngle: 89 m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 m_SortingLayerID: 0 m_SortingLayer: 0 m_SortingOrder: 0 + m_MaskInteraction: 0 m_AdditionalVertexStreams: {fileID: 0} --- !u!33 &195655953 MeshFilter: @@ -2885,6 +2900,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -3410,6 +3426,7 @@ MonoBehaviour: InfiniteAmmoIcon: {fileID: 2044077503} CooldownFill: {fileID: 757740693} HeatFill: {fileID: 2060807771} + SelectedIndicator: {fileID: 0} --- !u!114 &232470601 MonoBehaviour: m_ObjectHideFlags: 0 @@ -3835,6 +3852,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -4117,6 +4135,8 @@ MeshRenderer: m_RayTracingAccelStructBuildFlagsOverride: 0 m_RayTracingAccelStructBuildFlags: 1 m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 m_RenderingLayerMask: 1 m_RendererPriority: 0 m_Materials: @@ -4138,9 +4158,11 @@ MeshRenderer: m_AutoUVMaxDistance: 0.5 m_AutoUVMaxAngle: 89 m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 m_SortingLayerID: 0 m_SortingLayer: 0 m_SortingOrder: 0 + m_MaskInteraction: 0 m_AdditionalVertexStreams: {fileID: 0} --- !u!33 &259676750 MeshFilter: @@ -4843,6 +4865,7 @@ RectTransform: m_Children: - {fileID: 2352149240727418666} - {fileID: 410701128} + - {fileID: 568886950} - {fileID: 1558520822} - {fileID: 1396909587} - {fileID: 1556666354} @@ -5153,6 +5176,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -5289,6 +5313,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -5695,6 +5720,7 @@ MonoBehaviour: m_VerticalAlignment: 256 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -7714,6 +7740,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -8358,6 +8385,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -8732,6 +8760,8 @@ MeshRenderer: m_RayTracingAccelStructBuildFlagsOverride: 0 m_RayTracingAccelStructBuildFlags: 1 m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 m_RenderingLayerMask: 1 m_RendererPriority: 0 m_Materials: @@ -8753,9 +8783,11 @@ MeshRenderer: m_AutoUVMaxDistance: 0.5 m_AutoUVMaxAngle: 89 m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 m_SortingLayerID: 0 m_SortingLayer: 0 m_SortingOrder: 0 + m_MaskInteraction: 0 m_AdditionalVertexStreams: {fileID: 0} --- !u!33 &542243549 MeshFilter: @@ -9080,6 +9112,82 @@ RectTransform: type: 3} m_PrefabInstance: {fileID: 2279630454421103832} m_PrefabAsset: {fileID: 0} +--- !u!1 &568886949 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 568886950} + - component: {fileID: 568886952} + - component: {fileID: 568886951} + m_Layer: 5 + m_Name: Debug Info Panel + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!224 &568886950 +RectTransform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 568886949} + m_LocalRotation: {x: -0, y: -0, z: -0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: + - {fileID: 1731988778} + m_Father: {fileID: 346000247} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} + m_AnchorMin: {x: 0, y: 1} + m_AnchorMax: {x: 0, y: 1} + m_AnchoredPosition: {x: 16, y: -800} + m_SizeDelta: {x: 220, y: 120} + m_Pivot: {x: 0, y: 0} +--- !u!114 &568886951 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 568886949} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: fe87c0e1cc204ed48ad3b37840f39efc, type: 3} + m_Name: + m_EditorClassIdentifier: + m_Material: {fileID: 2100000, guid: 46238d909c2092c4eb873278d84c6ce8, type: 2} + m_Color: {r: 1, g: 1, b: 1, a: 1} + m_RaycastTarget: 0 + m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0} + m_Maskable: 0 + m_OnCullStateChanged: + m_PersistentCalls: + m_Calls: [] + m_Sprite: {fileID: 21300000, guid: 84d126f984de5fd42ac66361c00f830c, type: 3} + m_Type: 1 + m_PreserveAspect: 0 + m_FillCenter: 1 + m_FillMethod: 4 + m_FillAmount: 1 + m_FillClockwise: 1 + m_FillOrigin: 0 + m_UseSpriteMesh: 0 + m_PixelsPerUnitMultiplier: 0.5 +--- !u!222 &568886952 +CanvasRenderer: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 568886949} + m_CullTransparentMesh: 0 --- !u!1 &569895571 GameObject: m_ObjectHideFlags: 0 @@ -10233,9 +10341,6 @@ MonoBehaviour: TintDisplay: {fileID: 218215088} InfluenceDisplay: {fileID: 1390179643} Scale: 1 - Position: - x: 0 - y: 0 IconSize: 32 --- !u!1001 &642590526 PrefabInstance: @@ -11058,6 +11163,8 @@ MeshRenderer: m_RayTracingAccelStructBuildFlagsOverride: 0 m_RayTracingAccelStructBuildFlags: 1 m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 m_RenderingLayerMask: 1 m_RendererPriority: 0 m_Materials: @@ -11079,9 +11186,11 @@ MeshRenderer: m_AutoUVMaxDistance: 0.5 m_AutoUVMaxAngle: 89 m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 m_SortingLayerID: 0 m_SortingLayer: 0 m_SortingOrder: 0 + m_MaskInteraction: 0 m_AdditionalVertexStreams: {fileID: 0} --- !u!33 &677960094 MeshFilter: @@ -11394,6 +11503,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -11512,6 +11622,8 @@ MeshRenderer: m_RayTracingAccelStructBuildFlagsOverride: 0 m_RayTracingAccelStructBuildFlags: 1 m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 m_RenderingLayerMask: 1 m_RendererPriority: 0 m_Materials: @@ -11533,9 +11645,11 @@ MeshRenderer: m_AutoUVMaxDistance: 0.5 m_AutoUVMaxAngle: 89 m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 m_SortingLayerID: 0 m_SortingLayer: 0 m_SortingOrder: 0 + m_MaskInteraction: 0 m_AdditionalVertexStreams: {fileID: 0} --- !u!33 &708399897 MeshFilter: @@ -12046,6 +12160,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -13167,6 +13282,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -13434,6 +13550,7 @@ MonoBehaviour: m_VerticalAlignment: 256 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -13738,6 +13855,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -14206,6 +14324,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -14343,6 +14462,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -14510,6 +14630,8 @@ MeshRenderer: m_RayTracingAccelStructBuildFlagsOverride: 0 m_RayTracingAccelStructBuildFlags: 1 m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 m_RenderingLayerMask: 1 m_RendererPriority: 0 m_Materials: @@ -14531,9 +14653,11 @@ MeshRenderer: m_AutoUVMaxDistance: 0.5 m_AutoUVMaxAngle: 89 m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 m_SortingLayerID: 0 m_SortingLayer: 0 m_SortingOrder: 0 + m_MaskInteraction: 0 m_AdditionalVertexStreams: {fileID: 0} --- !u!33 &926083405 MeshFilter: @@ -14699,6 +14823,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -14927,6 +15052,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -15094,6 +15220,8 @@ MeshRenderer: m_RayTracingAccelStructBuildFlagsOverride: 0 m_RayTracingAccelStructBuildFlags: 1 m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 m_RenderingLayerMask: 1 m_RendererPriority: 0 m_Materials: @@ -15115,9 +15243,11 @@ MeshRenderer: m_AutoUVMaxDistance: 0.5 m_AutoUVMaxAngle: 89 m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 m_SortingLayerID: 0 m_SortingLayer: 0 m_SortingOrder: 0 + m_MaskInteraction: 0 m_AdditionalVertexStreams: {fileID: 0} --- !u!33 &936330464 MeshFilter: @@ -15244,6 +15374,8 @@ MeshRenderer: m_RayTracingAccelStructBuildFlagsOverride: 0 m_RayTracingAccelStructBuildFlags: 1 m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 m_RenderingLayerMask: 1 m_RendererPriority: 0 m_Materials: @@ -15265,9 +15397,11 @@ MeshRenderer: m_AutoUVMaxDistance: 0.5 m_AutoUVMaxAngle: 89 m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 m_SortingLayerID: 0 m_SortingLayer: 0 m_SortingOrder: 0 + m_MaskInteraction: 0 m_AdditionalVertexStreams: {fileID: 0} --- !u!33 &945347194 MeshFilter: @@ -16545,9 +16679,6 @@ MonoBehaviour: m_Name: m_EditorClassIdentifier: Settings: {fileID: 11400000, guid: cd2344e2001a37544aab0960c164d4ff, type: 2} - Sensitivity: - x: -0.001 - y: 0.001 Credits: 100000000 TargetSpottedBlinkFrequency: 20 TargetSpottedBlinkOffset: -0.25 @@ -16594,6 +16725,7 @@ MonoBehaviour: HitMarkerDuration: 0.25 SchematicDisplay: {fileID: 7628189704109037584} TargetSchematicDisplay: {fileID: 410701129} + DebugInfoText: {fileID: 1731988779} TargetIndicator: {fileID: 1556666357} TargetHitpointsFill: {fileID: 1691348884} TargetVisibilityFill: {fileID: 1715434759} @@ -16997,6 +17129,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -17431,6 +17564,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -17567,6 +17701,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -18347,6 +18482,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -18759,6 +18895,7 @@ MonoBehaviour: m_VerticalAlignment: 256 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -19045,6 +19182,8 @@ MeshRenderer: m_RayTracingAccelStructBuildFlagsOverride: 0 m_RayTracingAccelStructBuildFlags: 1 m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 m_RenderingLayerMask: 1 m_RendererPriority: 0 m_Materials: @@ -19066,9 +19205,11 @@ MeshRenderer: m_AutoUVMaxDistance: 0.5 m_AutoUVMaxAngle: 89 m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 m_SortingLayerID: 0 m_SortingLayer: 0 m_SortingOrder: 0 + m_MaskInteraction: 0 m_AdditionalVertexStreams: {fileID: 0} --- !u!33 &1163671429 MeshFilter: @@ -21015,6 +21156,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -21635,6 +21777,8 @@ MeshRenderer: m_RayTracingAccelStructBuildFlagsOverride: 0 m_RayTracingAccelStructBuildFlags: 1 m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 m_RenderingLayerMask: 1 m_RendererPriority: 0 m_Materials: @@ -21656,9 +21800,11 @@ MeshRenderer: m_AutoUVMaxDistance: 0.5 m_AutoUVMaxAngle: 89 m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 m_SortingLayerID: 0 m_SortingLayer: 0 m_SortingOrder: 0 + m_MaskInteraction: 0 m_AdditionalVertexStreams: {fileID: 0} --- !u!33 &1390575965 MeshFilter: @@ -21855,6 +22001,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -22054,6 +22201,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -22240,6 +22388,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -22746,6 +22895,7 @@ MonoBehaviour: InfiniteAmmoIcon: {fileID: 0} CooldownFill: {fileID: 0} HeatFill: {fileID: 0} + SelectedIndicator: {fileID: 0} --- !u!114 &1462704894 MonoBehaviour: m_ObjectHideFlags: 0 @@ -23011,6 +23161,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -23237,6 +23388,8 @@ MeshRenderer: m_RayTracingAccelStructBuildFlagsOverride: 0 m_RayTracingAccelStructBuildFlags: 1 m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 m_RenderingLayerMask: 1 m_RendererPriority: 0 m_Materials: @@ -23258,9 +23411,11 @@ MeshRenderer: m_AutoUVMaxDistance: 0.5 m_AutoUVMaxAngle: 89 m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 m_SortingLayerID: 0 m_SortingLayer: 0 m_SortingOrder: 0 + m_MaskInteraction: 0 m_AdditionalVertexStreams: {fileID: 0} --- !u!33 &1476765551 MeshFilter: @@ -23428,6 +23583,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -23824,6 +23980,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -24490,6 +24647,11 @@ PrefabInstance: propertyPath: m_firstOverflowCharacterIndex value: 0 objectReference: {fileID: 0} + - target: {fileID: 1713457305180270767, guid: 2758dfa2744d7ad4d8877545189d116e, + type: 3} + propertyPath: 'm_ActiveFontFeatures.Array.data[0]' + value: 1801810542 + objectReference: {fileID: 0} - target: {fileID: 2297648055057363568, guid: 2758dfa2744d7ad4d8877545189d116e, type: 3} propertyPath: m_Pivot.x @@ -24931,6 +25093,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -25211,6 +25374,8 @@ MeshRenderer: m_RayTracingAccelStructBuildFlagsOverride: 0 m_RayTracingAccelStructBuildFlags: 1 m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 m_RenderingLayerMask: 1 m_RendererPriority: 0 m_Materials: @@ -25232,9 +25397,11 @@ MeshRenderer: m_AutoUVMaxDistance: 0.5 m_AutoUVMaxAngle: 89 m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 m_SortingLayerID: 0 m_SortingLayer: 0 m_SortingOrder: 0 + m_MaskInteraction: 0 m_AdditionalVertexStreams: {fileID: 0} --- !u!33 &1566772338 MeshFilter: @@ -25567,6 +25734,7 @@ MonoBehaviour: m_VerticalAlignment: 1024 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 50 m_lineSpacingMax: 0 @@ -25710,6 +25878,11 @@ PrefabInstance: propertyPath: m_firstOverflowCharacterIndex value: 0 objectReference: {fileID: 0} + - target: {fileID: 1713457305180270767, guid: 2758dfa2744d7ad4d8877545189d116e, + type: 3} + propertyPath: 'm_ActiveFontFeatures.Array.data[0]' + value: 1801810542 + objectReference: {fileID: 0} - target: {fileID: 2297648055057363568, guid: 2758dfa2744d7ad4d8877545189d116e, type: 3} propertyPath: m_Pivot.x @@ -26204,6 +26377,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -26715,6 +26889,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -26962,6 +27137,8 @@ MeshRenderer: m_RayTracingAccelStructBuildFlagsOverride: 0 m_RayTracingAccelStructBuildFlags: 1 m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 m_RenderingLayerMask: 1 m_RendererPriority: 0 m_Materials: @@ -26983,9 +27160,11 @@ MeshRenderer: m_AutoUVMaxDistance: 0.5 m_AutoUVMaxAngle: 89 m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 m_SortingLayerID: 0 m_SortingLayer: 0 m_SortingOrder: 0 + m_MaskInteraction: 0 m_AdditionalVertexStreams: {fileID: 0} --- !u!33 &1713745167 MeshFilter: @@ -27473,6 +27652,151 @@ MonoBehaviour: TabButtons: {fileID: 1783772015} ActiveTabColor: {r: 1, g: 0.44583336, b: 0.050000012, a: 1} InactiveTabColor: {r: 0.6981132, g: 0.6981132, b: 0.6981132, a: 1} +--- !u!1 &1731988777 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 1731988778} + - component: {fileID: 1731988780} + - component: {fileID: 1731988779} + m_Layer: 5 + m_Name: Debug Info Text + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!224 &1731988778 +RectTransform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1731988777} + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 568886950} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} + m_AnchorMin: {x: 0, y: 0} + m_AnchorMax: {x: 1, y: 1} + m_AnchoredPosition: {x: 2, y: 2} + m_SizeDelta: {x: -20, y: -20} + m_Pivot: {x: 0.5, y: 0.5} +--- !u!114 &1731988779 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1731988777} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: f4688fdb7df04437aeb418b961361dc5, type: 3} + m_Name: + m_EditorClassIdentifier: Unity.TextMeshPro::TMPro.TextMeshProUGUI + m_Material: {fileID: 0} + m_Color: {r: 1, g: 1, b: 1, a: 1} + m_RaycastTarget: 1 + m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0} + m_Maskable: 1 + m_OnCullStateChanged: + m_PersistentCalls: + m_Calls: [] + m_text: 'New TextNew TextNew Text + + New TextNew TextNew TextNew Text + + New + TextNew Text + + New TextNew Text' + m_isRightToLeft: 0 + m_fontAsset: {fileID: 11400000, guid: 53555f7a2d5e745428813f1b1118734f, type: 2} + m_sharedMaterial: {fileID: -2804126062856497447, guid: 53555f7a2d5e745428813f1b1118734f, + type: 2} + m_fontSharedMaterials: [] + m_fontMaterial: {fileID: 0} + m_fontMaterials: [] + m_fontColor32: + serializedVersion: 2 + rgba: 4294967295 + m_fontColor: {r: 1, g: 1, b: 1, a: 1} + m_enableVertexGradient: 0 + m_colorMode: 3 + m_fontColorGradient: + topLeft: {r: 1, g: 1, b: 1, a: 1} + topRight: {r: 1, g: 1, b: 1, a: 1} + bottomLeft: {r: 1, g: 1, b: 1, a: 1} + bottomRight: {r: 1, g: 1, b: 1, a: 1} + m_fontColorGradientPreset: {fileID: 0} + m_spriteAsset: {fileID: 0} + m_tintAllSprites: 0 + m_StyleSheet: {fileID: 0} + m_TextStyleHashCode: -1183493901 + m_overrideHtmlColors: 0 + m_faceColor: + serializedVersion: 2 + rgba: 4294967295 + m_fontSize: 15.85 + m_fontSizeBase: 12 + m_fontWeight: 400 + m_enableAutoSizing: 1 + m_fontSizeMin: 8 + m_fontSizeMax: 72 + m_fontStyle: 0 + m_HorizontalAlignment: 1 + m_VerticalAlignment: 1024 + m_textAlignment: 65535 + m_characterSpacing: 0 + m_characterHorizontalScale: 1 + m_wordSpacing: 0 + m_lineSpacing: 0 + m_lineSpacingMax: 0 + m_paragraphSpacing: 0 + m_charWidthMaxAdj: 0 + m_TextWrappingMode: 1 + m_wordWrappingRatios: 0.4 + m_overflowMode: 0 + m_linkedTextComponent: {fileID: 0} + parentLinkedComponent: {fileID: 0} + m_enableKerning: 0 + m_ActiveFontFeatures: 6e72656b + m_enableExtraPadding: 0 + checkPaddingRequired: 0 + m_isRichText: 1 + m_EmojiFallbackSupport: 1 + m_parseCtrlCharacters: 1 + m_isOrthographic: 1 + m_isCullingEnabled: 0 + m_horizontalMapping: 0 + m_verticalMapping: 0 + m_uvLineOffset: 0 + m_geometrySortingOrder: 0 + m_IsTextObjectScaleStatic: 0 + m_VertexBufferAutoSizeReduction: 0 + m_useMaxVisibleDescender: 1 + m_pageToDisplay: 1 + m_margin: {x: 0, y: 0, z: 0, w: 0} + m_isUsingLegacyAnimationComponent: 0 + m_isVolumetricText: 0 + m_hasFontAssetChanged: 0 + m_baseMaterial: {fileID: 0} + m_maskOffset: {x: 0, y: 0, z: 0, w: 0} +--- !u!222 &1731988780 +CanvasRenderer: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1731988777} + m_CullTransparentMesh: 1 --- !u!1 &1733988258 GameObject: m_ObjectHideFlags: 0 @@ -27569,6 +27893,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -27928,6 +28253,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -28979,6 +29305,8 @@ MeshRenderer: m_RayTracingAccelStructBuildFlagsOverride: 0 m_RayTracingAccelStructBuildFlags: 1 m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 m_RenderingLayerMask: 1 m_RendererPriority: 0 m_Materials: @@ -29000,9 +29328,11 @@ MeshRenderer: m_AutoUVMaxDistance: 0.5 m_AutoUVMaxAngle: 89 m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 m_SortingLayerID: 0 m_SortingLayer: 0 m_SortingOrder: 0 + m_MaskInteraction: 0 m_AdditionalVertexStreams: {fileID: 0} --- !u!33 &1857285688 MeshFilter: @@ -30080,6 +30410,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -31056,6 +31387,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -31112,6 +31444,8 @@ MeshRenderer: m_RayTracingAccelStructBuildFlagsOverride: 0 m_RayTracingAccelStructBuildFlags: 1 m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 m_RenderingLayerMask: 1 m_RendererPriority: 0 m_Materials: @@ -31133,9 +31467,11 @@ MeshRenderer: m_AutoUVMaxDistance: 0.5 m_AutoUVMaxAngle: 89 m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 m_SortingLayerID: 0 m_SortingLayer: 0 m_SortingOrder: 0 + m_MaskInteraction: 0 m_AdditionalVertexStreams: {fileID: 0} --- !u!1 &2095699056 GameObject: @@ -31368,6 +31704,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -31708,6 +32045,8 @@ MeshRenderer: m_RayTracingAccelStructBuildFlagsOverride: 0 m_RayTracingAccelStructBuildFlags: 1 m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 m_RenderingLayerMask: 1 m_RendererPriority: 0 m_Materials: @@ -31729,9 +32068,11 @@ MeshRenderer: m_AutoUVMaxDistance: 0.5 m_AutoUVMaxAngle: 89 m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 m_SortingLayerID: 0 m_SortingLayer: 0 m_SortingOrder: 0 + m_MaskInteraction: 0 m_AdditionalVertexStreams: {fileID: 0} --- !u!33 &2117796868 MeshFilter: @@ -31892,6 +32233,7 @@ MonoBehaviour: m_VerticalAlignment: 256 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -32028,6 +32370,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -33170,6 +33513,7 @@ MonoBehaviour: m_VerticalAlignment: 1024 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -33270,6 +33614,7 @@ MonoBehaviour: m_VerticalAlignment: 1024 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -33590,6 +33935,7 @@ MonoBehaviour: m_VerticalAlignment: 256 m_textAlignment: 65535 m_characterSpacing: 9.01 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -33719,6 +34065,7 @@ MonoBehaviour: m_VerticalAlignment: 1024 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -33819,6 +34166,7 @@ MonoBehaviour: m_VerticalAlignment: 1024 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -34030,6 +34378,7 @@ MonoBehaviour: m_VerticalAlignment: 1024 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -34369,6 +34718,7 @@ MonoBehaviour: m_VerticalAlignment: 1024 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -34506,6 +34856,7 @@ MonoBehaviour: m_VerticalAlignment: 1024 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -34625,6 +34976,7 @@ MonoBehaviour: m_VerticalAlignment: 1024 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -34780,6 +35132,7 @@ MonoBehaviour: m_VerticalAlignment: 1024 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -34917,6 +35270,7 @@ MonoBehaviour: m_VerticalAlignment: 1024 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -35222,6 +35576,7 @@ MonoBehaviour: m_VerticalAlignment: 1024 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -35358,6 +35713,7 @@ MonoBehaviour: m_VerticalAlignment: 1024 m_textAlignment: 65535 m_characterSpacing: 9.01 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -35457,6 +35813,7 @@ MonoBehaviour: m_VerticalAlignment: 1024 m_textAlignment: 65535 m_characterSpacing: 9.01 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -35631,6 +35988,7 @@ MonoBehaviour: m_VerticalAlignment: 1024 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -35787,6 +36145,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -37131,6 +37490,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -37577,6 +37937,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 @@ -37888,6 +38249,7 @@ MonoBehaviour: m_VerticalAlignment: 512 m_textAlignment: 65535 m_characterSpacing: 0 + m_characterHorizontalScale: 1 m_wordSpacing: 0 m_lineSpacing: 0 m_lineSpacingMax: 0 diff --git a/Assets/Scenes/FieldShieldTest.unity b/Assets/Scenes/FieldShieldTest.unity index b1b2e50b9..51c915b7f 100644 --- a/Assets/Scenes/FieldShieldTest.unity +++ b/Assets/Scenes/FieldShieldTest.unity @@ -13,7 +13,7 @@ OcclusionCullingSettings: --- !u!104 &2 RenderSettings: m_ObjectHideFlags: 0 - serializedVersion: 9 + serializedVersion: 10 m_Fog: 0 m_FogColor: {r: 0.5, g: 0.5, b: 0.5, a: 1} m_FogMode: 3 @@ -38,13 +38,12 @@ RenderSettings: m_ReflectionIntensity: 1 m_CustomReflection: {fileID: 0} m_Sun: {fileID: 0} - m_IndirectSpecularColor: {r: 0.023674645, g: 0.04639011, b: 0.122535154, a: 1} m_UseRadianceAmbientProbe: 0 --- !u!157 &3 LightmapSettings: m_ObjectHideFlags: 0 - serializedVersion: 12 - m_GIWorkflowMode: 1 + serializedVersion: 13 + m_BakeOnSceneLoad: 0 m_GISettings: serializedVersion: 2 m_BounceScale: 1 @@ -67,9 +66,6 @@ LightmapSettings: m_LightmapParameters: {fileID: 0} m_LightmapsBakeMode: 1 m_TextureCompression: 1 - m_FinalGather: 0 - m_FinalGatherFiltering: 1 - m_FinalGatherRayCount: 256 m_ReflectionCompression: 2 m_MixedBakeMode: 2 m_BakeBackend: 1 @@ -104,7 +100,7 @@ NavMeshSettings: serializedVersion: 2 m_ObjectHideFlags: 0 m_BuildSettings: - serializedVersion: 2 + serializedVersion: 3 agentTypeID: 0 agentRadius: 0.5 agentHeight: 2 @@ -117,7 +113,7 @@ NavMeshSettings: cellSize: 0.16666667 manualTileSize: 0 tileSize: 256 - accuratePlacement: 0 + buildHeightMesh: 0 maxJobWorkers: 0 preserveTilesOutsideBounds: 0 debug: @@ -155,7 +151,6 @@ RectTransform: m_ConstrainProportionsScale: 0 m_Children: [] m_Father: {fileID: 815358592} - m_RootOrder: 5 m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_AnchorMin: {x: 0, y: 0} m_AnchorMax: {x: 0, y: 0} @@ -296,7 +291,6 @@ RectTransform: m_ConstrainProportionsScale: 0 m_Children: [] m_Father: {fileID: 815358592} - m_RootOrder: 3 m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_AnchorMin: {x: 0, y: 0} m_AnchorMax: {x: 1, y: 1} @@ -308,6 +302,7 @@ PrefabInstance: m_ObjectHideFlags: 0 serializedVersion: 2 m_Modification: + serializedVersion: 3 m_TransformParent: {fileID: 815358592} m_Modifications: - target: {fileID: 257019179550429270, guid: 426f9dcb510530d4dbfa678c9f727ec1, @@ -360,6 +355,11 @@ PrefabInstance: propertyPath: m_text value: Aetheric Field Tester objectReference: {fileID: 0} + - target: {fileID: 7131821417072393581, guid: 426f9dcb510530d4dbfa678c9f727ec1, + type: 3} + propertyPath: 'm_ActiveFontFeatures.Array.data[0]' + value: 1801810542 + objectReference: {fileID: 0} - target: {fileID: 7131821417165215474, guid: 426f9dcb510530d4dbfa678c9f727ec1, type: 3} propertyPath: m_Enabled @@ -481,6 +481,9 @@ PrefabInstance: value: Properties objectReference: {fileID: 0} m_RemovedComponents: [] + m_RemovedGameObjects: [] + m_AddedGameObjects: [] + m_AddedComponents: [] m_SourcePrefab: {fileID: 100100000, guid: 426f9dcb510530d4dbfa678c9f727ec1, type: 3} --- !u!114 &401779935 stripped MonoBehaviour: @@ -505,6 +508,7 @@ PrefabInstance: m_ObjectHideFlags: 0 serializedVersion: 2 m_Modification: + serializedVersion: 3 m_TransformParent: {fileID: 815358592} m_Modifications: - target: {fileID: 9194050151789067659, guid: 5c3a7b786419db74a8c1d17039fd4f67, @@ -663,6 +667,9 @@ PrefabInstance: value: 0 objectReference: {fileID: 0} m_RemovedComponents: [] + m_RemovedGameObjects: [] + m_AddedGameObjects: [] + m_AddedComponents: [] m_SourcePrefab: {fileID: 100100000, guid: 5c3a7b786419db74a8c1d17039fd4f67, type: 3} --- !u!224 &415327856 stripped RectTransform: @@ -713,7 +720,6 @@ RectTransform: m_ConstrainProportionsScale: 0 m_Children: [] m_Father: {fileID: 1112533349} - m_RootOrder: 0 m_LocalEulerAnglesHint: {x: 0, y: 0, z: 180} m_AnchorMin: {x: 0, y: 0} m_AnchorMax: {x: 1, y: 1} @@ -812,13 +818,13 @@ Transform: m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} m_GameObject: {fileID: 699508825} + serializedVersion: 2 m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} m_LocalPosition: {x: 0, y: 0, z: 0} m_LocalScale: {x: 1, y: 1, z: 1} m_ConstrainProportionsScale: 0 m_Children: [] m_Father: {fileID: 0} - m_RootOrder: 5 m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} --- !u!1 &815358588 GameObject: @@ -896,7 +902,9 @@ Canvas: m_OverrideSorting: 0 m_OverridePixelPerfect: 0 m_SortingBucketNormalizedSize: 0 + m_VertexColorAlwaysGammaSpace: 0 m_AdditionalShaderChannelsFlag: 25 + m_UpdateRectTransformForStandalone: 0 m_SortingLayerID: 0 m_SortingOrder: 0 m_TargetDisplay: 0 @@ -919,7 +927,6 @@ RectTransform: - {fileID: 415327856} - {fileID: 87891009} m_Father: {fileID: 0} - m_RootOrder: 3 m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_AnchorMin: {x: 0, y: 0} m_AnchorMax: {x: 0, y: 0} @@ -931,6 +938,7 @@ PrefabInstance: m_ObjectHideFlags: 0 serializedVersion: 2 m_Modification: + serializedVersion: 3 m_TransformParent: {fileID: 0} m_Modifications: - target: {fileID: 411033920054375063, guid: fe47fadcdb5ae5c4caca516b4af6fde6, @@ -1008,7 +1016,39 @@ PrefabInstance: propertyPath: m_LocalEulerAnglesHint.z value: 0 objectReference: {fileID: 0} + - target: {fileID: 2212592724187910978, guid: fe47fadcdb5ae5c4caca516b4af6fde6, + type: 3} + propertyPath: m_Enabled + value: 0 + objectReference: {fileID: 0} m_RemovedComponents: [] + m_RemovedGameObjects: [] + m_AddedGameObjects: [] + m_AddedComponents: + - targetCorrespondingSourceObject: {fileID: 411033920054375063, guid: fe47fadcdb5ae5c4caca516b4af6fde6, + type: 3} + insertIndex: -1 + addedObject: {fileID: 909026487} + - targetCorrespondingSourceObject: {fileID: 411033920054375063, guid: fe47fadcdb5ae5c4caca516b4af6fde6, + type: 3} + insertIndex: -1 + addedObject: {fileID: 909026488} + - targetCorrespondingSourceObject: {fileID: 411033920054375063, guid: fe47fadcdb5ae5c4caca516b4af6fde6, + type: 3} + insertIndex: -1 + addedObject: {fileID: 909026489} + - targetCorrespondingSourceObject: {fileID: 411033920054375063, guid: fe47fadcdb5ae5c4caca516b4af6fde6, + type: 3} + insertIndex: -1 + addedObject: {fileID: 909026490} + - targetCorrespondingSourceObject: {fileID: 411033920054375063, guid: fe47fadcdb5ae5c4caca516b4af6fde6, + type: 3} + insertIndex: -1 + addedObject: {fileID: 909026492} + - targetCorrespondingSourceObject: {fileID: 411033920054375063, guid: fe47fadcdb5ae5c4caca516b4af6fde6, + type: 3} + insertIndex: -1 + addedObject: {fileID: 909026491} m_SourcePrefab: {fileID: 100100000, guid: fe47fadcdb5ae5c4caca516b4af6fde6, type: 3} --- !u!1 &909026486 stripped GameObject: @@ -1024,9 +1064,17 @@ MeshCollider: m_PrefabAsset: {fileID: 0} m_GameObject: {fileID: 909026486} m_Material: {fileID: 0} + m_IncludeLayers: + serializedVersion: 2 + m_Bits: 0 + m_ExcludeLayers: + serializedVersion: 2 + m_Bits: 0 + m_LayerOverridePriority: 0 m_IsTrigger: 0 + m_ProvidesContacts: 0 m_Enabled: 1 - serializedVersion: 4 + serializedVersion: 5 m_Convex: 1 m_CookingOptions: 30 m_Mesh: {fileID: -5495902117074765545, guid: dd6644c1aa6c16c47bb9fea9822ce805, type: 3} @@ -1043,11 +1091,9 @@ MonoBehaviour: m_Name: m_EditorClassIdentifier: Camera: {fileID: 1153356615} + FrontTwist: 0 + RearTwist: 0 TestMagnitude: 0.5 - Throttle: - x: 0 - y: 0 - ThrottleDecay: 5 FlowSpeed: 2 FlowSpeedThrottleExponent: 1 WaveThrottleExponent: 0 @@ -1084,8 +1130,8 @@ MonoBehaviour: m_Enabled: 1 m_EditorHideFlags: 0 m_Script: {fileID: 11500000, guid: 52538a3309c36fc4583c964a1c0b705f, type: 3} - m_Name: - m_EditorClassIdentifier: + m_Name: + m_EditorClassIdentifier: --- !u!114 &909026490 MonoBehaviour: m_ObjectHideFlags: 0 @@ -1096,8 +1142,40 @@ MonoBehaviour: m_Enabled: 1 m_EditorHideFlags: 0 m_Script: {fileID: 11500000, guid: d8f4aa77146e46a2a4b28d9fefb98496, type: 3} - m_Name: - m_EditorClassIdentifier: + m_Name: + m_EditorClassIdentifier: +--- !u!114 &909026491 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 909026486} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: fdca6a832d764b6e89fc83fdcf7b42d7, type: 3} + m_Name: + m_EditorClassIdentifier: Assembly-CSharp::ShieldInterceptor + Envelope: {fileID: 909026492} + PanelPrototype: {fileID: 1087039186} + MaxLivePanels: 12 + PanelRadius: 2 + EnergyScale: 1 + ReuseFraction: 1 + DeathMargin: 0.25 + PatternByDamageType: +--- !u!114 &909026492 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 909026486} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: e25c67ee83c01364fb121caeda952606, type: 3} + m_Name: + m_EditorClassIdentifier: Assembly-CSharp::ShieldEnvelope --- !u!1 &1012179276 GameObject: m_ObjectHideFlags: 0 @@ -1138,13 +1216,13 @@ Transform: m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} m_GameObject: {fileID: 1012179276} + serializedVersion: 2 m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} m_LocalPosition: {x: 0, y: 0, z: 0} m_LocalScale: {x: 1, y: 1, z: 1} m_ConstrainProportionsScale: 0 m_Children: [] m_Father: {fileID: 0} - m_RootOrder: 4 m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} --- !u!114 &1012179280 MonoBehaviour: @@ -1186,11 +1264,14 @@ MonoBehaviour: type: 3} m_DeselectOnBackgroundClick: 1 m_PointerBehavior: 0 + m_CursorLockBehavior: 0 + m_ScrollDeltaPerTick: 6 --- !u!1001 &1080184620 PrefabInstance: m_ObjectHideFlags: 0 serializedVersion: 2 m_Modification: + serializedVersion: 3 m_TransformParent: {fileID: 0} m_Modifications: - target: {fileID: 105552333169325610, guid: 4a3db6090789ca24cb57c43666754017, @@ -1275,7 +1356,101 @@ PrefabInstance: objectReference: {fileID: 0} m_RemovedComponents: - {fileID: 7577392600424441103, guid: 4a3db6090789ca24cb57c43666754017, type: 3} + m_RemovedGameObjects: [] + m_AddedGameObjects: [] + m_AddedComponents: [] m_SourcePrefab: {fileID: 100100000, guid: 4a3db6090789ca24cb57c43666754017, type: 3} +--- !u!1 &1087039185 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 1087039188} + - component: {fileID: 1087039187} + - component: {fileID: 1087039186} + m_Layer: 0 + m_Name: Panel + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 0 +--- !u!114 &1087039186 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1087039185} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 1281fe44325f9b548aa42dc331b17266, type: 3} + m_Name: + m_EditorClassIdentifier: Assembly-CSharp::Prototype +--- !u!114 &1087039187 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1087039185} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: c1b180b85c234a4488cf0662b75ac77f, type: 3} + m_Name: + m_EditorClassIdentifier: Assembly-CSharp::ShieldField.ShieldPanel + tiling: 3 + panelRadius: 5 + cellSize: 0.35 + mergeRhombs: 1 + takeDual: 0 + seed: 1337 + sim: {fileID: 7200000, guid: 1d17c100121b5ba47af9c75de451a2f1, type: 3} + panelShader: {fileID: 4800000, guid: 6a7f034931432c2418a0f0f0b9af0b93, type: 3} + growDuration: 0.35 + wavefrontSpeed: 8 + delayJitter: 0.06 + waveSpeed2: 26 + damping: 1.6 + stressClamp: 4 + substeps: 6 + fixedSubstepDt: 0.0025 + echoDelaySteps: 5 + echoReflect: -0.65 + tensileStrength: 0.9 + temperInit: 0.55 + temperErosion: 2.2 + storedEnergyGain: 0.8 + damageGain: 6 + breakAt: 0.75 + crackStart: 0.18 + breakBudgetFraction: 0.02 + radialKick: 1.6 + lateralKick: 2.2 + normalKick: 1.1 + spinKick: 7 + shardLife: 2.5 + shardDrag: 0.7 + maxShards: 6 + conduitGain: 2.5 +--- !u!4 &1087039188 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1087039185} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 0} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} --- !u!1 &1112013867 GameObject: m_ObjectHideFlags: 0 @@ -1300,6 +1475,7 @@ Transform: m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} m_GameObject: {fileID: 1112013867} + serializedVersion: 2 m_LocalRotation: {x: 0.05948526, y: -0.910067, z: 0.14047916, w: 0.38536373} m_LocalPosition: {x: 0, y: 0, z: 0} m_LocalScale: {x: 1, y: 1, z: 1} @@ -1307,7 +1483,6 @@ Transform: m_Children: - {fileID: 1153356611} m_Father: {fileID: 0} - m_RootOrder: 2 m_LocalEulerAnglesHint: {x: 17.55, y: -134.1, z: 0} --- !u!114 &1112013869 MonoBehaviour: @@ -1353,7 +1528,6 @@ RectTransform: m_Children: - {fileID: 602735389} m_Father: {fileID: 815358592} - m_RootOrder: 2 m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_AnchorMin: {x: 1, y: 0} m_AnchorMax: {x: 1, y: 1} @@ -1387,13 +1561,13 @@ Transform: m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} m_GameObject: {fileID: 1153356610} + serializedVersion: 2 m_LocalRotation: {x: -0, y: -0, z: -0, w: 1} m_LocalPosition: {x: 0, y: 0, z: -50} m_LocalScale: {x: 1, y: 1, z: 1} m_ConstrainProportionsScale: 0 m_Children: [] m_Father: {fileID: 1112013868} - m_RootOrder: 0 m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} --- !u!114 &1153356612 MonoBehaviour: @@ -1492,9 +1666,17 @@ Camera: m_projectionMatrixMode: 1 m_GateFitMode: 2 m_FOVAxisMode: 0 + m_Iso: 200 + m_ShutterSpeed: 0.005 + m_Aperture: 16 + m_FocusDistance: 10 + m_FocalLength: 50 + m_BladeCount: 5 + m_Curvature: {x: 2, y: 11} + m_BarrelClipping: 0.25 + m_Anamorphism: 0 m_SensorSize: {x: 36, y: 24} m_LensShift: {x: 0, y: 0} - m_FocalLength: 50 m_NormalizedViewPortRect: serializedVersion: 2 x: 0 @@ -1564,9 +1746,17 @@ BoxCollider: m_PrefabAsset: {fileID: 0} m_GameObject: {fileID: 1424996955} m_Material: {fileID: 0} + m_IncludeLayers: + serializedVersion: 2 + m_Bits: 0 + m_ExcludeLayers: + serializedVersion: 2 + m_Bits: 0 + m_LayerOverridePriority: 0 m_IsTrigger: 1 + m_ProvidesContacts: 0 m_Enabled: 1 - serializedVersion: 2 + serializedVersion: 3 m_Size: {x: 1, y: 1, z: 1} m_Center: {x: 0, y: 0, z: 0} --- !u!4 &1424996958 @@ -1576,13 +1766,13 @@ Transform: m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} m_GameObject: {fileID: 1424996955} + serializedVersion: 2 m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} m_LocalPosition: {x: -1.4908371, y: -143.55145, z: 0.8193531} m_LocalScale: {x: 1, y: 1, z: 1} m_ConstrainProportionsScale: 0 m_Children: [] m_Father: {fileID: 0} - m_RootOrder: 1 m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} --- !u!1 &1618381683 GameObject: @@ -1617,7 +1807,6 @@ RectTransform: m_ConstrainProportionsScale: 0 m_Children: [] m_Father: {fileID: 815358592} - m_RootOrder: 0 m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_AnchorMin: {x: 0, y: 0} m_AnchorMax: {x: 1, y: 1} @@ -1691,3 +1880,15 @@ CanvasRenderer: m_PrefabAsset: {fileID: 0} m_GameObject: {fileID: 1618381683} m_CullTransparentMesh: 1 +--- !u!1660057539 &9223372036854775807 +SceneRoots: + m_ObjectHideFlags: 0 + m_Roots: + - {fileID: 909026485} + - {fileID: 1424996958} + - {fileID: 1112013868} + - {fileID: 815358592} + - {fileID: 1012179279} + - {fileID: 699508827} + - {fileID: 1080184620} + - {fileID: 1087039188} From c6430c93e44503446236cd68be97a882d09b9194 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 00:08:34 +0200 Subject: [PATCH 46/49] Make AetherDb the agent's data tool in project doctrine Missing catalog or save capabilities are added to AetherDb as commands, not written as scratch projects, loose scripts or byte readers. Records the serializer options and the CultLib pin gate an agent hits on first use. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..16fe9b214 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,21 @@ +# Aetheria agent doctrine + +## Data tooling: AetherDb + +`tools/AetherDb` is the agent's tool for reading and writing Aetheria's CultCache stores (`GameData/Aetheria.cc`, +`run.cc`, `player.cc`). The simulation lives in `Aetheria.Shared` (the headless build of +`Assets/Scripts/ServerShared`) and needs no Unity; Unity is only the shell for graphics and input. + +- When a task needs a catalog or save capability AetherDb lacks (authoring or cloning designs, setting fields, + fixtures for a play smoke, a query), add it to AetherDb as a command. Do not write a scratch console project, + a loose `.cs` script, a Python or PowerShell reader of the `.cc` bytes, or a Unity editor script instead. + Loose scripts were retired in `9f9149bd` because they ran against stale schemas. +- A command dry-runs by default and writes only with `apply`, validates with `CultRecordRefs.Validate` and + commits in one `CultCache.Commit` batch, like the existing migrations in `tools/AetherDb/Program.cs`. +- Deep copies of records use CultCache's own options, + `CultDocumentMessagePackSerialization.OptionsFor(typeof(ItemData).Assembly)`; MessagePack's standard options + cannot serialise `CultMath` types or `CultRecordRef`. +- The headless build refuses to compile unless CultLib is a clean checkout at the revisions pinned in + `Directory.Build.props` (`Directory.Build.targets` enforces it). When `F:\Projects\CultLib` is elsewhere, point + `CULTLIB_ROOT` and `CULTMATH_ROOT` at detached worktrees of those revisions; never move the shared checkout. +- CultCache Studio is the operator's GUI over the same files. It is not an agent path. From f935c29b4ba5d609f89e33037fa81c262248798f Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 01:04:11 +0200 Subject: [PATCH 47/49] Fix console text input and route give through ship commissioning - ConsoleView appended the control characters text input delivers (\b, \r), so Backspace inserted a glyph and Enter's \r got every command refused since d503f076 made the parser refuse instead of strip. Control characters are no longer text; Backspace deletes like Delete. - give reports an unknown name or a full cargo bay. A ship hull is refused unless docked, and when docked is commissioned through ActionGameManager.CommissionShip, the primitive TradeMenu.Buy now uses: a bare player ship moored at the docked entity, no docking bay. Non-ship hulls are refused. - Record play smoke results for steps 3-6 and the docking bay finding. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/Gameplay/ActionGameManager.cs | 32 ++++++++++++- Assets/Scripts/UI/ConsoleView.cs | 6 ++- Assets/Scripts/UI/Menu/TradeMenu.cs | 3 +- docs/merge-to-master-checklist.md | 47 ++++++++++++++++++++ 4 files changed, 82 insertions(+), 6 deletions(-) diff --git a/Assets/Scripts/Gameplay/ActionGameManager.cs b/Assets/Scripts/Gameplay/ActionGameManager.cs index 50f2ff2f5..c62d980e4 100644 --- a/Assets/Scripts/Gameplay/ActionGameManager.cs +++ b/Assets/Scripts/Gameplay/ActionGameManager.cs @@ -518,10 +518,29 @@ public class ActionGameManager : MonoBehaviour var itemName = string.Join(" ", args); var item = ItemManager.ItemData.GetAll() .FirstOrDefault(itemData => string.Equals(itemData.Name, itemName, StringComparison.InvariantCultureIgnoreCase)); - if (item != null) + if (item == null) { - _currentEntity.CargoBays.First().TryStore(ItemManager.CreateInstance(ItemManager.CreateLot(item, default, .95f))); + ConsoleController.Instance.AppendLogLine($"No equippable item named \"{itemName}\""); + return; } + if (item is HullData hull) + { + if (hull.HullType != HullType.Ship) + { + ConsoleController.Instance.AppendLogLine($"Refused: {item.Name} is a {hull.HullType} hull, not a ship"); + return; + } + if (DockedEntity == null) + { + ConsoleController.Instance.AppendLogLine($"Refused: dock to take delivery of a {item.Name}"); + return; + } + CommissionShip(ItemManager.CreateInstance(ItemManager.CreateLot(item, default, .95f)) as EquippableItem); + ConsoleController.Instance.AppendLogLine($"{item.Name} moored at {DockedEntity.Name}"); + return; + } + if (!_currentEntity.CargoBays.First().TryStore(ItemManager.CreateInstance(ItemManager.CreateLot(item, default, .95f)))) + ConsoleController.Instance.AppendLogLine($"Refused: no cargo space for {item.Name}"); }); ConsoleController.AddCommand("trackmissile", @@ -874,6 +893,15 @@ public class ActionGameManager : MonoBehaviour } } + // A ship hull acquired while docked becomes a bare player ship moored at the docked entity. It takes no docking + // bay: mothballed ships do not need one. Buying from the trade menu and the give command both land here. + public Ship CommissionShip(EquippableItem hull) + { + var ship = new Ship(ItemManager, Zone, hull, ItemManager.GameplaySettings.DefaultEntitySettings) { IsPlayerShip = true }; + ship.SetParent(DockedEntity); + return ship; + } + private void DoDock(Entity entity, EquippedDockingBay dockingBay) { TradeMenu.Inventory = entity.CargoBays.First(); diff --git a/Assets/Scripts/UI/ConsoleView.cs b/Assets/Scripts/UI/ConsoleView.cs index fcde87e29..64e0a4597 100644 --- a/Assets/Scripts/UI/ConsoleView.cs +++ b/Assets/Scripts/UI/ConsoleView.cs @@ -50,7 +50,9 @@ public class ConsoleView : MonoBehaviour { Keyboard.current.onTextInput += c => { - if (!Visible || c == '`') return; + // Text input also delivers control characters (backspace as '\b', enter as '\r'); editing keys are + // handled in Update, so only printable characters are text. + if (!Visible || c == '`' || char.IsControl(c)) return; InputString += c; }; InputString = ""; @@ -81,7 +83,7 @@ public class ConsoleView : MonoBehaviour { if (!Visible) return; - if(keyboard.deleteKey.wasPressedThisFrame && InputString.Length>0) + if((keyboard.backspaceKey.wasPressedThisFrame || keyboard.deleteKey.wasPressedThisFrame) && InputString.Length>0) { InputString = InputString.Substring(0, InputString.Length - 1); } diff --git a/Assets/Scripts/UI/Menu/TradeMenu.cs b/Assets/Scripts/UI/Menu/TradeMenu.cs index 9bc285b75..3b1643dab 100644 --- a/Assets/Scripts/UI/Menu/TradeMenu.cs +++ b/Assets/Scripts/UI/Menu/TradeMenu.cs @@ -374,8 +374,7 @@ public class TradeMenu : MonoBehaviour { if (hullData.HullType != HullType.Ship) throw new ArgumentException("Attempted to buy non-ship hull from station, WTF are you doing?!"); - var ship = new Ship(GameManager.ItemManager, GameManager.Zone, item as EquippableItem, GameManager.ItemManager.GameplaySettings.DefaultEntitySettings) { IsPlayerShip = true }; - ship.SetParent(GameManager.DockedEntity); + GameManager.CommissionShip(item as EquippableItem); GameManager.Credits -= data.Price; UpdateCreditsLabel(); diff --git a/docs/merge-to-master-checklist.md b/docs/merge-to-master-checklist.md index 0848b4b2d..b9559a50b 100644 --- a/docs/merge-to-master-checklist.md +++ b/docs/merge-to-master-checklist.md @@ -111,3 +111,50 @@ Only what the maps name. - CultMath bounded least squares is locomotion Cut 2, not in this stack (`locomotion-cut.md:508`, `:360`). It gates nothing here. - Unrecorded: a Unity batchmode compile at the tip `4b594e11`. 12.2 and 12.3 record clean compiles at their own heads. - **Soul status at merge time: Self fills in.** + +## 8. Results: play smoke, 2026-09-30 (operator plays, agent records) + +Tree: `codex/fire-control-12` at `113164fa` plus the fixes listed below. `run.cc` and `player.cc` moved to +`GameData/stale-2026-09-22/` before launch. + +**Compile.** The Unity 6000.3.24f1 batchmode compile at `113164fa` was clean (exit 0, no `error CS`). The four +never-compiled files needed no fix. One warning, `EntityInstance._destroyed` assigned but never read, is legacy +(`729ab0e8`). + +**Changes made during the smoke.** +- `e0a80208`: `AetheriaInput.cs` regenerated; Cycle Target Item bound through `Input.Player.CycleTargetItem`, + retiring the by-name `FindAction` stopgap. Batchmode recompile clean. +- `97edf48c`: `ARPG.unity` Debug Info Panel and `FieldShieldTest.unity` shield-panel Cut 4 rig committed. +- Console: control characters from text input are no longer appended, and Backspace deletes (see step 6 finding). +- `give` (operator ruling 2026-09-30): an unknown name and a full cargo bay now say so instead of doing nothing. A + ship hull is refused unless docked; when docked it goes through `ActionGameManager.CommissionShip`, the same + primitive `TradeMenu.Buy` now uses, and moors a bare player ship at the docked entity with no docking bay + ("mothballed ships do not require docking bays"). Non-ship hulls are refused. This is the step 7 path to a + restored hull. +- Scratch weapons authored in place in `GameData/Aetheria.cc` (not committed; restore with + `git checkout -- GameData/Aetheria.cc`): `Smoke Proximity` (Proximity, blast 8), `Smoke Delayed` (Delayed, + blast 4, penetration 2 cells), `Smoke Refused` (Proximity, blast 30, range 20). Each is a Spectra clone, energy, + 1 column by 2 rows, to fit the test ship's only energy hardpoint. + +| Step | Result | Evidence | +|---|---|---| +| 3 Launch to main menu | pass | Operator reached play through step 6 with no reported Addressables errors. | +| 4 New Game | pass | As above. | +| 5 Addressables, Use Asset Database | partial | Charged weapon (ChargeBlast SG) unprovable: the test ship's only energy hardpoint is 1x2 and ChargeBlast SG is 2x2. Instant weapon not separately reported. | +| 6 Provenance in play | partial | Properties panel correct for starting gear and for a purchased item. **Tier colours missing everywhere**: known defect, `settings-globals-cut.md:150` (Unity-serialised `RarityTier.Color` loads as zero); fixed by settings-globals Cut 1, not part of this merge. `give Lamp` was first refused (console finding), then silently did nothing: `Lamp` is a test-fixture name, absent from the live catalog, and `give` reported no miss (now it does). No-Manufacturer check still open with a real unbranded design. Loot brand not yet reported. | + +**Finding, console (not fire control).** Every console command was refused with "commands take only letters, +digits, spaces and hyphens", and Backspace inserted a glyph. Cause: `Keyboard.onTextInput` delivers control +characters (`\b`, `\r`), and `ConsoleView` appended them to the input line. Enter's `\r` rode into every command; +`d503f076` (2026-09-14, on master) correctly changed the parser from stripping disallowed characters to refusing +them, which exposed it. Backspace had never been handled (only Delete), which is legacy (`7006a6b0`). + +**Feel notes.** Engaging an AI Djinni: the player was destroyed decisively. "Game is hard." + +**Finding, ship purchase and docking bays (design gap, not a merge gate).** The intended UX is that a purchased +ship is assigned its own docking bay. The code does not do that: +- `TradeMenu.Buy` (`TradeMenu.cs:373-381`) creates a bare `Ship` parented to the docked station and assigns it no + bay. It also charges `data.Price` rather than the lot's quality price that `GetPrice` computes for other items. +- `LoadoutGenerator` equips exactly one docking bay per station (`LoadoutGenerator.cs:84-92`), so Zenith has one. +- The inventory panel's Current button (`InventoryPanel.cs:110-120`) sets the new ship as current and overwrites + the single bay's `DockedShip`; the previous ship stays a station child with no bay. From d3075eb06db54d737b019613058005a1757d5b6f Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 01:15:06 +0200 Subject: [PATCH 48/49] Record the play smoke stop, operator direction and stale checklist expectations Co-Authored-By: Claude Opus 5.5 --- docs/merge-to-master-checklist.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/merge-to-master-checklist.md b/docs/merge-to-master-checklist.md index b9559a50b..4feec9edb 100644 --- a/docs/merge-to-master-checklist.md +++ b/docs/merge-to-master-checklist.md @@ -158,3 +158,15 @@ ship is assigned its own docking bay. The code does not do that: - `LoadoutGenerator` equips exactly one docking bay per station (`LoadoutGenerator.cs:84-92`), so Zenith has one. - The inventory panel's Current button (`InventoryPanel.cs:110-120`) sets the new ship as current and overwrites the single bay's `DockedShip`; the previous ship stays a station child with no bay. + +**Smoke stopped after step 6 (operator, 2026-09-30).** The operator rejected the script as a monolithic play order +that does not respect how the game works: step 7 alone meant spawning and fitting a whole Djinni by catalog name. +Direction: operator checks become a list of behaviours verified over time, each set up by an authored, retained +scenario selectable from the main menu (a Eureka cut). Steps 7-20 and handoff checks A-F are unplayed. The operator +prefers to merge now, with those as open entries to verify through scenarios; Self rules on the merge. + +**Stale expectations found in this checklist.** Step 6 transcribes `item-provenance-cut.md:529-537` (written +2026-09-17 03:38, `7425ff1b`). "Starting-ship items show tier colour" was falsified by that day's smoke and recorded +at `settings-globals-cut.md:150` (`db420981`, 17:36); the provenance map was never corrected and this checklist +copied it despite its own section 5. "`give Lamp`" names a test fixture (`LoadoutTests.cs:289-312`), never a live +catalog design, and `give` could not have run at all between `d503f076` (09-14) and `f935c29b`. From 65c634953733004a9cd2cba71a3008d4ac6b1ed4 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 02:11:09 +0200 Subject: [PATCH 49/49] Map the scenario cut: authored arenas launched from the main menu Imagination pass 1 against dcd7bbc5, designed from the operator's own words (quoted in the map). Cuts 0-4; four operator questions open (Q1 arena in a real run, Q2 who records results, Q3 test-only designs, Q4 which ad hoc affordances retire). Co-Authored-By: Claude Opus 5.5 --- docs/scenarios-cut.md | 674 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 674 insertions(+) create mode 100644 docs/scenarios-cut.md diff --git a/docs/scenarios-cut.md b/docs/scenarios-cut.md new file mode 100644 index 000000000..d7354ecbc --- /dev/null +++ b/docs/scenarios-cut.md @@ -0,0 +1,674 @@ +# Scenarios: Cut Map + +Date: 2026-09-30, 02:10 CEST. Imagination pass. + +Status: cut map. Nothing has landed. The target lives in section 0 until Self splits it out into +`docs/scenarios-target.md`; this document owns the means. + +Anchors are against Aetheria `origin/master` `dcd7bbc5` (the fire-control merge), read in the clone +`C:\ws72-fuse5` with `git show`/`git grep`. The operator's tree `F:\Projects\Aetheria` was not touched. No +build ran and no Yggdrasil job ran. **(read)** marks a claim read from source at the anchor. **(probe)** marks a +claim measured against a real file. + +--- + +## 0. Target + +### 0.1 The request, in the operator's words (2026-09-30) + +> "I feel like this would be much more manageable as a list of things to verify over time rather than a +> monolithic smoke script that doesn't respect how the game works. Move gear onto Djinni? That's a lot of items +> to spawn, and am I expected to have the catalog memorized to spawn them? If you want me to test a bunch of +> behaviors under specific conditions, find a way to spawn me in an arena where those conditions are present. If +> you want me to kill a guy and loot him, spawn me next to a ship with no armor or weapons, that sort of thing. I +> wouldn't mind a scenario affordance in the main menu for this sort of job, that way the test fixtures can be +> authored, retained and available. But that sounds like a Eureka cut." + +What those words settle, so no question below re-asks them: +- **A scenario is an arena whose conditions are present at spawn.** The player's ship and every other entity are + already fitted and placed. The operator never spawns gear by catalog name and never fits a ship by hand. +- **The fixtures are authored and retained,** and the main menu lists them. +- **Verification is a list of behaviours checked over time,** not a play order. + +### 0.2 Ends + +- The main menu lists scenarios. Picking one starts a run in its arena. +- A scenario is typed catalog data. It is authored once, kept in git, and replayed at any later revision. +- Every operator check is a typed **check** record: what to do, what to observe, where the rule came from, and + the scenario that sets it up. Results accumulate as typed records carrying the revision they were observed at. +- Checks A-F and checklist steps 7-20 are the first consumers (section 5). + +### 0.3 Invariants + +- **One owner decides what a new run starts with.** A plain New Game and a scenario launch share the menu path, + the galaxy build, `StartGame` and the staging owner. Scenario launch is New Game with a scenario as input; there + is no second boot path. A plain New Game is the absent-scenario case. +- **One primitive admits an entity into a zone.** Zone construction, staging, warp arrival and undock all admit + through it, and agents are created there and nowhere else. +- **A scenario sets conditions and nothing else.** Once staged, its entities are ordinary run state. The rules + under test run through the same code whether or not a scenario started the run. No scenario-only branch exists + in simulation code. +- **Fixtures are typed state.** Scenario, loadout presets and test designs are CultCache catalog records. Checks and + results are CultCache records. No load-bearing JSON, text scripts or prose checklists. +- **A scenario that cannot stage is refused before anything is cleared,** with its failures listed. +- **The catalog stays read-only at runtime** (`AetheriaStores.cs:13-21`). + +### 0.4 Not in scope + +- The full run lifecycle owner (`Run`) of `docs/headless-playground-cut.md` Cut 1 (warp, dock, save, death as one + ServerShared owner) and its `AetherDb play` interpreter (Cut 3). This map takes over only the new-run staging + half. The playground map is stale (anchors `1e647953`, fork S superseded by settings-globals) and must be + re-anchored before Hands. When it lands, its `new` command stages a `Scenario` record instead of reading a + `.play` text line. +- Brokkr. It is not needed for any cut here: agents author scenarios headless through `AetherDb`, and the operator + launches them from the menu. Brokkr's Unity actions (`setEditorPlayState`, `captureEditorView`, scene edits; + `F:\Projects\Brokkr` `1357705`, read) have no way to pick a scenario in the menu. Agent-driven Unity play would + need an Aetheria install cut plus a "launch scenario" intent. Recorded as a follow-up, not a fork. +- The ship-purchase docking-bay gap (checklist section 8). It is a design gap in `TradeMenu.Buy` and + `CommissionShip`, not a verification affordance. `CommissionShip` stays; `TradeMenu.Buy` is its consumer. +- Tier colours (settings-globals Cut 1). The check is recorded as a known failure (section 5). +- Balance and feel tuning. A check can record a feel call; it does not tune. + +--- + +## 1. Body findings + +### 1.1 The new-run path today (read) + +| Concern | Anchor | Notes | +|---|---|---| +| Menu | `MainMenu.cs:92-184` | Continue `:103-108`; New Game `:110-176`; Settings; Quit. The same component runs in-game (`InGame`, `:96`), so the menu reappears on death (`ActionGameManager.cs:1171`). | +| Clear the run | `MainMenu.cs:113` | `RunSave.Clear` at click time, before the galaxy is built. | +| Galaxy | `MainMenu.cs:124-175` | `TutorialPassed` is never set anywhere, so New Game always takes the prelude branch `:146-175`. The background noise position comes from `UnityEngine.Random` (`:128,153`). The seed defaults to the clock (`Galaxy.cs:106,158`). | +| Handoff | statics `ActionGameManager.CurrentGalaxy`, `IsTutorial` (`:96-97`) | read by `StartGame`, `SectorMap`, and nulled by `Die` (`:1173`). | +| Start | `StartGame` `:796-834`, called from `Start` `:491` | New run `:803-815`: `PopulateLevel(Entrance)`, `LoadoutGenerator.GenerateShipLoadout` filtered by `GameSettings.StartingHullName` (`GameSettings.cs:13`, authored `LonginusX`), unpack, `Zone.Entities.Add`, `Activate`, `BindToEntity`. Continue `:817-833`. | +| Zone entry | `PopulateLevel` `:710-744` | generates the pack lazily (`:714-722`), moves the pilot (`:731-737`). | + +### 1.2 What a scenario can reuse (read, one probe) + +- **Loadout presets already exist.** `aetheria.loadout` (`Loadout.cs:13-20`) holds a hull design, per-cell slot + designs with rotation, and weapon groups. It is a catalog type (`AetheriaStores.cs:9`), authored in Studio or by + the editor console command `capturepreset` (`ActionGameManager.cs:624-662`, `Loadouts.Commit` `Loadout.cs:70-87`). + `Loadouts.Materialize` (`:96-159`) builds a live `Ship` all-or-nothing, listing every failure. + - **No game path calls `Materialize`** (`LoadoutGenerator.cs:152-153` says so; only `LoadoutTests.cs:310,899` + call it). + - **(probe)** The live catalog holds no preset: the schema name `aetheria.loadout` does not occur in + `GameData/Aetheria.cc` (12,698,108 bytes at `dcd7bbc5`), while every populated type's name does (for example + `aetheria.hulldata`, `aetheria.weaponitemdata`). Method: `grep -a -o` over the file. + - `Materialize` always builds a `Ship` (`:131`). A turret hull cannot be materialized. + - It resolves every design through a product (`Resolve`, `:103-115`). A design with no product fails. +- **Unbranded lots exist.** `ItemManager.CreateLot(design, maker, quality)` (`ItemManager.cs:161`) with no maker is + how `give` made its items (`ActionGameManager.cs:538,542`). Provenance's "no Manufacturer row" check depends on + them. +- **Economy exclusion follows from products.** Generation iterates products and requires + `product.Manufacturer.IsSet()` and `design.Price > 0` (`LoadoutGenerator.cs:126-133`). A design with no product + is never generated, stocked or sold. Loot is what a killed entity carries (`EntityInstance.cs:302-321`), so it + cannot leak through loot either unless a scenario fits it. +- **Prelude galaxies offer every product.** `IsAvailable` returns true when `Galaxy.IsPrelude` + (`LoadoutGenerator.cs:154-156`). A preset materialized in a prelude arena never fails on faction availability. +- **Agents are made only at zone construction.** `Zone`'s constructor gives every packed non-player `Ship` a + `Minion` (`Zone.cs:120-131`, `CreateAgent` `:135-142`). An entity added later gets none. A `Minion` targets the + first visible enemy and fights (`Minion.cs`). +- **An entity joins a zone in four other places,** each by hand: `spawnturret` (`ActionGameManager.cs:587`), + `PopulateLevel` (`:735`), `StartGame` (`:813`) and `Entity.TryUndock` (`Entity.cs:1006`). +- **IFF overrides are entity-local and unsaved.** `Entity.SetIff(other, bool?)` (`Entity.cs:529-533`) decides + `IsHostileTo` ahead of faction rules (`:535-552`). A `Materialize`d ship has no faction, so it is neutral unless + overridden. +- **A target that dies and a target that leaves are the same case in fire control.** + `targetGone = shot.Target != null && !zone.Entities.Contains(shot.Target)` (`FireControl.cs:610`), and death + removes the entity from `Zone.Entities` (`Zone.cs:93`). Check A.3 ("the target warps away") is therefore set up by + killing a fragile target while a fused round is in flight. No departure machinery is needed. +- **An orbital entity with no orbit stays where it is put** (`OrbitalEntity.cs` `Update`: position follows the + orbit only when one is set). That is how `spawnturret` placed turrets (`ActionGameManager.cs:583-588`). +- **Headless tests already open the live catalog and the authored settings** + (`RestoredHullsTests.cs`: `AuthoredSettings.Load`, `OpenReadOnlyRealCatalog`). A staging test can generate a + prelude galaxy, build the arena zone and stage a scenario with no Unity. + +### 1.3 Debug affordances a scenario replaces (read) + +| Affordance | Anchor | Added for | +|---|---|---| +| `give ` | `ActionGameManager.cs:515-544` | ad hoc items; the hull route through `CommissionShip` was added tonight in `f935c29b` for step 7 | +| `spawnturret` | `:564-589` | a hostile turret next to the player | +| Neutral wanderers | `ZoneGenerator.cs:342-356`, `EligibleWandererFactions` `:361-365`, `ZoneGenerationSettings.NeutralWandererCount` (`Settings.cs:156`), tests `IffAndCombatTests.cs:329-350` | commit `f732cff9` (2026-09-17): "Spawn neutral wanderers so combat has something non-hostile to target". The code comment calls it a "Testing affordance". Every generated zone carries two. | + +Staying, because no scenario replaces them: `iff` (flip a live target's stance mid-play), `trackmissile` (camera), +`revealzones` (map), `tow` (a verb bound to the console), `capturepreset` (the in-play authoring tool for presets). + +### 1.4 Collisions with open branches (read) + +- `codex/mining` rewrites `Zone.cs` (+239/-). Cut 1 edits the constructor loop and adds the admission primitive. + Land Cut 1 after mining merges, or re-anchor. +- `codex/moddable-ships` edits `AetheriaStores.cs` (line 9, the catalog type list). Cuts 1 and 3 edit the same + lines. +- `codex/item-provenance` carries the narrative map, which edits `MainMenu.cs:132,160` and the `Galaxy` + constructors. Cut 2 edits the same New Game block. + +--- + +## 0b. Identity, lifecycle, authority + +No cut is mapped against an empty cell. + +| Kind | What names it | Lifecycle | Who decides | +|---|---|---|---| +| **Scenario** (`aetheria.scenario`, catalog) | `CultName` `Name`; key `scenario:`, derived like `Loadouts.KeyOf` | Authored, then edited in place. A rename is a new record; the old one is deleted, and the checks that pointed at it are repointed in the same commit. A result never points at a scenario (it points at a check), so history survives. A scenario whose presets stop materializing is caught by the staging test (Cut 4), not at play time. | Authored by agents through `AetherDb` or by the operator in Studio. The operator owns what an arena contains. | +| **Loadout preset** (`aetheria.loadout`, existing) | key `loadout:` (`Loadout.cs:62`) | Unchanged: authored, replaced only explicitly (`Commit(..., replace)`). | Unchanged. | +| **Test design** (catalog `ItemData`, no product) | name; key minted by `Upsert` | Authored with the scenario that needs it. Lives as long as a preset references it. Per Q3. | Operator (Q3) on whether they exist at all. | +| **Check** (`aetheria.check`, ledger store) | `CultName` `Slug`; key `check:` | Authored. Its statement is edited in place when a ruling changes (git keeps the history). It is retired, never deleted, once any result references it: `Retired` plus a reason. | The rule's owner (a ruling or a map) decides what it says. Agents transcribe it with a source pointer. | +| **Check result** (`aetheria.checkresult`, ledger store) | key `checkresult::` | Append-only. Never edited or deleted. A correction is a new result whose note says what it corrects. The latest by `At` is the current status. Order is carried in the data (`At`), not in delivery. | The operator's observation. Who writes the record is Q2. | +| **Pending scenario** (runtime) | `ActionGameManager.PendingScenario`, a static | Written once by the menu's launch, read and nulled once by `StartGame`'s new-run branch. Null for Continue. Never saved. | Command-only transport. It decides nothing. | +| **Staged entities** (runtime, then run state) | ordinary entities | Born at staging. From then on they are run state and saved with the zone like any other entity. Two things are not saved: whether a ship was left unpiloted, and scenario IFF overrides. After Continue, an unpiloted scenario ship gets a `Minion` by the constructor rule and its stance reverts to faction rules (neutral, since presets carry no faction). A scenario run is meant to be played through in one sitting. | `RunStart` at birth; the zone afterwards. | + +--- + +## 2. Target shape + +### 2.1 Types (names and rules; bodies are Hands') + +`Assets/Scripts/ServerShared/Scenario.cs`, catalog: + +- `Scenario` (`aetheria.scenario` v1): + - `Name` (`CultName`). + - `Brief`: the conditions in one or two lines, shown in the menu. + - `Seed` (`uint`): the arena galaxy and the background noise both derive from it, so the arena is the same layout + every launch. Combat rolls are already zone-seeded (`Zone.cs:84`). Lot quality still comes from the + clock-seeded `ItemManager.Random` (`ItemManager.cs:20`), so stats vary slightly per launch. Accepted. + - `Ambient` (`bool`): true keeps the arena zone as generated. False keeps planets, orbits and stations but drops + generated ships and turrets, so nothing wanders into the conditions. + - `Player` (`ScenarioShip`). + - `Entities` (`List`). +- `ScenarioShip`: `Loadout` (`CultRecordRef`), `Position` (`float2`, zone xz), `Direction` (`float2`), + `Cargo` (`List>`, stored in the first cargo bay). +- `ScenarioEntity : ScenarioShip`: `Stance` (`Neutral | Hostile`, applied both ways through `SetIff`) and `Piloted` + (`bool`: a `Minion`, or no agent at all). +- A preset's hull type picks the entity class: a ship hull gives a `Ship`; a turret hull gives an orbital entity + with no orbit, stationary at its position. A station hull is refused as a staging failure. + +`Assets/Scripts/ServerShared/Checks.cs`, ledger store (`GameData/Checks.cc`, committed, LFS by the existing +`GameData/*.cc` rule): + +- `Check` (`aetheria.check` v1): `Slug` (`CultName`), `Statement` (what to do and what must be observed), + `Source` (the ruling or map pointer, `doc:line`), `Scenario` (`CultRecordRef`, optional), `Setup` + (text, only when there is no scenario: "FieldShieldTest scene", "CultCache Studio", "Addressables play mode Use + Existing Build"), `Retired` and `RetiredReason`. +- `CheckResult` (`aetheria.checkresult` v1): `Check` (ref), `Result` (`Pass | Fail | Partial | Blocked`), + `Revision` (the commit the operator played, plus `+dirty` when the tree had changes), `At` (UTC), `Note`, + `Recorder`. + +Naming: "check", not "behaviour". `Behavior` is already the gear-behaviour type family +(`Assets/Scripts/ServerShared/Behaviors/`), and a document named `Behaviour` beside it reads as a typo. + +Where they live, and why: +- Scenarios, presets and test designs are design-side fixtures. They go in the catalog: presets are already + catalog data, cross-type refs resolve inside one store, and Studio edits them side by side. +- Checks and results are the verification ledger. They go in their own store. The shipped catalog carries no + history, results never trigger a catalog write, and "one commit lands in one store" holds: recording a result + touches the ledger only. + +### 2.2 The staging owner + +**Owner: `RunStart`, a static class in `Assets/Scripts/ServerShared/RunStart.cs`.** It decides what a new run +starts with: how the arena zone is generated, the player ship, the other entities, their stance, pilot and cargo. +It holds no Unity type. + +- **Inputs:** `ItemManager` (and through it the catalog), the `Galaxy`, `ZoneGenerationSettings`, the optional + `Scenario`, and, for the absent-scenario case, the default hull filter and faction `StartGame` uses today + (`:805-809`). +- **Outputs:** + - the arena's `ZonePack`, with the `Ambient` rule applied inside generation (`ZoneGenerator.GenerateZone` + gains the parameter), not generated and then pruned; + - the admitted player `Ship`, flagged `IsPlayerShip`; + - the admitted scenario entities; + - a failure list. Staging is all-or-nothing, as `Materialize` is. +- **A check entry** that validates a scenario without a zone (every preset materializes, every cargo design + exists). The menu calls it before `RunSave.Clear`; the staging tests call it on every catalog scenario. +- **Derived state:** `ActionGameManager.CurrentEntity` is bound to the returned player. `PendingScenario` is + transport only. +- **Forbidden writers:** `StartGame` may not generate, unpack or admit the player ship. `MainMenu` may not stage. + No console command admits entities (`give` and `spawnturret` die, Q4). +- **Shared paths:** New Game and scenario launch both run menu, galaxy, `StartGame`, `RunStart`. Continue does not + stage. +- **Deletion line:** `StartGame` `:805-814` (generator, unpack, add, activate). The only surviving line is the + bind. + +**Admission primitive: `Zone.Admit(Entity entity, bool piloted)`.** It adds the entity to `Entities`, activates it, +and, when `piloted`, gives it the zone's `Minion` (the `CreateAgent` rule, `Zone.cs:135-142`). It is the only writer +of `Entities.Add` and the only creator of agents. +- The constructor loop (`:120-131`) calls it with `piloted: ship && !IsPlayerShip`. It keeps its own rule that + moves a ship packed at the origin (`:129-130`); that is generation placement, not admission. +- `PopulateLevel` (`ActionGameManager.cs:735`), `Entity.TryUndock` (`Entity.cs:1006-1007`) and `RunStart` call it. + `spawnturret` (`:587`) is deleted. +- The `Death` subscription (`Zone.cs:93`) stays on `ObserveAdd` and still covers every join. + +**`Loadouts.Materialize` gains two rules** (`Loadout.cs:96-159`): +- A design with **no product at all** is built unbranded, through `CreateLot(design, default, quality)` at one + named quality constant (0.95, the quality `give` used). A design whose products are all unavailable still fails, + as today. The rule turns "no manufacturer makes it" into "outside the economy", which generation already obeys. +- The **hull type picks the entity class** (section 2.1). +- The comment at `LoadoutGenerator.cs:150-153` ("No game path materializes a preset yet") is rewritten to name + `RunStart`. + +### 2.3 The Unity lowering + +- `MainMenu.ShowMain` (`:92-184`) gains **Scenarios**, shown when `Debug.isDebugBuild` (editor and development + builds). It opens a panel with one button per catalog scenario, ordered by name. Under each button is its brief + and its checks, each with its latest result and revision, read from the ledger (display only). A scenario whose + check entry fails shows the failures and is disabled. +- **One launch body** is extracted from the New Game lambda (`:110-176`). New Game calls it with no scenario; a + scenario button calls it with one. With a scenario, it runs the prelude branch (`:146-175`) with `Seed`, derives + the noise position from `Seed` instead of `UnityEngine.Random`, sets `PendingScenario`, and loads `ARPG`. The + check entry runs first; `RunSave.Clear` (`:113`) moves after it, so a refused scenario leaves the saved run intact. +- `StartGame`'s new-run branch (`:803-815`) calls `RunStart`, binds the returned player and nulls + `PendingScenario`. The Continue branch asserts it is null. +- `AetheriaStores.Open` gains an optional ledger path. The game attaches `GameData/Checks.cc` read-only; `AetherDb` + attaches it writable for the record commands (Cut 3). + +### 2.4 Authoring + +- **Agents author headless through `AetherDb`.** A seed command (Cut 4) declares the first set in C#. It writes a + record only when the key is absent. When the key exists and differs from what it would write, it refuses and + reports; that means Studio edited it, and Studio wins. When the key exists and matches, it does nothing. It + validates every scenario through the `RunStart` check entry before committing. So the catalog is the authority, + and the seed code never clobbers it. +- **The operator authors in Studio and in play:** Studio for scenario fields; `capturepreset` for a ship fitted by + hand in play. +- Results are recorded per Q2. + +--- + +## 3. Cuts + +Build budget, every cut: +- `Aetheria.Shared` (netstandard2.1, the Unity-independence check), `tests/Aetheria.Shared.Tests`, `tools/AetherDb` + (net10.0), Debug. +- CultLib is unchanged; the pins are those in `hands-fuse8/run.sh`. +- No new project, target, package or daemon. One new store file (`GameData/Checks.cc`), three new schemas. +- **Build host:** Yggdrasil builds the headless projects (`run.sh `). `Assembly-CSharp` is proven only by a + Unity 6000.3.24f1 batchmode compile on Starfire, with the editor closed by the operator. Never kill Unity. +- `run.sh` mounts a pinned `data/Aetheria.cc` over the tree's LFS file. Any cut that changes the catalog (Cut 4) + must refresh that pin, or its content tests read the old catalog. Cut 4's tests that read the ledger mount it the + same way. Yggdrasil's disk is saturated: one job per cut, `--artifacts-path` in the container. + +Order: 0, 1, 2, 3, 4. Cut 0 is subtraction alone, so Soul can falsify it alone. Cuts 1 and 3 are headless. Cut 2 +is the only Unity-side cut. Cut 4 is content. + +### Cut 0. Retire the ad hoc test affordances (Q4) + +- **Repo/branch:** Aetheria, `codex/scenarios` from `master` `dcd7bbc5`. +- **Deletes first** (per Q4 A; B keeps the item branch of `give` and the wanderers): + - `ActionGameManager.cs:515-544`, `give` (30 lines). + - `ActionGameManager.cs:564-589`, `spawnturret` (26 lines). + - `ZoneGenerator.cs:342-356`, neutral wanderers (15 lines), and `EligibleWandererFactions` `:361-365` (5). + - `Settings.cs:156`, `NeutralWandererCount` (1). It is absent from `Settings.asset` (settings-globals `:72`), so + no asset edit is needed. + - `IffAndCombatTests.cs:329-350`, the two wanderer tests (22). +- **Keeps:** `CommissionShip` (`:898-903`) and its consumer `TradeMenu.Buy`; the console fixes in `f935c29b`. +- **Docs:** `docs/merge-to-master-checklist.md` section 8, the `give` bullet: add "retired by the scenarios cut" + with the commit. `docs/settings-globals-cut.md:72,348` get a one-line history note. +- **Verification:** + - builds: `Aetheria.Shared`, the test project. Batchmode compile (`ActionGameManager.cs` changed). + - tests: the full suite is green. The locomotion "real enemy-count check" (`95394f78`) counts generated ships; + if it counted wanderers, fix its expectation and say so. + - negative: `rg -n '"give"|"spawnturret"' Assets/Scripts` is empty; + `rg -n "NeutralWanderer|EligibleWandererFactions" Assets tests tools` is empty. Both patterns were checked to + match only these sites today. + - operator: none. There is nothing to see until Cut 2. +- **Ledger:** about -99 lines; 0 added. + +### Cut 1. `RunStart`, `Zone.Admit`, and the scenario type (headless) + +- **Repo/branch:** Aetheria `codex/scenarios`, on Cut 0. Re-anchor `Zone.cs` if mining has merged. +- **First:** with `AetherDb hardpoint-fit` and a scratch `Materialize` against the live catalog, confirm that + `Djinni`, `LonginusX` and one turret hull each have a product and materialize bare. If one has no product, stop + and report: the Q3 rule would make it unbranded, which may not be intended for a real hull. +- **Deletes first:** + - `ActionGameManager.cs:805-814`, the new-run spawn (it moves into `RunStart`; this cut leaves `StartGame` + calling `RunStart` with no scenario, so behaviour is unchanged). + - The four hand-written joins: `ActionGameManager.cs:735-736`, `Entity.cs:1006-1007`, `Zone.cs:124-125`, and the + agent add at `Zone.cs:128`. Each becomes `Admit`. +- **Adds:** `Scenario.cs`, `RunStart.cs`, `Zone.Admit`, the `ambient` parameter on `ZoneGenerator.GenerateZone` + (`:38-43`; the ship loop `:334-340` and `PlaceTurrets` calls `:309,331` honour it), `Scenario` in + `AetheriaStores.CatalogTypes` (`:9`), and the two `Materialize` rules (`Loadout.cs:103-115,131`). +- **Authority map:** section 2.2. Owner `RunStart`; admission `Zone.Admit`; deletion line as above. +- **Verification:** + - builds: `Aetheria.Shared`, tests, `AetherDb`. Batchmode compile (`ActionGameManager.cs`, `Entity.cs` changed). + - tests (new `RunStartTests`: live catalog, authored settings, a prelude galaxy at a fixed seed). Each rule + must fail under its own mutation: + - With no scenario, staging yields a player of the `StartingHullName` hull, `IsPlayerShip`, admitted, at the + origin. This pins that New Game is unchanged. + - With a scenario, the player is the preset's hull and fit at the authored position and direction, and its + cargo holds the listed designs. + - A piloted entity has exactly one agent in `Zone.Agents`; an unpiloted one has none and stays put over 5 s of + `Zone.Update`. + - `Hostile` makes both `IsHostileTo` directions true. `Neutral` makes both false. + - `Ambient: false` leaves no ship or turret in the arena pack that the scenario did not place. Stations remain. + - A turret preset stages as a stationary orbital entity. A station preset is a staging failure. + - A scenario naming a missing design fails with that design named. The zone is unchanged, and no + `RunSave` call was made (the check entry touches no store). + - A product-less design materializes unbranded; a design whose only product is unavailable still fails. + This is two tests, because the second case is the existing contract. + - `Admit` is the only agent creator: after a warp (`PopulateLevel`'s join through `Admit(pilot, false)`) the + pilot has no agent. The constructor's pack path still gives every non-player ship a `Minion`. + - Stryker on `RunStart.cs`, `Zone.cs` (`Admit` and the constructor loop), `Loadout.cs` (`Materialize`), scoped + to the diff. Triage survivors by name. + - negative: `rg -n "Entities\.Add\(" Assets/Scripts --glob '!**/ZoneGenerator.cs'` matches only + `Zone.cs` (once, inside `Admit`), `Sensor.cs:169` and `Entity.cs:242` (both are `VisibleEntities`/pings, not + zones). `rg -n "new Minion\(" Assets/Scripts` matches only `Zone.cs`. + - operator: none beyond the Cut 2 smoke. +- **Ledger:** `Scenario.cs` +60; `RunStart.cs` +110; `Zone` +12 -8; `Loadout.cs` +20; `ZoneGenerator` +4; + `ActionGameManager` -11 +4; `Entity` ±2; `AetheriaStores` ±1; tests +300. + +### Cut 2. The main menu launches scenarios (Unity) + +- **Repo/branch:** Aetheria `codex/scenarios`, on Cut 1. +- **Deletes first:** the duplicated body of the two galaxy branches in the New Game lambda (`MainMenu.cs:124-175`) + collapses into one launch body. The standard branch (`:124-145`) is live code only when `TutorialPassed` is + true, and it is kept. Only the duplication goes. +- **Adds:** the Scenarios panel, `PendingScenario`, the seed-derived noise, the check entry before + `RunSave.Clear`. `StartGame` passes `PendingScenario` to `RunStart`. +- **Authority map:** + - Owner of what the run starts with: `RunStart` (unchanged). + - `MainMenu` owns which scenario was picked and when the run is cleared. `PendingScenario` is a command-only + transport, consumed once. + - Forbidden: `MainMenu` stages nothing and admits nothing. `StartGame` reads `PendingScenario` only in the + new-run branch. + - Shared paths: both buttons run one launch body. In-game menu (after death) and title menu run the same one. +- **Verification:** + - builds: batchmode compile, editor closed. + - negative: `rg -n "RunSave\.Clear" Assets/Scripts/UI/MainMenu.cs` appears once, after the check entry. + `rg -n "PendingScenario\s*=" Assets/Scripts` matches the launch body and `StartGame`'s null only. + - operator (Unity, Starfire): + 1. Title menu: Scenarios is listed. New Game still starts a LonginusX at the entrance with a Zenith station + (the Cut 1 test pins the ship; this proves the lowering). + 2. Launch any scenario. The arena matches its brief; launch it again and the layout is the same. + 3. Die in a scenario. From the in-game menu, relaunch the same scenario. + 4. Quit mid-scenario, then Continue. The run continues (the 0b rule: unpiloted ships now patrol). + 5. Break a scenario on purpose in a scratch catalog (a missing design). It is shown disabled with the failure, + and Continue still offers the previous run. +- **Ledger:** `MainMenu` +45 -25; `ActionGameManager` +4; one static. + +### Cut 3. The verification ledger (headless; Q2) + +- **Repo/branch:** Aetheria `codex/scenarios`, on Cut 1 (independent of Cut 2). +- **Adds:** `Checks.cs` (`Check`, `CheckResult`); `AetheriaStores.LedgerTypes` and the optional path; + `GameData/Checks.cc` (empty, committed). + - Under Q2 B, `AetherDb` gains two commands: + - `checks [scenario]`: each check with its latest result, its revision, and "never" when there is none. + - `record --revision [--note ]`. It refuses an unknown or + retired slug. It opens the ledger writable and the catalog read-only. + - Under Q2 A, the menu panel gains Pass/Fail/Partial and a note field per check. It writes through the same + ledger primitive, and the game attaches the ledger writable. +- **Authority map:** + - Owner of results: the ledger store. The only writer is the Q2 writer, which calls one append primitive, + `Ledger.Record`. + - Derived, display only: the menu's latest-status line and `checks`' output. + - Forbidden: nothing edits or deletes a `CheckResult`; nothing writes a result into the catalog. +- **Verification:** + - tests (new `LedgerTests`, temp stores): + - Recording appends and never replaces: two results for one check both survive a reopen, and the latest is + the one with the later `At`, even when written first. + - A retired or unknown slug is refused with nothing written. + - The catalog file is byte-identical after `record` (a catalog opened read-only cannot be written; this pins + that the command opened it that way). + - A result carries the revision it was given, and `record` without `--revision` is a usage error. + - Stryker on `Checks.cs` and the `AetherDb` record path. + - negative: `rg -n "CheckResult" Assets/Scripts --glob '!**/Checks.cs'` matches only the menu's read (and under + Q2 A its record call). +- **Ledger:** `Checks.cs` +50; `AetheriaStores` +4; `AetherDb` +90 (B) or `MainMenu` +50 (A); tests +120. + +### Cut 4. The first scenarios, checks and history (content) + +- **Repo/branch:** Aetheria `codex/scenarios`, on Cuts 2 and 3. Hands authors on the branch clone, never in + `F:\Projects\Aetheria`. +- **First:** tell the operator that the uncommitted smoke weapons in their tree's `GameData/Aetheria.cc` must be + restored (`git checkout -- GameData/Aetheria.cc`, their own command) before they pull. This cut recreates them as + committed designs. +- **Adds:** + - A transient `AetherDb scenario-seed [apply]` command, with the never-clobber rule of section 2.4. + - It writes the test designs (section 5.3), the presets (5.4), the scenarios (5.2) and the checks (5.1). + - It imports the 2026-09-30 smoke results (checklist section 8) as `CheckResult`s at revision `113164fa`, + recorder "operator (play), recorded by agent". + - It runs once, the data is committed, and the command is deleted in the same cut. The catalog is the authority + from then on. + - `ScenarioConditionTests` (live catalog; pinned catalog refreshed, see the build budget). Per scenario, stage it + and assert the conditions its checks need, not the behaviour, which existing suites own. For example: + - `Fused rounds`: the bare hull lies within the arc on the player's initial aim line and has no target + selected; the close hull is nearer than `Smoke Proximity`'s `BlastRadius`; `Smoke Refused` has + `Range < BlastRadius`; a direct-fire weapon can kill the fragile hull. + - `Duel`: the AI is piloted, hostile both ways, and carries a slow missile weapon; the player can see it. + - `Arcs`: the three bare hulls lie in the bow, beam and stern sectors of the player's mounts; the turret is + hostile. + - Every catalog scenario passes the check entry. This is the rot test. + - Every non-retired check with a scenario points at an existing one. Every check without a scenario has a + `Setup`. + - Docs: `docs/merge-to-master-checklist.md` sections 2 and 8 get one line: steps 7-20 and handoff checks A-F are + now checks in `GameData/Checks.cc`, listed by `AetherDb checks`. The prose steps stay as history. +- **Verification:** + - tests: `ScenarioConditionTests`, with mutation by editing a fixture. Moving the close hull outside the blast + radius, dropping the AI's missile weapon, or flipping a stance must each fail its condition. This is a one-off + Soul probe, not a committed suite. + - `AetherDb checks` lists every check in section 5.1, with the imported results on 3, 4, 5, 6 and "never" + elsewhere. + - operator: play each scenario and report per check. That report is the first real use of the ledger. +- **Ledger:** seed command +300 then -300; catalog +5 designs, +11 presets, +10 scenarios; ledger +44 checks, +7 + results; tests +250; docs ±4. + +--- + +## 4. Authority map (whole campaign) + +- **Owner of a new run's contents:** `RunStart`. It reads the scenario (or its absence) and writes the arena pack + and the admitted entities. +- **Owner of zone membership and agents:** `Zone.Admit`. +- **Owner of fixtures:** the catalog (`Scenario`, `Loadout`, test designs), written by the seed, Studio or + `capturepreset`, and read-only at runtime. +- **Owner of verification history:** the ledger store. It is append-only and written by the Q2 writer. +- **Derived:** menu status lines; `AetherDb checks`; `PendingScenario` (transport). +- **Forbidden writers:** `StartGame` spawning ships; `MainMenu` staging; console commands admitting entities; + anything editing a result; any catalog write at runtime. +- **Shared paths:** New Game and every scenario launch: one launch body, `StartGame`, `RunStart`, `Admit`. Warp, + undock, construction and staging: `Admit`. +- **Deletion line:** Cut 0's affordances; `StartGame` `:805-814`; the four hand-written joins; the duplicated New + Game branches; the transient seed command. + +--- + +## 5. The first consumers + +Sources: `docs/merge-to-master-checklist.md` section 2 (steps 7-20) and section 8; checks A-F from +`F:\Projects\HANDOFF-aetheria-play-smoke-2026-09-29.md` section 3. That file sits outside the repo, so the checks +below restate A-F and the ledger becomes their home. The rulings behind A-F are in `fire-control-cut.md`, in the +block "Ruled (operator, 2026-09-30)" (`:2625-2690`). + +### 5.1 Checks + +| Slug | Statement (abridged; the record carries the full text) | Source | Setup | +|---|---|---|---| +| `restored-hull-flies` | Thrusters move and turn the Djinni. | step 7; `locomotion-cut.md:501` | Djinni shakedown | +| `restored-hull-speed-cap` | Take the hull offline with thrusters firing; speed does not run away. | step 7; `:377` | Djinni shakedown | +| `weak-reactor-starves-legibly` | A reactor too small for the loadout shows starvation, not a cooked reactor. | step 8; `stats-and-power-cut.md:705-706` | Starved reactor | +| `brownout-degrades` | Brownout reads as degradation, not breakage. | step 9; `:774,990` | Starved reactor | +| `brownout-refill-stutters` | A weapon refilling under brownout stutters rather than firing at full rate. | step 9; `:729-730` | Starved reactor | +| `memory-flat-across-zones` | Several zones with kills; memory does not climb. | step 10; `:672-673,980-982` | Long haul | +| `side-mounts-fire-abeam` | Side-mounted weapons fire at the beam target. | step 11; `fire-control-cut.md:397-398` | Arcs | +| `turret-tracks-round` | The hostile turret tracks the player all the way round. | step 11 | Arcs | +| `no-fire-through-hull` | Nothing fires at the stern target through the hull. | step 11 | Arcs | +| `shots-roll` | Rolled impacts and misses; a miss reads as a near-miss; damage matches the HUD. | step 12; `:659-661` | Duel | +| `subsystem-aim` | Reveal and selection work; aiming at a revealed subsystem concentrates damage. | step 12 | Duel | +| `kill-drops-loot` | The kill drops its cargo; pickup stores it; the tractor beam pulls. | step 12; `headless-playground-cut.md:789-794` | Duel | +| `loot-keeps-brand` | Picked-up loot shows its manufacturer. | step 6; `item-provenance-cut.md:528-539` | Duel | +| `target-vanish-safe` | The target dies with shots in flight: no crash, no frozen ship. | step 12; `fire-control-cut.md:1464-1470` | Duel | +| `beam-reads-higher` | The HUD `hull` factor reads higher from the beam than from the bow. | step 13; `:2380-2383` | Duel | +| `armour-face-missile` | Turn the armoured face into the AI's slow missile; it lands there. | step 13 | Duel | +| `ai-engages` | The AI still engages normally. | D; `:2658-2660` | Duel | +| `launcher-edge-pulse` | Launcher into the bow, then a flank: the schematic pulses the facing edge only. | step 14; `:2519-2520` | Launcher angles | +| `guided-explodes-on-detonation` | A guided round's explosion shows only when the simulation detonated. | E | Launcher angles | +| `guided-target-left-fades` | Kill the target while a guided round flies: the round fades with no explosion. | E | Launcher angles | +| `guided-hit-at-target` | A guided hit plays its effect at the target. | E | Launcher angles | +| `fused-clear-line-max-range` | Nothing selected, clear line: the round bursts at max range along the arc-clamped aim. | A.1; `:2625,2636-2642` | Fused rounds | +| `fused-first-hull-stop` | Nothing selected, aim at the bare hull: the round stops and detonates on it. | A.2; `:2632-2634` | Fused rounds | +| `fused-target-left-no-burst` | Fire at the fragile target, kill it with the direct weapon before arrival: no burst. | A.3; `:2636-2642`, `FireControl.cs:610` | Fused rounds | +| `fused-out-of-arc` | Select the stern target: the round fires and bursts at its range along the arc-clamped aim. | A.4; `:2643-2645` | Fused rounds | +| `fused-arming-pushout` | Select the close hull: the burst is pushed out to `BlastRadius` from the ship's centre. | A.5; `:2646-2653` | Fused rounds | +| `airburst-at-model` | A proximity airburst beside the LonginusX lands damage where the model shows the blast. | step 15; `:3094-3097` | Fused rounds | +| `penetrator-reaches-interior` | A delayed penetrator into the LonginusX nose detonates inside. | step 15 | Fused rounds | +| `hud-forecast-fused` | The HUD shows Direct, Burst at N m or Refused for fused weapons, and a percentage for direct weapons. | C; `:2661-2663` | Fused rounds | +| `unbranded-no-manufacturer` | An unbranded smoke weapon shows no Manufacturer row. | step 6 | Fused rounds | +| `refused-shot-free` | A refused shot costs no ammo, energy, heat, sound, visibility or cooldown. | B; `:2664-2666` | Refused rounds | +| `refused-beam-no-flicker` | A refused beam never starts. | B | Refused rounds | +| `refused-charge-never-charges` | A refused charged weapon never starts charging. | B | Refused rounds | +| `instant-and-charged-fire` | Autocannon and ChargeBlast SG fire; effects, particles and action-bar icons show. | step 5; `addressables-cut.md:362-366` | Weapon feel | +| `burst-cadence` | Burst cadence is unchanged. | F | Weapon feel | +| `charge-release-press` | A fast release-and-press on a charged weapon starts no second charge. | F | Weapon feel | +| `existing-build-play` | Under "Use Existing Build", the Weapon feel checks hold (GUID keys in the player catalog). | step 20; `addressables-cut.md:367-370` | Weapon feel, Setup: play mode Use Existing Build | +| `ai-never-fires-refused` | The hostile AI whose only weapon is refused never fires it. | D | AI fused discipline | +| `ai-never-bomb-fishes` | The neutral AI carrying a fused weapon never fires it. | D | AI fused discipline | +| `continue-keeps-items` | Warp, quit, Continue: same items, same tier and brand. | step 16; `item-provenance-cut.md:536-537` | Long haul | +| `death-ends-run` | Die: Continue is disabled and `run.cc` holds no ledger. | step 17; `:540-541` | Long haul | +| `shield-panel-rig` | The numbered shield-panel look, including the Cut 5 dicing and break-through. | step 18; `shield-panel-cut.md:683-700,737-739` | Setup: FieldShieldTest scene | +| `studio-*` (5 checks) | The fire-control union renders; the schematic underlay; restored hull records; the fuse fields edit; the provenance ledger renders from a real `run.cc`. | step 19 | Setup: CultCache Studio | +| `addressables-duplicates` | Analyze the duplicate dependencies: 5 known leftovers. | step 20 | Setup: Addressables Analyze | +| `new-game-start`, `purchase-keeps-brand`, `tier-colour` | New Game; buying keeps the brand; tier colours show. | steps 3, 4, 6 | Setup: New Game | + +Imported results (checklist section 8, revision `113164fa`, 2026-09-30): +- `new-game-start`: pass. +- `purchase-keeps-brand`: pass. +- `instant-and-charged-fire`: partial ("charged weapon unprovable: 1x2 hardpoint"). +- `tier-colour`: fail ("known defect, `settings-globals-cut.md:150`; fixed by settings-globals Cut 1"). +- `unbranded-no-manufacturer`: blocked ("`give Lamp` named a test fixture"). +- `loot-keeps-brand`: no result ("not yet reported"). + +### 5.2 Scenarios + +All use `Ambient: false` except Long haul. Positions are authored in the seed and pinned by the condition tests. + +| Scenario | Player | Entities | Checks | +|---|---|---|---| +| Djinni shakedown | Djinni, full fit | none | 2 | +| Starved reactor | a combat hull with an undersized reactor, energy weapons and a shield | 1 unpiloted bare hull ahead, neutral | 3 | +| Arcs | a hull with side mounts | 3 unpiloted bare hulls (bow, beam, stern), neutral; 1 turret preset, hostile | 3 | +| Duel | combat fit | 1 piloted LonginusX, hostile, carrying a slow missile weapon, cargo holding a branded item | 8 | +| Launcher angles | GT 3K or pswarm plus a direct weapon | 1 unpiloted LonginusX, hostile; 1 fragile bare hull | 4 | +| Fused rounds | Smoke Proximity, Smoke Delayed, a direct weapon | unpiloted: LonginusX mid range, hostile; bare hull on the clear bearing; fragile bare hull; close bare hull inside `BlastRadius`; stern target | 9 | +| Refused rounds | Smoke Refused (instant, beam, charged) plus one working weapon | 1 unpiloted bare hull | 3 | +| Weapon feel | a hull with a 2x2 energy hardpoint: ChargeBlast SG, Autocannon, a burst weapon | 1 unpiloted bare hull | 4 | +| AI fused discipline | a sturdy hull | 1 piloted neutral with a fused weapon; 1 piloted hostile whose only weapon is refused | 2 | +| Long haul | standard combat fit, `Ambient: true` | none | 3 | + +### 5.3 Test designs (Q3) + +None has a product. They are unbranded and outside the economy. +- `Smoke Proximity`: Proximity, blast 8. +- `Smoke Delayed`: Delayed, blast 4, penetration 2 cells. +- `Smoke Refused`: Proximity, blast 30, range 20. + +These three are the operator's own designs from tonight, Spectra clones, energy, 1x2. +- `Smoke Refused Beam` and `Smoke Refused Charge`: a constant weapon and a charged weapon with a fuse and + `Range < BlastRadius`. +- All must pass `CultRecordRefs.Validate` (R-heat, stat modifiers, roles). + +### 5.4 Presets + +About eleven, named after their scenarios. The seed author picks designs with `AetherDb hardpoint-fit`. The only +hard constraints are those the condition tests pin. + +--- + +## 6. Subtraction ledger + +| Cut | Removed | Added | Targets, schemas, stores | +|---|---|---|---| +| 0 | `give` 30, `spawnturret` 26, wanderers 20, setting 1, tests 22 | 0 | no change | +| 1 | `StartGame` spawn 10, joins 6 | `Scenario.cs` 60, `RunStart.cs` 110, `Admit` 12, `Materialize` 20, `GenerateZone` 4; tests 300 | +1 schema (`aetheria.scenario`) | +| 2 | duplicated New Game body about 25 | menu panel and launch body about 45; 1 static | none | +| 3 | none | `Checks.cs` 50, stores 4, `AetherDb` 90 (or the menu 50); tests 120 | +2 schemas, +1 store file | +| 4 | the seed command, once spent (300) | seed 300; condition tests 250; catalog and ledger data | catalog content, ledger content | + +Net production code is about +250. That buys: +- the main-menu scenario affordance; +- typed checks with history; +- one staging owner and one admission primitive (from five hand-written joins); +- the first game consumer of the presets that already existed. + +It removes three ad hoc test affordances and the wanderers that ran in every zone. + +--- + +## 7. Operator questions + +Each question stands alone. The first is what the scenario request leaves open. + +**Q1. Is a scenario arena part of a real run?** +Your words settle what an arena holds. They leave open what surrounds it. Checks 16 and 17 (warp, save, +Continue, death) need a real run. Every other check needs only the arena. +- **A.** The arena is the entrance zone of a small prelude galaxy built from the scenario's seed. Stations, + planets and wormholes are there, and the run saves on warp and quit, continues and ends at death like any run. + Launching a scenario replaces your saved run, as New Game does. Scenario-only details (unpiloted ships, forced + stances) do not survive a Continue. +- **B.** A sealed arena: one zone, no galaxy, no saving. Launching never touches your saved run. Checks 16 and 17 + go back to plain New Game play. This is a second zone-entry path with no galaxy under it, which `PopulateLevel` + and the sector map do not support today. +- **Recommended: A.** It is the same New Game path with an input, so there is no second boot path. Every rule + under test then runs exactly as it does in a real run. Replacing the saved run seems harmless for a dev + affordance. +- **Depends on it:** Cut 2's launch body; the 0b lifecycle row for staged entities; whether Long haul exists. + +**Q2. Who records a check's result, and where?** +You play and observe. Something has to write "pass at `abc123`, note ...". +- **A.** In game. The Scenarios panel gets Pass/Fail/Partial and a note per check. The game writes the ledger file + in your working tree, and you commit it with your other work. +- **B.** The agent. You tell the session what you saw, and it records typed results with `AetherDb record` on a + branch and pushes. The menu shows each check's latest result, read-only. +- **C.** No typed results. Checks are typed, and results go in a prose ledger doc, as checklist section 8 does now. +- **Recommended: B.** It matches how tonight's smoke already worked: you play, the agent records. Nothing in the + game writes a file that git tracks, and no agent needs to touch your tree. The menu still shows what is stale. + A can be added later on the same append primitive. +- **Depends on it:** Cut 3's writer and whether the game attaches the ledger writable. + +**Q3. Where do test-only item designs live?** +Checks A-C need fused weapons the catalog does not ship. Tonight they were authored in place and left +uncommitted. +- **A.** Committed catalog designs with no product. With no product nothing makes them, so they are never + generated, stocked, sold or looted. A preset builds them unbranded (a new `Materialize` rule). Studio lists them + beside real content. +- **B.** Committed designs with a product and price 0. Generation skips price 0 today, but only through a filter + written for another reason, so the exclusion is a coincidence. +- **C.** A scratch catalog swapped in per session, as checklist step 2a proposed. Scenarios would then depend on + which catalog is loaded, and the fixtures would not be retained. +- **Recommended: A.** +- **Depends on it:** the `Materialize` rule in Cut 1; section 5.3. + +**Q4. Which ad hoc test affordances do scenarios retire?** +`give` got its hull route tonight (your ruling: "mothballed ships do not require docking bays"), and step 7 was its +reason. `spawnturret` places a turret. Neutral wanderers spawn two non-hostile ships in every generated zone, "so +combat has something non-hostile to target" (`f732cff9`). +- **A.** Delete all three. Scenarios cover ships, cargo, turrets and neutral targets. `CommissionShip` stays for + purchases. +- **B.** Delete the hull route and `spawnturret`. Keep `give` for items (ad hoc poking outside scenarios) and keep + the wanderers. +- **C.** Keep everything. +- **Recommended: A.** Each one is a second way to create what scenarios now author, and the wanderers change every + zone of real play for a testing reason. `iff`, `trackmissile`, `revealzones`, `tow` and `capturepreset` stay. +- **Depends on it:** Cut 0. + +Defaults Self took, which you may overrule: +- Scenarios show only in editor and development builds. +- The ledger is its own committed store, `GameData/Checks.cc`, not the catalog. +- "Check", not "behaviour", because `Behavior` is the gear type family. +- Brokkr is not installed in this campaign (section 0.4). +- The scenario record holds no "start docked" field. No first consumer needs one. + +--- + +## 8. Follow-ups outside this campaign + +- Re-anchor `docs/headless-playground-cut.md`. Its Cut 1 now builds on `RunStart` and `Zone.Admit`; its `new` + command stages a `Scenario`. +- Brokkr install plus a "launch scenario" intent, if agents should drive Unity play. +- A durable agent authoring command for presets (fit by design name with validation), instead of transient seed + commands. +- `AetherDb` carries spent one-shot migration commands (`targeting-catalog-6c`, `-6d`, `firing-arc-migrate`, and + others; `Program.cs` is 1,477 lines). This is a subtraction pass of its own. +- `ItemManager`'s clock seed (`ItemManager.cs:20`) makes lot quality vary per scenario launch. It is recorded, + not fixed here.