sots-engine/docs/B3.md

15 KiB
Raw Blame History

B3 — TechTree::ProcessResearch old-vs-new, with the RNG state as a declared region

Status (2026-09-08): code complete, cross-built and staged; every VM step still owed. VM140 is held by another lane for the whole of this milestone, so nothing was deployed, the game was not stopped or relaunched, and C:\SOTS\binkw32.dll / C:\SOTS\shimdist were not touched. Everything below is offline work plus what the binary says; the run list is at the end. The build lives in its own tree (/srv/re-lab/build/sots-engine-b3) and its own dist (/srv/re-lab/shim/dist-b3), not the shared ones.

The point of the target: one call exercises the MT19937, the completion-odds formula and the Zuul double roll at once, so a match validates all three together — and its RNG consumption is observable, which makes the draw count checkable rather than merely plausible.

What was hooked

hook name (record hook) RVA prototype
Game::TechTree::ProcessResearch 0x001876c0 void (TechTree*, Mars::RNG*, vector<{TechDef*,int}>*, int*)

__thiscall, [verified], so it goes through Hook<> with CallConv::Thiscall (M2's addition). Source: src/shim/hooks/research.{h,cpp}, installed from src/shim/main.cpp after the M1/M2 hooks. There is exactly one caller (inside ServerPlayer::ProcessTurn), which fires once per player per turn.

The second parameter was a ? in the address contract; it is the Mars::RNG object. The call site loads it from StrategyServer+0x16c and the function re-bases it with +4 before every draw. That is now in ghidra/addresses.json along with the tree/node offsets, the generator layout and TechTree::Cost, and regenerated into sots_addresses.h.

Region model

Three kinds of region, all snapshotted before the original runs:

region size describer
rng 0x9cc {vptr:ptr, mt:bytes(2496 → sha256+head), left:i32, next_index:i64}
overbudget 4 {v:i32}
node[i] 0x34 each, one per non-null slot {def, tech_id, kids_*, unk10, state, cost_rp, progress, turn_available, turn_researched, order, flag, unk30}

next is a heap address, so it is reported as its index into mt — which is what it means, and what survives being written by a reimplementation. left alone already pins the stream position (next == &mt[624 - left] always), so the index is a cross-check, not the evidence.

Args carry the evidence a golden log needs to replay offline: tree, owner, species, node_count, rng, rng_left_in, the alloc list as {tech_id, points}, overbudget_in, and fpu_cw — the x87 control word in force for the call (see "Float mapping" below).

Declaring every node, not just the ones we expect to change, is deliberate: it is what proves ours neither misses a write nor makes an extra one. The cost is size — a compare record is roughly 200 nodes × three snapshots, so the b3 configs turn every other hook off.

The comparison design

ProcessResearch consumes RNG, so comparing two implementations that draw from different streams would diverge for a reason that has nothing to do with the formulas. Instead:

  1. the generator object is a declared region, so its mt[624] + left are snapshotted before the original runs, alongside the tech-tree state;
  2. the original runs and advances the real generator;
  3. ours runs on the scratch copies, and seeds a mars::rng::MT19937 with load_state() from the pre-call snapshot — so both implementations read the identical stream;
  4. ours writes its final generator state back into the scratch copy, so the diff compares the post-call RNG state as well as the outputs.

If the post-states match, we consumed the same words in the same order. That is the check with teeth: getting the odds right but drawing twice (or not drawing at all) moves left and the hash. tests/mars_stream/test_rng.cpp pins the property offline, including the negative case (one draw too few leaves a different state).

next is rebuilt by ours against the live generator address so the describer's index arithmetic reads the same on both sides; ours never dereferences it.

What ours covers, and what it deliberately does not

ours is sots::sim::ProcessResearchTurn (new, src/game/sim/research.{h,cpp}) plus a thin shim adapter. It reproduces exactly the words the hooked function writes itself:

  • the allocation loop — spend window, spend, *overbudget, progress, the roll, the completion decision, the over-budget flag, the "completed early" flag, state = 4 on completion;
  • the decay sweep over every available node.

It does not reproduce TechTree::SetResearched, which the original calls on completion: the turn/order stamps, the child-unlock cascade and the owner's tech-effect callback. That is its own milestone, and the callback writes live player state that compare mode must never touch. So:

A turn on which a tech completes is expected to diverge, in the completing node's turn_researched / order and in the child nodes SetResearched unlocks — and in nothing else, including the RNG (the cascade makes no draw; verified statically, though the owner callback itself was not audited). A turn on which nothing completes — the overwhelmingly common case — must match everywhere.

The effective cost of a node comes from the game's own TechTree::Cost (read-only: it only reads costRP, the def and the owner, and calls the read-only cost-multiplier helper). The cost multiplier is a separate, medium-confidence formula and not what this milestone measures; this is the same delegation M2 makes to LoadWeapon. ours receives the live tree pointer and treats it as read-only — every node it writes is a scratch copy.

ours also works in replace mode, where no regions/rebind ran: it then reads the tree's own node vector and the live generator. The per-call statics carry a flag that is cleared at the end of every ours, so a replace call can never inherit a stale compare mapping.

Float mapping — the headline finding

A draw is y / (2^32 − 1), not y × 2^-32. The multiplier in the image is the double 0x3df0000000001000, which is exactly 1/4294967295; the constant next to it is the +2^32 unsigned fix-up applied after a sign-extending integer load. So:

  • MT19937::kUnitScale is now 1.0 / 4294967295.0;
  • the range is closed: y == 0xffffffff maps to exactly 1.0, not to just below it;
  • the value is left in st(0) and the caller narrows it — every consumer in the strategic sim stores it to a 4-byte float first, which is what next_float() models.

Honest caveat, because it decides how to read a passing compare: the old and new divisors differ by 2^-32 relative, far below a float32 ulp. Measured over 10^6 draws they give a different float 0.78 % of the time, and they flip an actual research completion decision (roll vs an odds of 1/3) 0 times in 10^6 — the expected rate is about one in two billion. So the compare cannot prove the divisor; the disassembly and the constant's bit pattern do, and the compare's job is the rest.

The one thing the binary cannot settle is the x87 precision-control field at run time. At the MSVC default (53-bit, cw = 0x027f) the multiply rounds to double and the caller's store rounds again — that is what next_float() does. A Direct3D 9 device created without FPU_PRESERVE leaves 24-bit precision, in which the fix-up and the multiply each round to 24 bits; MT19937::float_from_pc24() models that, and the two differ for 0.094 % of words — last-bit only. The hook records fpu_cw on every call, so the first trace settles it; if it comes back 24-bit the change is to route next_float through float_from_pc24 and to compute the odds the same way, and nothing else in the milestone moves.

float10 in the decompile is just the i386 float return ABI and was not chased.

Bugs found and fixed in our implementation

All five are read off the instruction sequence, not tuned to make anything match.

  1. The unit divisor (above): 2^-32 → 1/(2^32 − 1).
  2. NextInt is inclusive. The rejection mask is built from n itself, not n − 1, and the loop re-draws while the masked word is greater than n — so the result is uniform on [0, n], one value wider than we had. The bound is also passed by pointer, which is why the prototype had stayed [unverified]. next_int(n) is now next_int_inclusive(n) and IRandom::NextInt is IRandom::NextIntInclusive, so every call site had to be re-read.
  3. spend has no floor at zero. The original is a plain signed min(points, hi − progress); ours clamped it at 0, which would have hidden a negative spend (and understated *overbudget) whenever progress was already past the 150 % ceiling.
  4. odds is a float32. The original computes (progress − lo) / hi on the x87 and stores it to a 4-byte slot before the comparison; ours kept it in double. Likewise the roll, the Zuul minimum, and the progress / cost ratio used for the "completed early" flag.
  5. Two constants are widened float literals, not decimals. The decay fraction is (double)0.05f = 0.05000000074505806 and the early-completion threshold is (double)0.8f = 0.800000011920929. Both sit on a truncation/compare boundary.

Two smaller ones in the same pass: the decay guard is progress != 0, not progress > 0; and a node whose cost is still INT_MAX is not special-cased by the original — it feeds that straight into the 5 % multiply, which wipes any progress out. The 50 %/150 % bounds are a 32-bit multiply that wraps near INT_MAX rather than a widening one.

Host tests

ctest 26/26. New coverage:

  • mars_rng_unit — the standard MT19937 vectors (seed 5489, and the 10000th output) were already there; added the unit mapping word by word (0 → 0, 0xffffffff → 1.0, the high-bit fix-up path), the measured rarity of the divisor difference, the PC24 variant's bound, cover_mask, the inclusive integer bound (including that n itself is reachable and that n == 0 still consumes a word), and the compare design end to end: snapshot → original draws → ours seeded from the snapshot reproduces the values and the post-state, with a negative case that one draw too few does not.
  • game_sim_research — 121 checks. Added the spend window (truncation, the floor/ceiling clamps, the INT_MAX edge), that the odds are float32, that spend goes negative rather than clamping, the early-completion boundary at exactly 80 % versus one point below, and ProcessResearchTurn (order of the passes, the funded node decaying too, hidden slots never touched, an out-of-range entry consuming no draw, overbudget accumulating).

Cross-build: b3-81218c7-dirty-20260908T0311Z, exports 66 names identical to binkw32.dll, staged in /srv/re-lab/shim/dist-b3. tools/clean_room_check.sh OK.

Gotchas

  1. Two different this pointers for one object. Twist, NextFloat and NextInt take &mt — the object plus 4 — so their this+0x9c0/+0x9c4 are next/left, while the object's own layout is {vftable @+0, mt[624] @+4, next @+0x9c4, left @+0x9c8} = 0x9cc bytes. The address contract used to state both readings as if they were one; it now says which is which. Getting this wrong shifts every generator field by a word.
  2. The save blob is 0x9c4 bytes = mt[624] + left, i.e. it skips next, which sits between them in the object. left is sufficient because next == &mt[624 - left] and the original's Read recomputes it.
  3. Region::name is a const char* held for the whole call, so the per-node name strings are resized once up front and never grown — a reallocation would dangle every name already pushed.
  4. Per-call state is kept in statics between regions() → rebind() → ours() (M1's concession). Safe here because the turn pass is single-threaded and ProcessResearch has one caller and never nests; do not copy the pattern to a re-entrant hook.
  5. A compare record is large (one region per tech node). The staged configs (shim.cfg.b3{trace,compare,replace}, in the dist) switch every other hook off for that reason; trace.inline_max stays at 256 so the 2496-byte state block is hashed rather than inlined.
  6. TechDef's first word is the tech id and it indexes TechTree+0x10; the allocation vector's stride is 8 ({TechDef*, int}). Both are in the address contract now rather than inferred.

What remains (needs the VM)

The lane holding VM140 must be finished first; then, in this order:

  1. Deploy /srv/re-lab/shim/dist-b3 (build b3-81218c7-dirty-20260908T0311Z): scp it to C:\SOTS\shimdist-b3\ and run deploy.ps1 -Dist C:\SOTS\shimdist-b3 — a separate staging directory from the shared C:\SOTS\shimdist, so the other lane's dist is not overwritten.
  2. Copy shim.cfg.b3trace over C:\SOTS\shim.cfg, relaunch, load ref-turn2.sav, press End Turn once, and pull C:\SOTS\shim.trace.jsonl → b3-trace-golden.jsonl. tracecmp.py must exit 0 with 0 invalid records. Expect one record per player that both has a research target and did not suffer a lab accident that turn — the caller gates the call on if (ResT && !RollResearchAccident()) — so a turn with no records at all is a setup problem, not a pass. Read off this trace before going further: fpu_cw (expect 0x027f; 0x007f/0x003f means 24-bit precision and the float_from_pc24 route), and, per record, rng_left_in minus the after-state's left — 1 for a non-Zuul player who rolled, 2 for a Zuul, 0 when the spend was zero or the tech was already at the 150 % ceiling.
  3. Copy shim.cfg.b3compare, relaunch, load ref-turn2.sav, End Turn → b3-compare.jsonl. Expect 0 divergences on every record where no tech completed. On a record whose ours shows a completion, the only permitted diffs are turn_researched / order on that node and the child nodes SetResearched unlocked; the rng region must still match. Any other diff is a real finding — report it, do not tune the formula. To get more turns, keep pressing End Turn; each turn adds one record per player.
  4. If the compare is clean: copy shim.cfg.b3replace, relaunch, load ref-turn2.sav, End Turn, and check the determinism oracle — (Autosave).sav = 978041ac…, (Autosave EndTurn).sav = bb4fd9ac…. This is the strongest single result available: replace mode means our research pass fed the game, and the save hash means the whole turn still landed byte for byte.
  5. Restore the previous shim.cfg (hooks=trace) and leave the game at the main menu, as M1/M2 left it.

Not done, and worth saying: the compare above cannot distinguish the two unit divisors (see "Float mapping"), and it will not exercise the Zuul path at all unless a Zuul player is in the reference save — check species in the trace args, and if none is 5, run one more compare from a Zuul save before calling the double roll verified by behaviour rather than by disassembly.