363 lines
22 KiB
Markdown
363 lines
22 KiB
Markdown
# B2 — `ServerPlayer::OnTechResearched` old-vs-new, with the player's fields as named regions
|
||
|
||
**Status (2026-09-08): code complete, cross-built and staged; every VM step still owed.**
|
||
VM140 was held by another lane for the whole of this milestone, so nothing was deployed,
|
||
the game was not stopped or relaunched, and `C:\SOTS` was not touched. The build lives in
|
||
its own tree (`/srv/re-lab/build/sots-engine-b2`) and its own dist
|
||
(`/srv/re-lab/shim/dist-b2`), not the shared ones. Everything below is offline work plus
|
||
what the *binary* says; the run list is at the end.
|
||
|
||
The point of the target: it is the single place where 36 hard-coded strategic effects, the
|
||
per-species xenotech flag words, the two design-option masks and the node-bore parameters
|
||
are all written. One call validates the whole `src/game/effects` layer at once.
|
||
|
||
## What was hooked
|
||
|
||
| hook name (record `hook`) | RVA | prototype |
|
||
|---|---|---|
|
||
| `Game::ServerPlayer::OnTechResearched` | 0x00491790 | `void (ServerPlayer*, TechDef* def, bool silent)` |
|
||
|
||
`__thiscall`, `[verified]`, vft slot 4, so it goes through `Hook<>` with
|
||
`CallConv::Thiscall`. Source: `src/shim/hooks/tech_effects.{h,cpp}` (the descriptor and the
|
||
delegation into the game's tech tree) over `src/shim/hooks/tech_effect_fields.{h,cpp}` (the
|
||
byte-level field adapter, host-tested — the split B1 made for `budget_inputs`). Installed
|
||
from `src/shim/main.cpp` after the B3 hook.
|
||
|
||
`TechTree::SetResearched` is the only caller. It fires **once per tech completion**, which
|
||
on a typical turn is zero times — see "Coverage" below, because that is the hard part of
|
||
this milestone.
|
||
|
||
### Region model
|
||
|
||
Fourteen regions, one per field group the callback writes, each with a struct describer, so
|
||
a divergence reads `side.modifiers.after.v.out_mod` rather than a byte offset in a blob:
|
||
|
||
| region | offset | what the describer names |
|
||
|---|---|---|
|
||
| `suit` | +0xb4, 8 B | `suit_tol`, `max_overharvest` |
|
||
| `res_mod` | +0xc0, 4 B | `res_mod` |
|
||
| `abilities` | +0xfc, 12 B | `reb_ai`, `ai_benefit`, `trade_allowed`, `commerce_raiding`, `view_intel`, `grav_synth`, `advanced_sensors`, `arcology`, + 4 unmodelled bytes |
|
||
| `modifiers` | +0x108, 0x54 B | `pddm`, `con_mod[3]`, `sav_mod[3]`, `out_mod`, `pop_mod`, `terra_mod`, `asteroid_mining`, `min_rate`, `per_gate_traffic`, `cast_range/efficiency/threshold`, + 5 unmodelled words |
|
||
| `design_masks` | +0x190, 8 B | `design_options_a`, `design_options_b` |
|
||
| `translation` | +0x1a4, 4 B | `translation_known` |
|
||
| `vaccines` | +0x288, 12 B | `has_vaccine`, `has_immunity`, `node_track_mask` |
|
||
| `research_target` | +0x294, 4 B | `research_target`, `research_target_set` |
|
||
| `node_bore_ptr` | +0x308, 4 B | the block pointer, as an opaque `ptr` |
|
||
| `inc_mod` | +0x30c, 4 B | `inc_mod` |
|
||
| `capture_designs` | +0x330, 4 B | `capture_designs` |
|
||
| `species_flags` | +0x348, 0x20 B | `species_flags[7]`, `count` |
|
||
| `roll` | +0x3b4, 8 B | `research_roll_pending`, `ai_rebellion` |
|
||
| `node_bore` | `*(+0x308)`, 12 B | `node_bore_params[3]` — declared **only when the block already exists** |
|
||
|
||
The unmodelled words are emitted as raw `u32` on purpose: if the original writes one of
|
||
them, that is a real finding and shows up as a divergence instead of going unseen. Pointer
|
||
words are emitted as `ptr` (ignored by the default policy) because they differ run to run.
|
||
`tests/shim_techfx` asserts that no two regions overlap and that each stays inside the
|
||
declared span.
|
||
|
||
### Comparison design
|
||
|
||
The regions do not cover the whole object, so there is no "scratch `ServerPlayer`" to hand
|
||
`ours`. Instead `rebind` passes `self` through unchanged and records the scratch buffer of
|
||
each region; `ours` then reads and writes **every field the callback can touch** through
|
||
those buffers, and reads only never-written fields (species, the tech-tree pointer) off the
|
||
live object, where "live" and "before" are the same value. `techfx::Views` is that
|
||
indirection, and it is what makes replace mode (no regions at all) the same code path.
|
||
|
||
Two things `ours` delegates to the game, in the same spirit as B3 delegating
|
||
`TechTree::Cost`:
|
||
|
||
* **the researched set** — 196 calls to `TechTree::HasResearched`, a verified, read-only
|
||
three-compare function. In compare mode the tree is the live, post-original one, which
|
||
is exactly the state the original's own tail saw (the completing node is already state 4).
|
||
* **the TechId of the definition** — 196 calls to `MasterTechTree::IsTech`, for the reason
|
||
in "Findings" below.
|
||
|
||
## What `ours` deliberately does not do
|
||
|
||
Compare mode must not touch live game state or consume randomness, so `ours` reproduces the
|
||
player-field writes and nothing else. Not reproduced, and each reported in `shim.log`
|
||
instead (one `techfx: ours ...` line per completion):
|
||
|
||
* the three completion events (`EVENT_RESEARCH_COMPLETE` / `_UNDERBUDGET` / `_TEMPERANCE`);
|
||
* the pending plague-cure roll — it draws from the strategic generator, so `ours` clears
|
||
the two words it guards and logs whether it would have fired;
|
||
* the writes to *other* objects: every owned system's AI flag (`CCC_AIVrus` / `CCC_AISlv`),
|
||
the arcology civilian-cap re-evaluation, the addiction cure behind the temperance sweep,
|
||
and the plague clear across systems and ships;
|
||
* `TechTree::SetResearched` for the Zuul boarding-pod grant (it would mutate the tree).
|
||
|
||
None of those touch a declared region, so **a compare stays clean through them** — but it
|
||
also does not check them. That is the honest boundary of this milestone: the compare proves
|
||
the player's fields, the log line proves what our layer decided about the rest.
|
||
|
||
One consequence worth stating: the Zuul grant makes the original call itself recursively.
|
||
The inner call rewrites the same tail fields with `IND_BrdPod` researched — and `IND_BrdPod`
|
||
is in neither mask table, has no chain branch and no xenotech bit, so the inner call's
|
||
writes equal the outer's. The compare is unaffected either way, since `ours` reads the
|
||
post-original tree.
|
||
|
||
## Findings — what the binary says that the notes did not
|
||
|
||
Read off the instruction stream first; the compare is confirmation, not discovery (B3's
|
||
lesson). Ten corrections, all of which changed code.
|
||
|
||
### 1. Every modifier is float32, and every constant is a widened float literal
|
||
|
||
The arithmetic is `fld dword [field]; fadd qword [k]; fstp dword [field]`: a float32 field,
|
||
combined with a *double* constant, stored back through float32. And every one of those
|
||
constants is a float32 value widened to double, not the decimal it looks like:
|
||
|
||
| written as | the double actually in the image | equals |
|
||
|---|---|---|
|
||
| 0.05 | 0x3FA99999A0000000 | `(double)0.05f` |
|
||
| 0.06 | 0x3FAEB851E0000000 | `(double)0.06f` |
|
||
| 0.10 | 0x3FB99999A0000000 | `(double)0.1f` |
|
||
| 0.15 | 0x3FC3333340000000 | `(double)0.15f` |
|
||
| 0.20 | 0x3FC99999A0000000 | `(double)0.2f` |
|
||
| 0.30 | 0x3FD3333340000000 | `(double)0.3f` |
|
||
| 0.45 | 0x3FDCCCCCC0000000 | `(double)0.45f` |
|
||
| 0.60 | 0x3FE3333340000000 | `(double)0.6f` |
|
||
| 0.90 | 0x3FECCCCCC0000000 | `(double)0.9f` |
|
||
| 0.35 | 0x3FD6666660000000 | `(double)0.35f` |
|
||
| 0.25, 0.5, 0.75, 1.0, 1.5 | exact | unaffected |
|
||
|
||
Ours held the state in `double` and used exact decimals. Rounding once at the end instead
|
||
of at every step drifts: `PlayerEconomyState` now holds `float`s, the apply layer rounds
|
||
through float32 at each step (`AddF` / `MulF`), and the table's literals carry an `f`
|
||
suffix so they widen to the same double bit pattern. `tests/game_effects` pins the six-tech
|
||
terraform chain against the stepwise result, and `tests/shim_techfx` pins the exact bit
|
||
patterns at the exact offsets.
|
||
|
||
Caveat, the same one B3 records: at 53-bit x87 precision control (the MSVC default) the
|
||
middle step rounds to double and the store rounds again, which is exactly what
|
||
`(float)((double)a + k)` gives on both the i386 target and the host. At 24-bit precision it
|
||
would not be. The hook records `fpu_cw` on every call, so the first trace settles it.
|
||
|
||
### 2. The AI-benefit bonus values are 0.5 — the table is dumped
|
||
|
||
`g_AITechValueTable` (6 × 12 bytes, `{int techId, float rebellionOdds, float bonus}`) was
|
||
"values not dumped" in the notes. It reads:
|
||
|
||
| tech | rebellion odds | bonus |
|
||
|---|---|---|
|
||
| CCC_AI, CCC_AIAdmin, CCC_AIFac | 0.1f | **0.5f** each |
|
||
| CCC_AIFRCON (10083) | 0.2f | 0 |
|
||
| CCC_AIVrus, CCC_AISlv | 0 | 0 |
|
||
|
||
So `AiBonusValues` now defaults to 0.5/0.5/0.5, and `AiRebellionOdds(id)` carries the odds
|
||
column. `ApplyAITechBonus` multiplies the value by `AIBn ? 1.0f : -1.0f` and narrows the
|
||
product to float32 before adding, which the apply layer now does too. `CCC_AIFRCON` is in
|
||
the table for its odds only — no branch reads its bonus.
|
||
|
||
### 3. `PrGtTrf` is an integer
|
||
|
||
The gate techs do `mov ecx,[cfg]; mov edx,[player+0x148]; cmp edx,[ecx]; jl ...` — a signed
|
||
**integer** max against an integer config word, not a float compare. Ours modelled the field
|
||
and both `PERGATETRAFFIC_*` tuning keys as `double`. Both are `int` now.
|
||
|
||
### 4. The node-bore parameters live behind a pointer, and the rule is "highest researched"
|
||
|
||
`ServerPlayer+0x308` is not three inline words: it is a pointer to a separately allocated
|
||
3-word block. The updater runs on **every** completion, allocates the block on first use and
|
||
`operator delete`s it when no bore drive is researched. The selector tests `DRV_RAD`
|
||
{95,60,5}, then `DRV_REND` {65,35,4}, then `DRV_RIP` {45,15,3}, first hit wins — which
|
||
confirms "highest wins" and raises it from medium to high confidence. There is **no species
|
||
gate**: the notes call these Zuul parameters, but nothing in the code checks the species (the
|
||
techs themselves are Zuul-only by data). `PlayerEconomyState` now carries
|
||
`hasNodeBoreParams` so "absent" is a state of its own, and the selection is re-derived in
|
||
the tail rather than applied by the completing tech.
|
||
|
||
### 5. A sticky "translation known" mask at +0x1a4 that was not in the notes
|
||
|
||
`RebuildSpeciesTechFlags` has a **second pass** the catalog does not mention: for each
|
||
species except the NPC race, if the species' flag word has bit 0 (level-1 translation), it
|
||
ORs that species' bit into `ServerPlayer+0x1a4`. Only ever ORed — never cleared, so it
|
||
survives the first pass clearing a flag. Modelled as `translationKnownMask`.
|
||
|
||
### 6. Three effects are tail checks, not effects of their tech
|
||
|
||
* **capture designs** — `if (!cdp) { if (HasResearched(SpyBm) && HasResearched(SlvgTech)) cdp = 1; }`
|
||
runs on *every* completion. Ours only fired when the completing tech was one of the two,
|
||
which is the same outcome in the normal path but wrong for a save where both are already
|
||
researched and the flag is not yet set.
|
||
* **the design-option masks** — recomputed wholesale from the researched set every time.
|
||
* **the node-bore parameters** — see above.
|
||
|
||
### 7. The completion callback has no already-researched guard
|
||
|
||
`ApplyTechEffect` returned early when the tech was already researched. The callback cannot
|
||
do that: `SetResearched` marks the node state 4 *before* invoking it, so the guard would
|
||
make every real call a no-op. `ApplyTechCompletion` is the unguarded form and is what the
|
||
hook calls; `ApplyTechEffect` keeps the guard for callers driving the layer themselves.
|
||
|
||
### 8. `TechDef`'s first word is **not** the TechId
|
||
|
||
`MasterTechTree::IsTech(def, id)` maps `id - 10000` into the master `TechDef*[196]` table and
|
||
compares pointers. `TechTree::HasResearched(id)` does the same map to reach a `TechDef`, and
|
||
*then* uses that def's first word as an index into the tree's node vector. So the first word
|
||
is a node index in a larger key space, and the TechId can only be got from the identity test.
|
||
`ours` therefore scans `IsTech` over 10000..10195 (196 three-compare calls, once per
|
||
completion) rather than trusting the word — and the record carries both, so a trace shows
|
||
the two key spaces side by side. The valid range is confirmed as exactly 10000..10195, with
|
||
197 as the "none" sentinel special-cased before the subtraction.
|
||
|
||
### 9. The 196-name table is dumped, so the key space is no longer reconstructed
|
||
|
||
`g_TechIdNames` is 196 × `{const char* name, int}`. Reading it out gives every name in
|
||
position order, which turns `tech_id.h` from a partial reconstruction (89 confirmed names,
|
||
~55 inferred, ~52 `Unresolved_NNN`) into the table itself. Sanity checks all pass: index 0
|
||
is `CCC_AdvSens` (the id the chain's first `push 0x2710` tests), index 30 `IND_HrdStrct`
|
||
(0x272e), indices 38..43 the six vaccines (0x2736..0x273b), index 66 `DRV_RAD` (0x2752).
|
||
Every one of the seven prefixes previously inferred for indices 100..107 was right, and so
|
||
were all 31 names the design-option mask tables would have implied.
|
||
|
||
Two guesses were **wrong**, and one of them was a bug:
|
||
|
||
* **The proliferate block has five entries, not six.** It is 158..162 (Human, Hiver,
|
||
Tarkas, Liir, Morrigi — no Zuul, like the other 5-entry families), and 163/164 are
|
||
`CCC_NDTRKHUM` / `CCC_NDTRKZUL`. Our block ran 158..163, so
|
||
`XenoTechId(Proliferate, Morrigi)` returned 10163 — the *Human node-track tech*. Fixed.
|
||
* Index 130, the Zuul slot of the level-3 translation block, is `XNC_DOMZUUL`, not a
|
||
translation by name. The position is unchanged, so nothing keyed on it moves.
|
||
|
||
The species order inside every block is Human, Hiver, Tarkas, Liir, [Zuul,] Morrigi, and
|
||
the families that omit the Zuul are Incorporate, Addict, Temperance, Accommodate **and
|
||
Proliferate** — all of which were medium/low confidence and are now high. The node-track
|
||
techs' ids are known for the first time (10163 / 10164), which is what lets the hook set
|
||
`NPTrk`: our layer resolves them by name, and before this the name did not resolve.
|
||
|
||
The two design-option mask tables (`{int techId, uint bit}`) were dumped in the same pass
|
||
and every id in them agrees with the name table, so `kDesignOptionIdsA/B` are in the
|
||
effects module and `ComputeDesignOptionMasks` has a by-id overload — which is what the hook
|
||
needs, since the game keys on ids.
|
||
|
||
### 10. About half the techs have no TechId at all, and the tail still runs for them
|
||
|
||
`resolve_tech_id` returns "none" for any definition outside the 196-name table — which is
|
||
roughly half the shipped `.tech` files. The callback has no branch for those, but it runs
|
||
its whole tail for them all the same: the bore selection, the flag words, the sticky
|
||
translation mask, the capture-designs pair test and the temperance sweep. `EffectsOf` is
|
||
empty for them, so an early return would have been easy to write and wrong.
|
||
`RunCompletionTail` is that tail on its own, and it is what `ours` calls in that case.
|
||
|
||
### Things the notes had right
|
||
|
||
The 36-branch chain is an if/else-if over ids 10000..10031 in exactly that order with no
|
||
gaps (`push 0x2710` … `push 0x272f`, then the tail's 0x2730..0x2733); at most one branch
|
||
runs. `IND_Waldo`/`IND_ExpSys` share a block, `BIO_EnvTail` falls through into
|
||
`BIO_TerBac`'s terraform add, and `IND_OrbDry` really does leave `ConMod[0]` alone. Every
|
||
magnitude in the catalog is confirmed. The plague-cure masks are bits 0..4 for ids
|
||
10038..10042 and 0x0f for 10043 — with one subtlety: the test is "this def **or a
|
||
descendant of it**", which our exact-id `PlagueCureMask` cannot express without the tree.
|
||
|
||
## Host tests
|
||
|
||
`ctest` 28/28 on the build box.
|
||
|
||
* `game_effects` — 399 checks (was 285). Added: the float32 state and the widened-literal
|
||
identities, the multiplicative tech on top of an additive one, the integer gate-traffic
|
||
max, node-bore highest-wins plus re-derivation on an unrelated completion, the
|
||
capture-designs tail check from a save where both are already researched, the sticky
|
||
translation mask (including that a rebuild does not take a bit back), that
|
||
`ApplyTechCompletion` applies twice while `ApplyTechEffect` does not, the recovered AI
|
||
bonus and odds, and that the by-name and by-id design-option builders agree bit for bit.
|
||
* `shim_techfx` (new) — 1473 checks. A synthetic `ServerPlayer` buffer driven through the
|
||
real adapter: the region table (no overlaps, all inside the span, every region named and
|
||
described), a full read/write round trip, **all 44 catalogued effects** byte-checked at
|
||
the offsets the original writes, hand-written float32 bit patterns for the additive,
|
||
multiplicative and exactly-representable cases, the integer gate max, the absent
|
||
node-bore block, the species flag words and their count, the research-target clear, and
|
||
that the describers emit field names a diff can point at.
|
||
|
||
Cross-build: `b2-9cd997d-dirty-20260908T0359Z`, exports 66 names identical to
|
||
`binkw32.dll`, staged in `/srv/re-lab/shim/dist-b2`. `tools/clean_room_check.sh` OK.
|
||
|
||
## Coverage — say it up front
|
||
|
||
**Only a tech that actually completes fires this hook.** The reference save is turn 2 of a
|
||
28-star game with 0 starting techs, so a given End Turn may produce no records at all. The
|
||
plan, in order of what it proves:
|
||
|
||
1. **Host-side, already done.** Every catalogued effect is applied to a synthetic player and
|
||
byte-checked (`shim_techfx`, 1473 checks). This is the exhaustive coverage; it does not
|
||
need the VM and it does not depend on which techs the save happens to finish.
|
||
2. **`shim.cfg.b2scout`** (staged alongside the three) turns on B3's `ProcessResearch` hook
|
||
in trace mode as well, so one End Turn shows both the completions that fired and every
|
||
node's `progress` against its `cost`. That says how many turns are needed before a
|
||
completion, instead of guessing.
|
||
3. **Trace, then compare, driven by whatever completes.** Press End Turn repeatedly; each
|
||
completion is one record. A run that yields zero records is a coverage failure to report,
|
||
not a pass — `tracecmp.py` exiting 0 on an empty trace proves nothing.
|
||
4. If the reference save cannot be driven to a completion in a reasonable number of turns,
|
||
the fallback is a fresh game with research set high (the game-setup research slider) and
|
||
a cheap first tech, saved as its own reference. That is a new save, so its determinism
|
||
oracle would have to be recorded before it is useful for step 4 of the run list.
|
||
|
||
## Gotchas
|
||
|
||
1. **`describe_args` runs before the original**, and therefore before `ours`. Anything
|
||
`ours` decides cannot ride on the record; it goes to `shim.log` instead.
|
||
2. **The hook can nest**, because the Zuul branch calls `SetResearched` → the callback. The
|
||
nested call happens *inside the original*, not inside the hook, so the per-call statics
|
||
are only ever written by the outer hook invocation. `ours` never recurses.
|
||
3. **The node-bore block cannot be created by `ours`.** In compare mode the region is
|
||
declared only when the block already exists, and the block pointer is described as an
|
||
opaque `ptr` so the original allocating it is not a divergence. In replace mode `ours`
|
||
calls the game's own updater to keep the allocation consistent — which means replace
|
||
mode does not exercise our node-bore selection; compare and the host tests do.
|
||
4. **Replace mode for this hook is deliberately incomplete.** It writes the player's fields
|
||
but raises no events, flags no systems, cures nothing and grants no boarding pods. On a
|
||
turn where nothing completes the hook never fires and the determinism oracle should still
|
||
hold — that is what step 4 below actually tests. On a turn where something completes, a
|
||
changed save hash is expected and is not a finding.
|
||
5. A compare record is small (14 regions, none over 0x54 bytes), so unlike B3 the configs
|
||
could leave other hooks on. They do not: the b2 configs switch everything else off so a
|
||
trace holds nothing but this callback.
|
||
6. `ServerPlayer+0x308` is a 32-bit pointer field. Host tests cannot write a real pointer
|
||
into a synthetic buffer at that offset (it would spill over `IncMod` at +0x30c), so they
|
||
set the view's block pointer directly.
|
||
|
||
## What remains (needs the VM)
|
||
|
||
The lane holding VM140 must be finished first; then, in this order:
|
||
|
||
1. Deploy `/srv/re-lab/shim/dist-b2` (build `b2-9cd997d-dirty-20260908T0359Z`): `scp` it to
|
||
`C:\SOTS\shimdist-b2\` and run `deploy.ps1 -Dist C:\SOTS\shimdist-b2` — **a separate
|
||
staging directory from the shared `C:\SOTS\shimdist`**, so no other lane's dist is
|
||
overwritten.
|
||
2. **Scout.** Copy `shim.cfg.b2scout` over `C:\SOTS\shim.cfg`, relaunch, load
|
||
`ref-turn2.sav`, press End Turn once, pull `C:\SOTS\shim.trace.jsonl`. Read off it:
|
||
* how many `Game::ServerPlayer::OnTechResearched` records there are (expect 0 or 1);
|
||
* from the `Game::TechTree::ProcessResearch` records, each player's funded node and its
|
||
`progress` vs `cost_rp`, i.e. how many more turns to a completion;
|
||
* `fpu_cw` (expect `0x027f`; `0x007f`/`0x003f` means 24-bit precision, which would change
|
||
the rounding shape in `AddF`/`MulF` and nothing else).
|
||
3. **Golden trace.** Copy `shim.cfg.b2trace`, relaunch, load `ref-turn2.sav`, and press End
|
||
Turn as many times as step 2 says are needed. Pull the trace →
|
||
`verify/traces/b2-techfx-golden.jsonl`; `tracecmp.py` must exit 0 with 0 invalid records
|
||
**and at least one record**. Check per record: `tech_id` is in 10000..10195 and
|
||
`tech_name` matches; `def_node_index` differs from `tech_id` (finding 8); the `side`
|
||
entries change only the fields that tech's branch should touch, plus the tail's
|
||
`design_masks` / `species_flags` / `translation` / `node_bore`.
|
||
4. **Compare.** Copy `shim.cfg.b2compare`, relaunch, load `ref-turn2.sav`, same number of
|
||
End Turns → `b2-techfx-compare.jsonl`. Expect **0 divergences on every record**. There is
|
||
no expected-divergence carve-out here: everything `ours` does not model is outside the
|
||
declared regions. Any diff is a real finding — report it, do not tune the table. Read the
|
||
matching `techfx: ours ...` lines out of `C:\SOTS\shim.log` and check them against the
|
||
completing tech (granted tech only for a Zuul `IND_CruisCon`, plague mask only for a
|
||
vaccine, and so on).
|
||
5. **Replace (weak check only).** Copy `shim.cfg.b2replace`, relaunch, load `ref-turn2.sav`,
|
||
press End Turn **once** (a turn on which nothing completes), and check the determinism
|
||
oracle — `(Autosave).sav` = `978041ac…`, `(Autosave EndTurn).sav` = `bb4fd9ac…`. That
|
||
proves the hook installs and perturbs nothing. Do **not** expect the oracle to hold on a
|
||
turn where a tech completes; see gotcha 4.
|
||
6. Restore the previous `shim.cfg` (`hooks=trace`) and leave the game at the main menu, as
|
||
M1/M2/B3 leave it.
|
||
|
||
Not done, and worth saying: no record of this hook has ever been captured, so unlike M2
|
||
there is not even a scouting trace to confirm the region model against a live object. The
|
||
first thing to check in step 2 is that the `before` snapshots look like plausible player
|
||
state (`out_mod`/`pop_mod`/`terra_mod` near 1, `con_mod`/`sav_mod` near 1,
|
||
`per_gate_traffic` a small integer) — if a field reads as garbage, the offset is wrong and
|
||
the run list stops there.
|