board+rules: lane J combat resolver; inlined draws invisible to call-graph sweeps; Ghidra sizes untrustworthy

This commit is contained in:
alex 2026-09-08 09:38:55 -04:00
parent 8dda49cfae
commit 1d7c6ef63e
2 changed files with 31 additions and 0 deletions

View file

@ -166,3 +166,8 @@ Status flow: `backlog → in-progress → mapped → verified` (or `blocked`).
| TurnCommands: what stays a hypothesis | verify | backlog | — | 0% | 2026-09-08 | Lane Q labelled honestly: 22 of the 27 lists are scalar sequences read off the helpers with NO SAVE EXERCISING THEM. Nested bodies not otherwise modelled (ShipDesignDef, FleetLayout, WeaponGroups, DefenceLayout, RaidTargets) are carried as opaque Nodes rather than guessed. Three prologue gates never seen set, one a whole CivilianRatios frame. List 14 is OBSERVED BUT NOT UNDERSTOOD: issuing one fleet move also queues {sameFleetId, 0, true} there. No version word exists - the version is in the CDT id and select() matches the exact `.TurnCommands_v5` suffix, so a _v6 falls back to the carried Node (unit-tested) |
| save_reader deliberately NOT mirrored (rule 8) | verify | verified | high | 100% | 2026-09-08 | Lane Q declined to mirror the CD shape into the Python oracle and wrote the reasoning down: it reads CD generically for the AIAgent bodies too; state_checksum's digest tree is built from the GENERIC tree so typing CD adds no named leaf to any diff; nothing consumes the typed dict; and RULE 8 POINTS STRAIGHT AT IT - a layout mirrored into both readers from ONE reading is not two checks. Instead test_save.cpp now asserts on every real save that each block is consumed with NOTHING LEFT OVER (extra.empty()) - the item-granular statement a wrong list count or element width breaks first |
| conformance check honest about what it cannot check | engine | verified | high | 100% | 2026-09-08 | Lane Q: the generic check<T>() could NOT be used, and that is the finding not an obstacle - every tag in this block is ".", so the LCS degenerates to a strict positional compare where any primitive disagreement is fatal, AND the table's tail describes ELEMENTS where the wire has COUNTS. The dedicated check states only what is checkable: prologue 17/17 ITEM FOR ITEM (SchemaProbe takes every branch, so this is real evidence that the conditional structure read from the instruction stream is the one the recovery flattened) and tail 27 lists vs 27 table items. Tail reported as WIRE-ONLY, never claimed as matched. 86 shapes/838 items -> 87/856, still 0 MISMATCH |
| INLINED RNG DRAWS ARE INVISIBLE TO CALL-GRAPH SWEEPS | verify | verified | high | 100% | 2026-09-08 | LANE J'S HEADLINE, and it invalidates a class of prior analysis. The combat resolver's NextFloat is INLINED, so its only call-graph edge is `caller -> RNG_Twist`, which reads as a bare Twist and is NOT one - it is the lazy twist INSIDE NextFloat. That is why lane K reported "no NextFloat in that subtree". An image-wide scan for the MT TEMPERING IMMEDIATES AT INSTRUCTION BOUNDARIES finds **14 game functions with inlined draws no RNG sweep can see**, and TWO OF THEM - FUN_004f7670 (84 bytes) and FUN_007aa240 - ARE IN ProcessTurn'S CLOSURE AT DEPTH 4. Any RNG accounting built from the call graph alone is a LOWER BOUND. Now rule 16 |
| CORRECTION: Ghidra's function size is not the function | meta | verified | high | 100% | 2026-09-08 | Ghidra reports 7,499 bytes for the combat resolver; the real body is 0x007d5af0..0x007d78c8 = 7,641 and Ghidra's end lands MID-INSTRUCTION. Lane J first read a callee as straight-line because it dumped at Ghidra's size and the outer BACK-EDGE FELL OUTSIDE - it is a loop over combatants, which changes the draw count of the very site it was measuring. A delegated sweep made the same mistake independently. Same defect lane X hit from the other side (clipping at fva+sizeInBytes lost 11% of functions; sweeping to the next function start took coverage 89% -> 100%). Now rule 17: never trust a function's end, disassemble to the next function start |
| combat resolver mapped | control-flow | verified | high | 85% | 2026-09-08 | Lane J read all 7,641 bytes. THE RESOLVER DRAWS NOTHING ITSELF - exactly three sites in its subtree, each behind a function whose callerCount is 1 (the resolver), so nothing else in the image can trigger them: R1 NextInt @0x007bb69b (node cannon, once per battle, gated on something having been flung AND a non-empty candidate list) mean 4/3 words; R2 an INLINED NextFloat @0x007a84bd, one per back-engineering candidate per combatant with a non-zero salvage slot, exactly 1 each; R3 NextInt @0x00852ec7, one per successful R2 roll if the project list is non-empty, mean 2^ceil(log2 n)/n. Structure: only EIGHT non-stack stores in the whole function and EXACTLY ONE indirect call (in a _CxxThrowException path) - it composes and posts events and delegates every mutation to its 70 callees. Also: the subtree DOES write the turn-results accumulator at S->+0x2f4[PlyrIdx*0x11c]+0x90 on the surrender arm (a DIFFERENT member from the +0x24 lane K found, and that writer has SEVEN callers); 23 EVENT_* keys in the closure vs lane K's 7, five new, INCLUDING PLAGUE BEING DECIDED INSIDE COMBAT; sizeof(Game::TacReport)=0x94 enumerated twice |
| resolver formula is UNTESTED (stated) | verify | backlog | — | 0% | 2026-09-08 | Lane J's honest limit: every encounter in lane Z's workload had res->+0x4 set, so ApplyEncounterResult was a no-op and THE RESOLVER HAS NEVER EXECUTED UNDER AN INSTRUMENT. The formula is untested. combat-resolver.md 10.3 specifies the workload; the first cheap PREDICTION is that a plain fleet battle with no node cannon and no salvage should cost the SAME 18-20 words as a peaceful turn, because the resolver has no unconditional draw |
| lane J errors caught before publishing | meta | verified | high | 100% | 2026-09-08 | Two, both corrected pre-publication and both worth keeping: (1) read a callee as straight-line from a truncated Ghidra range, hiding the outer loop that sets R2's count - now rule 17; (2) drafted a 0x11c site as a false positive after converting its address BY HAND, wrongly - re-disassembling from a known boundary showed a real SETurnResults write. THE SCANNER WAS RIGHT AND THE READER WAS NOT |

