From bbba536d99379d0cc9d826f0dac8d3d247ec5add Mon Sep 17 00:00:00 2001 From: alex Date: Tue, 8 Sep 2026 01:18:39 -0400 Subject: [PATCH] b2 live: 3 completions compared 0 divergences; float32 confirmed on the game; RollResearchEvent RNG region added; xenotech block size bug fixed --- docs/B2.md | 126 ++++++++++++++++---------- include/generated/sots_addresses.h | 10 +- src/shim/hooks/tech_effect_fields.cpp | 38 +++++++- src/shim/hooks/tech_effect_fields.h | 8 ++ src/shim/hooks/tech_effects.cpp | 42 ++++++++- tests/shim_techfx/unit_tests.cpp | 15 ++- 6 files changed, 182 insertions(+), 57 deletions(-) diff --git a/docs/B2.md b/docs/B2.md index 34ce91d..aab7416 100644 --- a/docs/B2.md +++ b/docs/B2.md @@ -1,11 +1,20 @@ # 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. +**Result (2026-09-08): verified on the live game.** Trace: 2 completions captured, `tracecmp.py` +exit 0, 0 invalid. Compare: **3 completions compared, 0 divergences, 0 errors.** Replace: one +End Turn from `ref-turn2.sav` reproduces the determinism oracle byte for byte +(`(Autosave).sav` = `978041acd168b56e…`, `(Autosave EndTurn).sav` = `bb4fd9ac89f41e3b…`) — a weak +check by construction, because no tech completes on that turn so the hook never fires; what it +proves is that the hook installs and perturbs nothing. Game left on VM140 at the main menu in +`hooks=trace`. + +Build `b2-646e4e8-dirty-20260908T0434Z`, staged in `/srv/re-lab/shim/dist-b2`, deployed from +`C:\SOTS\shimdist-b2`. Evidence in `sots-re/verify/traces/b2-*` and +`verify/results/compare/b2-techfx-golden.json`. + +All three runs are archived: `sots-re/verify/traces/b2-techfx-golden.jsonl`, +`b2-techfx-compare.jsonl`, `b2-shim.log`, `b2-final-menu.png`, with the `tracecmp.py` reports in +`verify/results/compare/b2-techfx-{golden,compare}.json`. 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 @@ -318,46 +327,71 @@ plan, in order of what it proves: 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) +## Runs -The lane holding VM140 must be finished first; then, in this order: +| file | mode | records | result | +|---|---|---|---| +| `b2-techfx-golden.jsonl` | trace | 2 OnTechResearched (+18 ProcessResearch from the scout config) | `tracecmp.py` exit 0, 0 invalid | +| `b2-techfx-compare.jsonl` | compare | 3 OnTechResearched | 3 compared, **0 diverged**, 0 errors, exit 0 | +| replace (no log) | replace | 0 (no completion that turn) | End Turn from `ref-turn2.sav`: `(Autosave).sav` `978041ac…` / `(Autosave EndTurn).sav` `bb4fd9ac…` = oracle | +| `b2-shim.log` | | | install banners and the three `techfx: ours mode=compare` lines | -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. +The completions that fired, all on the AI player (index 1, species 2 = Tarkas), driven by six to +eight End Turns from `ref-turn2.sav`: **`IND_Waldo` (10001)**, **`BIO_GnMod` (10010)** and +**`BIO_SPNDANI` (10070)**. The third is the interesting one: it is not in the effects table at +all, so only the tail ran — which is exactly the case finding 10 predicted and the case an early +return would have broken. -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. +### What the live trace confirms + +* **The float32 model, exactly.** `IND_Waldo` moved `con_mod` `1.0 → 0.899999976` and `out_mod` + `1.10000002 → 1.25`; `BIO_GnMod` moved `pop_mod` `1.10000002 → 1.20000005`. Those are bit for + bit `(float)(x + (double)0.1f)` / `(double)0.15f`, not the exact decimals. Finding 1 is now + confirmed against the game and not only against the image. +* **`TechDef`'s first word is not the TechId** (finding 8): the records carry both, and they are + `144` vs `10001` and `9` vs `10010`. +* **`fpu_cw` = `0x127f`** — 53-bit precision control, matching B3, so the rounding shape in + `AddF`/`MulF` is the right one. +* **The gate-traffic config words are 3 and 6 at call time.** The hook's init banner prints + `gate=0/0` because the data files have not been read when the DLL loads; the values are + dereferenced per call, and the args show the live ones. +* **The RNG region resolves and is consistent**: `left` 413 with `next_index` 211, and + `624 − 413 = 211`, which validates both the two-hop pointer chain to the generator and the + describer's index arithmetic. +* The two unmodelled words at `+0x128`/`+0x12c` hold `1.0f`, and `+0x13c` holds `0.2f`. None of + the unmodelled words moved on any call. + +### The RNG draw B3 handed over + +B3 saw one completion where the original drew one word more than its implementation and +attributed it to this callback. That is right, and it is the roll at the very top: +`ServerPlayer::RollResearchEvent` (`+0x3b4` set and the completing def is the current research +target) makes **exactly one `NextFloat`** from the strategic generator, unconditionally, before +any comparison — then fires the AI-rebellion/plague path only if `odds > roll`. `ours` reproduces +that draw against the scratch copy of the generator (B3's design), so the post-state is +comparable, and the whole `Mars::RNG` object is now a declared region. + +**On this workload the branch never fired**: `research_roll_pending` was `false` on all three +completions, so the callback drew nothing and the `rng` region is unchanged before and after. +That means the compare could not exercise the draw — it is modelled and host-reasoned, not +behaviourally proven. The region is declared so the next run that does set the byte will check it. + +### Where the compare is blind + +Per B3's harness lesson: `ours` posts no events, and the player's event list is **not** a declared +region (its offset was not located in this window). Both non-silent completions raise +`EVENT_RESEARCH_COMPLETE` or `_UNDERBUDGET`, and a temperance completion would raise +`EVENT_TEMPERANCE`. So a clean compare here bounds the player's economy fields and nothing else, +and **replace mode is not oracle-clean on a turn where a tech completes** — the same failure mode +B3 hit, known in advance rather than discovered by the oracle. The replace run above deliberately +used a turn with no completion. + +## What remains + +1. **Locate the player's event list and declare it as a region**, then either post the events + through the game's own API in `ours` (as B3 concluded for its own event) or accept a named, + expected divergence there. Until then the compare's scope is the economy fields. +2. **Exercise the roll.** Find a turn where `+0x3b4` is set at completion (the record's + `research_roll_pending` before-value says when) and confirm the `rng` post-state matches. +3. The Zuul branches — `IND_CruisCon` granting boarding pods, and the temperance sweep — need a + Zuul player; the reference save has none. diff --git a/include/generated/sots_addresses.h b/include/generated/sots_addresses.h index 7ef76c0..ebb5e45 100644 --- a/include/generated/sots_addresses.h +++ b/include/generated/sots_addresses.h @@ -1,5 +1,5 @@ // GENERATED — do not edit. Facts about Sword of the Stars.exe (GOG 1.8.1). -// Source: sots-re ghidra/addresses.json @ 8a6c0e8, generated 2026-09-08 by tools/gen_addresses.py +// Source: sots-re ghidra/addresses.json @ 535d1c3, generated 2026-09-08 by tools/gen_addresses.py // Runtime address = (uintptr_t)GetModuleHandle(NULL) + RVA (the exe is ASLR-relocated). #pragma once #include @@ -429,5 +429,13 @@ constexpr uint32_t ServerPlayer_SpeciesOfTranslationTech = 0x0040e410; constexpr uint32_t AITechRow = 0x00290f70; // data int -- number of rows in g_AITechValueTable (6) [verified] constexpr uint32_t g_AITechValueCount = 0x006ea2ec; +// offset void* -- the StrategyServer subobject pointer; StrategyServer = *(void**)(player+8) - 4. Its +0x16c is the Mars::RNG* the strategic sim draws from, and its +8 the event manager [verified] +constexpr uint32_t ServerPlayer_off_ServerLink = 0x00000008; +// offset Mars::RNG* -- the strategic generator (same object TechTree::ProcessResearch is handed) [verified] +constexpr uint32_t StrategyServer_off_RNG = 0x0000016c; +// fastcall void (ServerPlayer* this) -- odds = ResearchEventOdds(this, this->ResT); draws EXACTLY ONE NextFloat from StrategyServer's generator, unconditionally, then fires 0x00889d60 when odds > roll. Called from OnTechResearched when the completing def is the current research target and the pending-roll byte at +0x3b4 is set. This is the extra RNG draw B3 observed on a completion [verified] +constexpr uint32_t ServerPlayer_RollResearchEvent = 0x0048df20; +// thiscall float (ServerPlayer* this, TechDef* def) -- 0 when def is null; a plague-family path via 0x00535480, else the AI-rebellion path via AITechRow (odds column) gated on !NPC. Read-only, makes no draw [verified] +constexpr uint32_t ServerPlayer_ResearchEventOdds = 0x00420380; } // namespace sots::addr diff --git a/src/shim/hooks/tech_effect_fields.cpp b/src/shim/hooks/tech_effect_fields.cpp index 2632ffe..ce5280d 100644 --- a/src/shim/hooks/tech_effect_fields.cpp +++ b/src/shim/hooks/tech_effect_fields.cpp @@ -173,6 +173,25 @@ Tv describe_roll(const void* p, std::size_t, unsigned) { return s; } +// The strategic generator, in the shape B3 pinned: the untempered block is hashed (it is +// far larger than inline_max), and equality of that hash plus `left` is exactly "the two +// generators are at the same place in the same stream". +const void* g_live_rng = nullptr; + +Tv describe_rng(const void* p, std::size_t, unsigned inline_max) { + Tv s = tv::struct_(); + s.add("vptr", tv::ptr(ptr_at(p, 0))); + s.add("mt", tv::bytes(static_cast(p) + A::RNG_off_State, + A::RNG_off_Next - A::RNG_off_State, inline_max)); + s.add("left", tv::i32(i32_at(p, A::RNG_off_Left))); + const std::uintptr_t next = reinterpret_cast(ptr_at(p, A::RNG_off_Next)); + const std::uintptr_t base = reinterpret_cast(g_live_rng) + A::RNG_off_State; + std::int64_t index = -1; + if (g_live_rng && next >= base) index = static_cast((next - base) / 4); + s.add("next_index", tv::i64(index)); + return s; +} + Tv describe_nodebore(const void* p, std::size_t, unsigned) { Tv s = tv::struct_(); std::vector v; @@ -198,19 +217,32 @@ const RegionDef kRegions[kRegionCount] = { {"species_flags", A::ServerPlayer_off_SpeciesTechFlags, 0x20, &describe_species_flags}, {"roll", A::ServerPlayer_off_ResearchRollPending, 8, &describe_roll}, {"node_bore", 0, 12, &describe_nodebore}, // base is *(player + off_NodeBore) + {"rng", 0, A::RNG_size, &describe_rng}, // base is the strategic generator }; std::size_t PlayerSpan() { return A::ServerPlayer_off_ResearchRollPending + 8; } Views Views::OverPlayer(void* player) { Views v; - for (int i = 0; i < kRegionCount; ++i) { - v.base[i] = i == R_NODEBORE ? ptr_at(player, A::ServerPlayer_off_NodeBore) - : static_cast(player) + kRegions[i].off; + // The generator is two pointer hops away: the player's server link, less the four bytes + // of subobject offset, then the server's generator slot. + void* link = ptr_at(player, A::ServerPlayer_off_ServerLink); + void* rng = nullptr; + if (link) { + void* server = static_cast(link) - 4; + rng = ptr_at(server, A::StrategyServer_off_RNG); } + for (int i = 0; i < kRegionCount; ++i) { + if (i == R_NODEBORE) v.base[i] = ptr_at(player, A::ServerPlayer_off_NodeBore); + else if (i == R_RNG) v.base[i] = rng; + else v.base[i] = static_cast(player) + kRegions[i].off; + } + v.liveRng = rng; return v; } +void SetLiveRngBase(const void* p) { g_live_rng = p; } + // Seed our model from the pre-call field values. Everything the callback can write comes // from the region copies; species (never written) comes from the live object. fx::PlayerEconomyState ReadPlayerState(const Views& v_, sots::sim::Species species) { diff --git a/src/shim/hooks/tech_effect_fields.h b/src/shim/hooks/tech_effect_fields.h index 50cd519..dbb75c8 100644 --- a/src/shim/hooks/tech_effect_fields.h +++ b/src/shim/hooks/tech_effect_fields.h @@ -36,6 +36,7 @@ enum RegionId { R_SPECIES_FLAGS, // flags[7] + count R_ROLL, // pending plague-cure roll + AIRebellion* R_NODEBORE, // the 3-word node-bore block itself (behind R_NODEBORE_PTR) + R_RNG, // the strategic Mars::RNG object (behind player+8 -> server+0x16c) kRegionCount }; @@ -55,6 +56,10 @@ std::size_t PlayerSpan(); // R_NODEBORE is ever legitimately null (no bore drive researched yet). struct Views { void* base[kRegionCount] = {}; + // The generator's live address, for the describer's next-pointer arithmetic: `next` is a + // heap address, so it is reported as its index into mt, which is what it means and what + // survives being rewritten by a reimplementation. + void* liveRng = nullptr; // Point every region at its offset inside a live (or synthetic) player object, and // R_NODEBORE at whatever the block pointer holds. @@ -77,4 +82,7 @@ bool ClearResearchTargetIfMatched(const Views& v, const void* def); // The two design-option masks, written straight into R_MASKS. void WriteDesignOptionMasks(const Views& v, std::uint32_t a, std::uint32_t b); +// The describer needs the live generator address; set it before a snapshot is described. +void SetLiveRngBase(const void* p); + } // namespace shim::hooks::techfx diff --git a/src/shim/hooks/tech_effects.cpp b/src/shim/hooks/tech_effects.cpp index 19b7175..4505958 100644 --- a/src/shim/hooks/tech_effects.cpp +++ b/src/shim/hooks/tech_effects.cpp @@ -19,6 +19,7 @@ #include "game/sim/species.h" #include "game/sim/tuning.h" #include "generated/sots_addresses.h" +#include "mars/rng/mt19937.h" namespace shim::hooks { @@ -109,6 +110,7 @@ struct CallState { bool compare = false; int region_of[tfx::kRegionCount] = {}; tfx::Views views; // the scratch copies, once rebind() has resolved them + void* live_rng = nullptr; // the real generator, for the describer's index arithmetic }; CallState g_call; @@ -212,6 +214,8 @@ void ServerPlayerOnTechResearchedHook::regions(std::vector& out, if (!readable(self, tfx::PlayerSpan())) throw std::runtime_error("ServerPlayer not readable"); const tfx::Views live = tfx::Views::OverPlayer(self); + g_call.live_rng = live.liveRng; + tfx::SetLiveRngBase(live.liveRng); for (int i = 0; i < tfx::kRegionCount; ++i) { const tfx::RegionDef& d = tfx::kRegions[i]; const void* base = live.base[i]; @@ -236,6 +240,7 @@ ServerPlayerOnTechResearchedHook::Args ServerPlayerOnTechResearchedHook::rebind( ? s.ptr(static_cast(g_call.region_of[i])) : nullptr; } + g_call.views.liveRng = g_call.live_rng; g_call.compare = true; // `self` is passed through unchanged: `ours` only reads never-written fields off it // (species, the tech tree) and takes everything else from the scratch copies above. @@ -252,9 +257,38 @@ void ServerPlayerOnTechResearchedHook::ours(void* self, void* def, bool silent) const int raw_id = resolve_tech_id(self, def); - // The pending plague-cure roll: the two words are cleared either way, but the roll - // itself draws from the strategic generator, so `ours` records it instead of running it. + // The pending research-event roll. It is the one place this callback consumes + // randomness -- exactly one NextFloat from the strategic generator, drawn before any + // comparison, whenever the completing definition is the current research target and the + // pending byte is set. In compare mode the generator is a scratch copy, so drawing here + // is safe and is what makes the post-state comparable (B3's design); in replace mode it + // is the live generator, which is what the game expects. const bool would_roll = tfx::ClearResearchTargetIfMatched(v, def); + float roll = -1.f; + if (would_roll) { + void* rng = v.base[tfx::R_RNG]; + if (!rng) throw std::runtime_error("strategic generator not reachable"); + mars::rng::MT19937 gen; + std::uint32_t mt[mars::rng::MT19937::N]; + std::memcpy(mt, static_cast(rng) + A::RNG_off_State, sizeof mt); + std::int32_t left = i32_at(rng, A::RNG_off_Left); + if (left < 0 || left > static_cast(mars::rng::MT19937::N)) + throw std::runtime_error("generator `left` out of range"); + gen.load_state(mt, left); + roll = gen.next_float(); + // Write the post-state back in the object's own layout; `next` is rebuilt against + // the LIVE base so the describer's index arithmetic reads the same on both sides. + std::uint8_t blob[mars::rng::MT19937::kStateBytes]; + gen.save_state(blob); + std::memcpy(static_cast(rng) + A::RNG_off_State, blob, + A::RNG_off_Next - A::RNG_off_State); + left = gen.left(); + std::memcpy(static_cast(rng) + A::RNG_off_Left, &left, sizeof left); + const std::uintptr_t base = + reinterpret_cast(compare ? v.liveRng : rng) + A::RNG_off_State; + void* next = reinterpret_cast(base + static_cast(gen.index()) * 4); + std::memcpy(static_cast(rng) + A::RNG_off_Next, &next, sizeof next); + } int species_index = i32_at(self, A::ServerPlayer_off_Species); if (species_index < 0 || species_index >= sots::sim::kSpeciesCount) species_index = 0; @@ -311,12 +345,12 @@ void ServerPlayerOnTechResearchedHook::ours(void* self, void* def, bool silent) // where the un-compared consequences -- the system-side writes, the Zuul grant, the // roll -- are visible at all. logf("techfx: ours mode=%s id=%d granted=%d plague=0x%02x systems_ai=%d civcaps=%d " - "temperance=0x%02x bore_changed=%d bore_present=%d roll=%d", + "temperance=0x%02x bore_changed=%d bore_present=%d roll=%d value=%.9g", compare ? "compare" : "replace", raw_id, static_cast(outcome.grantedTech), outcome.plagueCuredMask, outcome.flagSystemsAI ? 1 : 0, outcome.reevaluateCivilianCaps ? 1 : 0, outcome.temperanceSpeciesMask, outcome.nodeBoreParamsChanged ? 1 : 0, v.base[tfx::R_NODEBORE] ? 1 : 0, - would_roll ? 1 : 0); + would_roll ? 1 : 0, static_cast(roll)); } void init_tech_effects(std::uintptr_t exe_base, void (*log_line)(const char* line)) { diff --git a/tests/shim_techfx/unit_tests.cpp b/tests/shim_techfx/unit_tests.cpp index daa4e23..18a0b78 100644 --- a/tests/shim_techfx/unit_tests.cpp +++ b/tests/shim_techfx/unit_tests.cpp @@ -41,6 +41,8 @@ struct FakePlayer { // host, so writing a real pointer into the buffer would spill over the neighbouring // field. The tests point the view at the block directly instead, which is what // Views::OverPlayer would do on the target. + // The generator is reached through two 32-bit pointer hops; the same 64-bit-host + // caveat as the node-bore block applies, so tests aim the view at it directly. tfx::Views views(bool with_bore) { tfx::Views v = tfx::Views::OverPlayer(bytes.data()); v.base[tfx::R_NODEBORE] = with_bore ? bore : nullptr; @@ -92,10 +94,12 @@ void test_region_table() { CHECK(d.describe != nullptr); CHECK(d.size > 0); } + // Two regions live outside the player object, so their `off` is meaningless. + auto in_object = [](int i) { return i != tfx::R_NODEBORE && i != tfx::R_RNG; }; for (int i = 0; i < tfx::kRegionCount; ++i) { - if (i == tfx::R_NODEBORE) continue; // a separate allocation, not part of the object + if (!in_object(i)) continue; for (int j = i + 1; j < tfx::kRegionCount; ++j) { - if (j == tfx::R_NODEBORE) continue; + if (!in_object(j)) continue; const std::uint32_t a0 = tfx::kRegions[i].off, a1 = a0 + tfx::kRegions[i].size; const std::uint32_t b0 = tfx::kRegions[j].off, b1 = b0 + tfx::kRegions[j].size; CHECK(a1 <= b0 || b1 <= a0); @@ -103,9 +107,12 @@ void test_region_table() { } // The declared span must reach past the last region. for (int i = 0; i < tfx::kRegionCount; ++i) { - if (i == tfx::R_NODEBORE) continue; + if (!in_object(i)) continue; CHECK(tfx::kRegions[i].off + tfx::kRegions[i].size <= tfx::PlayerSpan()); } + // The generator region is the whole Mars::RNG object, and it is absent when the player + // has no server link (a synthetic object, or a client-side player). + CHECK_EQ(tfx::kRegions[tfx::R_RNG].size, static_cast(A::RNG_size)); // The node-bore region is the only one that may legitimately be absent: on the target // the block pointer is the 32-bit word at its offset, and a zero word means "no block". FakePlayer p; @@ -113,6 +120,8 @@ void test_region_table() { CHECK_EQ(p.get(A::ServerPlayer_off_NodeBore), 0u); CHECK(p.views(true).base[tfx::R_NODEBORE] == p.bore); CHECK(p.views(false).base[tfx::R_NODEBORE] == nullptr); + CHECK(tfx::Views::OverPlayer(p.base()).base[tfx::R_RNG] == nullptr); + CHECK(tfx::Views::OverPlayer(p.base()).liveRng == nullptr); } void test_read_round_trip() {