Lane P wrote the expected numbers into sots-engine docs/P-events-wiring.md §4 before this run existed. Every one held. First End Turn (ref-turn2 -> Launch -> End Turn, shim.cfg.recapb3 unchanged, build eventlive-dd38117-20260908T0916Z, main dd38117 with no source change): 3 calls, 3 compared, 0 divergent, tracecmp exit 0 — where lane R's run exited 1 on side.events.after.v.next_id orig=4 ours=3. Call 0 read turn=3, events_turn_bucket_exists=true, events_next_id_in=3, events_in_turn_bucket=1, events_dedup_risk=0, no events_scan_truncated, next_id 3->4 on both sides, turns/turns_bytes 2/48 unchanged, node[144].progress 2879->5768, flag 1->2, rng identical — the whole predicted list. Five End Turns: exit 1 with 2 divergent calls instead of 3, each short by exactly 1 (next_id orig 7 ours 6, orig 12 ours 11) = the deliberately unmodelled EVENT_TECHS_UNLOCKED. A 0-divergence result there would have been suspicious. sizeof(Game::ObservedTech) = 44 measured live: observed_techs.bytes grew by exactly 44 on both completion calls (440->484, 484->528), confirming lane X's static pin behaviourally. Non-researching players measured 880 = 20 x 44 and never moved. One deviation from lane R, and it is a workload effect, not a defect: rng diverged on call 9 because the completed tech had research_roll_pending set and the OnTechResearched callback drew one word ours does not (left 374 vs 375). Lane R's "RNG matched 15 of 15" was workload luck; the honest statement is that the rng region matches on every call that does not complete a roll-triggering tech. Zuul double roll CLOSED. No species-5 save existed, so this lane made one: a custom game with only Zuul in the Available Species pool, 4 End Turns under the same compare config. 8 calls, 8 compared, 0 divergent, exit 0; all four researching calls have species=5 and advance the generator by two, not one (left 540->538, 522->520, 504->502, 485->483), and ours reproduced every post-state bit-for-bit. Save added as verify/results/saves/zuul-turn5-species5.sav. End-Turn oracle byte-identical to lane R's on the first turn, so none of this perturbs the game. clean_room_check OK and host ctest 33/33, run as separate commands. VM140 restored to the recap build at the main menu and released.
1054 lines
32 KiB
JSON
1054 lines
32 KiB
JSON
{
|
|
"coverage_contradicted": [],
|
|
"coverage_unstated": [],
|
|
"format": 1,
|
|
"hooks": {
|
|
"Game::TechTree::ProcessResearch": {
|
|
"calls": 15,
|
|
"compared": 15,
|
|
"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": 15,
|
|
"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",
|
|
"player+0x196:1",
|
|
"player+0x3b4:1"
|
|
]
|
|
},
|
|
"state": "partial",
|
|
"undeclared_calls": 2,
|
|
"undeclared_writes": 10,
|
|
"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": "medium",
|
|
"what": "posts EVENT_TECHS_UNLOCKED once after the per-node loop, for the nodes SetResearched made available this turn",
|
|
"why": "the set comes from the child-unlock cascade, which ours does not run; the pass driver takes the unlock list as an input and is given `no list` rather than an empty one, so a missing input cannot look like a modelled negative. Expect region:events to under-count next_id by exactly 1 on every call that completes a tech"
|
|
},
|
|
{
|
|
"mitigation": "region:observed_techs",
|
|
"risk": "high",
|
|
"what": "appends to the owner's vector<ObservedTech> (ServerPlayer+0x274) on every tech completion",
|
|
"why": "serialized ServerPlayer state that no coverage note in B2 or B3 mentioned until lane R's guard caught it. The element is now fully pinned (sizeof 44, {u16 turn_first, u16 turn_last, bool detected, string tech_name, int with}) and the append de-duplicates by tech name, but ours still does not append; the region reports the byte span, which must grow by exactly 44 per completion"
|
|
},
|
|
{
|
|
"mitigation": "guard:player, guard:tree_header",
|
|
"risk": "high",
|
|
"what": "TechTree::SetResearched on completion: the turn/order stamps, the child unlock cascade, the recursive research of zero-cost children, and the owner's OnTechResearched callback",
|
|
"why": "its own milestone (B2); the callback writes live player state that compare mode must not touch, and it consumes one extra RNG word"
|
|
},
|
|
{
|
|
"mitigation": "guard:tree_header",
|
|
"risk": "medium",
|
|
"what": "bumps the tree's completion-order counter (TechTree+0x20)",
|
|
"why": "part of SetResearched; the per-node `order` word is compared but the counter it comes from was not a region"
|
|
},
|
|
{
|
|
"mitigation": "",
|
|
"risk": "low",
|
|
"what": "writes a completion line to the game log",
|
|
"why": "log text is not simulation state"
|
|
}
|
|
],
|
|
"verdict": "partial",
|
|
"why": ""
|
|
},
|
|
"diffs": [
|
|
{
|
|
"call_id": 3,
|
|
"diff": [
|
|
{
|
|
"orig": {
|
|
"t": "i32",
|
|
"v": 7
|
|
},
|
|
"ours": {
|
|
"t": "i32",
|
|
"v": 6
|
|
},
|
|
"path": "side.events.after.v.next_id",
|
|
"why": "exact"
|
|
},
|
|
{
|
|
"orig": {
|
|
"t": "i32",
|
|
"v": 10000
|
|
},
|
|
"ours": {
|
|
"t": "i32",
|
|
"v": 2147483647
|
|
},
|
|
"path": "side.node[132].after.v.cost_rp",
|
|
"why": "exact"
|
|
},
|
|
{
|
|
"orig": {
|
|
"t": "i32",
|
|
"v": 2
|
|
},
|
|
"ours": {
|
|
"t": "i32",
|
|
"v": 0
|
|
},
|
|
"path": "side.node[132].after.v.state",
|
|
"why": "exact"
|
|
},
|
|
{
|
|
"orig": {
|
|
"t": "i32",
|
|
"v": 4
|
|
},
|
|
"ours": {
|
|
"t": "i32",
|
|
"v": -1
|
|
},
|
|
"path": "side.node[132].after.v.turn_available",
|
|
"why": "exact"
|
|
},
|
|
{
|
|
"orig": {
|
|
"t": "i32",
|
|
"v": 16000
|
|
},
|
|
"ours": {
|
|
"t": "i32",
|
|
"v": 2147483647
|
|
},
|
|
"path": "side.node[136].after.v.cost_rp",
|
|
"why": "exact"
|
|
},
|
|
{
|
|
"orig": {
|
|
"t": "i32",
|
|
"v": 2
|
|
},
|
|
"ours": {
|
|
"t": "i32",
|
|
"v": 0
|
|
},
|
|
"path": "side.node[136].after.v.state",
|
|
"why": "exact"
|
|
},
|
|
{
|
|
"orig": {
|
|
"t": "i32",
|
|
"v": 4
|
|
},
|
|
"ours": {
|
|
"t": "i32",
|
|
"v": -1
|
|
},
|
|
"path": "side.node[136].after.v.turn_available",
|
|
"why": "exact"
|
|
},
|
|
{
|
|
"orig": {
|
|
"t": "i32",
|
|
"v": 8000
|
|
},
|
|
"ours": {
|
|
"t": "i32",
|
|
"v": 2147483647
|
|
},
|
|
"path": "side.node[142].after.v.cost_rp",
|
|
"why": "exact"
|
|
},
|
|
{
|
|
"orig": {
|
|
"t": "i32",
|
|
"v": 2
|
|
},
|
|
"ours": {
|
|
"t": "i32",
|
|
"v": 0
|
|
},
|
|
"path": "side.node[142].after.v.state",
|
|
"why": "exact"
|
|
},
|
|
{
|
|
"orig": {
|
|
"t": "i32",
|
|
"v": 4
|
|
},
|
|
"ours": {
|
|
"t": "i32",
|
|
"v": -1
|
|
},
|
|
"path": "side.node[142].after.v.turn_available",
|
|
"why": "exact"
|
|
},
|
|
{
|
|
"orig": {
|
|
"t": "i32",
|
|
"v": 22
|
|
},
|
|
"ours": {
|
|
"t": "i32",
|
|
"v": -1
|
|
},
|
|
"path": "side.node[144].after.v.order",
|
|
"why": "exact"
|
|
},
|
|
{
|
|
"orig": {
|
|
"t": "i32",
|
|
"v": 4
|
|
},
|
|
"ours": {
|
|
"t": "i32",
|
|
"v": -1
|
|
},
|
|
"path": "side.node[144].after.v.turn_researched",
|
|
"why": "exact"
|
|
},
|
|
{
|
|
"orig": {
|
|
"t": "i32",
|
|
"v": 484
|
|
},
|
|
"ours": {
|
|
"t": "i32",
|
|
"v": 440
|
|
},
|
|
"path": "side.observed_techs.after.v.bytes",
|
|
"why": "exact"
|
|
}
|
|
],
|
|
"file": "v-b3-t1-5.jsonl",
|
|
"line": 5
|
|
},
|
|
{
|
|
"call_id": 9,
|
|
"diff": [
|
|
{
|
|
"orig": {
|
|
"t": "i32",
|
|
"v": 12
|
|
},
|
|
"ours": {
|
|
"t": "i32",
|
|
"v": 11
|
|
},
|
|
"path": "side.events.after.v.next_id",
|
|
"why": "exact"
|
|
},
|
|
{
|
|
"orig": {
|
|
"t": "i32",
|
|
"v": 8000
|
|
},
|
|
"ours": {
|
|
"t": "i32",
|
|
"v": 2147483647
|
|
},
|
|
"path": "side.node[133].after.v.cost_rp",
|
|
"why": "exact"
|
|
},
|
|
{
|
|
"orig": {
|
|
"t": "i32",
|
|
"v": 2
|
|
},
|
|
"ours": {
|
|
"t": "i32",
|
|
"v": 0
|
|
},
|
|
"path": "side.node[133].after.v.state",
|
|
"why": "exact"
|
|
},
|
|
{
|
|
"orig": {
|
|
"t": "i32",
|
|
"v": 6
|
|
},
|
|
"ours": {
|
|
"t": "i32",
|
|
"v": -1
|
|
},
|
|
"path": "side.node[133].after.v.turn_available",
|
|
"why": "exact"
|
|
},
|
|
{
|
|
"orig": {
|
|
"t": "i32",
|
|
"v": 23
|
|
},
|
|
"ours": {
|
|
"t": "i32",
|
|
"v": -1
|
|
},
|
|
"path": "side.node[142].after.v.order",
|
|
"why": "exact"
|
|
},
|
|
{
|
|
"orig": {
|
|
"t": "i32",
|
|
"v": 6
|
|
},
|
|
"ours": {
|
|
"t": "i32",
|
|
"v": -1
|
|
},
|
|
"path": "side.node[142].after.v.turn_researched",
|
|
"why": "exact"
|
|
},
|
|
{
|
|
"orig": {
|
|
"t": "i32",
|
|
"v": 528
|
|
},
|
|
"ours": {
|
|
"t": "i32",
|
|
"v": 484
|
|
},
|
|
"path": "side.observed_techs.after.v.bytes",
|
|
"why": "exact"
|
|
},
|
|
{
|
|
"orig": {
|
|
"t": "i32",
|
|
"v": 374
|
|
},
|
|
"ours": {
|
|
"t": "i32",
|
|
"v": 375
|
|
},
|
|
"path": "side.rng.after.v.left",
|
|
"why": "exact"
|
|
},
|
|
{
|
|
"orig": {
|
|
"t": "i64",
|
|
"v": 250
|
|
},
|
|
"ours": {
|
|
"t": "i64",
|
|
"v": 249
|
|
},
|
|
"path": "side.rng.after.v.next_index",
|
|
"why": "exact"
|
|
}
|
|
],
|
|
"file": "v-b3-t1-5.jsonl",
|
|
"line": 11
|
|
}
|
|
],
|
|
"diverged": 2,
|
|
"diverged_call_ids": [
|
|
3,
|
|
9
|
|
],
|
|
"errors": 0,
|
|
"modes": {
|
|
"compare": 15
|
|
}
|
|
}
|
|
},
|
|
"inputs": [
|
|
"/tmp/claude-1000/-home-alex/ec8e34f8-af37-4ef2-a309-ed6a15293097/scratchpad/out/v-b3-t1-5.jsonl"
|
|
],
|
|
"invalid": [],
|
|
"kind": "report",
|
|
"meta": [
|
|
{
|
|
"build": "eventlive-dd38117-20260908T0916Z",
|
|
"exe_sha256": "970b7de729956a53094c7eb98aba4270aee98e2fed5daf0d39e290013c90c841",
|
|
"format": 1,
|
|
"hooks": {
|
|
"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": "",
|
|
"risk": "medium",
|
|
"what": "the difficulty-mods row from StrategyServer::GetDifficultyMods",
|
|
"why": "not reachable from a ServerPlayer, so the two relevant entries are fitted constants measured from the B1 trace rather than snapshotted inputs"
|
|
},
|
|
{
|
|
"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 exactly one word from the strategic generator unconditionally; 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::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::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::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::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": "medium",
|
|
"what": "posts EVENT_TECHS_UNLOCKED once after the per-node loop, for the nodes SetResearched made available this turn",
|
|
"why": "the set comes from the child-unlock cascade, which ours does not run; the pass driver takes the unlock list as an input and is given `no list` rather than an empty one, so a missing input cannot look like a modelled negative. Expect region:events to under-count next_id by exactly 1 on every call that completes a tech"
|
|
},
|
|
{
|
|
"mitigation": "region:observed_techs",
|
|
"risk": "high",
|
|
"what": "appends to the owner's vector<ObservedTech> (ServerPlayer+0x274) on every tech completion",
|
|
"why": "serialized ServerPlayer state that no coverage note in B2 or B3 mentioned until lane R's guard caught it. The element is now fully pinned (sizeof 44, {u16 turn_first, u16 turn_last, bool detected, string tech_name, int with}) and the append de-duplicates by tech name, but ours still does not append; the region reports the byte span, which must grow by exactly 44 per completion"
|
|
},
|
|
{
|
|
"mitigation": "guard:player, guard:tree_header",
|
|
"risk": "high",
|
|
"what": "TechTree::SetResearched on completion: the turn/order stamps, the child unlock cascade, the recursive research of zero-cost children, and the owner's OnTechResearched callback",
|
|
"why": "its own milestone (B2); the callback writes live player state that compare mode must not touch, and it consumes one extra RNG word"
|
|
},
|
|
{
|
|
"mitigation": "guard:tree_header",
|
|
"risk": "medium",
|
|
"what": "bumps the tree's completion-order counter (TechTree+0x20)",
|
|
"why": "part of SetResearched; the per-node `order` word is compared but the counter it comes from was not a region"
|
|
},
|
|
{
|
|
"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-08T09:20:00Z"
|
|
}
|
|
],
|
|
"totals": {
|
|
"calls": 15,
|
|
"compared": 15,
|
|
"coverage_contradicted": 0,
|
|
"coverage_unstated": 0,
|
|
"diverged": 2,
|
|
"guarded_calls": 15,
|
|
"invalid_records": 0,
|
|
"undeclared_calls": 2,
|
|
"undeclared_writes": 10
|
|
},
|
|
"warnings": []
|
|
}
|