View file

@ -160,3 +160,29 @@ player, and **13 of 22 slots zero on every call**, including 5 of 6 declared inp
The most useful sections of the best reports here have been the honest lists of what was *not*
covered.
## 16. Inlined draws are invisible to call-graph sweeps
The combat resolver's `NextFloat` is **inlined**, so the only call-graph edge it leaves is
`caller -> RNG_Twist`, which reads as a bare Twist and is not one — it is the lazy twist *inside*
`NextFloat`. Lane K's "no NextFloat in that subtree" was wrong for exactly this reason.
An image-wide scan for the MT tempering immediates at instruction boundaries finds **14 game
functions with inlined draws that no RNG call-graph sweep can see**, two of them inside
`ProcessTurn`'s closure at depth 4.
Scan for the **constants at instruction boundaries**, not for calls. Any RNG accounting built from
the call graph alone is a lower bound.
## 17. A truncated range makes a loop look like a sequence
Lane J read a function as straight-line because it dumped the range at **Ghidra's reported size**,
and the outer back-edge fell just outside it. A delegated sweep made the same mistake independently.
That changed the draw count of the very site it was there to measure.
This is the same defect lane X hit from the other side: clipping sweeps at `fva + sizeInBytes` lost
**11%** of functions to mid-instruction truncation, and sweeping to the *next function start* took
coverage 89% -> 100%. Ghidra's size is also simply wrong sometimes — it gave 7,499 bytes for a body
that is 7,641 and ends mid-instruction.
**Never trust a function's end. Disassemble to the next function start and find the real boundary.**