Replace mode was tried live on a turn that actually completes a tech, with a two-process hooks=off oracle established first on that exact (save, procedure, route). Verdict: game/sim/research stays compared. What displaced: all 13 tech-tree leaves the turn moves -- 2 from the pass itself and 11 from the SetResearched cascade -- produced by our code in live game memory, with the original's ProcessResearch never executing. What did not: 16 leaves, every one written by ServerPlayer::OnTechResearched. Five player tech-effect fields (OutMod, ConMod[0..2], ResTNm), one ObservedTech element, two event records plus EvNxID, and five derived leaves behind them. Also: ref-turn2 + one End Turn does NOT complete a tech, so every research oracle before this one was taken on a quiet turn; and a config that names all 27 registered hooks off and passes check_shim_configs.py still installs six detours, because the M0 stub and the FPU module's four sampling detours have no hook. key.
1336 lines
66 KiB
JSON
1336 lines
66 KiB
JSON
{
|
|
"coverage_contradicted": [],
|
|
"coverage_unstated": [],
|
|
"format": 1,
|
|
"hooks": {
|
|
"Game::TechTree::ProcessResearch": {
|
|
"calls": 3,
|
|
"compared": 3,
|
|
"coverage": {
|
|
"checked_regions": [
|
|
"events",
|
|
"node[0]",
|
|
"node[105]",
|
|
"node[106]",
|
|
"node[107]",
|
|
"node[108]",
|
|
"node[109]",
|
|
"node[10]",
|
|
"node[110]",
|
|
"node[111]",
|
|
"node[112]",
|
|
"node[113]",
|
|
"node[114]",
|
|
"node[115]",
|
|
"node[116]",
|
|
"node[117]",
|
|
"node[118]",
|
|
"node[119]",
|
|
"node[11]",
|
|
"node[120]",
|
|
"node[121]",
|
|
"node[122]",
|
|
"node[123]",
|
|
"node[124]",
|
|
"node[125]",
|
|
"node[126]",
|
|
"node[127]",
|
|
"node[128]",
|
|
"node[129]",
|
|
"node[12]",
|
|
"node[130]",
|
|
"node[131]",
|
|
"node[132]",
|
|
"node[133]",
|
|
"node[134]",
|
|
"node[135]",
|
|
"node[136]",
|
|
"node[137]",
|
|
"node[138]",
|
|
"node[139]",
|
|
"node[13]",
|
|
"node[140]",
|
|
"node[141]",
|
|
"node[142]",
|
|
"node[143]",
|
|
"node[144]",
|
|
"node[145]",
|
|
"node[146]",
|
|
"node[147]",
|
|
"node[148]",
|
|
"node[149]",
|
|
"node[14]",
|
|
"node[151]",
|
|
"node[152]",
|
|
"node[153]",
|
|
"node[154]",
|
|
"node[155]",
|
|
"node[156]",
|
|
"node[157]",
|
|
"node[158]",
|
|
"node[159]",
|
|
"node[15]",
|
|
"node[160]",
|
|
"node[161]",
|
|
"node[162]",
|
|
"node[163]",
|
|
"node[164]",
|
|
"node[165]",
|
|
"node[166]",
|
|
"node[167]",
|
|
"node[168]",
|
|
"node[169]",
|
|
"node[16]",
|
|
"node[170]",
|
|
"node[171]",
|
|
"node[172]",
|
|
"node[173]",
|
|
"node[174]",
|
|
"node[175]",
|
|
"node[177]",
|
|
"node[178]",
|
|
"node[179]",
|
|
"node[17]",
|
|
"node[180]",
|
|
"node[181]",
|
|
"node[182]",
|
|
"node[183]",
|
|
"node[184]",
|
|
"node[185]",
|
|
"node[186]",
|
|
"node[187]",
|
|
"node[188]",
|
|
"node[189]",
|
|
"node[18]",
|
|
"node[190]",
|
|
"node[191]",
|
|
"node[192]",
|
|
"node[193]",
|
|
"node[194]",
|
|
"node[195]",
|
|
"node[196]",
|
|
"node[197]",
|
|
"node[198]",
|
|
"node[199]",
|
|
"node[19]",
|
|
"node[1]",
|
|
"node[200]",
|
|
"node[201]",
|
|
"node[202]",
|
|
"node[203]",
|
|
"node[204]",
|
|
"node[205]",
|
|
"node[206]",
|
|
"node[207]",
|
|
"node[208]",
|
|
"node[209]",
|
|
"node[20]",
|
|
"node[210]",
|
|
"node[211]",
|
|
"node[212]",
|
|
"node[213]",
|
|
"node[214]",
|
|
"node[215]",
|
|
"node[216]",
|
|
"node[217]",
|
|
"node[218]",
|
|
"node[219]",
|
|
"node[21]",
|
|
"node[220]",
|
|
"node[221]",
|
|
"node[222]",
|
|
"node[223]",
|
|
"node[224]",
|
|
"node[225]",
|
|
"node[226]",
|
|
"node[227]",
|
|
"node[228]",
|
|
"node[229]",
|
|
"node[22]",
|
|
"node[230]",
|
|
"node[231]",
|
|
"node[232]",
|
|
"node[233]",
|
|
"node[234]",
|
|
"node[235]",
|
|
"node[236]",
|
|
"node[237]",
|
|
"node[238]",
|
|
"node[239]",
|
|
"node[23]",
|
|
"node[240]",
|
|
"node[241]",
|
|
"node[242]",
|
|
"node[243]",
|
|
"node[244]",
|
|
"node[245]",
|
|
"node[246]",
|
|
"node[247]",
|
|
"node[248]",
|
|
"node[249]",
|
|
"node[24]",
|
|
"node[250]",
|
|
"node[251]",
|
|
"node[252]",
|
|
"node[253]",
|
|
"node[254]",
|
|
"node[255]",
|
|
"node[256]",
|
|
"node[257]",
|
|
"node[258]",
|
|
"node[259]",
|
|
"node[25]",
|
|
"node[260]",
|
|
"node[261]",
|
|
"node[262]",
|
|
"node[263]",
|
|
"node[264]",
|
|
"node[265]",
|
|
"node[266]",
|
|
"node[267]",
|
|
"node[268]",
|
|
"node[269]",
|
|
"node[26]",
|
|
"node[270]",
|
|
"node[271]",
|
|
"node[272]",
|
|
"node[273]",
|
|
"node[274]",
|
|
"node[275]",
|
|
"node[276]",
|
|
"node[277]",
|
|
"node[278]",
|
|
"node[279]",
|
|
"node[27]",
|
|
"node[280]",
|
|
"node[281]",
|
|
"node[282]",
|
|
"node[283]",
|
|
"node[284]",
|
|
"node[285]",
|
|
"node[286]",
|
|
"node[287]",
|
|
"node[288]",
|
|
"node[289]",
|
|
"node[28]",
|
|
"node[290]",
|
|
"node[291]",
|
|
"node[292]",
|
|
"node[29]",
|
|
"node[2]",
|
|
"node[30]",
|
|
"node[31]",
|
|
"node[32]",
|
|
"node[33]",
|
|
"node[34]",
|
|
"node[35]",
|
|
"node[36]",
|
|
"node[37]",
|
|
"node[38]",
|
|
"node[39]",
|
|
"node[3]",
|
|
"node[40]",
|
|
"node[41]",
|
|
"node[42]",
|
|
"node[43]",
|
|
"node[44]",
|
|
"node[45]",
|
|
"node[46]",
|
|
"node[47]",
|
|
"node[48]",
|
|
"node[49]",
|
|
"node[4]",
|
|
"node[50]",
|
|
"node[51]",
|
|
"node[52]",
|
|
"node[53]",
|
|
"node[54]",
|
|
"node[55]",
|
|
"node[56]",
|
|
"node[57]",
|
|
"node[58]",
|
|
"node[5]",
|
|
"node[60]",
|
|
"node[61]",
|
|
"node[62]",
|
|
"node[63]",
|
|
"node[64]",
|
|
"node[65]",
|
|
"node[66]",
|
|
"node[67]",
|
|
"node[68]",
|
|
"node[69]",
|
|
"node[6]",
|
|
"node[70]",
|
|
"node[72]",
|
|
"node[74]",
|
|
"node[77]",
|
|
"node[78]",
|
|
"node[7]",
|
|
"node[80]",
|
|
"node[81]",
|
|
"node[85]",
|
|
"node[86]",
|
|
"node[87]",
|
|
"node[88]",
|
|
"node[89]",
|
|
"node[8]",
|
|
"node[90]",
|
|
"node[91]",
|
|
"node[92]",
|
|
"node[94]",
|
|
"node[98]",
|
|
"node[99]",
|
|
"node[9]",
|
|
"observed_techs",
|
|
"overbudget",
|
|
"rng"
|
|
],
|
|
"guarded_calls": 3,
|
|
"guards": [
|
|
"player",
|
|
"tree_header"
|
|
],
|
|
"spans": {
|
|
"compare": [
|
|
"player+0x10c:3",
|
|
"player+0x110:3",
|
|
"player+0x114:3",
|
|
"player+0x124:3",
|
|
"player+0x294:4",
|
|
"tree_header+0x20:1"
|
|
]
|
|
},
|
|
"state": "partial",
|
|
"undeclared_calls": 1,
|
|
"undeclared_writes": 6,
|
|
"unmodelled": [
|
|
{
|
|
"mitigation": "region:events",
|
|
"risk": "medium",
|
|
"what": "posts EVENT_RESEARCH_OVERBUDGET on the owner's EventStorage: ours reproduces the decision and the id sequence, so region:events compares next_id, but the composed EvDsc/EvMsg text is not reproduced and no region can see it",
|
|
"why": "text comes from the game's string table, which the engine must not carry; ours posts into its own EventStorage and writes only the counts into the scratch copy, so no live byte moves and replace mode posts nothing at all"
|
|
},
|
|
{
|
|
"mitigation": "region:events",
|
|
"risk": "low",
|
|
"what": "composes EVENT_TECHS_UNLOCKED's message from the unlocked techs' names",
|
|
"why": "the trigger and the list are modelled (SetResearched's availability sweep plus the tail collector, both read off the instruction stream), so region:events compares next_id; the names come from the game's string table, so the message is composed from node indices instead and is not the game's text"
|
|
},
|
|
{
|
|
"mitigation": "guard:player, guard:tree_header",
|
|
"risk": "high",
|
|
"what": "TechTree::SetResearched in REPLACE mode: only its TechTree half runs, and only when research.replace_cascade=on",
|
|
"why": "with the flag OFF (the default) nothing of the cascade runs, so a replace run leaves the completed node unstamped and no tech unlocked. With it ON, the four TechNode words (costRP, turnAvailable, turnResearched, order) and the tree's completion-order counter are written live, and the ServerPlayer half is still not: no event is posted, no ObservedTech element is appended and no tech effect is applied. Neither setting is a full displacement of the completion path; the pair measures where the boundary is"
|
|
},
|
|
{
|
|
"mitigation": "guard:player",
|
|
"risk": "high",
|
|
"what": "ServerPlayer::OnTechResearched's tech effects: the ~90 hard-coded ServerPlayer field writes, the plague-cure masks, the design-option bitmasks and the species tech flags",
|
|
"why": "B2's milestone. `ours` models only the two parts of the callback this hook's regions can see -- the observed-tech append and the RNG word RollResearchEvent draws before its branch (one word on a missed roll, two on a fired plague roll) -- and the rest is what the player guard reports"
|
|
},
|
|
{
|
|
"mitigation": "region:rng",
|
|
"risk": "high",
|
|
"what": "the research-event branch RollResearchEvent takes when its roll beats the odds (ServerPlayer::OnResearchRollSucceeded: the plague and AI-rebellion event paths)",
|
|
"why": "RollResearchEvent draws one NextFloat unconditionally and that draw IS modelled -- but that is only the cost of REACHING the branch. A FIRED roll costs one or two words: the plague path draws a SECOND word (NextInt) to pick an owned system and posts EVENT_PLAGUE_OUTBREAK, while the rebellion path allocates an AIRebellion at ServerPlayer+0x3b8 and CANCELS the current research (no further draw). The branch is entered only for the plague and AI-rebellion tech families, whose odds are 0 everywhere else, and it has never been observed firing in three sessions -- which is why every earlier note in this repo said 'exactly one NextFloat' and nothing caught it. If it is ever entered, region:rng is the check"
|
|
},
|
|
{
|
|
"mitigation": "region:observed_techs",
|
|
"risk": "medium",
|
|
"what": "constructs the ObservedTech element it appends to ServerPlayer+0x274",
|
|
"why": "`ours` models the append DECISION -- RecordObservedTech de-duplicates by tech name, so it decides whether the vector grows -- and moves the scratch header's byte span by one 0x2c element per append. The element's own fields (turn_first, turn_last, detected, the name string, `with`) are not built, and no region can see them"
|
|
},
|
|
{
|
|
"mitigation": "guard:tree_header",
|
|
"risk": "low",
|
|
"what": "the tree's completion-order counter (TechTree+0x20) is read pre-call, not modelled as a region",
|
|
"why": "the per-node `order` word IS compared, and it is stamped from a counter `ours` seeds from the pre-call read and advances itself; the counter's own final value is only seen by the tree_header guard"
|
|
},
|
|
{
|
|
"mitigation": "",
|
|
"risk": "low",
|
|
"what": "writes a completion line to the game log",
|
|
"why": "log text is not simulation state"
|
|
}
|
|
],
|
|
"verdict": "partial",
|
|
"why": ""
|
|
},
|
|
"diffs": [],
|
|
"diverged": 0,
|
|
"diverged_call_ids": [],
|
|
"errors": 0,
|
|
"modes": {
|
|
"compare": 3
|
|
}
|
|
},
|
|
"Shim::SelfTest::Fill": {
|
|
"calls": 1,
|
|
"compared": 0,
|
|
"coverage": {
|
|
"checked_regions": [
|
|
"buf"
|
|
],
|
|
"guarded_calls": 0,
|
|
"guards": [],
|
|
"spans": {},
|
|
"state": "complete",
|
|
"undeclared_calls": 0,
|
|
"undeclared_writes": 0,
|
|
"unmodelled": [],
|
|
"verdict": "complete",
|
|
"why": "Fill writes buf[0..n) and nothing else; the whole range is a declared region"
|
|
},
|
|
"diffs": [],
|
|
"diverged": 0,
|
|
"diverged_call_ids": [],
|
|
"errors": 0,
|
|
"modes": {
|
|
"trace": 1
|
|
}
|
|
}
|
|
},
|
|
"inputs": [
|
|
"/tmp/claude-1000/-home-alex/ec8e34f8-af37-4ef2-a309-ed6a15293097/scratchpad/out/N.trace.jsonl"
|
|
],
|
|
"invalid": [],
|
|
"kind": "report",
|
|
"meta": [
|
|
{
|
|
"build": "cr-618ccb1-20260909T131556Z",
|
|
"exe_sha256": "970b7de729956a53094c7eb98aba4270aee98e2fed5daf0d39e290013c90c841",
|
|
"format": 1,
|
|
"hooks": {
|
|
"Game::EncounterDetect::AssignContacts": {
|
|
"coverage": {
|
|
"state": "partial",
|
|
"unmodelled": [
|
|
{
|
|
"mitigation": "region:rng is the only check; the turn's own correctness is B1/B3/B4's job",
|
|
"risk": "high",
|
|
"what": "everything the original writes except the strategic generator",
|
|
"why": "this hook family measures ONE thing -- how many words the generator advances and where. It declares no region over game state and makes no claim about it. A clean run here says the RNG accounting is right and says nothing whatever about whether the turn was computed correctly"
|
|
},
|
|
{
|
|
"mitigation": "region:rng carries left/block/words, never a call count",
|
|
"risk": "low",
|
|
"what": "the ledger reports WORDS, not draws",
|
|
"why": "a NextInt that rejects three times is four words and one call. Words are the unit that decides whether a save reproduces; they are the wrong unit for counting decisions, and nothing here should be read as a draw count"
|
|
},
|
|
{
|
|
"mitigation": "region:rng reads the state, so an inlined draw is as visible as a called one",
|
|
"risk": "low",
|
|
"what": "the ledger is deliberately blind to WHICH primitive spent a word",
|
|
"why": "that is the design, and it is why this instrument was preferred to hooking the primitives: the image has FOUR draw entry points (NextFloat 0x0047d830, NextInt 0x004271c0, Chance 0x008e6dd0 and NextUInt 0x004f7670, the last of which appears in no previous lane's primitive set) plus inlined draws in at least twelve functions, two of them reachable from the turn roots. A primitive-counting hook would have silently undercounted every one of those"
|
|
},
|
|
{
|
|
"mitigation": "region:rng emits null explicitly; tracecmp shows it as a value, not a gap",
|
|
"risk": "medium",
|
|
"what": "a generator position the ledger cannot place reads `words: null`",
|
|
"why": "a block more than 4096 twists ahead of the frontier, or any state behind the anchor, is reported unknown rather than guessed. A null in a ledger field is a measurement failure and must not be read as zero"
|
|
},
|
|
{
|
|
"mitigation": "region:rng; arg:detectors/contacts/max_trials bound the expected count",
|
|
"risk": "medium",
|
|
"what": "the contact-to-detector assignment itself, and the two-pass outer loop",
|
|
"why": "this hook exists because the draw here is INLINED and therefore invisible to every call-graph sweep and to the entry-point detours -- it is the one site in ProcessTurn's closure that neither instrument can see. It measures the word cost of the whole call and models nothing"
|
|
},
|
|
{
|
|
"mitigation": "arg:max_trials is the upper bound only",
|
|
"risk": "low",
|
|
"what": "the per-trial threshold is 0.25f or 0.0f depending on two tech lookups, and the accept test short-circuits the inner loop",
|
|
"why": "so the measured cost is between |contacts| and |contacts| x |detectors| and the exact number depends on tech state this hook does not read"
|
|
}
|
|
],
|
|
"why": ""
|
|
},
|
|
"ftol": 0,
|
|
"ftol_kind": "abs",
|
|
"ptr": "ignore"
|
|
},
|
|
"Game::EncounterDetect::ProcessTeamRecord": {
|
|
"coverage": {
|
|
"state": "partial",
|
|
"unmodelled": [
|
|
{
|
|
"mitigation": "region:rng is the only check; the turn's own correctness is B1/B3/B4's job",
|
|
"risk": "high",
|
|
"what": "everything the original writes except the strategic generator",
|
|
"why": "this hook family measures ONE thing -- how many words the generator advances and where. It declares no region over game state and makes no claim about it. A clean run here says the RNG accounting is right and says nothing whatever about whether the turn was computed correctly"
|
|
},
|
|
{
|
|
"mitigation": "region:rng carries left/block/words, never a call count",
|
|
"risk": "low",
|
|
"what": "the ledger reports WORDS, not draws",
|
|
"why": "a NextInt that rejects three times is four words and one call. Words are the unit that decides whether a save reproduces; they are the wrong unit for counting decisions, and nothing here should be read as a draw count"
|
|
},
|
|
{
|
|
"mitigation": "region:rng reads the state, so an inlined draw is as visible as a called one",
|
|
"risk": "low",
|
|
"what": "the ledger is deliberately blind to WHICH primitive spent a word",
|
|
"why": "that is the design, and it is why this instrument was preferred to hooking the primitives: the image has FOUR draw entry points (NextFloat 0x0047d830, NextInt 0x004271c0, Chance 0x008e6dd0 and NextUInt 0x004f7670, the last of which appears in no previous lane's primitive set) plus inlined draws in at least twelve functions, two of them reachable from the turn roots. A primitive-counting hook would have silently undercounted every one of those"
|
|
},
|
|
{
|
|
"mitigation": "region:rng emits null explicitly; tracecmp shows it as a value, not a gap",
|
|
"risk": "medium",
|
|
"what": "a generator position the ledger cannot place reads `words: null`",
|
|
"why": "a block more than 4096 twists ahead of the frontier, or any state behind the anchor, is reported unknown rather than guessed. A null in a ledger field is a measurement failure and must not be read as zero"
|
|
},
|
|
{
|
|
"mitigation": "region:rng; arg:gate/pred_contacts/pred_detectors are a prediction, not a check",
|
|
"risk": "medium",
|
|
"what": "the whole body of ProcessTeamRecord: the gate call, the two vector builds and the bucket construction",
|
|
"why": "this hook measures the word cost of the call and recomputes three integers the original derives from the same record. It models none of the work and asserts nothing about the contact assignment"
|
|
},
|
|
{
|
|
"mitigation": "arg:fc_byte_vs_dword_disagreements; arg:entry_flags carries the raw values",
|
|
"risk": "medium",
|
|
"what": "`+0xfc` and `+0xfb` are read as BYTES, following lane I's reading of the classifier functions",
|
|
"why": "if either is really a wider field, every count here is wrong in the same direction and no cross-check inside this hook would notice. So the dword at +0xfc is read as well and any row where the two disagree is COUNTED, not silently resolved -- a non-zero disagreement count means the byte reading is unsafe on this workload"
|
|
},
|
|
{
|
|
"mitigation": "arg:entries against the three class counts",
|
|
"risk": "low",
|
|
"what": "an entry whose object pointer is unreadable is skipped",
|
|
"why": "it is not counted into any of the three classes, so entries != contacts + detectors + neither is the signal that this happened"
|
|
}
|
|
],
|
|
"why": ""
|
|
},
|
|
"ftol": 0,
|
|
"ftol_kind": "abs",
|
|
"ptr": "ignore"
|
|
},
|
|
"Game::SectionDictionary::SectionDictionary": {
|
|
"coverage": {
|
|
"state": "partial",
|
|
"unmodelled": [
|
|
{
|
|
"mitigation": "see docs/M2.md; compare mode for this hook is not safe to run",
|
|
"risk": "high",
|
|
"what": "LoadSection registers each section with the string table and the live TechTree, and may append to the dictionary's own vector",
|
|
"why": "M3 scope; ours delegates to the game's LoadSection after the original has already built all 885 definitions, so the second pass registers duplicates -- the leading hypothesis for this hook's compare-mode crash"
|
|
},
|
|
{
|
|
"mitigation": "",
|
|
"risk": "medium",
|
|
"what": "post-load validation pass over every definition's @-token against the string table",
|
|
"why": "runs after the loop and touches no declared region"
|
|
},
|
|
{
|
|
"mitigation": "",
|
|
"risk": "low",
|
|
"what": "allocates 885 SectionDef objects (0x3d8 bytes each) on the game heap",
|
|
"why": "they do not exist at hook entry; compared by index/species/id/token"
|
|
},
|
|
{
|
|
"mitigation": "guard:dict",
|
|
"risk": "low",
|
|
"what": "the word at dictionary+0x14",
|
|
"why": "not modelled; emitted as an ignored pointer"
|
|
},
|
|
{
|
|
"mitigation": "",
|
|
"risk": "medium",
|
|
"what": "the before-snapshot of the object is uninitialised heap",
|
|
"why": "the hook is on the constructor, so `before` is meaningless and only `after` carries information"
|
|
}
|
|
],
|
|
"why": ""
|
|
},
|
|
"ftol": 0,
|
|
"ftol_kind": "abs",
|
|
"ptr": "ignore"
|
|
},
|
|
"Game::ServerPlayer::ComputeBudget": {
|
|
"coverage": {
|
|
"state": "partial",
|
|
"unmodelled": [
|
|
{
|
|
"mitigation": "declared input boundary; see budget_inputs.h",
|
|
"risk": "high",
|
|
"what": "slots 1, 2, 3, 4, 7 and 11 are produced by callees this milestone does not model (per-system output, trade, ship-carried population, a second manager, the build-queue spend)",
|
|
"why": "they are copied out of the original's own output and back into the same slots, so they match BY CONSTRUCTION and prove nothing"
|
|
},
|
|
{
|
|
"mitigation": "guard:budget_object does not reach the ships; unverified",
|
|
"risk": "high",
|
|
"what": "ServerSystem::ComputeOutput repairs damaged ships in orbit",
|
|
"why": "replace mode runs the original a second time on a scratch Budget to harvest the six unmodelled slots, so that repair happens TWICE per turn in replace mode and nothing in the trace would show it"
|
|
},
|
|
{
|
|
"mitigation": "logged as `inputs.live_difficulty` on every record",
|
|
"risk": "low",
|
|
"what": "the difficulty-mods row still DRIVES `ours` as two fitted constants",
|
|
"why": "the old note here said the row was 'not reachable from a ServerPlayer'. That was wrong: the record is at ServerPlayer+0x36c and every consumer calls DifficultyMods_Select(p->+0x36c, p). Lane L5 now snapshots it and emits it as `inputs.live_difficulty` alongside the fitted pair, so a run SHOWS whether the fit is right instead of assuming it. `ours` was deliberately left on the fitted constants so that this run is evidence about them; switching it over is the next lane's one-line change"
|
|
},
|
|
{
|
|
"mitigation": "the record carries `inputs.live_consts` so the widths are readable, and tests/game_sim/test_economy.cpp pins all three at their boundaries",
|
|
"risk": "medium",
|
|
"what": "three of the four float widths this routine turns on are unexercised by any reference turn",
|
|
"why": "the savings-interest rate is a widened 0.01f and its boundary IS in the corpus (a treasury of 50,000 earns 499, not 500). The research yield factor is a widened 0.85f whose boundary needs a research money that is a multiple of 40,000, and the three research modifiers are summed in SINGLE precision, which needs two of them non-zero -- the corpus has shrm = TRM = 0. So a green compare here verifies one of the three and says nothing about the other two"
|
|
},
|
|
{
|
|
"mitigation": "",
|
|
"risk": "low",
|
|
"what": "the research-allocation vector's heap block",
|
|
"why": "only the element count is compared; the three words are heap pointers the default policy ignores"
|
|
}
|
|
],
|
|
"why": ""
|
|
},
|
|
"ftol": 0,
|
|
"ftol_kind": "abs",
|
|
"ptr": "ignore"
|
|
},
|
|
"Game::ServerPlayer::OnTechResearched": {
|
|
"coverage": {
|
|
"state": "partial",
|
|
"unmodelled": [
|
|
{
|
|
"mitigation": "guard:player (EventStorage is inline at ServerPlayer+0x29c)",
|
|
"risk": "high",
|
|
"what": "posts EVENT_RESEARCH_COMPLETE / _UNDERBUDGET / _TEMPERANCE on the owner's EventStorage when !silent",
|
|
"why": "the same class of write as B3's defect, and this hook has no replace-mode oracle that could catch it: gotcha 4 in docs/B2.md says a changed save hash on a completion turn is expected and therefore not a finding"
|
|
},
|
|
{
|
|
"mitigation": "",
|
|
"risk": "high",
|
|
"what": "writes every owned system's AI flag (CCC_AIVrus / CCC_AISlv), re-evaluates the arcology civilian cap, cures addiction and clears plague across systems AND ships",
|
|
"why": "writes through pointers to other objects; compare mode must not touch live state, and no region reaches them"
|
|
},
|
|
{
|
|
"mitigation": "this is the extra draw B3 observed on a completion",
|
|
"risk": "high",
|
|
"what": "the pending plague-cure roll (ServerPlayer::RollResearchEvent)",
|
|
"why": "it draws one word from the strategic generator unconditionally, and a SECOND word (NextInt) when the roll beats the odds and takes the plague path -- which also posts EVENT_PLAGUE_OUTBREAK, while the rebellion path cancels the research. So a fired roll costs one or two words, not one; running it in compare mode would consume real randomness. The two words it guards are still cleared and the record says whether it would have fired"
|
|
},
|
|
{
|
|
"mitigation": "",
|
|
"risk": "medium",
|
|
"what": "TechTree::SetResearched for the Zuul boarding-pod grant",
|
|
"why": "it would mutate the live tree, and it recurses"
|
|
},
|
|
{
|
|
"mitigation": "region:node_bore, declared only when the block already exists",
|
|
"risk": "medium",
|
|
"what": "allocates or frees the node-bore block at ServerPlayer+0x308",
|
|
"why": "ours has no allocator the game's runtime could free, so replace mode calls the game's own updater -- which means replace mode never exercises our node-bore selection at all"
|
|
}
|
|
],
|
|
"why": ""
|
|
},
|
|
"ftol": 0,
|
|
"ftol_kind": "abs",
|
|
"ptr": "ignore"
|
|
},
|
|
"Game::ServerPlayer::ProcessTurn": {
|
|
"coverage": {
|
|
"state": "partial",
|
|
"unmodelled": [
|
|
{
|
|
"mitigation": "guard:player reports Sav, +0xc8 and +0xcc moving",
|
|
"risk": "high",
|
|
"what": "phases 2, 3 and 6 -- the savings apply Sav = SatAdd(budget[0], net), the three aid/trade records copied out of the budget, and the research refund -- are NOT declared and NOT compared",
|
|
"why": "all three are pure functions of ComputeBudget's 22-slot output and of ProcessResearch's overBudget out-parameter, and both live in the original's own stack frame ([ebp-0x90] and [ebp-0x14]). The three ways to reach them are each worse than not having them: call ComputeBudget ourselves (it repairs damaged ships in orbit -- harness-audit #6), read them out of the nested B1/B3 hooks (harness-audit #5, the self-fulfilling compare), or infer them from the observed Sav delta (the same trap). The formulas ARE written and unit-tested in player_turn_inputs.cpp; they are simply not wired into the verdict"
|
|
},
|
|
{
|
|
"mitigation": "run hook.Game::ServerPlayer::ComputeBudget=compare alongside",
|
|
"risk": "high",
|
|
"what": "ComputeBudget (0x00863030) itself, including ServerSystem::ComputeOutput, which REPAIRS DAMAGED SHIPS IN ORBIT",
|
|
"why": "B1 is the hook that checks the budget; its ship-repair side effect happens inside this call too, once per system per player per turn"
|
|
},
|
|
{
|
|
"mitigation": "guard:player, and hook.Game::TechTree::ProcessResearch=compare",
|
|
"risk": "high",
|
|
"what": "TechTree::ProcessResearch and the whole SetResearched / OnTechResearched cascade it can trigger: progress, the completion roll, the 5% decay sweep, the child-unlock cascade, ~90 tech-effect field writes, EVENT_RESEARCH_* and EVENT_TECHS_UNLOCKED",
|
|
"why": "declared input boundary. B3/U own it; this hook reports the pre-call research state and lets the player guard say how much moved"
|
|
},
|
|
{
|
|
"mitigation": "region:rng shows the draws; nothing shows the systems it wrote",
|
|
"risk": "high",
|
|
"what": "RollResearchAccident (0x00889dc0) draws Mars::RNG::NextInt(100) whenever the research boost is non-zero, can apply progress loss across EVERY system, and posts EVENT_LABACCIDENT_{SMALL,MEDIUM,LARGE}",
|
|
"why": "it runs before ProcessResearch and gates it entirely. Its draws move the declared `rng` region, so they are visible -- but as an unexplained RNG divergence, not as their cause"
|
|
},
|
|
{
|
|
"mitigation": "region:rng + region:roll_flags + guard:player; ours never rolls",
|
|
"risk": "high",
|
|
"what": "the ResearchRollPending roll: RollResearchEvent draws one NextFloat, and when it BEATS the odds the plague branch draws a SECOND word (NextInt) and posts EVENT_PLAGUE_OUTBREAK, while the rebellion branch allocates an AIRebellion at ServerPlayer+0x3b8 and cancels the current research",
|
|
"why": "every previous coverage note in this repo says 'exactly one NextFloat'. That is the cost of REACHING the branch; a fired roll costs one or two words. The branch has never been observed firing. `predict_roll` says whether we expected the roll, `region:rng` says what it actually cost, and `roll_flags` says whether ResErrRoll was consumed"
|
|
},
|
|
{
|
|
"mitigation": "guard:player",
|
|
"risk": "medium",
|
|
"what": "ServerPlayer::ProcessSpecialProjects (0x00840fe0) and, for an AI player, ConstructionSpend (0x00817f90)",
|
|
"why": "1015 bytes of special-project state, plus the log line \"SpecialProject: %s completed investigation phase\""
|
|
},
|
|
{
|
|
"mitigation": "guard:player reports the EvNxID bump",
|
|
"risk": "medium",
|
|
"what": "the EVENT_NO_RESEARCH post at 0x0089168c, which appends to the player's EventStorage (+0x29c) and bumps EvNxID (+0x2b0)",
|
|
"why": "B3's exact failure mode: a list append outside every Result region. Its condition is fully known (ResT == 0, nothing completed this turn, and at least one state-2 tech exists) but ours posts nothing"
|
|
},
|
|
{
|
|
"mitigation": "guard:player sees the header move only",
|
|
"risk": "low",
|
|
"what": "PruneRaidTargets (0x00863cf0) erases from the heap vector at +0x338",
|
|
"why": "the vector header is inside the player guard, but its elements are heap memory no region reaches, and the erase runs each removed element's scalar destructor"
|
|
},
|
|
{
|
|
"mitigation": "region:bonus_header",
|
|
"risk": "low",
|
|
"what": "the timed-bonus element regions are captured from the PRE-CALL element addresses, and the sweep may shrink the vector under them",
|
|
"why": "erase moves data down inside the same buffer and only lowers _Mylast, so the addresses stay valid and readable. Elements past the new _Mylast are compared as stale bytes; `bonus_header` is the region that carries the count, and ours writes the same stale tail the original leaves"
|
|
},
|
|
{
|
|
"mitigation": "",
|
|
"risk": "medium",
|
|
"what": "replace mode is refused for this hook",
|
|
"why": "ours models the driver's own writes and none of ComputeBudget, ProcessResearch, ProcessSpecialProjects or the raid-target prune. A replace run would leave a player in a state no code path produces, and there is therefore no oracle layer behind this compare"
|
|
}
|
|
],
|
|
"why": ""
|
|
},
|
|
"ftol": 0,
|
|
"ftol_kind": "abs",
|
|
"ptr": "ignore"
|
|
},
|
|
"Game::ServerSystem::ComputeTotalOutput": {
|
|
"coverage": {
|
|
"state": "partial",
|
|
"unmodelled": [
|
|
{
|
|
"mitigation": "declared input boundary",
|
|
"risk": "high",
|
|
"what": "the station count, as for GroupOutput",
|
|
"why": "`ours` assumes zero stations, so a system with an imperial population and a station diverges by the station factor"
|
|
},
|
|
{
|
|
"mitigation": "declared input boundary",
|
|
"risk": "high",
|
|
"what": "the slave population and its xenotech adjustment",
|
|
"why": "the original's slave count is not a plain field: it runs the count through a per-species xenotech factor. `ours` takes the slave term as ZERO, so any system holding slaves diverges. The record logs the raw group-2 population sums so a divergence can be attributed"
|
|
},
|
|
{
|
|
"mitigation": "declared input boundary",
|
|
"risk": "medium",
|
|
"what": "the capacity surplus the civilian term adds for the owner's own species",
|
|
"why": "the original calls the carrying-capacity helper twice with different out-parameter slots and adds max(0, B - A) to the civilian count; `ours` uses the raw civilian population. The surplus is zero except when the colony is at its cap"
|
|
},
|
|
{
|
|
"mitigation": "",
|
|
"risk": "low",
|
|
"what": "the addiction phase",
|
|
"why": "`ours` assumes it is below 3, so ADDICTION_OUTPUT_MOD never applies; the record logs the system's addiction table length so the case is visible"
|
|
}
|
|
],
|
|
"why": ""
|
|
},
|
|
"ftol": 0,
|
|
"ftol_kind": "abs",
|
|
"ptr": "ignore"
|
|
},
|
|
"Game::ServerSystem::GroupOutput": {
|
|
"coverage": {
|
|
"state": "partial",
|
|
"unmodelled": [
|
|
{
|
|
"mitigation": "declared input boundary; the trace makes it recoverable",
|
|
"risk": "high",
|
|
"what": "the station count that scales the imperial term",
|
|
"why": "the original gets it from a helper that walks the system's fleets and their ships through virtual calls and takes the system in EBX, which a hook cannot call portably. `ours` therefore assumes ZERO stations. A divergence on an imperial row is expected to be exactly the station factor, and the record carries `count` and the return, so the factor is MEASURED from the trace rather than fitted"
|
|
},
|
|
{
|
|
"mitigation": "logged as `tuning` on every record",
|
|
"risk": "medium",
|
|
"what": "the slave row's output modifier, and every value the data files supply",
|
|
"why": "SLAVES_OUTPUT_MOD, the two morale thresholds and their two modifiers, and STATION_BONUS_IMPERIAL_OUTPUT are read out of the live process's globals and logged with every record, so the record says which value drove it"
|
|
}
|
|
],
|
|
"why": "the imperial and civilian output modifiers, the 1.8 factor and the 500000 divisor are literals inside the executable, so nothing about them is assumed from the data files"
|
|
},
|
|
"ftol": 0,
|
|
"ftol_kind": "abs",
|
|
"ptr": "ignore"
|
|
},
|
|
"Game::ServerSystem::ProcessTurn": {
|
|
"coverage": {
|
|
"state": "partial",
|
|
"unmodelled": [
|
|
{
|
|
"mitigation": "guard:system",
|
|
"risk": "high",
|
|
"what": "the addiction sweep raises MoraleEvents, which are constructed and appended to the system's capped morale history",
|
|
"why": "the same class of write as B3's defect. sim::ProcessColonyTurn does compute the morale events (ColonyTurnResult), but the hook never emits them: DescribeMoraleEvents is dead code, so they are neither compared nor logged"
|
|
},
|
|
{
|
|
"mitigation": "guard:system covers the system object only, not the other objects",
|
|
"risk": "high",
|
|
"what": "every callee: the plague pass, imperial and civilian growth, the resource debit, in-orbit refuel, slaves, rebellion and the build queue",
|
|
"why": "declared input boundary -- ProcessTurn is a dispatcher and only the words it writes itself are modelled. The callees raise EVENT_SLAVES_DEAD, EVENT_SYSTEM_REBELLION_CONTINUES, the plague events and SEBuildCompleted, create ships and bump per-player ShipRecords counters"
|
|
},
|
|
{
|
|
"mitigation": "",
|
|
"risk": "medium",
|
|
"what": "ApplyInfraBonus / ApplyPopBonus read the owner's home-system id, and the build queue writes the owning ServerPlayer",
|
|
"why": "writes through a pointer to another object; no region reaches the player"
|
|
},
|
|
{
|
|
"mitigation": "",
|
|
"risk": "low",
|
|
"what": "ProcessRebellion is the pass's only RNG consumer and its draw count is data-dependent",
|
|
"why": "the generator IS a declared region, so a moved post-state is visible and names the system whose rebellion fired -- it is reported, not modelled"
|
|
},
|
|
{
|
|
"mitigation": "",
|
|
"risk": "medium",
|
|
"what": "replace mode is refused for this hook",
|
|
"why": "our side models the dispatcher's own writes and none of the callees, so a replace run would silently skip a colony's whole turn. There is therefore no oracle layer behind the compare for this hook"
|
|
}
|
|
],
|
|
"why": ""
|
|
},
|
|
"ftol": 0,
|
|
"ftol_kind": "abs",
|
|
"ptr": "ignore"
|
|
},
|
|
"Game::StrategyHost::Autosave": {
|
|
"coverage": {
|
|
"state": "partial",
|
|
"unmodelled": [
|
|
{
|
|
"mitigation": "region:rng is the only check; the turn's own correctness is B1/B3/B4's job",
|
|
"risk": "high",
|
|
"what": "everything the original writes except the strategic generator",
|
|
"why": "this hook family measures ONE thing -- how many words the generator advances and where. It declares no region over game state and makes no claim about it. A clean run here says the RNG accounting is right and says nothing whatever about whether the turn was computed correctly"
|
|
},
|
|
{
|
|
"mitigation": "region:rng carries left/block/words, never a call count",
|
|
"risk": "low",
|
|
"what": "the ledger reports WORDS, not draws",
|
|
"why": "a NextInt that rejects three times is four words and one call. Words are the unit that decides whether a save reproduces; they are the wrong unit for counting decisions, and nothing here should be read as a draw count"
|
|
},
|
|
{
|
|
"mitigation": "region:rng reads the state, so an inlined draw is as visible as a called one",
|
|
"risk": "low",
|
|
"what": "the ledger is deliberately blind to WHICH primitive spent a word",
|
|
"why": "that is the design, and it is why this instrument was preferred to hooking the primitives: the image has FOUR draw entry points (NextFloat 0x0047d830, NextInt 0x004271c0, Chance 0x008e6dd0 and NextUInt 0x004f7670, the last of which appears in no previous lane's primitive set) plus inlined draws in at least twelve functions, two of them reachable from the turn roots. A primitive-counting hook would have silently undercounted every one of those"
|
|
},
|
|
{
|
|
"mitigation": "region:rng emits null explicitly; tracecmp shows it as a value, not a gap",
|
|
"risk": "medium",
|
|
"what": "a generator position the ledger cannot place reads `words: null`",
|
|
"why": "a block more than 4096 twists ahead of the frontier, or any state behind the anchor, is reported unknown rather than guessed. A null in a ledger field is a measurement failure and must not be read as zero"
|
|
},
|
|
{
|
|
"mitigation": "region:rng only",
|
|
"risk": "low",
|
|
"what": "the whole save write: four path buffers, the ENDTURN pair removal, the backup rotation, the per-player connection detach/reattach, and SaveGame_WriteFile 0x00877070 itself",
|
|
"why": "this hook exists to timestamp the generator at the two moments the two save files are written. It is a marker and models nothing"
|
|
},
|
|
{
|
|
"mitigation": "arg:server_cached / host_plus_0x54 / server_agrees say which pointer was used and whether the +0x54 candidate is the same object",
|
|
"risk": "medium",
|
|
"what": "the generator is reached through a CACHED StrategyServer pointer, not from this call's own arguments",
|
|
"why": "`this` is the global at 0x00b29f98, hardcoded by both call sites, and no argument here names the server. On the first pre-turn autosave after a load no turn driver has run yet, so the cache is empty and that record carries no ledger position -- the FIRST BRACKET OF A SESSION IS INCOMPLETE BY CONSTRUCTION and must not be read as a zero-cost turn"
|
|
}
|
|
],
|
|
"why": ""
|
|
},
|
|
"ftol": 0,
|
|
"ftol_kind": "abs",
|
|
"ptr": "ignore"
|
|
},
|
|
"Game::StrategyServer::ApplyEncounterResult": {
|
|
"coverage": {
|
|
"state": "partial",
|
|
"unmodelled": [
|
|
{
|
|
"mitigation": "region:rng is the only check; the turn's own correctness is B1/B3/B4's job",
|
|
"risk": "high",
|
|
"what": "everything the original writes except the strategic generator",
|
|
"why": "this hook family measures ONE thing -- how many words the generator advances and where. It declares no region over game state and makes no claim about it. A clean run here says the RNG accounting is right and says nothing whatever about whether the turn was computed correctly"
|
|
},
|
|
{
|
|
"mitigation": "region:rng carries left/block/words, never a call count",
|
|
"risk": "low",
|
|
"what": "the ledger reports WORDS, not draws",
|
|
"why": "a NextInt that rejects three times is four words and one call. Words are the unit that decides whether a save reproduces; they are the wrong unit for counting decisions, and nothing here should be read as a draw count"
|
|
},
|
|
{
|
|
"mitigation": "region:rng reads the state, so an inlined draw is as visible as a called one",
|
|
"risk": "low",
|
|
"what": "the ledger is deliberately blind to WHICH primitive spent a word",
|
|
"why": "that is the design, and it is why this instrument was preferred to hooking the primitives: the image has FOUR draw entry points (NextFloat 0x0047d830, NextInt 0x004271c0, Chance 0x008e6dd0 and NextUInt 0x004f7670, the last of which appears in no previous lane's primitive set) plus inlined draws in at least twelve functions, two of them reachable from the turn roots. A primitive-counting hook would have silently undercounted every one of those"
|
|
},
|
|
{
|
|
"mitigation": "region:rng emits null explicitly; tracecmp shows it as a value, not a gap",
|
|
"risk": "medium",
|
|
"what": "a generator position the ledger cannot place reads `words: null`",
|
|
"why": "a block more than 4096 twists ahead of the frontier, or any state behind the anchor, is reported unknown rather than guessed. A null in a ledger field is a measurement failure and must not be read as zero"
|
|
},
|
|
{
|
|
"mitigation": "region:rng measures the subtotal",
|
|
"risk": "high",
|
|
"what": "the combat resolver 0x007d5af0 (7499 B) is completely unread",
|
|
"why": "this hook measures what its subtree spends and models none of it. Nothing about combat determinism can be settled until that function is read; this only puts a number on the hole"
|
|
},
|
|
{
|
|
"mitigation": "",
|
|
"risk": "medium",
|
|
"what": "the ~0xea0-byte combat report, the CombatReport list append at S+0x1fc, the ClientEncounterResults push into S+0x2f4, the per-ship turn stamps and the pairwise engagement bits",
|
|
"why": "all of it is game state this hook does not declare and does not check"
|
|
}
|
|
],
|
|
"why": ""
|
|
},
|
|
"ftol": 0,
|
|
"ftol_kind": "abs",
|
|
"ptr": "ignore"
|
|
},
|
|
"Game::StrategyServer::MoveFleet": {
|
|
"coverage": {
|
|
"state": "partial",
|
|
"unmodelled": [
|
|
{
|
|
"mitigation": "guard:fleet sees the fleet's own words; the event and the system do not",
|
|
"risk": "high",
|
|
"what": "on arrival: dispatches SEFleetArrived and runs one of three arrival handlers by destination kind (enter system / join fleet / stop at point)",
|
|
"why": "declared input boundary -- an arriving call is expected to differ in all of it, and none of it is declared, so the compare says nothing about arrivals"
|
|
},
|
|
{
|
|
"mitigation": "",
|
|
"risk": "high",
|
|
"what": "on departure: cancels every still-acting ship (with a log line each) and calls ServerSystem::FleetDeparts, which rewrites the system's ownership bits",
|
|
"why": "writes through pointers to ships and to the system"
|
|
},
|
|
{
|
|
"mitigation": "",
|
|
"risk": "medium",
|
|
"what": "the tanker top-up refuels other ships in the fleet",
|
|
"why": "the per-ship range regions would show it, but ours does not model it, so a fleet with a tanker diverges for a known reason"
|
|
},
|
|
{
|
|
"mitigation": "declared gap: docs/B4.md",
|
|
"risk": "medium",
|
|
"what": "a node-line waypoint's step comes from the stutter profile",
|
|
"why": "NodeLineStep / BuildStutterSegments are written and unit-tested but not wired in; the hook steps every waypoint type as speed x dt, so a node-line leg is knowingly mis-stepped and only its type is recorded"
|
|
},
|
|
{
|
|
"mitigation": "",
|
|
"risk": "medium",
|
|
"what": "a missed probabilistic jump scatters the fleet in a random direction",
|
|
"why": "the direction is a second draw whose mapping is not modelled; ours leaves the position alone and reports the scatter distance, so the generator region diverges by one word on a miss"
|
|
},
|
|
{
|
|
"mitigation": "",
|
|
"risk": "medium",
|
|
"what": "the route revalidation and the waypoint list itself",
|
|
"why": "declared input boundary; the waypoint vector is not a region"
|
|
}
|
|
],
|
|
"why": ""
|
|
},
|
|
"ftol": 0,
|
|
"ftol_kind": "abs",
|
|
"ptr": "ignore"
|
|
},
|
|
"Game::StrategyServer::NodeLineDecay": {
|
|
"coverage": {
|
|
"state": "partial",
|
|
"unmodelled": [
|
|
{
|
|
"mitigation": "region:rng is the only check; the turn's own correctness is B1/B3/B4's job",
|
|
"risk": "high",
|
|
"what": "everything the original writes except the strategic generator",
|
|
"why": "this hook family measures ONE thing -- how many words the generator advances and where. It declares no region over game state and makes no claim about it. A clean run here says the RNG accounting is right and says nothing whatever about whether the turn was computed correctly"
|
|
},
|
|
{
|
|
"mitigation": "region:rng carries left/block/words, never a call count",
|
|
"risk": "low",
|
|
"what": "the ledger reports WORDS, not draws",
|
|
"why": "a NextInt that rejects three times is four words and one call. Words are the unit that decides whether a save reproduces; they are the wrong unit for counting decisions, and nothing here should be read as a draw count"
|
|
},
|
|
{
|
|
"mitigation": "region:rng reads the state, so an inlined draw is as visible as a called one",
|
|
"risk": "low",
|
|
"what": "the ledger is deliberately blind to WHICH primitive spent a word",
|
|
"why": "that is the design, and it is why this instrument was preferred to hooking the primitives: the image has FOUR draw entry points (NextFloat 0x0047d830, NextInt 0x004271c0, Chance 0x008e6dd0 and NextUInt 0x004f7670, the last of which appears in no previous lane's primitive set) plus inlined draws in at least twelve functions, two of them reachable from the turn roots. A primitive-counting hook would have silently undercounted every one of those"
|
|
},
|
|
{
|
|
"mitigation": "region:rng emits null explicitly; tracecmp shows it as a value, not a gap",
|
|
"risk": "medium",
|
|
"what": "a generator position the ledger cannot place reads `words: null`",
|
|
"why": "a block more than 4096 twists ahead of the frontier, or any state behind the anchor, is reported unknown rather than guessed. A null in a ledger field is a measurement failure and must not be read as zero"
|
|
},
|
|
{
|
|
"mitigation": "region:rng only",
|
|
"risk": "high",
|
|
"what": "the collapse itself: 0x007a92e0 (690 B) and 0x007a4700 (2244 B) destroy or halt fleets and post EVENT_NODEDECAY_FLEET_DESTROYED_VIANODE / _HALTED / _HALTED_VIANODE, and loop 3 posts two more decay-stage events",
|
|
"why": "ours advances the generator and writes nothing else. In compare mode that is the intent -- the check is the word count -- but it means a clean verdict here says nothing about which lines actually collapsed"
|
|
},
|
|
{
|
|
"mitigation": "arg:predict_words vs region:rng is exactly that check",
|
|
"risk": "medium",
|
|
"what": "the draw-count model is verified by a DIRECT-call sweep of the downstream pair; their subtrees contain unresolved indirect call sites",
|
|
"why": "if one of those vtable slots reaches a generator, the measured delta will exceed `predict_words` and this hook will diverge -- which is the correct outcome, and the reason the prediction is recorded as an argument"
|
|
},
|
|
{
|
|
"mitigation": "",
|
|
"risk": "low",
|
|
"what": "the expiry formula reproduces a signed idiv on nptf/npdtf without knowing whether nptf can be negative",
|
|
"why": "the original never sign-checks the traffic accumulator. The model truncates toward zero the same way; if the field is always non-negative the question never arises, and no save has been observed with a negative one"
|
|
}
|
|
],
|
|
"why": ""
|
|
},
|
|
"ftol": 0,
|
|
"ftol_kind": "abs",
|
|
"ptr": "ignore"
|
|
},
|
|
"Game::StrategyServer::OnAllCombatDone_Tail": {
|
|
"coverage": {
|
|
"state": "partial",
|
|
"unmodelled": [
|
|
{
|
|
"mitigation": "region:rng is the only check; the turn's own correctness is B1/B3/B4's job",
|
|
"risk": "high",
|
|
"what": "everything the original writes except the strategic generator",
|
|
"why": "this hook family measures ONE thing -- how many words the generator advances and where. It declares no region over game state and makes no claim about it. A clean run here says the RNG accounting is right and says nothing whatever about whether the turn was computed correctly"
|
|
},
|
|
{
|
|
"mitigation": "region:rng carries left/block/words, never a call count",
|
|
"risk": "low",
|
|
"what": "the ledger reports WORDS, not draws",
|
|
"why": "a NextInt that rejects three times is four words and one call. Words are the unit that decides whether a save reproduces; they are the wrong unit for counting decisions, and nothing here should be read as a draw count"
|
|
},
|
|
{
|
|
"mitigation": "region:rng reads the state, so an inlined draw is as visible as a called one",
|
|
"risk": "low",
|
|
"what": "the ledger is deliberately blind to WHICH primitive spent a word",
|
|
"why": "that is the design, and it is why this instrument was preferred to hooking the primitives: the image has FOUR draw entry points (NextFloat 0x0047d830, NextInt 0x004271c0, Chance 0x008e6dd0 and NextUInt 0x004f7670, the last of which appears in no previous lane's primitive set) plus inlined draws in at least twelve functions, two of them reachable from the turn roots. A primitive-counting hook would have silently undercounted every one of those"
|
|
},
|
|
{
|
|
"mitigation": "region:rng emits null explicitly; tracecmp shows it as a value, not a gap",
|
|
"risk": "medium",
|
|
"what": "a generator position the ledger cannot place reads `words: null`",
|
|
"why": "a block more than 4096 twists ahead of the frontier, or any state behind the anchor, is reported unknown rather than guessed. A null in a ledger field is a measurement failure and must not be read as zero"
|
|
},
|
|
{
|
|
"mitigation": "region:rng plus the two nested hooks",
|
|
"risk": "high",
|
|
"what": "36 phases, of which two can draw and neither is modelled here",
|
|
"why": "phase 6 reaches the unread 7499-byte combat resolver 0x007d5af0 (NextInt on the node-cannon path, Twist plus NextInt on the salvage path) and phase 11 draws one word per expired node line. `predict_nodeline_words` covers only the second, and the nested ApplyEncounterResult / NodeLineDecay hooks are what attribute the split"
|
|
},
|
|
{
|
|
"mitigation": "arg:encounters says how many encounters this call saw; a call with 0 settles it",
|
|
"risk": "medium",
|
|
"what": "whether this handler runs on a turn with NO combat is what this hook is here to settle, and until it has run it is a hypothesis",
|
|
"why": "combat-done-tail.md \u00c2\u00a76 infers it from the determinism note -- the post-turn autosave appears on every End Turn and this handler is its only reachable caller -- not from the instruction stream"
|
|
}
|
|
],
|
|
"why": ""
|
|
},
|
|
"ftol": 0,
|
|
"ftol_kind": "abs",
|
|
"ptr": "ignore"
|
|
},
|
|
"Game::StrategyServer::ProcessFleetMovement": {
|
|
"coverage": {
|
|
"state": "partial",
|
|
"unmodelled": [
|
|
{
|
|
"mitigation": "",
|
|
"risk": "high",
|
|
"what": "`ours` re-reads the LIVE fleet list after the original has run",
|
|
"why": "the gate-traffic total is computed by the original at the very end of the pass, so a pre-call snapshot would diverge for the wrong reason. It breaks the compare invariant that ours never touches live memory, and it makes this hook's verdict partly self-fulfilling: the input to our arithmetic is the original's own post-move state"
|
|
},
|
|
{
|
|
"mitigation": "",
|
|
"risk": "high",
|
|
"what": "drives MoveFleet up to five times per fleet",
|
|
"why": "every undeclared effect of MoveFleet happens inside this call too; the pass schedule is recorded in the arguments but never compared"
|
|
},
|
|
{
|
|
"mitigation": "",
|
|
"risk": "high",
|
|
"what": "writes FPdpos into every fleet and clears flags 0x2 and 0x100 on every fleet",
|
|
"why": "no region covers the fleets, only the players' gate-traffic words"
|
|
},
|
|
{
|
|
"mitigation": "",
|
|
"risk": "high",
|
|
"what": "OnFleetArrived posts EVENT_FLEET_ARRIVED",
|
|
"why": "the same class of write as B3's defect, and there is no replace mode for this hook, so nothing behind the compare could catch it either"
|
|
},
|
|
{
|
|
"mitigation": "",
|
|
"risk": "medium",
|
|
"what": "the original accumulates by player->index but writes back by the player's position in the server vector, into a fixed 32-int array with no bounds check",
|
|
"why": "a real latent bug in the original that our side reproduces only while index == position; the reference save never separates them"
|
|
},
|
|
{
|
|
"mitigation": "",
|
|
"risk": "medium",
|
|
"what": "PassSchedule() is never called by the hook, and FleetSummary::targetFleetId / relation are never filled",
|
|
"why": "the header claims ours predicts the call order for a trace to check; that prediction is not actually emitted"
|
|
}
|
|
],
|
|
"why": ""
|
|
},
|
|
"ftol": 0,
|
|
"ftol_kind": "abs",
|
|
"ptr": "ignore"
|
|
},
|
|
"Game::StrategyServer::ProcessNodeSpaceTravel": {
|
|
"coverage": {
|
|
"state": "partial",
|
|
"unmodelled": [
|
|
{
|
|
"mitigation": "region:rng is the only check; the turn's own correctness is B1/B3/B4's job",
|
|
"risk": "high",
|
|
"what": "everything the original writes except the strategic generator",
|
|
"why": "this hook family measures ONE thing -- how many words the generator advances and where. It declares no region over game state and makes no claim about it. A clean run here says the RNG accounting is right and says nothing whatever about whether the turn was computed correctly"
|
|
},
|
|
{
|
|
"mitigation": "region:rng carries left/block/words, never a call count",
|
|
"risk": "low",
|
|
"what": "the ledger reports WORDS, not draws",
|
|
"why": "a NextInt that rejects three times is four words and one call. Words are the unit that decides whether a save reproduces; they are the wrong unit for counting decisions, and nothing here should be read as a draw count"
|
|
},
|
|
{
|
|
"mitigation": "region:rng reads the state, so an inlined draw is as visible as a called one",
|
|
"risk": "low",
|
|
"what": "the ledger is deliberately blind to WHICH primitive spent a word",
|
|
"why": "that is the design, and it is why this instrument was preferred to hooking the primitives: the image has FOUR draw entry points (NextFloat 0x0047d830, NextInt 0x004271c0, Chance 0x008e6dd0 and NextUInt 0x004f7670, the last of which appears in no previous lane's primitive set) plus inlined draws in at least twelve functions, two of them reachable from the turn roots. A primitive-counting hook would have silently undercounted every one of those"
|
|
},
|
|
{
|
|
"mitigation": "region:rng emits null explicitly; tracecmp shows it as a value, not a gap",
|
|
"risk": "medium",
|
|
"what": "a generator position the ledger cannot place reads `words: null`",
|
|
"why": "a block more than 4096 twists ahead of the frontier, or any state behind the anchor, is reported unknown rather than guessed. A null in a ledger field is a measurement failure and must not be read as zero"
|
|
},
|
|
{
|
|
"mitigation": "region:rng",
|
|
"risk": "medium",
|
|
"what": "2945 bytes of node-space movement, entirely unmodelled and never swept for RNG by any lane",
|
|
"why": "it is hooked here only because it runs TWICE a turn -- ProcessTurn phase 7 and tail phase 10 -- so a draw inside it would be double-counted by anyone modelling it once. The record says whether it draws at all"
|
|
}
|
|
],
|
|
"why": ""
|
|
},
|
|
"ftol": 0,
|
|
"ftol_kind": "abs",
|
|
"ptr": "ignore"
|
|
},
|
|
"Game::StrategyServer::ProcessTurn": {
|
|
"coverage": {
|
|
"state": "partial",
|
|
"unmodelled": [
|
|
{
|
|
"mitigation": "region:rng is the only check; the turn's own correctness is B1/B3/B4's job",
|
|
"risk": "high",
|
|
"what": "everything the original writes except the strategic generator",
|
|
"why": "this hook family measures ONE thing -- how many words the generator advances and where. It declares no region over game state and makes no claim about it. A clean run here says the RNG accounting is right and says nothing whatever about whether the turn was computed correctly"
|
|
},
|
|
{
|
|
"mitigation": "region:rng carries left/block/words, never a call count",
|
|
"risk": "low",
|
|
"what": "the ledger reports WORDS, not draws",
|
|
"why": "a NextInt that rejects three times is four words and one call. Words are the unit that decides whether a save reproduces; they are the wrong unit for counting decisions, and nothing here should be read as a draw count"
|
|
},
|
|
{
|
|
"mitigation": "region:rng reads the state, so an inlined draw is as visible as a called one",
|
|
"risk": "low",
|
|
"what": "the ledger is deliberately blind to WHICH primitive spent a word",
|
|
"why": "that is the design, and it is why this instrument was preferred to hooking the primitives: the image has FOUR draw entry points (NextFloat 0x0047d830, NextInt 0x004271c0, Chance 0x008e6dd0 and NextUInt 0x004f7670, the last of which appears in no previous lane's primitive set) plus inlined draws in at least twelve functions, two of them reachable from the turn roots. A primitive-counting hook would have silently undercounted every one of those"
|
|
},
|
|
{
|
|
"mitigation": "region:rng emits null explicitly; tracecmp shows it as a value, not a gap",
|
|
"risk": "medium",
|
|
"what": "a generator position the ledger cannot place reads `words: null`",
|
|
"why": "a block more than 4096 twists ahead of the frontier, or any state behind the anchor, is reported unknown rather than guessed. A null in a ledger field is a measurement failure and must not be read as zero"
|
|
},
|
|
{
|
|
"mitigation": "region:rng measures the total; the per-phase split is not resolved here",
|
|
"risk": "high",
|
|
"what": "no model of the turn's RNG cost: 32 phases, each of which may draw",
|
|
"why": "ProcessResearch's completion roll, RollResearchAccident's NextInt(100), the ResearchRollPending roll (one word, or two on the plague path), and whatever ProcessStations / ProcessSurrenders / ProcessMissions / ProcessSpecialProjects spend -- none of which has ever been measured. `ours` predicts nothing and the record reports the measurement"
|
|
},
|
|
{
|
|
"mitigation": "shim.log records the MH_CreateHook status for both",
|
|
"risk": "low",
|
|
"what": "this hook takes the address the fpu module also wants to sample",
|
|
"why": "MinHook allows one hook per target. `fpu.sample_turn=off` releases StrategyServer::ProcessTurn so this hook can install; with it on, this hook fails to install and the trace is missing half the ledger"
|
|
}
|
|
],
|
|
"why": ""
|
|
},
|
|
"ftol": 0,
|
|
"ftol_kind": "abs",
|
|
"ptr": "ignore"
|
|
},
|
|
"Game::TechTree::ProcessResearch": {
|
|
"coverage": {
|
|
"state": "partial",
|
|
"unmodelled": [
|
|
{
|
|
"mitigation": "region:events",
|
|
"risk": "medium",
|
|
"what": "posts EVENT_RESEARCH_OVERBUDGET on the owner's EventStorage: ours reproduces the decision and the id sequence, so region:events compares next_id, but the composed EvDsc/EvMsg text is not reproduced and no region can see it",
|
|
"why": "text comes from the game's string table, which the engine must not carry; ours posts into its own EventStorage and writes only the counts into the scratch copy, so no live byte moves and replace mode posts nothing at all"
|
|
},
|
|
{
|
|
"mitigation": "region:events",
|
|
"risk": "low",
|
|
"what": "composes EVENT_TECHS_UNLOCKED's message from the unlocked techs' names",
|
|
"why": "the trigger and the list are modelled (SetResearched's availability sweep plus the tail collector, both read off the instruction stream), so region:events compares next_id; the names come from the game's string table, so the message is composed from node indices instead and is not the game's text"
|
|
},
|
|
{
|
|
"mitigation": "guard:player, guard:tree_header",
|
|
"risk": "high",
|
|
"what": "TechTree::SetResearched in REPLACE mode: only its TechTree half runs, and only when research.replace_cascade=on",
|
|
"why": "with the flag OFF (the default) nothing of the cascade runs, so a replace run leaves the completed node unstamped and no tech unlocked. With it ON, the four TechNode words (costRP, turnAvailable, turnResearched, order) and the tree's completion-order counter are written live, and the ServerPlayer half is still not: no event is posted, no ObservedTech element is appended and no tech effect is applied. Neither setting is a full displacement of the completion path; the pair measures where the boundary is"
|
|
},
|
|
{
|
|
"mitigation": "guard:player",
|
|
"risk": "high",
|
|
"what": "ServerPlayer::OnTechResearched's tech effects: the ~90 hard-coded ServerPlayer field writes, the plague-cure masks, the design-option bitmasks and the species tech flags",
|
|
"why": "B2's milestone. `ours` models only the two parts of the callback this hook's regions can see -- the observed-tech append and the RNG word RollResearchEvent draws before its branch (one word on a missed roll, two on a fired plague roll) -- and the rest is what the player guard reports"
|
|
},
|
|
{
|
|
"mitigation": "region:rng",
|
|
"risk": "high",
|
|
"what": "the research-event branch RollResearchEvent takes when its roll beats the odds (ServerPlayer::OnResearchRollSucceeded: the plague and AI-rebellion event paths)",
|
|
"why": "RollResearchEvent draws one NextFloat unconditionally and that draw IS modelled -- but that is only the cost of REACHING the branch. A FIRED roll costs one or two words: the plague path draws a SECOND word (NextInt) to pick an owned system and posts EVENT_PLAGUE_OUTBREAK, while the rebellion path allocates an AIRebellion at ServerPlayer+0x3b8 and CANCELS the current research (no further draw). The branch is entered only for the plague and AI-rebellion tech families, whose odds are 0 everywhere else, and it has never been observed firing in three sessions -- which is why every earlier note in this repo said 'exactly one NextFloat' and nothing caught it. If it is ever entered, region:rng is the check"
|
|
},
|
|
{
|
|
"mitigation": "region:observed_techs",
|
|
"risk": "medium",
|
|
"what": "constructs the ObservedTech element it appends to ServerPlayer+0x274",
|
|
"why": "`ours` models the append DECISION -- RecordObservedTech de-duplicates by tech name, so it decides whether the vector grows -- and moves the scratch header's byte span by one 0x2c element per append. The element's own fields (turn_first, turn_last, detected, the name string, `with`) are not built, and no region can see them"
|
|
},
|
|
{
|
|
"mitigation": "guard:tree_header",
|
|
"risk": "low",
|
|
"what": "the tree's completion-order counter (TechTree+0x20) is read pre-call, not modelled as a region",
|
|
"why": "the per-node `order` word IS compared, and it is stamped from a counter `ours` seeds from the pre-call read and advances itself; the counter's own final value is only seen by the tree_header guard"
|
|
},
|
|
{
|
|
"mitigation": "",
|
|
"risk": "low",
|
|
"what": "writes a completion line to the game log",
|
|
"why": "log text is not simulation state"
|
|
}
|
|
],
|
|
"why": ""
|
|
},
|
|
"ftol": 0,
|
|
"ftol_kind": "abs",
|
|
"ptr": "ignore"
|
|
},
|
|
"Game::WeaponDictionary::Init": {
|
|
"coverage": {
|
|
"state": "partial",
|
|
"unmodelled": [
|
|
{
|
|
"mitigation": "suspected cause of the sibling section hook's compare crash (docs/M2.md)",
|
|
"risk": "high",
|
|
"what": "LoadWeapon -> WeaponDef::ParseScript registers each weapon's name with the string table and resolves `requires` against the live TechTree",
|
|
"why": "per-file parsing is M3 scope; ours delegates to the game's own LoadWeapon, so a compare run performs the registration a SECOND time and neither the string table nor the tech tree is a declared region"
|
|
},
|
|
{
|
|
"mitigation": "",
|
|
"risk": "low",
|
|
"what": "allocates 123 WeaponDef objects (0x278 bytes each) on the game heap",
|
|
"why": "the definitions do not exist when the hook is entered, so they cannot be a before-snapshot; the dictionary region compares them by id/name/path"
|
|
},
|
|
{
|
|
"mitigation": "guard:dict",
|
|
"risk": "low",
|
|
"what": "the word at dictionary+0x14",
|
|
"why": "not modelled; emitted as an opaque pointer, which the default policy ignores -- a change is visible in a trace but never a divergence"
|
|
},
|
|
{
|
|
"mitigation": "",
|
|
"risk": "low",
|
|
"what": "writes lines to the game log for a missing manifest",
|
|
"why": "log text is not simulation state"
|
|
},
|
|
{
|
|
"mitigation": "",
|
|
"risk": "low",
|
|
"what": "std::sort tie order for equal weapon names",
|
|
"why": "msvc_sort.h replays MSVC 2010's introsort, but the shipped data has no tied names, so the tie rule is unexercised rather than verified"
|
|
}
|
|
],
|
|
"why": ""
|
|
},
|
|
"ftol": 0,
|
|
"ftol_kind": "abs",
|
|
"ptr": "ignore"
|
|
},
|
|
"Mars::GlobalConsts::LoadFile": {
|
|
"coverage": {
|
|
"state": "partial",
|
|
"unmodelled": [
|
|
{
|
|
"mitigation": "LoadAll's post-state would have to be hooked to see it",
|
|
"risk": "medium",
|
|
"what": "erases each consumed key from the caller's std::map",
|
|
"why": "the map is a LoadAll temporary; declaring a red-black tree as a region is not possible before the call. First-occurrence-wins is reproduced in game::config::apply instead, so the *effect* is modelled, the container is not"
|
|
},
|
|
{
|
|
"mitigation": "",
|
|
"risk": "low",
|
|
"what": "writes three kinds of line to the game log (unrecognised key, applied key, expected-but-not-found)",
|
|
"why": "log text is not part of the simulation state"
|
|
},
|
|
{
|
|
"mitigation": "",
|
|
"risk": "low",
|
|
"what": "opens the file through the VFS and allocates/releases a refcounted buffer",
|
|
"why": "ours performs the same two calls, so allocation behaviour matches by construction rather than by comparison"
|
|
},
|
|
{
|
|
"mitigation": "",
|
|
"risk": "low",
|
|
"what": "String slots assign through the engine's own std::string, leaking one heap block per long string in compare mode",
|
|
"why": "start-up only; documented in docs/M1.md"
|
|
}
|
|
],
|
|
"why": ""
|
|
},
|
|
"ftol": 0,
|
|
"ftol_kind": "abs",
|
|
"ptr": "ignore"
|
|
},
|
|
"Shim::SelfTest::Fill": {
|
|
"coverage": {
|
|
"state": "complete",
|
|
"unmodelled": [],
|
|
"why": "Fill writes buf[0..n) and nothing else; the whole range is a declared region"
|
|
},
|
|
"ftol": 0,
|
|
"ftol_kind": "abs",
|
|
"ptr": "ignore"
|
|
}
|
|
},
|
|
"inline_max": 256,
|
|
"started": "2026-09-09T13:33:54Z"
|
|
}
|
|
],
|
|
"totals": {
|
|
"calls": 4,
|
|
"compared": 3,
|
|
"coverage_contradicted": 0,
|
|
"coverage_unstated": 0,
|
|
"diverged": 0,
|
|
"guarded_calls": 3,
|
|
"invalid_records": 0,
|
|
"undeclared_calls": 1,
|
|
"undeclared_writes": 6
|
|
},
|
|
"warnings": []
|
|
}
|