merge lane P: count-only event posting; next_id reaches 4 host-side; observed_techs declared region
This commit is contained in:
commit
59590d9774
13 changed files with 1431 additions and 30 deletions
|
|
@ -62,6 +62,11 @@ add_library(shim_movement STATIC src/shim/hooks/movement_inputs.cpp)
|
|||
target_link_libraries(shim_movement PUBLIC shim_trace sots_game_sim)
|
||||
target_compile_options(shim_movement PRIVATE -Wall -Wextra -Werror)
|
||||
|
||||
# ---- P events adapter: live Game::EventStorage <-> sots::events model (pure, host-tested) ----
|
||||
add_library(shim_events STATIC src/shim/hooks/event_inputs.cpp)
|
||||
target_link_libraries(shim_events PUBLIC shim_trace sots_addresses sots_game_events)
|
||||
target_compile_options(shim_events PRIVATE -Wall -Wextra -Werror)
|
||||
|
||||
if(WIN32)
|
||||
# ---- shim: proxy binkw32.dll that the original game loads (Phase 2 frontend) ----
|
||||
add_library(minhook STATIC
|
||||
|
|
@ -80,7 +85,7 @@ if(WIN32)
|
|||
src/shim/hooks/fleet_movement.cpp)
|
||||
target_link_libraries(shim_hooks PUBLIC shim_trace sots_addresses sots_game_config sots_game_sim
|
||||
sots_game_effects mars_rng shim_budget shim_techfx
|
||||
shim_colony shim_movement)
|
||||
shim_colony shim_movement shim_events)
|
||||
target_compile_options(shim_hooks PRIVATE -Wall -Wextra -Werror)
|
||||
|
||||
add_library(binkw32 SHARED src/shim/main.cpp src/shim/binkw32.def)
|
||||
|
|
@ -96,7 +101,7 @@ else()
|
|||
add_executable(addr_smoke tests/addr_smoke.cpp)
|
||||
target_link_libraries(addr_smoke PRIVATE sots_addresses)
|
||||
add_test(NAME addr_smoke COMMAND addr_smoke)
|
||||
foreach(_t mars_parse game_config game_data game_design game_sim mars_stream mars_text mars_vfs shim_trace game_effects game_events shim_budget shim_techfx shim_colony shim_movement)
|
||||
foreach(_t mars_parse game_config game_data game_design game_sim mars_stream mars_text mars_vfs shim_trace game_effects game_events shim_budget shim_techfx shim_colony shim_movement shim_events)
|
||||
if(EXISTS ${CMAKE_SOURCE_DIR}/tests/${_t}/CMakeLists.txt)
|
||||
add_subdirectory(tests/${_t})
|
||||
endif()
|
||||
|
|
|
|||
241
docs/P-events-wiring.md
Normal file
241
docs/P-events-wiring.md
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
# P — wiring the event posts into `ours`
|
||||
|
||||
Lane E recovered the event API and landed `src/game/events/` as a pure module. Lane R's guarded
|
||||
recapture then made the missing post precise: `TechTree::ProcessResearch` exits 1 with exactly one
|
||||
divergent field,
|
||||
|
||||
```
|
||||
side.events.after.v.next_id [exact] orig={"t":"i32","v":4} ours={"t":"i32","v":3}
|
||||
```
|
||||
|
||||
on a call where the arithmetic reproduces perfectly (`node[144].progress` 2879→5768,
|
||||
`flag` 1→2, the single RNG draw identical). This lane closes that field.
|
||||
|
||||
It takes lane E's **option (a), count-only**. The game's `EventStorage::PostEvent` is never
|
||||
called and no live byte is written; `ours` posts into its own `sots::events::EventStorage` and
|
||||
writes the resulting counts into the **scratch copy** of the owner's storage header. That is what
|
||||
makes it safe to put near the VM, and it is also the honest limit of what it proves.
|
||||
|
||||
---
|
||||
|
||||
## 1. What changed
|
||||
|
||||
| file | change |
|
||||
|---|---|
|
||||
| `src/game/events/research_events.{h,cpp}` | `PostResearchPassEvents` — the events one `ProcessResearch` call raises, as a pure function of the per-node outcomes; `KeylessEventText()` for a caller with no string table |
|
||||
| `src/shim/hooks/event_inputs.{h,cpp}` (new lib `shim_events`) | the live↔model adapter: scan a `Game::EventStorage` before the original runs, seed the model from it, write the counts back into a scratch header |
|
||||
| `src/shim/hooks/research.{h,cpp}` | the wiring, the `observed_techs` region, the new args, the rewritten `Coverage` |
|
||||
| `tests/game_events/test_events.cpp` | 6 new cases on the pass driver (the three call shapes lane R captured, plus dedup and the keyless lookup) |
|
||||
| `tests/shim_events/unit_tests.cpp` (new) | 10 cases on the adapter, driven on a byte buffer laid out with the real offsets |
|
||||
| `include/generated/sots_addresses.h` | regenerated from `sots-re ghidra/addresses.json` (386 → 396 entries) |
|
||||
|
||||
`tools/clean_room_check.sh` — **OK**. Host `ctest` — **33/33 passed** (was 32/32; `shim_events_unit`
|
||||
is new). Both run as separate commands. The shim TU itself is syntax-checked only
|
||||
(`g++ -std=c++17 -Wall -Wextra -fsyntax-only`, clean): there is no MinGW i686 cross toolchain on
|
||||
this box, so `tools/build-shim.sh` still has to run on CT111 before any deploy.
|
||||
|
||||
### New binary facts (via `addresses.json` → `tools/gen_addresses.py`, never hand-edited)
|
||||
|
||||
Read out of `TechTree::SetResearched` (0x00581e10) by this lane, plus one from lane R's guard:
|
||||
|
||||
`ServerPlayer_off_ObservedTechs` (0x274) · `TechNode_off_TurnAvailable` (0x20) ·
|
||||
`TechNode_off_TurnResearched` (0x24) · `TechNode_off_Order` (0x28) ·
|
||||
`TechNode_off_Children` (0x04) · `TechEdge_off_CostRP` (0x1c) · `TechEdge_off_ChildDef` (0x40) ·
|
||||
`TechTree_off_OrderCounter` (0x20) · `TechTree_SetResearched_flag_Force` (0x2) ·
|
||||
`TechTree_SetResearched_flag_Silent` (0x4)
|
||||
|
||||
The last one settles a question the count depends on. `SetResearched` invokes the owner callback as
|
||||
`vft+0x10(def, (flags >> 2) & 1)`, so bit 2 of its `flags` argument **is** `OnTechResearched`'s
|
||||
`silent`. `ProcessResearch` calls `SetResearched(def, 2)`, so `silent` is false and the completion
|
||||
event *is* posted. That was previously an inference from an event count; it is now read off the
|
||||
instruction stream.
|
||||
|
||||
---
|
||||
|
||||
## 2. How the count model works, and why each piece is there
|
||||
|
||||
### The scan is taken *before* the original
|
||||
|
||||
In compare mode `ours` runs **after** the original (`hook.h`, `run<true>`). A scan of the event
|
||||
list taken inside `ours` would see the original's own freshly posted records — and the model would
|
||||
deduplicate against them, post nothing, and agree for exactly the wrong reason. So
|
||||
`ScanEventStorage` runs in `describe_args`, which `hook.h` calls immediately before `regions()`
|
||||
and before the original.
|
||||
|
||||
### Ours posts into its own storage, seeded from the scan
|
||||
|
||||
`SeedFromScan` rebuilds the **turn buckets** (their `EvTurn` values, in order) and `EvNxID`, and
|
||||
leaves the buckets **empty**. Empty because the records' `EvDsc`/`EvMsg` are localized game text,
|
||||
which the engine must not carry.
|
||||
|
||||
That has one consequence, and it is measured rather than assumed. A post can only be deduplicated
|
||||
against a record already in the same turn bucket, and only a record with the same `EvImg` can ever
|
||||
match. So the scan counts the events in the current turn's bucket that carry one of the six
|
||||
research `EvImg` identifiers and reports it as the argument **`events_dedup_risk`**:
|
||||
|
||||
* `events_dedup_risk = 0` → nothing in the bucket can collide with anything we post, and the
|
||||
count-only model is **exact** for that call.
|
||||
* anything else → our id count is a lower bound, and the report says so instead of the reader
|
||||
having to guess.
|
||||
|
||||
On the reference workload it should be 0 on every call (see §4).
|
||||
|
||||
### The text stand-in preserves exactly the property dedup needs
|
||||
|
||||
`KeylessEventText()` resolves every string-table key to the bare format `"%s"`, and the hook passes
|
||||
the node's own index as the substitution. So `EvMsg` is the node index and nothing else. That is
|
||||
not the game's text and must never be shown or serialized — but it preserves the one property the
|
||||
comparator cares about:
|
||||
|
||||
* two different nodes going over budget in the same turn get different `EvMsg` → both are kept,
|
||||
which is what the original does (their real messages differ by the tech name);
|
||||
* the same node twice collapses to one id, which is also what the original does;
|
||||
* an over-budget and a completion for the same node share `EvMsg` but differ in `EvImg`, which the
|
||||
comparator also tests → both are kept.
|
||||
|
||||
`tests/game_events` pins all three through this lookup.
|
||||
|
||||
### Only compare mode writes
|
||||
|
||||
`WriteBackCounts` is called **only when `compare` is set**. In replace mode the region pointer is
|
||||
live game memory, and bumping `EvNxID` there without a serialized record behind it would corrupt
|
||||
the very save the oracle hashes — strictly worse than the missing event. Replace mode therefore
|
||||
still posts nothing, and the `Coverage` note says so.
|
||||
|
||||
---
|
||||
|
||||
## 3. What is verified, what is modelled by analogy, and what is not modelled
|
||||
|
||||
**Verified from the instruction stream** (lane E's read, plus this lane's read of 0x00581e10):
|
||||
|
||||
* `EVENT_RESEARCH_OVERBUDGET` fires iff the completion roll failed **and** `!wasDone && nowDone`.
|
||||
`sim::ResearchStepResult::overbudgetEvent` already computes exactly that, in the same branch
|
||||
that sets `node.flag = 2` — the flag lane R confirmed on the game.
|
||||
* One completion event fires on every completion reached from `ProcessResearch`, because
|
||||
`silent = (flags >> 2) & 1` and `ProcessResearch` passes `flags = 2`.
|
||||
* The posting rules themselves: dedup keys, `act == 0 && !obj && !pos → 2`, the `FLT_MAX` default,
|
||||
`EvNxID` starting at 0, the last-match bucket lookup, the prune off-by-one. All of lane E's, all
|
||||
unit-tested.
|
||||
|
||||
**Modelled by analogy, and count-neutral:** which of `EVENT_RESEARCH_COMPLETE` /
|
||||
`_UNDERBUDGET` is posted. The original splits on `TechTree::GetProgressRatio(tree, def)` against
|
||||
`(double)0.8f`; `ours` splits on `ResearchStepResult::completedEarly`, which is `progress/cost`
|
||||
against the same constant, computed by `ProcessResearch` itself two instructions earlier for the
|
||||
`flag = 0` decision. They are very probably the same number, but this lane did not read
|
||||
`GetProgressRatio` (0x0057e950). **It does not affect `next_id`**: both branches post exactly one
|
||||
event. It would affect a text or `EvImg` comparison, and no region has one.
|
||||
|
||||
**Not modelled — flagged, not guessed:**
|
||||
|
||||
* **`EVENT_TECHS_UNLOCKED`.** Its trigger *is* pinned: `SetResearched`'s second sweep sets
|
||||
`state = 2` and stamps `turnAvailable` (only when it reads −1), and the tail loop at 0x00587ff4
|
||||
collects `state == 2 && turnAvailable == currentTurn`. But evaluating it needs the child-unlock
|
||||
cascade, which `ours` deliberately does not run — B3 declared it out of scope and its writes
|
||||
land on live objects compare mode must not touch. `PostResearchPassEvents` therefore takes the
|
||||
unlock list as an **input** and is handed `nullptr` (*"no list"*), which is deliberately
|
||||
distinct from an empty list (*"computed, and empty"*). **Predicted residual: `next_id` short by
|
||||
exactly 1 on every call that completes a tech.** Guessing the trigger — "post it whenever
|
||||
something completed" — would score on this save and be wrong the first time a completion
|
||||
unlocks nothing.
|
||||
* **`EVENT_TEMPERANCE`.** Posted from the same `OnTechResearched` call, but only when the
|
||||
completed tech is a temperance tech *and* the per-species sweep actually cured at least one
|
||||
addicted system. Neither input is available here, and no save in `verify/` has addiction, so
|
||||
this has never been observed firing. The helper exists and is unit-tested; it is not driven.
|
||||
* **`EVENT_NO_RESEARCH`.** Posted from `ServerPlayer::ProcessTurn` (0x0089168c), not from this
|
||||
hook. Out of scope by call site, not by omission.
|
||||
* **The text.** `EvDsc`/`EvMsg` come from the game's string table. No region can see them, and
|
||||
none pretends to.
|
||||
|
||||
---
|
||||
|
||||
## 4. What the next VM run should show
|
||||
|
||||
This is a prediction, not a fishing trip. Run the existing `shim.cfg.recapb3` recipe
|
||||
(`ref-turn2.sav` → Launch → End Turn) unchanged, then the five-End-Turn continuation.
|
||||
|
||||
### 4.1 The first End Turn — the headline
|
||||
|
||||
**`Game::TechTree::ProcessResearch`: 3 calls, 3 compared, 0 divergent, `tracecmp` exit 0.**
|
||||
|
||||
| call | alloc | `events.next_id` orig | ours before this lane | ours now |
|
||||
|---|---|---|---|---|
|
||||
| **0** | `{tech 144, 2889}`, species 2 | 3 → **4** | 3 ✗ | 3 → **4** ✓ |
|
||||
| 1 | `{90, 0}` | 0 → 0 | 0 ✓ | 0 ✓ |
|
||||
| 2 | `{9, 0}` | 0 → 0 | 0 ✓ | 0 ✓ |
|
||||
|
||||
Also on call 0, and these are the fields that say the model is right for the right reason:
|
||||
|
||||
* `turn` = **3**. If it is not 3, nothing below is trustworthy: the whole post lands in the wrong
|
||||
bucket. (`ModCount` at `*(int*)(*(char**)(player+8) + 8)`.)
|
||||
* `events_turn_bucket_exists` = **true** — the turn-3 bucket already holds `EVENT_SHIPS_BUILT`.
|
||||
This is what keeps `events.turns` at 2 and `turns_bytes` at 0x30 on both sides. If it comes back
|
||||
false, our post creates a bucket the original did not and `turns_bytes` diverges by 0x18 — which
|
||||
would mean `turn` is wrong, not that the model is.
|
||||
* `events_next_id_in` = **3**, `events_in_turn_bucket` = **1**, `events_dedup_risk` = **0**.
|
||||
* `events_scan_truncated` must be **absent**. Its presence means a vector header did not parse and
|
||||
every event number in the record is unreliable.
|
||||
* `node[144].progress` 2879 → 5768 and `flag` 1 → 2 on both sides, `rng` identical — unchanged
|
||||
from lane R's capture. If any of those regressed, the events wiring broke something it should
|
||||
not have touched.
|
||||
* `observed_techs.bytes` **unchanged on all three calls**: turn 1 of `ref-turn2` contains no tech
|
||||
completion, so nothing appends.
|
||||
|
||||
Calls 1 and 2 are other players with empty event lists (`EvNxID = 0`). `WriteBackCounts` must leave
|
||||
their scratch header byte-identical — the "no-op" case is unit-tested.
|
||||
|
||||
### 4.2 The five-turn continuation — expected to still diverge, by a known amount
|
||||
|
||||
Lane R warns that anything past the first End Turn is *a* run, not *the* run (from turn 4 the AI
|
||||
picks a different target). Treat the call ids as indicative and the **shape** as the prediction:
|
||||
|
||||
| call | what happens | orig `next_id` | ours now | residual |
|
||||
|---|---|---|---|---|
|
||||
| 0 | over budget | 4 | **4** | none |
|
||||
| 3 | completion | 7 | **6** | 1 = `EVENT_TECHS_UNLOCKED` |
|
||||
| 6 | rolled, failed, no cost crossing | 8 | 8 | none |
|
||||
| 9 | completion | 12 | **11** | 1 = `EVENT_TECHS_UNLOCKED` |
|
||||
| 12 | rolled, failed | 14 | 14 | none |
|
||||
| all others | zero-spend | 0 | 0 | none |
|
||||
|
||||
So `tracecmp` should still exit 1 on the five-turn run, with **exactly two divergent calls instead
|
||||
of three**, and both remaining divergences off by exactly 1 in `next_id`. Any other number is new
|
||||
information: off by 2 means the completion event is not firing either; off by −1 means we posted
|
||||
something the original did not.
|
||||
|
||||
### 4.3 What the guard should stop saying
|
||||
|
||||
`observed_techs` is now a **Result** region, so its bytes are excluded from the `player` guard's
|
||||
undeclared-write scan. Expect the guard's span count on the two completion calls to drop by the
|
||||
`player+0x274/0x278/0x27c` entries (lane R saw 3 spans on call 3 and 1 on call 9) and those
|
||||
offsets to appear in the diff instead. That is the conversion, not a regression.
|
||||
|
||||
### 4.4 The measurement to take while you are there
|
||||
|
||||
On a completion call, `observed_techs.bytes` grows by exactly one element.
|
||||
**That delta is `sizeof(ObservedTech)`,** which nothing in the project has pinned. On disk the
|
||||
element is `{int otnF; int otnL; int odet; string otch; int owith}` (confirmed in
|
||||
`turn3-state.sav` — the `otch` string holds the tech's name, e.g. `WEP_RedLas`), so a by-value
|
||||
element should measure 0x2c or 0x30 and a `vector<ObservedTech*>` would measure 4. Write the answer
|
||||
into `addresses.json`; the entry there currently says explicitly that it is not pinned.
|
||||
|
||||
---
|
||||
|
||||
## 5. Open
|
||||
|
||||
* `sizeof(ObservedTech)` and its **append call site**. It is somewhere under the
|
||||
`OnTechResearched` callback (both guards see it, which only bounds it to that subtree); it is not
|
||||
in `OnTechResearched`'s own decompilation, and a constant search for `0x274` finds only unrelated
|
||||
objects, so it is reached through a `lea` displacement in a callee. §4.4 measures the stride
|
||||
without finding the site.
|
||||
* `TechTree::GetProgressRatio` (0x0057e950) — the COMPLETE/UNDERBUDGET split is analogy until it
|
||||
is read.
|
||||
* The unlock cascade. It is now fully decompiled (see the sots-re note), so
|
||||
`EVENT_TECHS_UNLOCKED` is implementable — but it belongs to whichever lane takes `SetResearched`,
|
||||
because modelling it means `ours` starts writing child `state` / `turn_available` / `cost_rp`,
|
||||
which B3 declared out of scope.
|
||||
* `EventStorage::PruneOldTurns` still has never run: the reference saves are at turn ≤ 7 and the
|
||||
window is 50. The model reproduces the off-by-one and the unit tests pin it; nothing has measured
|
||||
it.
|
||||
* Option (b) — delegating to the game's own `PostEvent` so the text and the save hash match — is
|
||||
untouched and remains the only path that can prove `EvDsc`/`EvMsg`. It changes VM behaviour and
|
||||
is deliberately not wired here.
|
||||
|
|
@ -23,7 +23,7 @@ often it fires.
|
|||
|
||||
| # | hook | undeclared side effect of the original | why it can hide a divergence | now |
|
||||
|---|---|---|---|---|
|
||||
| 1 | `TechTree::ProcessResearch` (B3) | posts **`EVENT_RESEARCH_OVERBUDGET`** on the owner's `EventStorage` (`ServerPlayer+0x29c`), bumping `EvNxID` and the turn's list | **the known defect.** Same branch that sets the modelled `node.flag = 2`; serialized into the save, so it changes the oracle | **declared** as region `events`; `ours` still does not post it, so it now diverges loudly instead of passing |
|
||||
| 1 | `TechTree::ProcessResearch` (B3) | posts **`EVENT_RESEARCH_OVERBUDGET`** on the owner's `EventStorage` (`ServerPlayer+0x29c`), bumping `EvNxID` and the turn's list | **the known defect.** Same branch that sets the modelled `node.flag = 2`; serialized into the save, so it changes the oracle | **declared** as region `events` and **modelled**: `ours` posts the pass's events into its own EventStorage and writes the counts into the region's scratch copy, so `next_id` is now a check. Count-only -- the composed text is still invisible. Lane P, `docs/P-events-wiring.md` |
|
||||
| 2 | `ServerPlayer::OnTechResearched` (B2) | posts **`EVENT_RESEARCH_COMPLETE` / `_UNDERBUDGET` / `_TEMPERANCE`** on the same `EventStorage` | identical shape to #1, and *worse*: B2 has no replace-mode oracle behind it — docs/B2.md gotcha 4 says a changed save hash on a completion turn is expected, so nothing would have flagged it | guard region `player` reports it |
|
||||
| 3 | `ServerSystem::ProcessTurn` (B4) | the addiction sweep constructs **MoraleEvents** and appends them to the system's capped history | same shape again. Worse still: `sim::ProcessColonyTurn` *computes* the morale events, and `DescribeMoraleEvents` exists — but the hook never calls it, so they are dropped on the floor: not compared, not even logged | guard region `system` reports it; the dead describer is called out below |
|
||||
| 4 | `StrategyServer::ProcessFleetMovement` (B4) | `OnFleetArrived` posts **`EVENT_FLEET_ARRIVED`**; the pass also writes `FPdpos` into every fleet and clears flags `0x2` and `0x100` on every fleet | fourth instance of the same class. No replace mode for this hook either, so neither layer could see it | coverage notes only (a guard would have to span every fleet; see §6) |
|
||||
|
|
@ -51,9 +51,9 @@ Things I could **not** settle offline, and did not guess:
|
|||
|
||||
- Whether the double `ComputeOutput` in B1 replace mode (#6) actually double-repairs ships.
|
||||
It needs a hook on `ComputeOutputFromRates`, or a VM run with a damaged ship in orbit.
|
||||
- The exact stride of `EventStorage::TurnEvents`, so the `events` region reports the outer
|
||||
vector's **byte** span (`turns_bytes`) rather than an element count. `EvNxID` is the field
|
||||
that actually carries the signal.
|
||||
- ~~The exact stride of `EventStorage::TurnEvents`~~ — settled by lane E (0x18) and read off the
|
||||
instruction stream; the `events` region now reports `turns` as an element count alongside
|
||||
`turns_bytes`. `EvNxID` is still the field that carries the signal.
|
||||
- Whether the section dictionary's `LoadSection` appends to the dictionary's own vector
|
||||
(docs/M2.md raises it as a hypothesis). Still a hypothesis; recorded as `risk: high`.
|
||||
- `ServerPlayer` / `ServerSystem` / `StarFleet` sizes come from the recovered object table in
|
||||
|
|
|
|||
|
|
@ -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 @ f5b37c2, generated 2026-09-08 by tools/gen_addresses.py
|
||||
// Source: sots-re ghidra/addresses.json @ 0cd8469, generated 2026-09-08 by tools/gen_addresses.py
|
||||
// Runtime address = (uintptr_t)GetModuleHandle(NULL) + RVA (the exe is ASLR-relocated).
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
|
|
@ -245,10 +245,16 @@ constexpr uint32_t TechTree_Cost = 0x0017da00;
|
|||
constexpr uint32_t ServerPlayer_TechCostMult = 0x0040db50;
|
||||
// thiscall void (TechTree* this, TechDef* def, int flags) // state 4 + turn/order stamps + owner callback + child unlock cascade. Makes no direct RNG draw; the owner callback is not audited, so a compare that runs it is out of scope [verified]
|
||||
constexpr uint32_t TechTree_SetResearched = 0x00181e10;
|
||||
// constant flags bit 1 of SetResearched(def, flags): skip the PrereqsMet test and complete unconditionally. TechTree::ProcessResearch passes flags = 2 [verified]
|
||||
constexpr uint32_t TechTree_SetResearched_flag_Force = 0x00000002;
|
||||
// constant flags bit 2 of SetResearched(def, flags): the owner callback is invoked as vft+0x10(def, (flags>>2)&1), so this bit IS OnTechResearched's `silent` argument. ProcessResearch passes flags = 2, so silent = FALSE and the completion event IS posted [verified]
|
||||
constexpr uint32_t TechTree_SetResearched_flag_Silent = 0x00000004;
|
||||
// offset ServerPlayer* owner (0 for a tree with no player) [verified]
|
||||
constexpr uint32_t TechTree_off_Owner = 0x0000000c;
|
||||
// offset std::vector<TechNode*> indexed by tech id (MSVC2010: 3 words {first@+0x10, last@+0x14, end@+0x18}); entries may be NULL [verified]
|
||||
constexpr uint32_t TechTree_off_Nodes = 0x00000010;
|
||||
// offset int completion-order counter; SetResearched stamps node+0x28 from it and post-increments it. Harness-audit row 9 [verified]
|
||||
constexpr uint32_t TechTree_off_OrderCounter = 0x00000020;
|
||||
// offset sizeof(TechNode) -- the ctor's operator new argument [verified]
|
||||
constexpr uint32_t TechNode_size = 0x00000034;
|
||||
// offset TechDef* def; *(int*)def is the tech id used to index TechTree_off_Nodes [verified]
|
||||
|
|
@ -259,6 +265,18 @@ constexpr uint32_t TechNode_off_State = 0x00000014;
|
|||
constexpr uint32_t TechNode_off_CostRP = 0x00000018;
|
||||
// offset int progress in RP; the only node word ProcessResearch itself writes besides the flag [verified]
|
||||
constexpr uint32_t TechNode_off_Progress = 0x0000001c;
|
||||
// offset int turnAvailable; SetResearched's second sweep stamps it with the server ModCount ONLY when it currently reads -1 (a sticky first-availability stamp). EVENT_TECHS_UNLOCKED collects nodes with state==2 && turnAvailable==currentTurn [verified]
|
||||
constexpr uint32_t TechNode_off_TurnAvailable = 0x00000020;
|
||||
// offset int turnResearched; SetResearched writes the server ModCount [verified]
|
||||
constexpr uint32_t TechNode_off_TurnResearched = 0x00000024;
|
||||
// offset int order; SetResearched writes the tree's completion-order counter, then bumps it [verified]
|
||||
constexpr uint32_t TechNode_off_Order = 0x00000028;
|
||||
// offset std::vector<TechEdge*> children (3 words at +0x04/+0x08/+0x0c) [verified]
|
||||
constexpr uint32_t TechNode_off_Children = 0x00000004;
|
||||
// offset int RP cost carried by the edge; SetResearched sets child.costRP = min(child.costRP, edge.costRP) [verified]
|
||||
constexpr uint32_t TechEdge_off_CostRP = 0x0000001c;
|
||||
// offset TechDef* the edge's child tech; SetResearched indexes tree->nodes by childDef->[0] [verified]
|
||||
constexpr uint32_t TechEdge_off_ChildDef = 0x00000040;
|
||||
// offset int flag (1 default from the ctor, 0 completed below 80% of cost, 2 over-budget event raised) [verified]
|
||||
constexpr uint32_t TechNode_off_Flag = 0x0000002c;
|
||||
// thiscall void (MasterTechTree* this) /* fills TechDef*[196] at this+0 from g_TechIdNames */ [verified]
|
||||
|
|
@ -351,6 +369,8 @@ constexpr uint32_t ServerPlayer_off_SetupResearchMult = 0x0000022c;
|
|||
constexpr uint32_t ServerPlayer_off_Sav = 0x00000284;
|
||||
// offset Tech* current research target (ResT); NULL = none [verified-by-save]
|
||||
constexpr uint32_t ServerPlayer_off_ResearchTarget = 0x00000294;
|
||||
// offset std::vector<ObservedTech> otch (3 words {first,last,end}); save tag otch, element {int otnF, otnL, odet; string otch; int owith}. All three words move on every tech completion (a realloc) -- observed live by both the ProcessResearch and the OnTechResearched player guards. NOT PINNED: the append call site and sizeof(ObservedTech); the byte span the observed_techs region reports is what will measure the stride [verified-by-save]
|
||||
constexpr uint32_t ServerPlayer_off_ObservedTechs = 0x00000274;
|
||||
// offset float IncMod [verified-by-save]
|
||||
constexpr uint32_t ServerPlayer_off_IncMod = 0x0000030c;
|
||||
// offset std::vector<PlayerAid> (3 words; entry 0x18 B, {+0x8 int researchPercent, +0xc int researchActive, +0x10 int savings, +0x14 int savingsActive}) [verified]
|
||||
|
|
|
|||
|
|
@ -85,4 +85,44 @@ int PostNoResearch(EventStorage& log, const EventText& text, int turn) {
|
|||
return log.PostGlobal(std::move(summary), std::move(message), turn, kImgNoResearch, 1);
|
||||
}
|
||||
|
||||
ResearchPassResult PostResearchPassEvents(EventStorage& log, const EventText& text,
|
||||
const std::vector<ResearchPassOutcome>& outcomes,
|
||||
const std::vector<std::string>* unlockedTechNames,
|
||||
int turn) {
|
||||
ResearchPassResult r;
|
||||
for (const ResearchPassOutcome& o : outcomes) {
|
||||
// The two are the failed and the succeeded side of one roll, so at most one fires.
|
||||
if (o.overbudgetEvent) {
|
||||
r.ids.push_back(PostResearchOverbudget(log, text, o.techName, turn));
|
||||
++r.overbudgetPosts;
|
||||
} else if (o.completed) {
|
||||
// PostResearchCompleted picks the image from the ratio; feed it a ratio on the
|
||||
// correct side of the threshold rather than re-deriving one, so the two callers
|
||||
// cannot drift apart. Both branches post exactly one event, so the count is the
|
||||
// same either way.
|
||||
const float ratio = o.completedEarly ? 0.f : 1.f;
|
||||
r.ids.push_back(PostResearchCompleted(log, text, o.techName, ratio, turn));
|
||||
++r.completionPosts;
|
||||
}
|
||||
}
|
||||
if (unlockedTechNames && !unlockedTechNames->empty()) {
|
||||
r.ids.push_back(
|
||||
PostTechsUnlocked(log, text, *unlockedTechNames, kTechsUnlockedSeparator, turn));
|
||||
++r.techsUnlockedPosts;
|
||||
}
|
||||
r.nextId = log.nextId();
|
||||
return r;
|
||||
}
|
||||
|
||||
namespace {
|
||||
std::string_view KeylessLookup(void*, std::string_view) { return std::string_view("%s"); }
|
||||
} // namespace
|
||||
|
||||
EventText KeylessEventText() {
|
||||
EventText t;
|
||||
t.lookup = &KeylessLookup;
|
||||
t.ctx = nullptr;
|
||||
return t;
|
||||
}
|
||||
|
||||
} // namespace sots::events
|
||||
|
|
|
|||
|
|
@ -130,4 +130,71 @@ int PostTechsUnlocked(EventStorage& log, const EventText& text,
|
|||
// `EvAct = 1`; both strings come from the key pair unformatted.
|
||||
int PostNoResearch(EventStorage& log, const EventText& text, int turn);
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// The events one `TechTree::ProcessResearch` pass raises
|
||||
// ---------------------------------------------------------------------------------------
|
||||
//
|
||||
// This is the *decision* half of the research event path, split out so it can be driven from
|
||||
// a hook and pinned by host tests. It deliberately covers only what ProcessResearch's own
|
||||
// arithmetic determines. See docs/P-events-wiring.md for what it does not.
|
||||
|
||||
// One allocation entry's outcome, as far as the event path can see. The three flags are exactly
|
||||
// `sim::ResearchStepResult::overbudgetEvent / completed / completedEarly`.
|
||||
struct ResearchPassOutcome {
|
||||
// The substitution for the message's single `%s`. Its only load-bearing property is that
|
||||
// two techs the original would keep apart carry different tokens here: the dedup comparator
|
||||
// compares `message`, so a caller that collapses two techs to the same token would post one
|
||||
// event where the original posts two.
|
||||
std::string techName;
|
||||
bool overbudgetEvent = false; // the node crossed its cost this turn and the roll FAILED
|
||||
bool completed = false; // the roll succeeded -> SetResearched -> OnTechResearched
|
||||
bool completedEarly = false; // progress/cost < (double)0.8f -> UNDERBUDGET, else COMPLETE
|
||||
};
|
||||
|
||||
struct ResearchPassResult {
|
||||
std::vector<int> ids; // the ids Post returned, in call order (a dedup repeats an id)
|
||||
int overbudgetPosts = 0;
|
||||
int completionPosts = 0;
|
||||
int techsUnlockedPosts = 0;
|
||||
int nextId = 0; // log.nextId() after the pass
|
||||
};
|
||||
|
||||
// Post, in the original's order, every event one ProcessResearch call raises:
|
||||
//
|
||||
// * per allocation entry, inside the loop: EVENT_RESEARCH_OVERBUDGET when the node crossed
|
||||
// its cost this turn and the completion roll failed, OR -- through SetResearched ->
|
||||
// OnTechResearched, which ProcessResearch invokes with flags = 2 so the callback's `silent`
|
||||
// argument ((flags >> 2) & 1) is FALSE -- EVENT_RESEARCH_COMPLETE / _UNDERBUDGET on a
|
||||
// completion. The two are mutually exclusive: they are the failed and the succeeded side of
|
||||
// the same roll.
|
||||
// * once after the loop: EVENT_TECHS_UNLOCKED, for the nodes that became available this turn.
|
||||
//
|
||||
// `unlockedTechNames` is that list. Passing **nullptr** means "this caller cannot compute the
|
||||
// unlock set" -- the tail event is then not posted and `techsUnlockedPosts` stays 0. That is
|
||||
// the shim's case: the set comes from SetResearched's child-unlock cascade, which `ours` does
|
||||
// not run. An empty (non-null) list means the caller computed the set and it was empty, which
|
||||
// is also "no post". The distinction is deliberate: a missing input must not look like a
|
||||
// modelled negative.
|
||||
//
|
||||
// EVENT_TEMPERANCE is NOT raised here. It is posted from the same OnTechResearched call, but
|
||||
// only when the completed tech is a temperance tech AND the sweep actually cured at least one
|
||||
// addicted system -- a condition outside this function's inputs. CONFIDENCE: high for the two
|
||||
// modelled triggers (both read out of the instruction stream); the split between COMPLETE and
|
||||
// UNDERBUDGET is modelled by analogy (see docs/P-events-wiring.md) and does not affect the
|
||||
// count, since both post exactly one event.
|
||||
ResearchPassResult PostResearchPassEvents(EventStorage& log, const EventText& text,
|
||||
const std::vector<ResearchPassOutcome>& outcomes,
|
||||
const std::vector<std::string>* unlockedTechNames,
|
||||
int turn);
|
||||
|
||||
// A `TextLookup` for a caller that has no string table -- the shim, which must never carry the
|
||||
// game's localized prose. Every key resolves to the bare format `"%s"`, so the composed
|
||||
// `message` is exactly the caller's `techName` token.
|
||||
//
|
||||
// This is enough, and only enough, for a **count-only** model: the dedup comparator tests
|
||||
// `action`, `location`, `pos`, `message` and `image`, and this lookup preserves the one property
|
||||
// that matters -- two events the original keeps apart stay apart, and two it collapses collapse.
|
||||
// The resulting text is NOT the game's text and must never be shown or serialized.
|
||||
EventText KeylessEventText();
|
||||
|
||||
} // namespace sots::events
|
||||
|
|
|
|||
185
src/shim/hooks/event_inputs.cpp
Normal file
185
src/shim/hooks/event_inputs.cpp
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
#include "shim/hooks/event_inputs.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
|
||||
#include "game/events/research_events.h"
|
||||
#include "generated/sots_addresses.h"
|
||||
|
||||
namespace shim::hooks {
|
||||
|
||||
namespace A = sots::addr;
|
||||
|
||||
namespace {
|
||||
|
||||
std::int32_t word_at(const void* obj, std::size_t off) {
|
||||
std::int32_t v = 0;
|
||||
std::memcpy(&v, static_cast<const char*>(obj) + off, sizeof v);
|
||||
return v;
|
||||
}
|
||||
void set_word(void* obj, std::size_t off, std::int32_t v) {
|
||||
std::memcpy(static_cast<char*>(obj) + off, &v, sizeof v);
|
||||
}
|
||||
// A pointer stored *inside a game object*: four bytes, whatever the host's word size is.
|
||||
std::uint32_t addr_at(const void* obj, std::size_t off) {
|
||||
std::uint32_t v = 0;
|
||||
std::memcpy(&v, static_cast<const char*>(obj) + off, sizeof v);
|
||||
return v;
|
||||
}
|
||||
void set_addr(void* obj, std::size_t off, std::uint32_t v) {
|
||||
std::memcpy(static_cast<char*>(obj) + off, &v, sizeof v);
|
||||
}
|
||||
|
||||
// A record's EvImg is one of the six the research path uses.
|
||||
bool is_research_image(const std::string& img) {
|
||||
using namespace sots::events;
|
||||
return img == kImgResearchOverbudget || img == kImgResearchComplete ||
|
||||
img == kImgResearchUnderbudget || img == kImgTemperance || img == kImgTechsUnlocked ||
|
||||
img == kImgNoResearch;
|
||||
}
|
||||
|
||||
// A 3-word MSVC vector header {first, last, end} living in game memory. Returns false when it
|
||||
// does not look like one; an all-null header is a valid empty vector.
|
||||
struct GameVector {
|
||||
std::uint32_t first = 0;
|
||||
std::size_t count = 0;
|
||||
std::size_t bytes = 0;
|
||||
const void* host = nullptr; // `first` mapped into something readable, null when empty
|
||||
};
|
||||
|
||||
bool read_vector(const void* vec, std::size_t stride, std::size_t cap, ReadProbe readable,
|
||||
ToHost toHost, GameVector& out, bool& truncated) {
|
||||
out = GameVector{};
|
||||
const std::uint32_t first = addr_at(vec, 0);
|
||||
const std::uint32_t last = addr_at(vec, kGamePtrSize);
|
||||
if (!first && !last) return true; // empty
|
||||
if (!first || last < first) return false;
|
||||
const std::size_t span = last - first;
|
||||
if (span % stride != 0) return false;
|
||||
if (span / stride > cap) {
|
||||
truncated = true;
|
||||
return false;
|
||||
}
|
||||
out.first = first;
|
||||
out.bytes = span;
|
||||
out.count = span / stride;
|
||||
if (out.count == 0) return true;
|
||||
out.host = toHost(first);
|
||||
if (!readable(out.host, span)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
const void* IdentityToHost(std::uint32_t gameAddr) {
|
||||
if constexpr (sizeof(void*) == kGamePtrSize) {
|
||||
return reinterpret_cast<const void*>(static_cast<std::uintptr_t>(gameAddr));
|
||||
} else {
|
||||
// A 64-bit build is not in the game's address space, so there is nothing to point at.
|
||||
// Returning null makes the read probe reject it rather than fabricating an address that
|
||||
// happens to be mappable.
|
||||
(void)gameAddr;
|
||||
return nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
bool ReadStdStringMapped(const void* p, ReadProbe readable, ToHost toHost, std::string& out) {
|
||||
if (!readable(p, kStdStringSize)) return false;
|
||||
const std::uint32_t size = static_cast<std::uint32_t>(word_at(p, kStdStringOffSize));
|
||||
const std::uint32_t res = static_cast<std::uint32_t>(word_at(p, kStdStringOffRes));
|
||||
// The event-type identifiers are short interface names; anything long is a bad read, not a
|
||||
// long string worth chasing. `res < size` is impossible in a well-formed string.
|
||||
if (size > 0x1000 || res < size) return false;
|
||||
if (res < kStdStringInlineCap) {
|
||||
if (size >= kStdStringInlineCap) return false;
|
||||
out.assign(static_cast<const char*>(p), size);
|
||||
return true;
|
||||
}
|
||||
const std::uint32_t buf = addr_at(p, 0);
|
||||
if (!buf) return false;
|
||||
const void* host = toHost(buf);
|
||||
if (size && !readable(host, size)) return false;
|
||||
out.assign(static_cast<const char*>(host), size);
|
||||
return true;
|
||||
}
|
||||
|
||||
EventStorageScan ScanEventStorage(const void* storage, int turn, ReadProbe readable,
|
||||
ToHost toHost) {
|
||||
EventStorageScan s;
|
||||
if (!readable(storage, A::EventStorage_sizeof)) return s;
|
||||
|
||||
GameVector buckets;
|
||||
if (!read_vector(static_cast<const char*>(storage) + A::EventStorage_off_Events,
|
||||
A::TurnEvents_sizeof, kMaxTurnBuckets, readable, toHost, buckets,
|
||||
s.scanTruncated))
|
||||
return s;
|
||||
|
||||
s.nextId = word_at(storage, A::EventStorage_off_EvNxID);
|
||||
s.turnsBytes = buckets.bytes;
|
||||
s.bucketTurns.reserve(buckets.count);
|
||||
for (std::size_t i = 0; i < buckets.count; ++i) {
|
||||
const char* b = static_cast<const char*>(buckets.host) + i * A::TurnEvents_sizeof;
|
||||
const int bt = word_at(b, A::TurnEvents_off_EvTurn);
|
||||
s.bucketTurns.push_back(bt);
|
||||
if (bt != turn) continue;
|
||||
|
||||
// GetOrCreateTurnBucket returns the LAST matching bucket, so a later duplicate bucket
|
||||
// replaces what an earlier one contributed rather than adding to it.
|
||||
s.turnBucketExists = true;
|
||||
s.eventsInTurnBucket = 0;
|
||||
s.researchEventsInTurnBucket = 0;
|
||||
|
||||
GameVector evs;
|
||||
if (!read_vector(b + A::TurnEvents_off_Events, A::PlayerEvent_sizeof, kMaxEventsPerBucket,
|
||||
readable, toHost, evs, s.scanTruncated)) {
|
||||
s.scanTruncated = true;
|
||||
continue;
|
||||
}
|
||||
s.eventsInTurnBucket = evs.count;
|
||||
for (std::size_t k = 0; k < evs.count; ++k) {
|
||||
const char* rec = static_cast<const char*>(evs.host) + k * A::PlayerEvent_sizeof;
|
||||
std::string img;
|
||||
if (!ReadStdStringMapped(rec + A::PlayerEvent_off_EvImg, readable, toHost, img)) {
|
||||
s.scanTruncated = true;
|
||||
break;
|
||||
}
|
||||
if (is_research_image(img)) ++s.researchEventsInTurnBucket;
|
||||
}
|
||||
}
|
||||
s.ok = true;
|
||||
return s;
|
||||
}
|
||||
|
||||
sots::events::EventStorage SeedFromScan(const EventStorageScan& scan) {
|
||||
sots::events::EventStorage log;
|
||||
log.set_nextId(scan.nextId);
|
||||
for (int t : scan.bucketTurns) {
|
||||
sots::events::TurnEvents b;
|
||||
b.turn = t;
|
||||
log.turns().push_back(std::move(b));
|
||||
}
|
||||
return log;
|
||||
}
|
||||
|
||||
void WriteBackCounts(void* scratchStorage, const sots::events::EventStorage& log,
|
||||
ReadProbe readable) {
|
||||
if (!readable(scratchStorage, A::EventStorage_sizeof)) return;
|
||||
set_word(scratchStorage, A::EventStorage_off_EvNxID, log.nextId());
|
||||
std::uint32_t first = addr_at(scratchStorage, A::EventStorage_off_Events);
|
||||
if (!first) {
|
||||
// A player who has never seen an event carries {null, null, null}, and two of the four
|
||||
// players in the reference save do. If our post creates that player's first bucket the
|
||||
// original allocates and its span becomes non-zero, so writing nothing here would show up
|
||||
// as a divergence in `turns_bytes` for the wrong reason. The region diff ignores pointer
|
||||
// *values* (HookPolicy::ptr_exact is false) and compares only the span, so a synthetic
|
||||
// base carries the meaning without pretending to name a heap block. Nothing dereferences
|
||||
// it: the scratch copy is a byte buffer the describer only reads through.
|
||||
if (log.turns().empty()) return;
|
||||
first = kSyntheticTurnsBase;
|
||||
set_addr(scratchStorage, A::EventStorage_off_Events, first);
|
||||
}
|
||||
set_addr(scratchStorage, A::EventStorage_off_Events + kGamePtrSize,
|
||||
first + static_cast<std::uint32_t>(log.turns().size() * A::TurnEvents_sizeof));
|
||||
}
|
||||
|
||||
} // namespace shim::hooks
|
||||
121
src/shim/hooks/event_inputs.h
Normal file
121
src/shim/hooks/event_inputs.h
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
// Adapter between a live `Game::EventStorage` and the pure `sots::events` model.
|
||||
//
|
||||
// The B3 hook needs three things the event module cannot get for itself, because they live in
|
||||
// the game's own memory:
|
||||
//
|
||||
// 1. what the owner's event list looked like **before** the original ran (its `EvNxID`, its
|
||||
// turn buckets, and whether the current turn's bucket already holds a research event that
|
||||
// our post could be deduplicated against);
|
||||
// 2. a model `EventStorage` seeded from that, so `ours` posts into the same shape;
|
||||
// 3. a way to write the *counts* back into a scratch copy of the storage header, which is what
|
||||
// the `events` region compares.
|
||||
//
|
||||
// It is a separate library from the hook so it can be built and tested on the host: every read
|
||||
// goes through a caller-supplied bounds probe, so the tests drive it on a plain byte buffer laid
|
||||
// out with the real offsets and the shim drives it on live game memory with `VirtualQuery`.
|
||||
//
|
||||
// Nothing here composes or reads event *text* for the model. `ScanEventStorage` does read each
|
||||
// record's `EvImg` -- the event-type identifier, an ASCII interface name, not localized prose --
|
||||
// because that is the only field that can make a duplicate possible.
|
||||
//
|
||||
// Layout facts all come from the generated header (lane E read them off the instruction stream):
|
||||
// EventStorage +0x04 vector<TurnEvents> {first,last,end} +0x14 EvNxID sizeof 0x1c
|
||||
// TurnEvents +0x04 EvTurn +0x08 vector<PlayerEvent> sizeof 0x18
|
||||
// PlayerEvent +0x50 EvImg (std::string) sizeof 0x74
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "game/events/event_log.h"
|
||||
|
||||
namespace shim::hooks {
|
||||
|
||||
// "Are `n` bytes at `p` safe to read?" The shim answers with VirtualQuery; the host tests answer
|
||||
// against their buffer. A null pointer must answer false.
|
||||
using ReadProbe = bool (*)(const void* p, std::size_t n);
|
||||
|
||||
// The game is a 32-bit process, so every pointer *inside* its objects is four bytes. The shim is
|
||||
// built 32-bit and could just dereference them; the host tests are 64-bit and cannot. So every
|
||||
// game pointer read out of an object is carried as an explicit `uint32` address and turned into
|
||||
// something readable through this hook -- a cast in the shim, an arena lookup in the tests.
|
||||
// Keeping the width explicit is not only what makes the adapter host-testable: it is also what
|
||||
// stops a 64-bit host build from silently reading the vector's `last` word on top of its `first`.
|
||||
using ToHost = const void* (*)(std::uint32_t gameAddr);
|
||||
|
||||
// The identity mapping the shim uses: in a 32-bit build a game address IS a host address. In a
|
||||
// 64-bit build it returns null, so a caller that forgot to supply a real mapping fails the read
|
||||
// probe instead of dereferencing a fabricated pointer.
|
||||
const void* IdentityToHost(std::uint32_t gameAddr);
|
||||
|
||||
// Width of a pointer inside a game object, always 4 regardless of the host.
|
||||
inline constexpr std::size_t kGamePtrSize = 4;
|
||||
|
||||
// MSVC 2010 `std::string`: a 16-byte union that is either the inline buffer or a heap pointer,
|
||||
// then `_Mysize` at +0x10 and `_Myres` at +0x14. `_Myres >= 16` selects the heap pointer. The
|
||||
// same layout lane E used to reach a TechDef's name through `_Myres` at def+0x54.
|
||||
inline constexpr std::size_t kStdStringSize = 0x1c;
|
||||
inline constexpr std::size_t kStdStringOffSize = 0x10;
|
||||
inline constexpr std::size_t kStdStringOffRes = 0x14;
|
||||
inline constexpr std::size_t kStdStringInlineCap = 16;
|
||||
|
||||
// Copies the string's bytes out of game memory, handling both the inline and the heap form.
|
||||
// False (and `out` untouched) when any part of it fails the probe or the length is implausible --
|
||||
// a bad read is reported, never guessed at.
|
||||
bool ReadStdStringMapped(const void* p, ReadProbe readable, ToHost toHost, std::string& out);
|
||||
|
||||
// A read-only view of a live EventStorage, taken BEFORE the original runs.
|
||||
//
|
||||
// Why "before" matters: in compare mode `ours` runs *after* the original, so a scan taken then
|
||||
// would show the original's own freshly posted events and our model would deduplicate against
|
||||
// them -- silently posting nothing and "matching" for the wrong reason.
|
||||
struct EventStorageScan {
|
||||
bool ok = false; // false: the header did not look like an EventStorage
|
||||
int nextId = 0; // EvNxID as it stood before the call
|
||||
std::vector<int> bucketTurns; // EvTurn of every TurnEvents, in order
|
||||
std::size_t turnsBytes = 0; // the outer vector's byte span (last - first)
|
||||
|
||||
// How many events the bucket for the scanned turn already holds, and how many of those carry
|
||||
// one of the research `EvImg` identifiers. The second number is the **dedup risk**: our model
|
||||
// rebuilds the buckets but not their contents, so it can only fail to deduplicate against a
|
||||
// pre-existing record, and only a record with the same `EvImg` can ever be a duplicate. When
|
||||
// it is 0, a count-only model is exact; when it is not, the count is a lower bound and the
|
||||
// run should say so rather than quietly assume.
|
||||
std::size_t eventsInTurnBucket = 0;
|
||||
std::size_t researchEventsInTurnBucket = 0;
|
||||
bool turnBucketExists = false;
|
||||
bool scanTruncated = false; // a vector looked longer than the sanity caps: treat as unknown
|
||||
};
|
||||
|
||||
// Loop guards, so a garbage vector header cannot walk the whole address space.
|
||||
inline constexpr std::size_t kMaxTurnBuckets = 4096;
|
||||
inline constexpr std::size_t kMaxEventsPerBucket = 4096;
|
||||
|
||||
EventStorageScan ScanEventStorage(const void* storage, int turn, ReadProbe readable,
|
||||
ToHost toHost);
|
||||
|
||||
// A model storage with the same turn buckets and the same `EvNxID` as the scan, and empty
|
||||
// buckets. Empty because the records' text is the game's, not ours: see the dedup-risk note
|
||||
// above for exactly what that costs.
|
||||
sots::events::EventStorage SeedFromScan(const EventStorageScan& scan);
|
||||
|
||||
// Write what `ours` posted back into a **scratch copy** of the EventStorage header, in the
|
||||
// storage's own layout, so the `events` region's describer reads it the same way for both sides:
|
||||
//
|
||||
// * `EvNxID` <- log.nextId()
|
||||
// * the turns vector's `last` pointer <- `first + turns * sizeof(TurnEvents)`, so the region's
|
||||
// `turns_bytes` reflects a bucket our post created. The pointer *values* are the original's
|
||||
// (the region diff ignores pointers); only their difference carries meaning.
|
||||
//
|
||||
// MUST NOT be called on live game memory: the header would then claim an id and a bucket that
|
||||
// no serialized record backs. Compare mode only.
|
||||
void WriteBackCounts(void* scratchStorage, const sots::events::EventStorage& log,
|
||||
ReadProbe readable);
|
||||
|
||||
// Stand-in `first` address used only when the scanned storage held an empty {null,null,null}
|
||||
// vector and our post created its first bucket -- see WriteBackCounts.
|
||||
inline constexpr std::uint32_t kSyntheticTurnsBase = 0x10000;
|
||||
|
||||
} // namespace shim::hooks
|
||||
|
|
@ -12,10 +12,13 @@
|
|||
#include <windows.h>
|
||||
#endif
|
||||
|
||||
#include "game/events/event_log.h"
|
||||
#include "game/events/research_events.h"
|
||||
#include "game/sim/research.h"
|
||||
#include "game/sim/species.h"
|
||||
#include "generated/sots_addresses.h"
|
||||
#include "mars/rng/mt19937.h"
|
||||
#include "shim/hooks/event_inputs.h"
|
||||
|
||||
namespace shim::hooks {
|
||||
|
||||
|
|
@ -40,6 +43,17 @@ constexpr std::size_t kPlayerEventsOff = A::ServerPlayer_off_Events; // 0x29c
|
|||
constexpr std::size_t kEventStorageSize = A::EventStorage_sizeof; // 0x1c
|
||||
constexpr std::size_t kEventsVecOff = A::EventStorage_off_Events; // 0x04
|
||||
constexpr std::size_t kEventsNextIdOff = A::EventStorage_off_EvNxID; // 0x14
|
||||
// The owner's vector<ObservedTech> (save tag `otch`). Lane R's player guard caught all three of
|
||||
// its words moving on both completion calls, in both this hook and OnTechResearched -- a third
|
||||
// serialized list append in the same neighbourhood as the event list, named in no coverage note
|
||||
// anywhere. Declaring it turns an undeclared write into a check.
|
||||
constexpr std::size_t kObservedTechsOff = A::ServerPlayer_off_ObservedTechs; // 0x274
|
||||
constexpr std::size_t kVectorHeaderSize = 0xc; // {first,last,end}
|
||||
// The turn every event is filed under: the owner's second StrategyServer base is at
|
||||
// ServerPlayer+8 and the turn counter (ModCount) sits at +8 in it. Read out of the instruction
|
||||
// stream twice -- lane E on every research-path post site, and again at 0x00581e10 where
|
||||
// SetResearched stamps node.turnResearched from the same chain.
|
||||
constexpr std::size_t kPlayerServerOff = 8;
|
||||
// Whole-object guard spans. ServerPlayer is 0x3e0 and the TechTree header we care about ends
|
||||
// at the order counter (+0x20). Source: findings/control-flow/turn-spine.md object table.
|
||||
constexpr std::size_t kPlayerSize = 0x3e0;
|
||||
|
|
@ -134,12 +148,39 @@ struct CallState {
|
|||
std::size_t rng_region = 0;
|
||||
std::size_t ob_region = 1;
|
||||
int events_region = -1; // the owner's EventStorage, -1 when the owner is unreadable
|
||||
int otch_region = -1; // the owner's vector<ObservedTech>
|
||||
void* scratch_events = nullptr; // compare mode: the scratch copy of the EventStorage header
|
||||
std::vector<int> node_region; // node index -> region index, -1 when the slot is null
|
||||
std::vector<std::string> names; // stable storage for Region::name
|
||||
std::vector<void*> scratch_nodes;
|
||||
};
|
||||
CallState g_call;
|
||||
|
||||
// The pre-call read of the owner's event list.
|
||||
//
|
||||
// It lives outside CallState on purpose. `describe_args` runs immediately before `regions()` for
|
||||
// the same call (hook.h), and `regions()` resets CallState -- so the scan is taken in
|
||||
// describe_args, where its numbers can also be reported as arguments in *every* mode, and read
|
||||
// back in regions()/ours(). ProcessResearch is called once per player from the single-threaded
|
||||
// turn pass and is never re-entrant, the same concession the CallState mapping already makes.
|
||||
struct EventScan {
|
||||
bool owner_ok = false;
|
||||
bool turn_ok = false;
|
||||
int turn = 0;
|
||||
EventStorageScan storage;
|
||||
};
|
||||
EventScan g_scan;
|
||||
|
||||
// The turn the events are filed under; `ok` is false when the chain is not walkable.
|
||||
int current_turn(const void* owner, bool& ok) {
|
||||
ok = false;
|
||||
if (!readable(owner, kPlayerServerOff + 4)) return 0;
|
||||
const void* srv = ptr_at(owner, kPlayerServerOff);
|
||||
if (!readable(srv, A::StrategyServer_off_ModCount + 4)) return 0;
|
||||
ok = true;
|
||||
return word_at(srv, A::StrategyServer_off_ModCount);
|
||||
}
|
||||
|
||||
// The tree's node vector, or false when the header does not look like one.
|
||||
bool tree_nodes(const void* tree, std::vector<void*>& out) {
|
||||
out.clear();
|
||||
|
|
@ -239,14 +280,21 @@ Tv describe_i32(const void* p, std::size_t, unsigned) {
|
|||
|
||||
// The owner's inline EventStorage. The three vector words are heap pointers (ignored by the
|
||||
// default policy), so what the diff actually compares is `turns` -- how many TurnEvents the
|
||||
// list holds -- and `next_id`, the counter every posted event bumps. Posting the
|
||||
// EVENT_RESEARCH_OVERBUDGET message moves `next_id`, so a run where ours does not post it now
|
||||
// diverges here instead of passing silently. That is the whole point of this region.
|
||||
// list holds -- and `next_id`, the counter every posted event bumps.
|
||||
//
|
||||
// `ours` now posts the research events into a model storage seeded from the pre-call scan and
|
||||
// writes the resulting counts back into the *scratch* copy of this header, so both sides of the
|
||||
// diff are produced the same way. Before that wiring, this region reported the defect; now it
|
||||
// checks it. `turns_bytes` was an element count the harness audit could not compute because the
|
||||
// TurnEvents stride was unknown -- lane E read it (0x18), so `turns` is emitted too.
|
||||
Tv describe_events(const void* p, std::size_t, unsigned) {
|
||||
Tv s = tv::struct_();
|
||||
const char* begin = static_cast<const char*>(ptr_at(p, kEventsVecOff));
|
||||
const char* end = static_cast<const char*>(ptr_at(p, kEventsVecOff + 4));
|
||||
s.add("turns_bytes", tv::i32(end >= begin ? static_cast<std::int32_t>(end - begin) : -1));
|
||||
const std::int32_t bytes = end >= begin ? static_cast<std::int32_t>(end - begin) : -1;
|
||||
s.add("turns_bytes", tv::i32(bytes));
|
||||
s.add("turns", tv::i32(bytes >= 0 ? bytes / static_cast<std::int32_t>(A::TurnEvents_sizeof)
|
||||
: -1));
|
||||
s.add("next_id", tv::i32(word_at(p, kEventsNextIdOff)));
|
||||
s.add("vec_begin", tv::ptr(ptr_at(p, kEventsVecOff)));
|
||||
s.add("vec_end", tv::ptr(ptr_at(p, kEventsVecOff + 4)));
|
||||
|
|
@ -254,6 +302,26 @@ Tv describe_events(const void* p, std::size_t, unsigned) {
|
|||
return s;
|
||||
}
|
||||
|
||||
// The owner's `vector<ObservedTech> otch` (ServerPlayer+0x274) -- serialized state that grows on
|
||||
// every tech completion and that no coverage note in B2 or B3 mentioned.
|
||||
//
|
||||
// `ours` cannot append to it: `sizeof(ObservedTech)` and the append call site are both unpinned
|
||||
// (the element is a Streamable with four ints and a string on disk, which bounds it but does not
|
||||
// name it). So this region is declared knowing it will diverge on a completion call -- and the
|
||||
// **byte delta it reports is what measures the stride**: one completion appends one element, so
|
||||
// `bytes` growing by n names sizeof(ObservedTech) = n. That is the point of declaring it now
|
||||
// rather than waiting for the element layout.
|
||||
Tv describe_otch(const void* p, std::size_t, unsigned) {
|
||||
Tv s = tv::struct_();
|
||||
const char* begin = static_cast<const char*>(ptr_at(p, 0));
|
||||
const char* end = static_cast<const char*>(ptr_at(p, 4));
|
||||
s.add("bytes", tv::i32(end >= begin ? static_cast<std::int32_t>(end - begin) : -1));
|
||||
s.add("vec_begin", tv::ptr(ptr_at(p, 0)));
|
||||
s.add("vec_end", tv::ptr(ptr_at(p, 4)));
|
||||
s.add("vec_cap", tv::ptr(ptr_at(p, 8)));
|
||||
return s;
|
||||
}
|
||||
|
||||
// ---- the generator seen by ours ----------------------------------------------------------
|
||||
|
||||
struct ShimRandom final : sots::sim::IRandom {
|
||||
|
|
@ -308,6 +376,36 @@ void TechTreeProcessResearchHook::describe_args(std::vector<Tv>& out, void* tree
|
|||
out.push_back(tv::i32(readable(overbudget, 4) ? word_at(overbudget, 0) : 0).named("overbudget_in"));
|
||||
// The x87 precision mode in force for this call (see fpu_control_word above).
|
||||
out.push_back(tv::u32(fpu_control_word()).named("fpu_cw"));
|
||||
|
||||
// ---- the pre-call read of the owner's event list ----------------------------------------
|
||||
//
|
||||
// Taken here, not in ours(): in compare mode ours runs AFTER the original, so a scan taken
|
||||
// then would see the original's own posts and our model would deduplicate against them --
|
||||
// posting nothing and "agreeing" for exactly the wrong reason.
|
||||
g_scan = EventScan{};
|
||||
g_scan.owner_ok = readable(owner, kPlayerEventsOff + kEventStorageSize);
|
||||
g_scan.turn = current_turn(owner, g_scan.turn_ok);
|
||||
if (g_scan.owner_ok && g_scan.turn_ok) {
|
||||
// The shim is a 32-bit build living in the game's own address space, so a game pointer
|
||||
// maps to a host pointer by a cast (the adapter static_asserts the width).
|
||||
g_scan.storage = ScanEventStorage(static_cast<const char*>(owner) + kPlayerEventsOff,
|
||||
g_scan.turn, &readable, &IdentityToHost);
|
||||
}
|
||||
out.push_back(tv::i32(g_scan.turn_ok ? g_scan.turn : -1).named("turn"));
|
||||
out.push_back(tv::i32(g_scan.storage.ok ? g_scan.storage.nextId : -1).named("events_next_id_in"));
|
||||
out.push_back(tv::u32(static_cast<std::uint32_t>(g_scan.storage.bucketTurns.size()))
|
||||
.named("events_turns_in"));
|
||||
out.push_back(tv::boolean(g_scan.storage.turnBucketExists).named("events_turn_bucket_exists"));
|
||||
out.push_back(tv::u32(static_cast<std::uint32_t>(g_scan.storage.eventsInTurnBucket))
|
||||
.named("events_in_turn_bucket"));
|
||||
// THE assumption behind a count-only model, measured instead of assumed. Our model rebuilds
|
||||
// the turn buckets but not their contents, so it can only ever fail to deduplicate against a
|
||||
// record that was already there -- and only a record carrying one of the six research
|
||||
// `EvImg` identifiers can be a duplicate of anything we post. 0 means count-only is exact on
|
||||
// this call; anything else means our id count is a lower bound and the run must say so.
|
||||
out.push_back(tv::u32(static_cast<std::uint32_t>(g_scan.storage.researchEventsInTurnBucket))
|
||||
.named("events_dedup_risk"));
|
||||
if (g_scan.storage.scanTruncated) out.push_back(tv::boolean(true).named("events_scan_truncated"));
|
||||
}
|
||||
|
||||
void TechTreeProcessResearchHook::regions(std::vector<trace::Region>& out, void* tree, void* rng,
|
||||
|
|
@ -358,8 +456,9 @@ void TechTreeProcessResearchHook::regions(std::vector<trace::Region>& out, void*
|
|||
|
||||
// The owner's event storage. This is the region whose absence made B3's compare clean while
|
||||
// replace mode diverged: the over-budget branch posts EVENT_RESEARCH_OVERBUDGET, which bumps
|
||||
// EvNxID, and nothing declared here could see it. `ours` still does not post the event, so
|
||||
// the divergence this region now reports is real and expected -- visible instead of silent.
|
||||
// EvNxID, and nothing declared here could see it. `ours` now posts into a model storage and
|
||||
// writes the counts into this region's scratch copy, so the region is a check and not just an
|
||||
// alarm -- see ours() and docs/P-events-wiring.md.
|
||||
void* owner = readable(tree, kTreeHeadSize) ? ptr_at(tree, A::TechTree_off_Owner) : nullptr;
|
||||
g_call.events_region = -1;
|
||||
if (readable(owner, kPlayerEventsOff + kEventStorageSize)) {
|
||||
|
|
@ -372,6 +471,18 @@ void TechTreeProcessResearchHook::regions(std::vector<trace::Region>& out, void*
|
|||
out.push_back(ev);
|
||||
}
|
||||
|
||||
// The owner's vector<ObservedTech>. Declared, not modelled: see describe_otch.
|
||||
g_call.otch_region = -1;
|
||||
if (readable(owner, kObservedTechsOff + kVectorHeaderSize)) {
|
||||
g_call.otch_region = static_cast<int>(out.size());
|
||||
trace::Region ot;
|
||||
ot.name = "observed_techs";
|
||||
ot.ptr = static_cast<char*>(owner) + kObservedTechsOff;
|
||||
ot.size = kVectorHeaderSize;
|
||||
ot.describe = &describe_otch;
|
||||
out.push_back(ot);
|
||||
}
|
||||
|
||||
// ---- guards: pushed LAST so every recorded index above is also a Scratch index ----------
|
||||
//
|
||||
// Coarse spans that no reimplementation writes. Anything the ORIGINAL moves inside them and
|
||||
|
|
@ -407,6 +518,10 @@ TechTreeProcessResearchHook::Args TechTreeProcessResearchHook::rebind(trace::Scr
|
|||
if (g_call.node_region[i] >= 0)
|
||||
g_call.scratch_nodes[i] = s.ptr(static_cast<std::size_t>(g_call.node_region[i]));
|
||||
}
|
||||
// The event storage is not one of the four parameters, so its scratch copy travels the same
|
||||
// way the node copies do: through CallState, resolved here where Scratch is in hand.
|
||||
g_call.scratch_events =
|
||||
g_call.events_region >= 0 ? s.ptr(static_cast<std::size_t>(g_call.events_region)) : nullptr;
|
||||
g_call.compare = true;
|
||||
// The tree pointer is passed through unchanged: ours only reads it (owner, node count) and
|
||||
// hands it to the game's own read-only Cost. Every node it writes is a scratch copy.
|
||||
|
|
@ -499,6 +614,58 @@ void TechTreeProcessResearchHook::ours(void* tree, void* rng, void* alloc, int*
|
|||
set_ptr(rng, A::RNG_off_Next,
|
||||
reinterpret_cast<void*>(base + static_cast<std::uintptr_t>(rand.gen.index()) * 4));
|
||||
}
|
||||
|
||||
// ---- the events this pass posts (count-only) --------------------------------------------
|
||||
//
|
||||
// Lane E's option (a): `ours` posts into its OWN sots::events::EventStorage, seeded from the
|
||||
// pre-call scan, and the counts go into the SCRATCH copy of the owner's storage header. The
|
||||
// game's own EventStorage::PostEvent is never called and no live byte is written, so this
|
||||
// cannot perturb the VM -- and it is the reason the write below is gated on `compare`.
|
||||
//
|
||||
// What that proves and what it does not: it proves the *decision* (did this pass post, how
|
||||
// many, in what id order) and it makes `region:events` a check. It does not prove the text,
|
||||
// which the game composes from its own string table -- see docs/P-events-wiring.md.
|
||||
//
|
||||
// In replace mode nothing is written: an EvNxID bumped without a serialized record behind it
|
||||
// would corrupt the save the oracle hashes, which is strictly worse than the missing event.
|
||||
if (compare && g_call.scratch_events && g_scan.storage.ok && g_scan.turn_ok) {
|
||||
sots::events::EventStorage log = SeedFromScan(g_scan.storage);
|
||||
const sots::events::EventText text = sots::events::KeylessEventText();
|
||||
|
||||
// ProcessResearchTurn emits one step per allocation entry **that was in range**, so the
|
||||
// steps are not index-aligned with `entries` when the budget names a tech this tree does
|
||||
// not have. Mirror its filter to recover which node each step belongs to. (Duplicating
|
||||
// the predicate is the price of not adding a field to the shared sim header while lane M
|
||||
// is working in src/game/sim.)
|
||||
std::vector<int> step_node;
|
||||
step_node.reserve(entries.size());
|
||||
for (const ResearchAllocEntry& e : entries) {
|
||||
if (e.nodeIndex < 0 || static_cast<std::size_t>(e.nodeIndex) >= model.size()) continue;
|
||||
step_node.push_back(e.nodeIndex);
|
||||
}
|
||||
if (step_node.size() != r.steps.size())
|
||||
throw std::runtime_error("research step/entry mapping is stale");
|
||||
|
||||
std::vector<sots::events::ResearchPassOutcome> outcomes;
|
||||
outcomes.reserve(r.steps.size());
|
||||
for (std::size_t i = 0; i < r.steps.size(); ++i) {
|
||||
sots::events::ResearchPassOutcome o;
|
||||
// The token only has to separate techs whose messages the original separates; the
|
||||
// node's own index does that and needs no string table. See KeylessEventText.
|
||||
char buf[24];
|
||||
std::snprintf(buf, sizeof buf, "%d", step_node[i]);
|
||||
o.techName = buf;
|
||||
o.overbudgetEvent = r.steps[i].overbudgetEvent;
|
||||
o.completed = r.steps[i].completed;
|
||||
o.completedEarly = r.steps[i].completedEarly;
|
||||
outcomes.push_back(std::move(o));
|
||||
}
|
||||
// nullptr, not an empty list: the EVENT_TECHS_UNLOCKED set comes from SetResearched's
|
||||
// child-unlock cascade, which `ours` deliberately does not run. Passing an empty list
|
||||
// would claim we computed the set and found it empty.
|
||||
sots::events::PostResearchPassEvents(log, text, outcomes, nullptr, g_scan.turn);
|
||||
WriteBackCounts(g_call.scratch_events, log, &readable);
|
||||
}
|
||||
}
|
||||
|
||||
void init_research(std::uintptr_t exe_base, void (*log_line)(const char* line)) {
|
||||
|
|
|
|||
|
|
@ -13,9 +13,15 @@
|
|||
// the Zuul double roll in one place, and its RNG consumption is observable. So the declared
|
||||
// regions are
|
||||
//
|
||||
// rng the whole 0x9cc-byte generator object -- mt[624] plus the stream position
|
||||
// overbudget the caller's accumulator
|
||||
// node[i] every non-null TechNode in the tree, 0x34 bytes each
|
||||
// rng the whole 0x9cc-byte generator object -- mt[624] plus the stream position
|
||||
// overbudget the caller's accumulator
|
||||
// node[i] every non-null TechNode in the tree, 0x34 bytes each
|
||||
// events the owner's inline EventStorage header (ServerPlayer+0x29c): EvNxID and the
|
||||
// turn-bucket count. `ours` posts the pass's events into its own model storage
|
||||
// and writes the counts into this region's scratch copy -- never into the game.
|
||||
// observed_techs the owner's vector<ObservedTech> header (ServerPlayer+0x274): declared so the
|
||||
// completion append is a named check rather than an undeclared write, and so
|
||||
// its byte delta measures the element stride, which is not yet pinned.
|
||||
//
|
||||
// and the compare is run with our own MT19937 seeded by load_state() from the *pre-call*
|
||||
// snapshot, so both implementations read the same stream. If the post-call generator state
|
||||
|
|
@ -54,22 +60,38 @@ struct TechTreeProcessResearchHook {
|
|||
static void ours(void* tree, void* rng, void* alloc, int* overbudget);
|
||||
static trace::HookPolicy policy() { return trace::HookPolicy{}; }
|
||||
static void coverage(trace::Coverage& c) {
|
||||
// THE B3 DEFECT. Kept as a note even though `events` is now a declared region, because
|
||||
// ours still does not post the event -- the region makes the gap loud, it does not close
|
||||
// it. See docs/harness-audit.md.
|
||||
c.unmodelled("posts EVENT_RESEARCH_OVERBUDGET on the owner's EventStorage in the same "
|
||||
"branch that sets node.flag = 2",
|
||||
trace::Risk::High,
|
||||
"the message text is composed from the tech name, so ours cannot synthesise "
|
||||
"it; it would have to be posted through the game's own event API. This is "
|
||||
"the defect that made a clean compare false: replace mode's autosave "
|
||||
"differed from the oracle by exactly this one event",
|
||||
"region:events (EvNxID now diverges instead of passing silently)");
|
||||
c.unmodelled("posts EVENT_TECHS_UNLOCKED for nodes that became available this turn",
|
||||
// THE B3 DEFECT, now modelled count-only (lane E option (a); see docs/P-events-wiring.md).
|
||||
c.unmodelled("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",
|
||||
trace::Risk::Medium,
|
||||
"the trailing unlock loop makes no draw and writes no node, but it does "
|
||||
"build a names list and post an event",
|
||||
"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",
|
||||
"region:events");
|
||||
// The one event the count model cannot decide. Its trigger IS pinned -- SetResearched's
|
||||
// second sweep sets state 2 and stamps turnAvailable, and the tail loop collects
|
||||
// state==2 && turnAvailable==currentTurn -- but evaluating it needs the cascade `ours`
|
||||
// does not run, so posting it would be a guess that happens to score.
|
||||
c.unmodelled("posts EVENT_TECHS_UNLOCKED once after the per-node loop, for the nodes "
|
||||
"SetResearched made available this turn",
|
||||
trace::Risk::Medium,
|
||||
"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",
|
||||
"region:events");
|
||||
c.unmodelled("appends to the owner's vector<ObservedTech> (ServerPlayer+0x274) on every "
|
||||
"tech completion",
|
||||
trace::Risk::High,
|
||||
"serialized ServerPlayer state that no coverage note in B2 or B3 mentioned "
|
||||
"until lane R's guard caught it. sizeof(ObservedTech) and the append call "
|
||||
"site are both unpinned, so ours cannot produce the bytes; the region "
|
||||
"reports the byte span, whose growth on a completion call measures the "
|
||||
"element stride",
|
||||
"region:observed_techs");
|
||||
c.unmodelled("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",
|
||||
|
|
|
|||
|
|
@ -326,6 +326,161 @@ static void test_real_save_shape() {
|
|||
CHECK_EQ(log.turns()[0].events[0].action, 0);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// The pass driver: what ONE TechTree::ProcessResearch call posts.
|
||||
//
|
||||
// The three cases are the three shapes lane R's guarded recapture actually captured
|
||||
// (findings/subsystems/golden-trace-recapture.md §2): an over-budget call, a completion call,
|
||||
// and the zero-spend calls in between.
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
static void keys_for_pass() {
|
||||
table().clear();
|
||||
table()["EVENTSUM_RESEARCH_OVERBUDGET"] = "sum-ob";
|
||||
table()["EVENTMSG_RESEARCH_OVERBUDGET"] = "ob %s";
|
||||
table()["EVENTSUM_RESEARCH_COMPLETE"] = "sum-c";
|
||||
table()["EVENTMSG_RESEARCH_COMPLETE"] = "c %s";
|
||||
table()["EVENTSUM_RESEARCH_UNDERBUDGET"] = "sum-u";
|
||||
table()["EVENTMSG_RESEARCH_UNDERBUDGET"] = "u %s";
|
||||
table()["EVENTSUM_UNLOCKEDTECHS"] = "sum-unl";
|
||||
table()["EVENTMSG_UNLOCKEDTECHS"] = "unl:";
|
||||
}
|
||||
|
||||
// recap-b3-compare call 0: alloc {node 144, 2889 points}, the roll failed on the turn the node
|
||||
// crossed its cost. The original took EvNxID 3 -> 4. This is harness-audit row 1.
|
||||
static void test_pass_overbudget_call() {
|
||||
keys_for_pass();
|
||||
EventStorage log;
|
||||
log.set_nextId(3);
|
||||
log.turns().push_back(TurnEvents{2, {}});
|
||||
log.turns().push_back(TurnEvents{3, {}}); // the bucket EVENT_SHIPS_BUILT already made
|
||||
|
||||
std::vector<ResearchPassOutcome> out(1);
|
||||
out[0].techName = "144";
|
||||
out[0].overbudgetEvent = true;
|
||||
|
||||
const ResearchPassResult r = PostResearchPassEvents(log, text_source(), out, nullptr, 3);
|
||||
CHECK_EQ(r.overbudgetPosts, 1);
|
||||
CHECK_EQ(r.completionPosts, 0);
|
||||
CHECK_EQ(r.techsUnlockedPosts, 0);
|
||||
CHECK_EQ(r.ids.size(), 1u);
|
||||
CHECK_EQ(r.ids[0], 3); // the id the record gets
|
||||
CHECK_EQ(r.nextId, 4); // <- the field the compare reports
|
||||
CHECK_EQ(log.turns().size(), 2u); // no new bucket: turn 3 already had one
|
||||
CHECK(log.turns()[1].events[0].image == "EVENT_RESEARCH_OVERBUDGET");
|
||||
CHECK_EQ(log.turns()[1].events[0].action, 1);
|
||||
CHECK(log.turns()[1].events[0].pos.x == FLT_MAX);
|
||||
}
|
||||
|
||||
// The zero-spend calls (1, 2, 4, 5, 7, 8, 10, 11, 13, 14): nothing happened, nothing posts,
|
||||
// and EvNxID must not move -- including for a player whose list is empty and whose EvNxID is 0.
|
||||
static void test_pass_posts_nothing_when_nothing_happened() {
|
||||
keys_for_pass();
|
||||
EventStorage log;
|
||||
std::vector<ResearchPassOutcome> out(2);
|
||||
out[0].techName = "90";
|
||||
out[1].techName = "9";
|
||||
const ResearchPassResult r = PostResearchPassEvents(log, text_source(), out, nullptr, 3);
|
||||
CHECK_EQ(r.ids.size(), 0u);
|
||||
CHECK_EQ(r.nextId, 0); // EvNxID is promoted to 1 only by an actual post
|
||||
CHECK_EQ(log.turns().size(), 0u);
|
||||
CHECK_EQ(log.total_events(), 0u);
|
||||
}
|
||||
|
||||
// recap-b3-compare call 3: the completion. The original posted TWO events (EvNxID 5 -> 7): the
|
||||
// completion event, which this driver models, and EVENT_TECHS_UNLOCKED, whose set comes from the
|
||||
// unlock cascade the hook does not run. With no unlock list the driver posts one, and that under-
|
||||
// count is the *predicted* residual -- not a silent miss.
|
||||
static void test_pass_completion_call() {
|
||||
keys_for_pass();
|
||||
EventStorage log;
|
||||
log.set_nextId(5);
|
||||
log.turns().push_back(TurnEvents{4, {}});
|
||||
|
||||
std::vector<ResearchPassOutcome> out(1);
|
||||
out[0].techName = "144";
|
||||
out[0].completed = true;
|
||||
out[0].completedEarly = false;
|
||||
|
||||
ResearchPassResult r = PostResearchPassEvents(log, text_source(), out, nullptr, 4);
|
||||
CHECK_EQ(r.completionPosts, 1);
|
||||
CHECK_EQ(r.techsUnlockedPosts, 0);
|
||||
CHECK_EQ(r.nextId, 6); // the original reached 7; the missing one is EVENT_TECHS_UNLOCKED
|
||||
CHECK(log.turns()[0].events[0].image == "EVENT_RESEARCH_COMPLETE");
|
||||
|
||||
// Given the unlock list, the same pass reaches 7 -- the model is complete, the input is not.
|
||||
EventStorage log2;
|
||||
log2.set_nextId(5);
|
||||
log2.turns().push_back(TurnEvents{4, {}});
|
||||
const std::vector<std::string> unlocked{"91", "92"};
|
||||
r = PostResearchPassEvents(log2, text_source(), out, &unlocked, 4);
|
||||
CHECK_EQ(r.techsUnlockedPosts, 1);
|
||||
CHECK_EQ(r.nextId, 7);
|
||||
CHECK(log2.turns()[0].events[1].image == "EVENT_TECHS_UNLOCKED");
|
||||
|
||||
// An empty (non-null) list is "computed, and empty": still no post, but a different claim.
|
||||
EventStorage log3;
|
||||
log3.set_nextId(5);
|
||||
const std::vector<std::string> none;
|
||||
r = PostResearchPassEvents(log3, text_source(), out, &none, 4);
|
||||
CHECK_EQ(r.techsUnlockedPosts, 0);
|
||||
CHECK_EQ(r.nextId, 6);
|
||||
}
|
||||
|
||||
// A cheap completion takes the UNDERBUDGET image -- and posts exactly one event either way, so
|
||||
// the id count does not depend on the split.
|
||||
static void test_pass_early_completion_picks_underbudget() {
|
||||
keys_for_pass();
|
||||
EventStorage log;
|
||||
std::vector<ResearchPassOutcome> out(1);
|
||||
out[0].techName = "7";
|
||||
out[0].completed = true;
|
||||
out[0].completedEarly = true;
|
||||
const ResearchPassResult r = PostResearchPassEvents(log, text_source(), out, nullptr, 1);
|
||||
CHECK_EQ(r.completionPosts, 1);
|
||||
CHECK_EQ(r.nextId, 2);
|
||||
CHECK(log.turns()[0].events[0].image == "EVENT_RESEARCH_UNDERBUDGET");
|
||||
}
|
||||
|
||||
// Two different techs going over budget in the same turn differ only in EvMsg -- which IS
|
||||
// compared -- so both survive; the same tech twice collapses to one id. This is the property
|
||||
// the shim's keyless lookup has to preserve, so it is pinned through that lookup.
|
||||
static void test_pass_dedup_across_nodes() {
|
||||
EventStorage log;
|
||||
std::vector<ResearchPassOutcome> out(2);
|
||||
out[0].techName = "144";
|
||||
out[0].overbudgetEvent = true;
|
||||
out[1].techName = "9";
|
||||
out[1].overbudgetEvent = true;
|
||||
ResearchPassResult r = PostResearchPassEvents(log, KeylessEventText(), out, nullptr, 3);
|
||||
CHECK_EQ(r.ids.size(), 2u);
|
||||
CHECK_EQ(r.ids[0], 1);
|
||||
CHECK_EQ(r.ids[1], 2);
|
||||
CHECK_EQ(r.nextId, 3);
|
||||
CHECK_EQ(log.total_events(), 2u);
|
||||
|
||||
EventStorage same;
|
||||
out[1].techName = "144"; // the same node twice: the original's messages would be identical
|
||||
r = PostResearchPassEvents(same, KeylessEventText(), out, nullptr, 3);
|
||||
CHECK_EQ(r.ids.size(), 2u);
|
||||
CHECK_EQ(r.ids[0], 1);
|
||||
CHECK_EQ(r.ids[1], 1); // the duplicate returns the existing id
|
||||
CHECK_EQ(r.nextId, 2); // and burns neither an id nor a slot
|
||||
CHECK_EQ(same.total_events(), 1u);
|
||||
}
|
||||
|
||||
// The keyless lookup must keep an over-budget and a completion for the SAME node apart: they
|
||||
// share a message but differ in EvImg, which the comparator also tests.
|
||||
static void test_keyless_lookup_keeps_images_apart() {
|
||||
EventStorage log;
|
||||
PostResearchOverbudget(log, KeylessEventText(), "144", 3);
|
||||
PostResearchCompleted(log, KeylessEventText(), "144", 1.f, 3);
|
||||
CHECK_EQ(log.total_events(), 2u);
|
||||
CHECK_EQ(log.nextId(), 3);
|
||||
CHECK(log.turns()[0].events[0].message == log.turns()[0].events[1].message);
|
||||
CHECK(log.turns()[0].events[0].image != log.turns()[0].events[1].image);
|
||||
}
|
||||
|
||||
int main() {
|
||||
test_ids_and_next_id();
|
||||
test_default_record();
|
||||
|
|
@ -340,5 +495,11 @@ int main() {
|
|||
test_research_event_posts();
|
||||
test_completion_message_is_capped();
|
||||
test_real_save_shape();
|
||||
test_pass_overbudget_call();
|
||||
test_pass_posts_nothing_when_nothing_happened();
|
||||
test_pass_completion_call();
|
||||
test_pass_early_completion_picks_underbudget();
|
||||
test_pass_dedup_across_nodes();
|
||||
test_keyless_lookup_keeps_images_apart();
|
||||
return simtest::finish("game_events");
|
||||
}
|
||||
|
|
|
|||
6
tests/shim_events/CMakeLists.txt
Normal file
6
tests/shim_events/CMakeLists.txt
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
# P: the event adapter (a live Game::EventStorage <-> the sots::events model).
|
||||
add_executable(shim_events_unit_tests unit_tests.cpp)
|
||||
target_link_libraries(shim_events_unit_tests PRIVATE shim_events)
|
||||
target_include_directories(shim_events_unit_tests PRIVATE ${CMAKE_SOURCE_DIR}/tests/game_sim)
|
||||
target_compile_options(shim_events_unit_tests PRIVATE -Wall -Wextra -Werror)
|
||||
add_test(NAME shim_events_unit COMMAND shim_events_unit_tests)
|
||||
366
tests/shim_events/unit_tests.cpp
Normal file
366
tests/shim_events/unit_tests.cpp
Normal file
|
|
@ -0,0 +1,366 @@
|
|||
// P adapter tests: read a live-shaped Game::EventStorage out of raw bytes, seed the model from
|
||||
// it, and write the counts back the way compare mode does.
|
||||
//
|
||||
// The fixture is a byte buffer laid out with the *real* offsets from the generated header
|
||||
// (EventStorage 0x1c / TurnEvents 0x18 / PlayerEvent 0x74, MSVC-2010 std::string 0x1c), so these
|
||||
// cases pin the nested turn-bucketed shape and the stride, not just the arithmetic. The shape is
|
||||
// the one verify/results/saves/turn3-state.sav holds for player 1 and the one
|
||||
// verify/state-checksum/state_checksum.py walks: EvNxID, then n x {EvTurn, m x PlayerEvent}.
|
||||
#include "shim/hooks/event_inputs.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "check.h"
|
||||
#include "game/events/research_events.h"
|
||||
#include "generated/sots_addresses.h"
|
||||
|
||||
using namespace shim::hooks;
|
||||
namespace A = sots::addr;
|
||||
|
||||
namespace {
|
||||
|
||||
// ---- a fake address space -----------------------------------------------------------------
|
||||
//
|
||||
// One arena; `probe` answers "is this range inside it?", which is the host stand-in for the
|
||||
// shim's VirtualQuery. Everything the adapter reads must go through it.
|
||||
std::vector<char>& arena() {
|
||||
static std::vector<char> a;
|
||||
return a;
|
||||
}
|
||||
|
||||
bool probe(const void* p, std::size_t n) {
|
||||
if (!p) return false;
|
||||
if (n == 0) return true;
|
||||
const char* c = static_cast<const char*>(p);
|
||||
const char* lo = arena().data();
|
||||
const char* hi = lo + arena().size();
|
||||
return c >= lo && c + n <= hi && c + n >= c;
|
||||
}
|
||||
|
||||
void put_word(std::size_t off, std::int32_t v) { std::memcpy(&arena()[off], &v, sizeof v); }
|
||||
|
||||
// A pointer *inside a game object* is four bytes, and in this fake address space a game address
|
||||
// is simply an offset into the arena, biased so that 0 stays "null".
|
||||
constexpr std::uint32_t kArenaBias = 0x40000000;
|
||||
void put_addr(std::size_t off, std::size_t targetOff) {
|
||||
const std::uint32_t a = kArenaBias + static_cast<std::uint32_t>(targetOff);
|
||||
std::memcpy(&arena()[off], &a, sizeof a);
|
||||
}
|
||||
void put_addr_raw(std::size_t off, std::uint32_t a) { std::memcpy(&arena()[off], &a, sizeof a); }
|
||||
void put_null(std::size_t off) { put_addr_raw(off, 0); }
|
||||
|
||||
const void* to_host(std::uint32_t a) {
|
||||
if (a < kArenaBias) return nullptr; // not one of ours: the probe will reject it
|
||||
const std::size_t off = a - kArenaBias;
|
||||
if (off > arena().size()) return nullptr;
|
||||
return arena().data() + off;
|
||||
}
|
||||
|
||||
// An MSVC 2010 std::string at `off`, short enough to live in the inline buffer (_Myres 15).
|
||||
void put_short_string(std::size_t off, const std::string& s) {
|
||||
CHECK(s.size() < kStdStringInlineCap);
|
||||
std::memcpy(&arena()[off], s.data(), s.size());
|
||||
arena()[off + s.size()] = '\0';
|
||||
put_word(off + kStdStringOffSize, static_cast<std::int32_t>(s.size()));
|
||||
put_word(off + kStdStringOffRes, static_cast<std::int32_t>(kStdStringInlineCap - 1));
|
||||
}
|
||||
|
||||
// The same string, but heap-allocated: _Myres >= 16 and the first word is the pointer. Every
|
||||
// EvImg in a real save takes this form -- "EVENT_RESEARCH_OVERBUDGET" is 25 characters -- so it
|
||||
// is the form that actually matters, and reading the inline buffer instead yields garbage.
|
||||
void put_long_string(std::size_t off, std::size_t bufOff, const std::string& s) {
|
||||
std::memcpy(&arena()[bufOff], s.data(), s.size());
|
||||
arena()[bufOff + s.size()] = '\0';
|
||||
put_addr(off, bufOff);
|
||||
put_word(off + kStdStringOffSize, static_cast<std::int32_t>(s.size()));
|
||||
put_word(off + kStdStringOffRes, static_cast<std::int32_t>(s.size() + 8));
|
||||
}
|
||||
|
||||
// Pick the form the real string would take.
|
||||
void put_string(std::size_t off, std::size_t bufOff, const std::string& s) {
|
||||
if (s.size() < kStdStringInlineCap)
|
||||
put_short_string(off, s);
|
||||
else
|
||||
put_long_string(off, bufOff, s);
|
||||
}
|
||||
|
||||
// ---- the fixture ----------------------------------------------------------------------------
|
||||
//
|
||||
// Layout inside the arena:
|
||||
// 0x0000 EventStorage (0x1c)
|
||||
// 0x0100 TurnEvents[] (0x18 each)
|
||||
// 0x0400 PlayerEvent[] for bucket 1 (0x74 each)
|
||||
// 0x1000 string heap
|
||||
constexpr std::size_t kStorage = 0x0000;
|
||||
constexpr std::size_t kBuckets = 0x0100;
|
||||
constexpr std::size_t kEvents = 0x0400;
|
||||
constexpr std::size_t kHeap = 0x1000;
|
||||
|
||||
// `images` is one EvImg per event in the *second* bucket; the first bucket is left empty.
|
||||
void build(int nextId, const std::vector<int>& bucketTurns, const std::vector<std::string>& images,
|
||||
std::size_t whichBucketHasEvents) {
|
||||
arena().assign(0x2000, 0);
|
||||
put_word(kStorage + A::EventStorage_off_EvNxID, nextId);
|
||||
if (bucketTurns.empty()) {
|
||||
put_null(kStorage + A::EventStorage_off_Events);
|
||||
put_null(kStorage + A::EventStorage_off_Events + kGamePtrSize);
|
||||
put_null(kStorage + A::EventStorage_off_Events + 2 * kGamePtrSize);
|
||||
return;
|
||||
}
|
||||
const std::size_t bytes = bucketTurns.size() * A::TurnEvents_sizeof;
|
||||
put_addr(kStorage + A::EventStorage_off_Events, kBuckets);
|
||||
put_addr(kStorage + A::EventStorage_off_Events + kGamePtrSize, kBuckets + bytes);
|
||||
put_addr(kStorage + A::EventStorage_off_Events + 2 * kGamePtrSize, kBuckets + bytes);
|
||||
|
||||
for (std::size_t i = 0; i < bucketTurns.size(); ++i) {
|
||||
const std::size_t b = kBuckets + i * A::TurnEvents_sizeof;
|
||||
put_word(b + A::TurnEvents_off_EvTurn, bucketTurns[i]);
|
||||
if (i != whichBucketHasEvents || images.empty()) {
|
||||
put_null(b + A::TurnEvents_off_Events);
|
||||
put_null(b + A::TurnEvents_off_Events + kGamePtrSize);
|
||||
put_null(b + A::TurnEvents_off_Events + 2 * kGamePtrSize);
|
||||
continue;
|
||||
}
|
||||
const std::size_t evBytes = images.size() * A::PlayerEvent_sizeof;
|
||||
put_addr(b + A::TurnEvents_off_Events, kEvents);
|
||||
put_addr(b + A::TurnEvents_off_Events + kGamePtrSize, kEvents + evBytes);
|
||||
put_addr(b + A::TurnEvents_off_Events + 2 * kGamePtrSize, kEvents + evBytes);
|
||||
for (std::size_t k = 0; k < images.size(); ++k) {
|
||||
const std::size_t img = kEvents + k * A::PlayerEvent_sizeof + A::PlayerEvent_off_EvImg;
|
||||
put_string(img, kHeap + k * 0x40, images[k]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const void* storage() { return arena().data() + kStorage; }
|
||||
|
||||
// Read the turns vector's {first, last} back out of a scratch header, as 32-bit game addresses.
|
||||
std::size_t scratch_turns_bytes(const std::vector<char>& scratch) {
|
||||
std::uint32_t first = 0, last = 0;
|
||||
std::memcpy(&first, scratch.data() + A::EventStorage_off_Events, sizeof first);
|
||||
std::memcpy(&last, scratch.data() + A::EventStorage_off_Events + kGamePtrSize, sizeof last);
|
||||
CHECK(last >= first);
|
||||
return last - first;
|
||||
}
|
||||
std::uint32_t scratch_turns_first(const std::vector<char>& scratch) {
|
||||
std::uint32_t first = 0;
|
||||
std::memcpy(&first, scratch.data() + A::EventStorage_off_Events, sizeof first);
|
||||
return first;
|
||||
}
|
||||
// The write-back runs on a scratch buffer, not on the arena, so it gets its own probe.
|
||||
bool scratch_probe(const void* p, std::size_t n) { return p != nullptr && n <= 0x100; }
|
||||
|
||||
// ---- cases ------------------------------------------------------------------------------------
|
||||
|
||||
// turn3-state.sav, player 1: EvNxID 4, buckets for turns 2 and 3, the turn-3 bucket holding
|
||||
// EVENT_SHIPS_BUILT and EVENT_RESEARCH_OVERBUDGET.
|
||||
void test_scan_reads_the_real_save_shape() {
|
||||
build(4, {2, 3}, {"EVENT_SHIPS_BUILT", "EVENT_RESEARCH_OVERBUDGET"}, 1);
|
||||
const EventStorageScan s = ScanEventStorage(storage(), 3, &probe, &to_host);
|
||||
CHECK(s.ok);
|
||||
CHECK(!s.scanTruncated);
|
||||
CHECK_EQ(s.nextId, 4);
|
||||
CHECK_EQ(s.bucketTurns.size(), 2u);
|
||||
CHECK_EQ(s.bucketTurns[0], 2);
|
||||
CHECK_EQ(s.bucketTurns[1], 3);
|
||||
CHECK_EQ(s.turnsBytes, 2u * A::TurnEvents_sizeof);
|
||||
CHECK(s.turnBucketExists);
|
||||
CHECK_EQ(s.eventsInTurnBucket, 2u);
|
||||
// Only the research event counts towards the dedup risk; EVENT_SHIPS_BUILT can never be a
|
||||
// duplicate of anything the research path posts.
|
||||
CHECK_EQ(s.researchEventsInTurnBucket, 1u);
|
||||
}
|
||||
|
||||
// The pre-call state of recap-b3-compare call 0: EvNxID 3 and no research event in the bucket
|
||||
// yet -- so a count-only model is exact on that call.
|
||||
void test_scan_reports_no_dedup_risk_before_the_post() {
|
||||
build(3, {2, 3}, {"EVENT_SHIPS_BUILT"}, 1);
|
||||
const EventStorageScan s = ScanEventStorage(storage(), 3, &probe, &to_host);
|
||||
CHECK_EQ(s.nextId, 3);
|
||||
CHECK_EQ(s.researchEventsInTurnBucket, 0u);
|
||||
CHECK_EQ(s.eventsInTurnBucket, 1u);
|
||||
}
|
||||
|
||||
// A player who has never seen an event: EvNxID 0 and {null,null,null}. Two of the four players
|
||||
// in the reference save look exactly like this.
|
||||
void test_scan_of_an_empty_storage() {
|
||||
build(0, {}, {}, 0);
|
||||
const EventStorageScan s = ScanEventStorage(storage(), 3, &probe, &to_host);
|
||||
CHECK(s.ok);
|
||||
CHECK_EQ(s.nextId, 0);
|
||||
CHECK_EQ(s.bucketTurns.size(), 0u);
|
||||
CHECK_EQ(s.turnsBytes, 0u);
|
||||
CHECK(!s.turnBucketExists);
|
||||
}
|
||||
|
||||
// The heap form of std::string must read the same as the inline form.
|
||||
void test_scan_reads_heap_strings() {
|
||||
build(4, {3}, {"EVENT_RESEARCH_UNDERBUDGET"}, 0);
|
||||
const EventStorageScan s = ScanEventStorage(storage(), 3, &probe, &to_host);
|
||||
CHECK(s.ok);
|
||||
CHECK_EQ(s.researchEventsInTurnBucket, 1u);
|
||||
}
|
||||
|
||||
// A header that is not a vector must not be believed, and must not walk off the arena.
|
||||
void test_scan_rejects_a_bad_header() {
|
||||
build(4, {3}, {}, 0);
|
||||
// last < first
|
||||
put_addr(kStorage + A::EventStorage_off_Events, kBuckets + A::TurnEvents_sizeof);
|
||||
put_addr(kStorage + A::EventStorage_off_Events + kGamePtrSize, kBuckets);
|
||||
CHECK(!ScanEventStorage(storage(), 3, &probe, &to_host).ok);
|
||||
|
||||
// a span that is not a whole number of TurnEvents
|
||||
build(4, {3}, {}, 0);
|
||||
put_addr(kStorage + A::EventStorage_off_Events + kGamePtrSize, kBuckets + 7);
|
||||
CHECK(!ScanEventStorage(storage(), 3, &probe, &to_host).ok);
|
||||
|
||||
// a span far larger than the arena
|
||||
build(4, {3}, {}, 0);
|
||||
put_addr(kStorage + A::EventStorage_off_Events + kGamePtrSize,
|
||||
kBuckets + (kMaxTurnBuckets + 1) * A::TurnEvents_sizeof);
|
||||
const EventStorageScan s = ScanEventStorage(storage(), 3, &probe, &to_host);
|
||||
CHECK(!s.ok);
|
||||
}
|
||||
|
||||
// ---- seed + post + write back: the whole compare-mode path -----------------------------------
|
||||
|
||||
// recap-b3-compare call 0, end to end. THE case: EvNxID 3 -> 4 with one over-budget post, and
|
||||
// `turns` unchanged because the turn-3 bucket already existed.
|
||||
void test_overbudget_call_reaches_next_id_4() {
|
||||
build(3, {2, 3}, {"EVENT_SHIPS_BUILT"}, 1);
|
||||
const EventStorageScan s = ScanEventStorage(storage(), 3, &probe, &to_host);
|
||||
CHECK_EQ(s.researchEventsInTurnBucket, 0u); // count-only is exact here
|
||||
|
||||
sots::events::EventStorage log = SeedFromScan(s);
|
||||
CHECK_EQ(log.nextId(), 3);
|
||||
CHECK_EQ(log.turns().size(), 2u);
|
||||
|
||||
std::vector<sots::events::ResearchPassOutcome> out(1);
|
||||
out[0].techName = "144";
|
||||
out[0].overbudgetEvent = true;
|
||||
const sots::events::ResearchPassResult r =
|
||||
sots::events::PostResearchPassEvents(log, sots::events::KeylessEventText(), out, nullptr, 3);
|
||||
CHECK_EQ(r.nextId, 4);
|
||||
|
||||
// The scratch copy the region diff reads is a byte copy of the header.
|
||||
std::vector<char> scratch(A::EventStorage_sizeof);
|
||||
std::memcpy(scratch.data(), storage(), scratch.size());
|
||||
WriteBackCounts(scratch.data(), log, &scratch_probe);
|
||||
|
||||
std::int32_t nextId = 0;
|
||||
std::memcpy(&nextId, scratch.data() + A::EventStorage_off_EvNxID, 4);
|
||||
CHECK_EQ(nextId, 4);
|
||||
CHECK_EQ(scratch_turns_bytes(scratch), 2u * A::TurnEvents_sizeof);
|
||||
}
|
||||
|
||||
// The same pass on a turn with no bucket yet: `turns` must grow by one, which is what the
|
||||
// region's turns_bytes reports.
|
||||
void test_write_back_grows_turns_when_a_bucket_is_created() {
|
||||
build(3, {2}, {}, 0);
|
||||
const EventStorageScan s = ScanEventStorage(storage(), 9, &probe, &to_host);
|
||||
CHECK(!s.turnBucketExists);
|
||||
sots::events::EventStorage log = SeedFromScan(s);
|
||||
std::vector<sots::events::ResearchPassOutcome> out(1);
|
||||
out[0].techName = "144";
|
||||
out[0].overbudgetEvent = true;
|
||||
sots::events::PostResearchPassEvents(log, sots::events::KeylessEventText(), out, nullptr, 9);
|
||||
CHECK_EQ(log.turns().size(), 2u);
|
||||
|
||||
std::vector<char> scratch(A::EventStorage_sizeof);
|
||||
std::memcpy(scratch.data(), storage(), scratch.size());
|
||||
WriteBackCounts(scratch.data(), log, &scratch_probe);
|
||||
CHECK_EQ(scratch_turns_bytes(scratch), 2u * A::TurnEvents_sizeof);
|
||||
}
|
||||
|
||||
// A previously empty storage: the header has no pointers to extend, so the write-back has to
|
||||
// synthesise a base or the span would read 0 where the original allocated one bucket.
|
||||
void test_write_back_on_a_previously_empty_storage() {
|
||||
build(0, {}, {}, 0);
|
||||
const EventStorageScan s = ScanEventStorage(storage(), 3, &probe, &to_host);
|
||||
sots::events::EventStorage log = SeedFromScan(s);
|
||||
CHECK_EQ(log.nextId(), 0);
|
||||
std::vector<sots::events::ResearchPassOutcome> out(1);
|
||||
out[0].techName = "144";
|
||||
out[0].overbudgetEvent = true;
|
||||
sots::events::PostResearchPassEvents(log, sots::events::KeylessEventText(), out, nullptr, 3);
|
||||
|
||||
std::vector<char> scratch(A::EventStorage_sizeof);
|
||||
std::memcpy(scratch.data(), storage(), scratch.size());
|
||||
WriteBackCounts(scratch.data(), log, &scratch_probe);
|
||||
std::int32_t nextId = 0;
|
||||
std::memcpy(&nextId, scratch.data() + A::EventStorage_off_EvNxID, 4);
|
||||
CHECK_EQ(nextId, 2); // 0 -> promoted to 1 by the post -> post-incremented
|
||||
CHECK_EQ(scratch_turns_first(scratch), kSyntheticTurnsBase);
|
||||
CHECK_EQ(scratch_turns_bytes(scratch), 1u * A::TurnEvents_sizeof);
|
||||
}
|
||||
|
||||
// A zero-spend call must leave the header byte-identical: no id, no bucket.
|
||||
void test_write_back_is_a_no_op_when_nothing_posted() {
|
||||
build(3, {2, 3}, {"EVENT_SHIPS_BUILT"}, 1);
|
||||
const EventStorageScan s = ScanEventStorage(storage(), 3, &probe, &to_host);
|
||||
sots::events::EventStorage log = SeedFromScan(s);
|
||||
std::vector<sots::events::ResearchPassOutcome> out(2);
|
||||
out[0].techName = "90";
|
||||
out[1].techName = "9";
|
||||
sots::events::PostResearchPassEvents(log, sots::events::KeylessEventText(), out, nullptr, 3);
|
||||
|
||||
std::vector<char> before(A::EventStorage_sizeof), scratch(A::EventStorage_sizeof);
|
||||
std::memcpy(before.data(), storage(), before.size());
|
||||
std::memcpy(scratch.data(), storage(), scratch.size());
|
||||
WriteBackCounts(scratch.data(), log, &scratch_probe);
|
||||
CHECK(std::memcmp(before.data(), scratch.data(), before.size()) == 0);
|
||||
}
|
||||
|
||||
// Both std::string forms read the same, and a misread is reported rather than guessed at.
|
||||
void test_read_std_string_forms_and_bounds() {
|
||||
arena().assign(0x200, 0);
|
||||
std::string out = "untouched";
|
||||
put_short_string(0, "EVENT_X");
|
||||
CHECK(ReadStdStringMapped(arena().data(), &probe, &to_host, out));
|
||||
CHECK(out == "EVENT_X");
|
||||
|
||||
// The heap form -- what every real EvImg uses.
|
||||
arena().assign(0x200, 0);
|
||||
put_long_string(0, 0x80, "EVENT_RESEARCH_OVERBUDGET");
|
||||
out = "untouched";
|
||||
CHECK(ReadStdStringMapped(arena().data(), &probe, &to_host, out));
|
||||
CHECK(out == "EVENT_RESEARCH_OVERBUDGET");
|
||||
|
||||
// A length past the sanity cap.
|
||||
put_word(kStdStringOffSize, 0x2000);
|
||||
out = "untouched";
|
||||
CHECK(!ReadStdStringMapped(arena().data(), &probe, &to_host, out));
|
||||
CHECK(out == "untouched");
|
||||
|
||||
// _Myres < _Mysize is impossible in a well-formed string.
|
||||
arena().assign(0x200, 0);
|
||||
put_long_string(0, 0x80, "EVENT_RESEARCH_OVERBUDGET");
|
||||
put_word(kStdStringOffRes, 4);
|
||||
CHECK(!ReadStdStringMapped(arena().data(), &probe, &to_host, out));
|
||||
|
||||
// A heap string whose buffer address is not in the arena at all.
|
||||
arena().assign(0x200, 0);
|
||||
put_addr_raw(0, 0xdead0000);
|
||||
put_word(kStdStringOffSize, 8);
|
||||
put_word(kStdStringOffRes, 31);
|
||||
CHECK(!ReadStdStringMapped(arena().data(), &probe, &to_host, out));
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
test_scan_reads_the_real_save_shape();
|
||||
test_scan_reports_no_dedup_risk_before_the_post();
|
||||
test_scan_of_an_empty_storage();
|
||||
test_scan_reads_heap_strings();
|
||||
test_scan_rejects_a_bad_header();
|
||||
test_overbudget_call_reaches_next_id_4();
|
||||
test_write_back_grows_turns_when_a_bucket_is_created();
|
||||
test_write_back_on_a_previously_empty_storage();
|
||||
test_write_back_is_a_no_op_when_nothing_posted();
|
||||
test_read_std_string_forms_and_bounds();
|
||||
return simtest::finish("shim_events");
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue