diff --git a/CMakeLists.txt b/CMakeLists.txt index 28052cf..23cca1e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -46,6 +46,11 @@ add_library(shim_budget STATIC src/shim/hooks/budget_inputs.cpp) target_link_libraries(shim_budget PUBLIC shim_trace sots_game_sim) target_compile_options(shim_budget PRIVATE -Wall -Wextra -Werror) +# ---- B2 tech-effect adapter: ServerPlayer fields <-> game::effects state (pure, host-tested) ---- +add_library(shim_techfx STATIC src/shim/hooks/tech_effect_fields.cpp) +target_link_libraries(shim_techfx PUBLIC shim_trace sots_addresses sots_game_effects) +target_compile_options(shim_techfx PRIVATE -Wall -Wextra -Werror) + if(WIN32) # ---- shim: proxy binkw32.dll that the original game loads (Phase 2 frontend) ---- add_library(minhook STATIC @@ -58,8 +63,10 @@ if(WIN32) # ---- hooks: one descriptor per hooked game function (src/shim/hooks/*) ---- add_library(shim_hooks STATIC src/shim/hooks/global_consts.cpp src/shim/hooks/dictionaries.cpp src/shim/hooks/research.cpp + src/shim/hooks/tech_effects.cpp src/shim/hooks/compute_budget.cpp) - target_link_libraries(shim_hooks PUBLIC shim_trace sots_addresses sots_game_config sots_game_sim mars_rng shim_budget) + 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) target_compile_options(shim_hooks PRIVATE -Wall -Wextra -Werror) add_library(binkw32 SHARED src/shim/main.cpp src/shim/binkw32.def) @@ -75,7 +82,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 shim_budget) + foreach(_t mars_parse game_config game_data game_design game_sim mars_stream mars_text mars_vfs shim_trace game_effects shim_budget shim_techfx) if(EXISTS ${CMAKE_SOURCE_DIR}/tests/${_t}/CMakeLists.txt) add_subdirectory(tests/${_t}) endif() diff --git a/docs/B2.md b/docs/B2.md new file mode 100644 index 0000000..34ce91d --- /dev/null +++ b/docs/B2.md @@ -0,0 +1,363 @@ +# B2 — `ServerPlayer::OnTechResearched` old-vs-new, with the player's fields as named regions + +**Status (2026-09-08): code complete, cross-built and staged; every VM step still owed.** +VM140 was held by another lane for the whole of this milestone, so nothing was deployed, +the game was not stopped or relaunched, and `C:\SOTS` was not touched. The build lives in +its own tree (`/srv/re-lab/build/sots-engine-b2`) and its own dist +(`/srv/re-lab/shim/dist-b2`), not the shared ones. Everything below is offline work plus +what the *binary* says; the run list is at the end. + +The point of the target: it is the single place where 36 hard-coded strategic effects, the +per-species xenotech flag words, the two design-option masks and the node-bore parameters +are all written. One call validates the whole `src/game/effects` layer at once. + +## What was hooked + +| hook name (record `hook`) | RVA | prototype | +|---|---|---| +| `Game::ServerPlayer::OnTechResearched` | 0x00491790 | `void (ServerPlayer*, TechDef* def, bool silent)` | + +`__thiscall`, `[verified]`, vft slot 4, so it goes through `Hook<>` with +`CallConv::Thiscall`. Source: `src/shim/hooks/tech_effects.{h,cpp}` (the descriptor and the +delegation into the game's tech tree) over `src/shim/hooks/tech_effect_fields.{h,cpp}` (the +byte-level field adapter, host-tested — the split B1 made for `budget_inputs`). Installed +from `src/shim/main.cpp` after the B3 hook. + +`TechTree::SetResearched` is the only caller. It fires **once per tech completion**, which +on a typical turn is zero times — see "Coverage" below, because that is the hard part of +this milestone. + +### Region model + +Fourteen regions, one per field group the callback writes, each with a struct describer, so +a divergence reads `side.modifiers.after.v.out_mod` rather than a byte offset in a blob: + +| region | offset | what the describer names | +|---|---|---| +| `suit` | +0xb4, 8 B | `suit_tol`, `max_overharvest` | +| `res_mod` | +0xc0, 4 B | `res_mod` | +| `abilities` | +0xfc, 12 B | `reb_ai`, `ai_benefit`, `trade_allowed`, `commerce_raiding`, `view_intel`, `grav_synth`, `advanced_sensors`, `arcology`, + 4 unmodelled bytes | +| `modifiers` | +0x108, 0x54 B | `pddm`, `con_mod[3]`, `sav_mod[3]`, `out_mod`, `pop_mod`, `terra_mod`, `asteroid_mining`, `min_rate`, `per_gate_traffic`, `cast_range/efficiency/threshold`, + 5 unmodelled words | +| `design_masks` | +0x190, 8 B | `design_options_a`, `design_options_b` | +| `translation` | +0x1a4, 4 B | `translation_known` | +| `vaccines` | +0x288, 12 B | `has_vaccine`, `has_immunity`, `node_track_mask` | +| `research_target` | +0x294, 4 B | `research_target`, `research_target_set` | +| `node_bore_ptr` | +0x308, 4 B | the block pointer, as an opaque `ptr` | +| `inc_mod` | +0x30c, 4 B | `inc_mod` | +| `capture_designs` | +0x330, 4 B | `capture_designs` | +| `species_flags` | +0x348, 0x20 B | `species_flags[7]`, `count` | +| `roll` | +0x3b4, 8 B | `research_roll_pending`, `ai_rebellion` | +| `node_bore` | `*(+0x308)`, 12 B | `node_bore_params[3]` — declared **only when the block already exists** | + +The unmodelled words are emitted as raw `u32` on purpose: if the original writes one of +them, that is a real finding and shows up as a divergence instead of going unseen. Pointer +words are emitted as `ptr` (ignored by the default policy) because they differ run to run. +`tests/shim_techfx` asserts that no two regions overlap and that each stays inside the +declared span. + +### Comparison design + +The regions do not cover the whole object, so there is no "scratch `ServerPlayer`" to hand +`ours`. Instead `rebind` passes `self` through unchanged and records the scratch buffer of +each region; `ours` then reads and writes **every field the callback can touch** through +those buffers, and reads only never-written fields (species, the tech-tree pointer) off the +live object, where "live" and "before" are the same value. `techfx::Views` is that +indirection, and it is what makes replace mode (no regions at all) the same code path. + +Two things `ours` delegates to the game, in the same spirit as B3 delegating +`TechTree::Cost`: + +* **the researched set** — 196 calls to `TechTree::HasResearched`, a verified, read-only + three-compare function. In compare mode the tree is the live, post-original one, which + is exactly the state the original's own tail saw (the completing node is already state 4). +* **the TechId of the definition** — 196 calls to `MasterTechTree::IsTech`, for the reason + in "Findings" below. + +## What `ours` deliberately does not do + +Compare mode must not touch live game state or consume randomness, so `ours` reproduces the +player-field writes and nothing else. Not reproduced, and each reported in `shim.log` +instead (one `techfx: ours ...` line per completion): + +* the three completion events (`EVENT_RESEARCH_COMPLETE` / `_UNDERBUDGET` / `_TEMPERANCE`); +* the pending plague-cure roll — it draws from the strategic generator, so `ours` clears + the two words it guards and logs whether it would have fired; +* the writes to *other* objects: every owned system's AI flag (`CCC_AIVrus` / `CCC_AISlv`), + the arcology civilian-cap re-evaluation, the addiction cure behind the temperance sweep, + and the plague clear across systems and ships; +* `TechTree::SetResearched` for the Zuul boarding-pod grant (it would mutate the tree). + +None of those touch a declared region, so **a compare stays clean through them** — but it +also does not check them. That is the honest boundary of this milestone: the compare proves +the player's fields, the log line proves what our layer decided about the rest. + +One consequence worth stating: the Zuul grant makes the original call itself recursively. +The inner call rewrites the same tail fields with `IND_BrdPod` researched — and `IND_BrdPod` +is in neither mask table, has no chain branch and no xenotech bit, so the inner call's +writes equal the outer's. The compare is unaffected either way, since `ours` reads the +post-original tree. + +## Findings — what the binary says that the notes did not + +Read off the instruction stream first; the compare is confirmation, not discovery (B3's +lesson). Ten corrections, all of which changed code. + +### 1. Every modifier is float32, and every constant is a widened float literal + +The arithmetic is `fld dword [field]; fadd qword [k]; fstp dword [field]`: a float32 field, +combined with a *double* constant, stored back through float32. And every one of those +constants is a float32 value widened to double, not the decimal it looks like: + +| written as | the double actually in the image | equals | +|---|---|---| +| 0.05 | 0x3FA99999A0000000 | `(double)0.05f` | +| 0.06 | 0x3FAEB851E0000000 | `(double)0.06f` | +| 0.10 | 0x3FB99999A0000000 | `(double)0.1f` | +| 0.15 | 0x3FC3333340000000 | `(double)0.15f` | +| 0.20 | 0x3FC99999A0000000 | `(double)0.2f` | +| 0.30 | 0x3FD3333340000000 | `(double)0.3f` | +| 0.45 | 0x3FDCCCCCC0000000 | `(double)0.45f` | +| 0.60 | 0x3FE3333340000000 | `(double)0.6f` | +| 0.90 | 0x3FECCCCCC0000000 | `(double)0.9f` | +| 0.35 | 0x3FD6666660000000 | `(double)0.35f` | +| 0.25, 0.5, 0.75, 1.0, 1.5 | exact | unaffected | + +Ours held the state in `double` and used exact decimals. Rounding once at the end instead +of at every step drifts: `PlayerEconomyState` now holds `float`s, the apply layer rounds +through float32 at each step (`AddF` / `MulF`), and the table's literals carry an `f` +suffix so they widen to the same double bit pattern. `tests/game_effects` pins the six-tech +terraform chain against the stepwise result, and `tests/shim_techfx` pins the exact bit +patterns at the exact offsets. + +Caveat, the same one B3 records: at 53-bit x87 precision control (the MSVC default) the +middle step rounds to double and the store rounds again, which is exactly what +`(float)((double)a + k)` gives on both the i386 target and the host. At 24-bit precision it +would not be. The hook records `fpu_cw` on every call, so the first trace settles it. + +### 2. The AI-benefit bonus values are 0.5 — the table is dumped + +`g_AITechValueTable` (6 × 12 bytes, `{int techId, float rebellionOdds, float bonus}`) was +"values not dumped" in the notes. It reads: + +| tech | rebellion odds | bonus | +|---|---|---| +| CCC_AI, CCC_AIAdmin, CCC_AIFac | 0.1f | **0.5f** each | +| CCC_AIFRCON (10083) | 0.2f | 0 | +| CCC_AIVrus, CCC_AISlv | 0 | 0 | + +So `AiBonusValues` now defaults to 0.5/0.5/0.5, and `AiRebellionOdds(id)` carries the odds +column. `ApplyAITechBonus` multiplies the value by `AIBn ? 1.0f : -1.0f` and narrows the +product to float32 before adding, which the apply layer now does too. `CCC_AIFRCON` is in +the table for its odds only — no branch reads its bonus. + +### 3. `PrGtTrf` is an integer + +The gate techs do `mov ecx,[cfg]; mov edx,[player+0x148]; cmp edx,[ecx]; jl ...` — a signed +**integer** max against an integer config word, not a float compare. Ours modelled the field +and both `PERGATETRAFFIC_*` tuning keys as `double`. Both are `int` now. + +### 4. The node-bore parameters live behind a pointer, and the rule is "highest researched" + +`ServerPlayer+0x308` is not three inline words: it is a pointer to a separately allocated +3-word block. The updater runs on **every** completion, allocates the block on first use and +`operator delete`s it when no bore drive is researched. The selector tests `DRV_RAD` +{95,60,5}, then `DRV_REND` {65,35,4}, then `DRV_RIP` {45,15,3}, first hit wins — which +confirms "highest wins" and raises it from medium to high confidence. There is **no species +gate**: the notes call these Zuul parameters, but nothing in the code checks the species (the +techs themselves are Zuul-only by data). `PlayerEconomyState` now carries +`hasNodeBoreParams` so "absent" is a state of its own, and the selection is re-derived in +the tail rather than applied by the completing tech. + +### 5. A sticky "translation known" mask at +0x1a4 that was not in the notes + +`RebuildSpeciesTechFlags` has a **second pass** the catalog does not mention: for each +species except the NPC race, if the species' flag word has bit 0 (level-1 translation), it +ORs that species' bit into `ServerPlayer+0x1a4`. Only ever ORed — never cleared, so it +survives the first pass clearing a flag. Modelled as `translationKnownMask`. + +### 6. Three effects are tail checks, not effects of their tech + +* **capture designs** — `if (!cdp) { if (HasResearched(SpyBm) && HasResearched(SlvgTech)) cdp = 1; }` + runs on *every* completion. Ours only fired when the completing tech was one of the two, + which is the same outcome in the normal path but wrong for a save where both are already + researched and the flag is not yet set. +* **the design-option masks** — recomputed wholesale from the researched set every time. +* **the node-bore parameters** — see above. + +### 7. The completion callback has no already-researched guard + +`ApplyTechEffect` returned early when the tech was already researched. The callback cannot +do that: `SetResearched` marks the node state 4 *before* invoking it, so the guard would +make every real call a no-op. `ApplyTechCompletion` is the unguarded form and is what the +hook calls; `ApplyTechEffect` keeps the guard for callers driving the layer themselves. + +### 8. `TechDef`'s first word is **not** the TechId + +`MasterTechTree::IsTech(def, id)` maps `id - 10000` into the master `TechDef*[196]` table and +compares pointers. `TechTree::HasResearched(id)` does the same map to reach a `TechDef`, and +*then* uses that def's first word as an index into the tree's node vector. So the first word +is a node index in a larger key space, and the TechId can only be got from the identity test. +`ours` therefore scans `IsTech` over 10000..10195 (196 three-compare calls, once per +completion) rather than trusting the word — and the record carries both, so a trace shows +the two key spaces side by side. The valid range is confirmed as exactly 10000..10195, with +197 as the "none" sentinel special-cased before the subtraction. + +### 9. The 196-name table is dumped, so the key space is no longer reconstructed + +`g_TechIdNames` is 196 × `{const char* name, int}`. Reading it out gives every name in +position order, which turns `tech_id.h` from a partial reconstruction (89 confirmed names, +~55 inferred, ~52 `Unresolved_NNN`) into the table itself. Sanity checks all pass: index 0 +is `CCC_AdvSens` (the id the chain's first `push 0x2710` tests), index 30 `IND_HrdStrct` +(0x272e), indices 38..43 the six vaccines (0x2736..0x273b), index 66 `DRV_RAD` (0x2752). +Every one of the seven prefixes previously inferred for indices 100..107 was right, and so +were all 31 names the design-option mask tables would have implied. + +Two guesses were **wrong**, and one of them was a bug: + +* **The proliferate block has five entries, not six.** It is 158..162 (Human, Hiver, + Tarkas, Liir, Morrigi — no Zuul, like the other 5-entry families), and 163/164 are + `CCC_NDTRKHUM` / `CCC_NDTRKZUL`. Our block ran 158..163, so + `XenoTechId(Proliferate, Morrigi)` returned 10163 — the *Human node-track tech*. Fixed. +* Index 130, the Zuul slot of the level-3 translation block, is `XNC_DOMZUUL`, not a + translation by name. The position is unchanged, so nothing keyed on it moves. + +The species order inside every block is Human, Hiver, Tarkas, Liir, [Zuul,] Morrigi, and +the families that omit the Zuul are Incorporate, Addict, Temperance, Accommodate **and +Proliferate** — all of which were medium/low confidence and are now high. The node-track +techs' ids are known for the first time (10163 / 10164), which is what lets the hook set +`NPTrk`: our layer resolves them by name, and before this the name did not resolve. + +The two design-option mask tables (`{int techId, uint bit}`) were dumped in the same pass +and every id in them agrees with the name table, so `kDesignOptionIdsA/B` are in the +effects module and `ComputeDesignOptionMasks` has a by-id overload — which is what the hook +needs, since the game keys on ids. + +### 10. About half the techs have no TechId at all, and the tail still runs for them + +`resolve_tech_id` returns "none" for any definition outside the 196-name table — which is +roughly half the shipped `.tech` files. The callback has no branch for those, but it runs +its whole tail for them all the same: the bore selection, the flag words, the sticky +translation mask, the capture-designs pair test and the temperance sweep. `EffectsOf` is +empty for them, so an early return would have been easy to write and wrong. +`RunCompletionTail` is that tail on its own, and it is what `ours` calls in that case. + +### Things the notes had right + +The 36-branch chain is an if/else-if over ids 10000..10031 in exactly that order with no +gaps (`push 0x2710` … `push 0x272f`, then the tail's 0x2730..0x2733); at most one branch +runs. `IND_Waldo`/`IND_ExpSys` share a block, `BIO_EnvTail` falls through into +`BIO_TerBac`'s terraform add, and `IND_OrbDry` really does leave `ConMod[0]` alone. Every +magnitude in the catalog is confirmed. The plague-cure masks are bits 0..4 for ids +10038..10042 and 0x0f for 10043 — with one subtlety: the test is "this def **or a +descendant of it**", which our exact-id `PlagueCureMask` cannot express without the tree. + +## Host tests + +`ctest` 28/28 on the build box. + +* `game_effects` — 399 checks (was 285). Added: the float32 state and the widened-literal + identities, the multiplicative tech on top of an additive one, the integer gate-traffic + max, node-bore highest-wins plus re-derivation on an unrelated completion, the + capture-designs tail check from a save where both are already researched, the sticky + translation mask (including that a rebuild does not take a bit back), that + `ApplyTechCompletion` applies twice while `ApplyTechEffect` does not, the recovered AI + bonus and odds, and that the by-name and by-id design-option builders agree bit for bit. +* `shim_techfx` (new) — 1473 checks. A synthetic `ServerPlayer` buffer driven through the + real adapter: the region table (no overlaps, all inside the span, every region named and + described), a full read/write round trip, **all 44 catalogued effects** byte-checked at + the offsets the original writes, hand-written float32 bit patterns for the additive, + multiplicative and exactly-representable cases, the integer gate max, the absent + node-bore block, the species flag words and their count, the research-target clear, and + that the describers emit field names a diff can point at. + +Cross-build: `b2-9cd997d-dirty-20260908T0359Z`, exports 66 names identical to +`binkw32.dll`, staged in `/srv/re-lab/shim/dist-b2`. `tools/clean_room_check.sh` OK. + +## Coverage — say it up front + +**Only a tech that actually completes fires this hook.** The reference save is turn 2 of a +28-star game with 0 starting techs, so a given End Turn may produce no records at all. The +plan, in order of what it proves: + +1. **Host-side, already done.** Every catalogued effect is applied to a synthetic player and + byte-checked (`shim_techfx`, 1473 checks). This is the exhaustive coverage; it does not + need the VM and it does not depend on which techs the save happens to finish. +2. **`shim.cfg.b2scout`** (staged alongside the three) turns on B3's `ProcessResearch` hook + in trace mode as well, so one End Turn shows both the completions that fired and every + node's `progress` against its `cost`. That says how many turns are needed before a + completion, instead of guessing. +3. **Trace, then compare, driven by whatever completes.** Press End Turn repeatedly; each + completion is one record. A run that yields zero records is a coverage failure to report, + not a pass — `tracecmp.py` exiting 0 on an empty trace proves nothing. +4. If the reference save cannot be driven to a completion in a reasonable number of turns, + the fallback is a fresh game with research set high (the game-setup research slider) and + a cheap first tech, saved as its own reference. That is a new save, so its determinism + oracle would have to be recorded before it is useful for step 4 of the run list. + +## Gotchas + +1. **`describe_args` runs before the original**, and therefore before `ours`. Anything + `ours` decides cannot ride on the record; it goes to `shim.log` instead. +2. **The hook can nest**, because the Zuul branch calls `SetResearched` → the callback. The + nested call happens *inside the original*, not inside the hook, so the per-call statics + are only ever written by the outer hook invocation. `ours` never recurses. +3. **The node-bore block cannot be created by `ours`.** In compare mode the region is + declared only when the block already exists, and the block pointer is described as an + opaque `ptr` so the original allocating it is not a divergence. In replace mode `ours` + calls the game's own updater to keep the allocation consistent — which means replace + mode does not exercise our node-bore selection; compare and the host tests do. +4. **Replace mode for this hook is deliberately incomplete.** It writes the player's fields + but raises no events, flags no systems, cures nothing and grants no boarding pods. On a + turn where nothing completes the hook never fires and the determinism oracle should still + hold — that is what step 4 below actually tests. On a turn where something completes, a + changed save hash is expected and is not a finding. +5. A compare record is small (14 regions, none over 0x54 bytes), so unlike B3 the configs + could leave other hooks on. They do not: the b2 configs switch everything else off so a + trace holds nothing but this callback. +6. `ServerPlayer+0x308` is a 32-bit pointer field. Host tests cannot write a real pointer + into a synthetic buffer at that offset (it would spill over `IncMod` at +0x30c), so they + set the view's block pointer directly. + +## What remains (needs the VM) + +The lane holding VM140 must be finished first; then, in this order: + +1. Deploy `/srv/re-lab/shim/dist-b2` (build `b2-9cd997d-dirty-20260908T0359Z`): `scp` it to + `C:\SOTS\shimdist-b2\` and run `deploy.ps1 -Dist C:\SOTS\shimdist-b2` — **a separate + staging directory from the shared `C:\SOTS\shimdist`**, so no other lane's dist is + overwritten. +2. **Scout.** Copy `shim.cfg.b2scout` over `C:\SOTS\shim.cfg`, relaunch, load + `ref-turn2.sav`, press End Turn once, pull `C:\SOTS\shim.trace.jsonl`. Read off it: + * how many `Game::ServerPlayer::OnTechResearched` records there are (expect 0 or 1); + * from the `Game::TechTree::ProcessResearch` records, each player's funded node and its + `progress` vs `cost_rp`, i.e. how many more turns to a completion; + * `fpu_cw` (expect `0x027f`; `0x007f`/`0x003f` means 24-bit precision, which would change + the rounding shape in `AddF`/`MulF` and nothing else). +3. **Golden trace.** Copy `shim.cfg.b2trace`, relaunch, load `ref-turn2.sav`, and press End + Turn as many times as step 2 says are needed. Pull the trace → + `verify/traces/b2-techfx-golden.jsonl`; `tracecmp.py` must exit 0 with 0 invalid records + **and at least one record**. Check per record: `tech_id` is in 10000..10195 and + `tech_name` matches; `def_node_index` differs from `tech_id` (finding 8); the `side` + entries change only the fields that tech's branch should touch, plus the tail's + `design_masks` / `species_flags` / `translation` / `node_bore`. +4. **Compare.** Copy `shim.cfg.b2compare`, relaunch, load `ref-turn2.sav`, same number of + End Turns → `b2-techfx-compare.jsonl`. Expect **0 divergences on every record**. There is + no expected-divergence carve-out here: everything `ours` does not model is outside the + declared regions. Any diff is a real finding — report it, do not tune the table. Read the + matching `techfx: ours ...` lines out of `C:\SOTS\shim.log` and check them against the + completing tech (granted tech only for a Zuul `IND_CruisCon`, plague mask only for a + vaccine, and so on). +5. **Replace (weak check only).** Copy `shim.cfg.b2replace`, relaunch, load `ref-turn2.sav`, + press End Turn **once** (a turn on which nothing completes), and check the determinism + oracle — `(Autosave).sav` = `978041ac…`, `(Autosave EndTurn).sav` = `bb4fd9ac…`. That + proves the hook installs and perturbs nothing. Do **not** expect the oracle to hold on a + turn where a tech completes; see gotcha 4. +6. Restore the previous `shim.cfg` (`hooks=trace`) and leave the game at the main menu, as + M1/M2/B3 leave it. + +Not done, and worth saying: no record of this hook has ever been captured, so unlike M2 +there is not even a scouting trace to confirm the region model against a live object. The +first thing to check in step 2 is that the `before` snapshots look like plausible player +state (`out_mod`/`pop_mod`/`terra_mod` near 1, `con_mod`/`sav_mod` near 1, +`per_gate_traffic` a small integer) — if a field reads as garbage, the offset is wrong and +the run list stops there. diff --git a/docs/game-effects.md b/docs/game-effects.md index 164d9c0..4954afb 100644 --- a/docs/game-effects.md +++ b/docs/game-effects.md @@ -20,17 +20,11 @@ those ids. A tech absent from the list has no code effect beyond what its data f (prerequisites, section/weapon availability). `TechId` reproduces that list as an enum with the same numeric values, generated from one -X-macro so the enum and the name table cannot drift: - -| entry kind | count | example | -|---|---|---| -| position and data-file name known | 89 | `IND_Waldo = 10001` | -| position known, name inferred or role-named | ~55 | `BIO_RetroPlague` (from its vaccine), `WEP_NUKMINE` (prefix inferred), `XNC_Temperance_Hiver` | -| position known, nothing else | ~52 | `Unresolved_052` | - -`TechIdFromName` resolves only the 89 confirmed names; everything else comes back as -`TechId::None` and is treated as "no code effect". `TechIdName` is `nullptr` for the -unconfirmed slots so a loader can tell them apart. +X-macro so the enum and the name table cannot drift. Since B2 it is the **whole** table, +read out of the executable: all 196 names, so `TechIdName` never returns `nullptr` for a +valid id and `TechIdFromName` resolves every tech the code can key on. Enum identifiers are +the data-file names except in the xenotech block, where the role-based names are kept +(`XNC_Temperance_Hiver` = `"XNC_TEMPHVR"`) because `XenoTechId` is built on them. ### Xenotech block @@ -41,26 +35,28 @@ block of one tech per target species (`XenoTechId(level, species)`): |---|---|---|---| | 0 | Translation 1 | 10114 | Human, Hiver, Tarkas, Liir, Zuul, Morrigi | | 1 | Translation 2 | 10120 | same six | -| 2 | Translation 3 | 10126 | same six | +| 2 | Translation 3 | 10126 | same six (the Zuul slot's data name is `XNC_DOMZUUL`) | | 3 | Incorporate | 10132 | five: no Zuul | | 4 | Addict | 10137 | five | | 5 | Temperance | 10142 | five | | 6 | Subjugate | 10147 | six | | 7 | Accommodate | 10153 | five | -| 8 | Proliferate | 10158 | six | +| 8 | Proliferate | 10158 | **five** | -The NPC race is never a target. Confidence: **high** on the family order and the block -bases; **medium** on the compact species order inside a block (enum order minus NPC); -**low** on which species the 5-entry blocks other than Incorporate omit — Zuul is assumed -for all four. Only `CCC_TRNSHUM` (10114) and `CCC_TRNSLIR` (10117) have confirmed data-file -names; the rest of the block is named by role. +Ids 10163 / 10164 — the two slots after the proliferate block — are the node-track techs +`CCC_NDTRKHUM` / `CCC_NDTRKZUL`. The NPC race is never a target. Confidence: **high** +throughout since B2 (the family order, the block bases, the species order inside a block +and which families omit the Zuul are all read off the name table). Before B2 the +proliferate block was modelled as six entries, which made +`XenoTechId(Proliferate, Morrigi)` return the Human node-track tech. ### Node-track techs Seeing a species' node-space traffic is granted by a tech keyed by *name* in the species -table, not by id: `CCC_NDTRKHUM` for Human traffic, `CCC_NDTRKZUL` for Zuul traffic, none -for the others. `NodeTrackTechName(species)` exposes that; `ApplyTechEffectByName` handles -it. Confidence: high on the names, medium on the reader semantics. +table rather than by the effect chain: `CCC_NDTRKHUM` (10163) for Human traffic, +`CCC_NDTRKZUL` (10164) for Zuul traffic, none for the others. `NodeTrackTechName(species)` +exposes the name and both now resolve through `TechIdFromName`. Confidence: high on the +names and ids, medium on the reader semantics. ## The effects table (`tech_effects.h`) @@ -68,6 +64,12 @@ it. Confidence: high on the names, medium on the reader semantics. tech completes. Effects are additive per research event and permanent. 44 ids carry an entry; every other id returns an empty list. +**Everything below is float32.** The modifiers are 4-byte floats in the player object and +each step is `field = (float)((double)field OP k)`, where `k` is the *widened float32* +literal the executable carries -- 0.05 is `(double)0.05f`, not 0.05. `PlayerEconomyState` +therefore holds floats and the table's constants are written with an `f` suffix; see +`docs/B2.md`. `PrGtTrf` is the exception: it is an `int`, raised with an integer max. + | tech | effects | |---|---| | CCC_AdvSens | flag AdvancedSensors | @@ -102,16 +104,27 @@ entry; every other id returns an empty list. | CCC_SpyBm, IND_SlvgTech | flag CaptureDesigns once both are researched | | BIO_PLGVAC / RTPLGVAC / BSTVAC / ASPLGVAC / CONNAN | HasVac, HasImm |= bit 0 / 1 / 2 / 3 / 4; cure that plague type on owned systems and ships (outcome) | | BIO_UNIANTI | same with mask 0x0f | -| DRV_RIP / REND / RAD | Zuul node-bore parameters {45,15,3} / {65,35,4} / {95,60,5}, highest wins | +| DRV_RIP / REND / RAD | node-bore parameters {45,15,3} / {65,35,4} / {95,60,5}, highest wins (re-derived in the tail, and the block is absent when none is researched) | -Confidence: **high** on every constant above (each was read with its literal); **medium** -on the AI-benefit re-application in `SetAiBenefit` and on the node-bore "highest wins" -rule; the three AI-bonus values themselves are **not recovered** — `ApplyContext::aiBonus` -carries them and defaults to 0. +Confidence: **high** on every constant above (each was read with its literal) and, since +B2, **high** on the node-bore rule (the selector tests RAD, then REND, then RIP, and the +first hit wins) and on the AI-benefit re-application. The three AI-bonus values are now +recovered as well: **0.5 each**, and `AiRebellionOdds` carries the same table's odds column +(0.1 for the three AI techs, 0.2 for `CCC_AIFRCON`). + +Three of the table's entries are not applied by the completing tech's own branch -- the +game does them in the tail of *every* completion, so `ApplyTechCompletion` does too: + +* the two design-option masks (`ComputeDesignOptionMasks`, now available keyed by id); +* the node-bore parameters, re-derived from the whole researched set; +* the capture-designs pair test, which fires on whichever completion first sees both + `CCC_SpyBm` and `IND_SlvgTech` researched -- not only on those two techs' own. Every completion also rebuilds `speciesFlags[]` (bit k of species sp = the level-k -xenotech for sp is researched) and reports, in the outcome, every species whose -temperance bit is held so the caller can cure addiction to it on owned systems. +xenotech for sp is researched), ORs a sticky `translationKnownMask` bit for every non-NPC +species whose level-1 translation is researched, and reports, in the outcome, every +species whose temperance bit is held so the caller can cure addiction to it on owned +systems. ### Where the modifiers are consumed @@ -138,20 +151,22 @@ ApplyContext ctx{&tuning, aiBonus}; TechApplyOutcome o = ApplyTechEffect(s, TechId::IND_HrdStrct, ctx); ``` -`ApplyTechEffect` marks the id researched, applies its effects, rebuilds the species -flags, and returns what the caller must do with real game state: `grantedTech`, +`ApplyTechEffect` marks the id researched, applies its effects, runs the tail, and returns +what the caller must do with real game state: `grantedTech`, `plagueCuredMask`, `flagSystemsAI`, `reevaluateCivilianCaps`, `temperanceSpeciesMask`, `nodeBoreParamsChanged`. Applying an invalid or already-researched id is a no-op -(`applied == false`). `ApplyTechEffectByName` resolves a data-file name first and also +(`applied == false`). `ApplyTechCompletion` is the same thing **without** the +already-researched guard: that is what the game's callback is, because by the time it runs +the node is already marked researched. A differential hook must use it. `ApplyTechEffectByName` resolves a data-file name first and also handles the node-track names. `SetAiBenefit(s, on, ctx)` adds or withdraws every researched AI tech's bonus (AI rebellion / AI slave tech). `RebuildSpeciesTechFlags` is also the load path. ## Not modelled / open -- Values of the three AI-benefit bonuses (a 6-entry table in the executable; not dumped). -- Data-file names for ~107 of the 196 slots (no strategic effect on any of them; the - gaps matter only for `TechIdFromName` on those names). +- Nothing is left open in the key space: all 196 names are in the table since B2. +- The plague-cure test in the game matches a vaccine tech **or any descendant of it**; + `PlagueCureMask` is exact-id only, because the descendant relation needs the tech tree. - The events raised on completion (research complete / under budget), the plague-cure roll, the Zuul starting immunity/temperance flags, and the home-system bonus initialisation that reads the `*_HOME` tuning keys. diff --git a/include/generated/sots_addresses.h b/include/generated/sots_addresses.h index 5bb917e..47ffcd1 100644 --- a/include/generated/sots_addresses.h +++ b/include/generated/sots_addresses.h @@ -1,5 +1,5 @@ // GENERATED — do not edit. Facts about Sword of the Stars.exe (GOG 1.8.1). -// Source: sots-re ghidra/addresses.json @ 1e7428d, generated 2026-09-07 by tools/gen_addresses.py +// Source: sots-re ghidra/addresses.json @ 906858f, generated 2026-09-07 by tools/gen_addresses.py // Runtime address = (uintptr_t)GetModuleHandle(NULL) + RVA (the exe is ASLR-relocated). #pragma once #include @@ -309,7 +309,7 @@ constexpr uint32_t g_SpeciesDefTable = 0x00710a00; constexpr uint32_t g_TechBitmaskTableA = 0x006df378; // data struct { int techId; uint bit; }[29] [verified] constexpr uint32_t g_TechBitmaskTableB = 0x006df478; -// data struct { int techId; int a; float value; }[6] /* values not dumped */ [unverified] +// data struct { int techId; float rebellionOdds; float bonus; }[6] /* rows: CCC_AI/CCC_AIAdmin/CCC_AIFac odds 0.1f bonus 0.5f, CCC_AIFRCON odds 0.2f bonus 0, CCC_AIVrus/CCC_AISlv odds 0 bonus 0 */ [verified] constexpr uint32_t g_AITechValueTable = 0x00617888; // data int (config storage; PTR slot 0x00aedfe0) [verified] constexpr uint32_t g_PERGATETRAFFIC_DRV_TpGate = 0x00723e2c; @@ -359,5 +359,75 @@ constexpr uint32_t ServerPlayer_off_ResearchTarget = 0x00000294; constexpr uint32_t ServerPlayer_off_IncMod = 0x0000030c; // offset std::vector (3 words; entry 0x18 B, {+0x8 int researchPercent, +0xc int researchActive, +0x10 int savings, +0x14 int savingsActive}) [verified] constexpr uint32_t ServerPlayer_off_Aid = 0x00000310; +// offset float SuitTol -- raised by the two adaptation techs; also caps the hazard money cost [verified] +constexpr uint32_t ServerPlayer_off_SuitTol = 0x000000b4; +// offset float MaxOH -- max over-harvest slider, raised by a float32 max() against 0.1f [verified] +constexpr uint32_t ServerPlayer_off_MaxOH = 0x000000b8; +// offset TechTree* the player's own tech tree; its +4 is the MasterTechTree the IsTech chain uses [verified] +constexpr uint32_t ServerPlayer_off_TechTree = 0x000000f4; +// offset bool AIBn -- AI benefit active; gates ApplyAITechBonus and flips the bonus sign [verified] +constexpr uint32_t ServerPlayer_off_AIBn = 0x000000fe; +// offset bool CnTrd -- trade routes allowed (set by CCC_FtlEcon unless RebAI) [verified] +constexpr uint32_t ServerPlayer_off_CnTrd = 0x000000ff; +// offset bool CnRad -- commerce raiding allowed (CCC_ComRaid) [verified] +constexpr uint32_t ServerPlayer_off_CnRad = 0x00000100; +// offset bool CnVItl -- may view other empires' intel (CCC_DatCor) [verified] +constexpr uint32_t ServerPlayer_off_CnVItl = 0x00000101; +// offset bool hgs -- gravitic-syncing drive researched (DRV_GrvSyn); client-synced only [verified] +constexpr uint32_t ServerPlayer_off_hgs = 0x00000102; +// offset bool hadvs -- advanced sensors (CCC_AdvSens) [verified] +constexpr uint32_t ServerPlayer_off_hadvs = 0x00000103; +// offset bool harcc -- arcologies (IND_ArcCon) [verified] +constexpr uint32_t ServerPlayer_off_harcc = 0x00000104; +// offset float pddm -- multiplied by 0.25 by IND_HrdStrct [verified] +constexpr uint32_t ServerPlayer_off_pddm = 0x00000108; +// offset float ConMod[3] -- construction cost per hull class; techs subtract from all three, IND_OrbDry only from [1] and [2] [verified] +constexpr uint32_t ServerPlayer_off_ConMod = 0x0000010c; +// offset float SavMod[3] -- IND_OrbFound subtracts 0.05f from all three [verified] +constexpr uint32_t ServerPlayer_off_SavMod = 0x00000118; +// offset float OutMod -- industrial output multiplier; several techs add, IND_HrdStrct multiplies by 0.9f [verified] +constexpr uint32_t ServerPlayer_off_OutMod = 0x00000124; +// offset float PopMod [verified] +constexpr uint32_t ServerPlayer_off_PopMod = 0x00000130; +// offset float TerraMod [verified] +constexpr uint32_t ServerPlayer_off_TerraMod = 0x00000134; +// offset bool AMine -- asteroid mining (IND_AstMine) [verified] +constexpr uint32_t ServerPlayer_off_AMine = 0x00000138; +// offset float MinRate -- mining rate; IND_MsMine adds 1.0 [verified] +constexpr uint32_t ServerPlayer_off_MinRate = 0x00000140; +// offset int PrGtTrf -- per-gate traffic capacity; the two gate techs raise it with a SIGNED INTEGER max against a config int, not a float compare [verified] +constexpr uint32_t ServerPlayer_off_PrGtTrf = 0x00000148; +// offset float CstR, CstE, CstT at +0x150/+0x154/+0x158 -- set to 10.0f / 2.0f / 1.0f (FLD1) by DRV_FarCast [verified] +constexpr uint32_t ServerPlayer_off_CstR = 0x00000150; +// offset uint32 design-option mask A; +0x194 is mask B. Rewritten wholesale by ComputeTechBitmasks on every completion; not serialised [verified] +constexpr uint32_t ServerPlayer_off_TechMaskA = 0x00000190; +// offset uint32 sticky 'level-1 translation researched for species sp' bit mask, one bit per species, NPC (4) excluded. Second pass of RebuildSpeciesTechFlags; only ever ORed [verified] +constexpr uint32_t ServerPlayer_off_TranslationKnown = 0x000001a4; +// offset uint32 HasVac; +0x28c HasImm. Both get |= the plague-cure mask of the completing tech [verified] +constexpr uint32_t ServerPlayer_off_HasVac = 0x00000288; +// offset uint32 NPTrk -- bit per species whose node-space traffic is visible; set by that species' node-track tech [verified] +constexpr uint32_t ServerPlayer_off_NPTrk = 0x00000290; +// offset int (*)[3] -- pointer to a separately allocated 3-word node-bore parameter block, NULL while no bore drive is researched (the updater allocates and frees it) [verified] +constexpr uint32_t ServerPlayer_off_NodeBore = 0x00000308; +// offset bool cdp -- set once CCC_SpyBm and IND_SlvgTech are both researched; the test is in the completion tail, not in either tech's branch [verified] +constexpr uint32_t ServerPlayer_off_CaptureDesigns = 0x00000330; +// offset uint32 flags[7] xenotech bits per target species, followed by a count word 7 at +0x364 (0x20 bytes assigned as a unit) [verified] +constexpr uint32_t ServerPlayer_off_SpeciesTechFlags = 0x00000348; +// offset bool -- a pending plague-cure roll; run and cleared when the completing tech is the current research target [verified] +constexpr uint32_t ServerPlayer_off_ResearchRollPending = 0x000003b4; +// offset AIRebellion* -- non-null while an AI rebellion object exists; the two AI techs notify it [verified] +constexpr uint32_t ServerPlayer_off_AIRebellion = 0x000003b8; +// offset MasterTechTree* -- the `this` MasterTechTree::IsTech / GetTechDef are called on [verified] +constexpr uint32_t TechTree_off_Master = 0x00000004; +// fastcall void (ServerPlayer* this) -- re-selects the node-bore parameters from the researched set and stores them in the +0x308 block, allocating it on first use and freeing it when no bore drive is researched [verified] +constexpr uint32_t ServerPlayer_UpdateNodeBoreParams = 0x004182c0; +// cdecl bool (int out[3], ServerPlayer* p) -- highest researched bore drive wins: DRV_RAD {95,60,5}, else DRV_REND {65,35,4}, else DRV_RIP {45,15,3}; false (and out left at {INT_MAX,INT_MAX,0}) when none. No species gate [verified] +constexpr uint32_t SelectNodeBoreParams = 0x002e18e0; +// custom int (TechDef* def in EBX) -- index of the species whose SpeciesDef+0x74 node-track tech this def is, or -1 [verified] +constexpr uint32_t ServerPlayer_SpeciesOfTranslationTech = 0x0040e410; +// cdecl struct {int techId; float rebellionOdds; float bonus;}* (TechDef* def) -- linear search of g_AITechValueTable, NULL when the def is none of them [verified] +constexpr uint32_t AITechRow = 0x00290f70; +// data int -- number of rows in g_AITechValueTable (6) [verified] +constexpr uint32_t g_AITechValueCount = 0x006ea2ec; } // namespace sots::addr diff --git a/src/game/effects/tech_effects.cpp b/src/game/effects/tech_effects.cpp index 724b439..55a3132 100644 --- a/src/game/effects/tech_effects.cpp +++ b/src/game/effects/tech_effects.cpp @@ -9,6 +9,18 @@ namespace { using sots::sim::Species; +// Every additive/multiplicative constant below is written with an `f` suffix on purpose. +// The literals in the executable are not the decimals they look like: they are float32 +// values widened to double (0.05 is 0x3FA99999A0000000 = (double)0.05f, and so on), and +// the fields they combine with are float32. Writing `0.05f` here reproduces the same +// double bit pattern exactly, because a float literal widens to the same double. The five +// that are exactly representable (0.25, 0.5, 0.75, 1.0, 1.5) are unaffected either way. + +// One additive / multiplicative step, in the shape the x87 performs it: load the float32 +// field, combine with the double constant, store back through float32. +inline float AddF(float a, double k) { return static_cast(static_cast(a) + k); } +inline float MulF(float a, double k) { return static_cast(static_cast(a) * k); } + constexpr int F(PlayerFlag f) { return static_cast(f); } constexpr int Sp(Species s) { return static_cast(s); } @@ -21,9 +33,6 @@ constexpr unsigned kCureAssimilationPlague = 0x08; constexpr unsigned kCureNaniteVirus = 0x10; constexpr unsigned kCureUniversal = 0x0f; -// Zuul node-bore parameter rows, lowest to highest drive. -constexpr int kNodeBoreRows[3][3] = {{45, 15, 3}, {65, 35, 4}, {95, 60, 5}}; - struct TableEntry { TechId id; std::vector effects; @@ -33,23 +42,23 @@ const std::vector& Table() { using K = EffectKind; static const std::vector table = { {TechId::CCC_AdvSens, {{K::SetFlag, F(PlayerFlag::AdvancedSensors)}}}, - {TechId::IND_Waldo, {{K::AddConstructionMod, 0, -0.10}, {K::AddOutputMod, 0, 0.15}}}, - {TechId::IND_CyberInt, {{K::AddConstructionMod, 0, -0.05}, {K::AddOutputMod, 0, 0.20}}}, - {TechId::IND_ExpSys, {{K::AddConstructionMod, 0, -0.10}, {K::AddOutputMod, 0, 0.15}}}, - {TechId::IND_OrbFound, {{K::AddSavingsMod, 0, -0.05}}}, - {TechId::IND_OrbDry, {{K::AddConstructionModClass, 1, -0.05}, {K::AddConstructionModClass, 2, -0.05}}}, - {TechId::IND_GravCon, {{K::AddOutputMod, 0, 0.30}}}, - {TechId::IND_HvyPlat, {{K::AddOutputMod, 0, 0.10}}}, + {TechId::IND_Waldo, {{K::AddConstructionMod, 0, -0.10f}, {K::AddOutputMod, 0, 0.15f}}}, + {TechId::IND_CyberInt, {{K::AddConstructionMod, 0, -0.05f}, {K::AddOutputMod, 0, 0.20f}}}, + {TechId::IND_ExpSys, {{K::AddConstructionMod, 0, -0.10f}, {K::AddOutputMod, 0, 0.15f}}}, + {TechId::IND_OrbFound, {{K::AddSavingsMod, 0, -0.05f}}}, + {TechId::IND_OrbDry, {{K::AddConstructionModClass, 1, -0.05f}, {K::AddConstructionModClass, 2, -0.05f}}}, + {TechId::IND_GravCon, {{K::AddOutputMod, 0, 0.30f}}}, + {TechId::IND_HvyPlat, {{K::AddOutputMod, 0, 0.10f}}}, {TechId::IND_AstMine, {{K::SetFlag, F(PlayerFlag::AsteroidMining)}}}, - {TechId::IND_MsMine, {{K::RaiseMaxOverharvest, 0, 0.1}, {K::AddMiningRate, 0, 1.0}}}, - {TechId::BIO_GnMod, {{K::AddPopulationMod, 0, 0.10}}}, - {TechId::BIO_AtmoAd, {{K::AddSuitTolerance, 0, 0.75}, {K::AddPopulationMod, 0, 0.06}, {K::AddTerraformMod, 0, 0.35}}}, - {TechId::BIO_EnvTail, {{K::AddPopulationMod, 0, 0.20}, {K::AddTerraformMod, 0, 0.45}}}, - {TechId::BIO_GrvAdpt, {{K::AddSuitTolerance, 0, 1.50}, {K::AddPopulationMod, 0, 0.10}, {K::AddTerraformMod, 0, 0.35}}}, - {TechId::IND_ArcCon, {{K::SetFlag, F(PlayerFlag::Arcology)}, {K::AddPopulationMod, 0, 0.15}, {K::ReevaluateCivilianCaps}}}, - {TechId::IND_EleNans, {{K::AddTerraformMod, 0, 0.60}}}, - {TechId::BIO_TerBac, {{K::AddTerraformMod, 0, 0.45}}}, - {TechId::IND_AtProc, {{K::AddTerraformMod, 0, 0.50}}}, + {TechId::IND_MsMine, {{K::RaiseMaxOverharvest, 0, 0.1f}, {K::AddMiningRate, 0, 1.0f}}}, + {TechId::BIO_GnMod, {{K::AddPopulationMod, 0, 0.10f}}}, + {TechId::BIO_AtmoAd, {{K::AddSuitTolerance, 0, 0.75f}, {K::AddPopulationMod, 0, 0.06f}, {K::AddTerraformMod, 0, 0.35f}}}, + {TechId::BIO_EnvTail, {{K::AddPopulationMod, 0, 0.20f}, {K::AddTerraformMod, 0, 0.45f}}}, + {TechId::BIO_GrvAdpt, {{K::AddSuitTolerance, 0, 1.50f}, {K::AddPopulationMod, 0, 0.10f}, {K::AddTerraformMod, 0, 0.35f}}}, + {TechId::IND_ArcCon, {{K::SetFlag, F(PlayerFlag::Arcology)}, {K::AddPopulationMod, 0, 0.15f}, {K::ReevaluateCivilianCaps}}}, + {TechId::IND_EleNans, {{K::AddTerraformMod, 0, 0.60f}}}, + {TechId::BIO_TerBac, {{K::AddTerraformMod, 0, 0.45f}}}, + {TechId::IND_AtProc, {{K::AddTerraformMod, 0, 0.50f}}}, {TechId::DRV_TpGate, {{K::RaiseGateTraffic, 0}}}, {TechId::DRV_GatAmp, {{K::RaiseGateTraffic, 1}}}, {TechId::DRV_FarCast, {{K::SetFarCasting}}}, @@ -62,8 +71,8 @@ const std::vector& Table() { {TechId::CCC_ComRaid, {{K::SetFlag, F(PlayerFlag::CommerceRaiding)}}}, {TechId::DRV_GrvSyn, {{K::SetFlag, F(PlayerFlag::GravSynth)}}}, {TechId::CCC_DatCor, {{K::SetFlag, F(PlayerFlag::ViewIntel)}}}, - {TechId::IND_HrdStrct, {{K::MulDefenceDamageMod, 0, 0.25}, {K::MulOutputMod, 0, 0.90}}}, - {TechId::DRN_AdvRob, {{K::AddConstructionMod, 0, -0.05}}}, + {TechId::IND_HrdStrct, {{K::MulDefenceDamageMod, 0, 0.25f}, {K::MulOutputMod, 0, 0.90f}}}, + {TechId::DRN_AdvRob, {{K::AddConstructionMod, 0, -0.05f}}}, {TechId::IND_CruisCon, {{K::GrantTechIfSpecies, Sp(Species::Zuul), static_cast(static_cast(TechId::IND_BrdPod))}}}, {TechId::CCC_SpyBm, {{K::CaptureDesignsWith, static_cast(TechId::IND_SlvgTech)}}}, {TechId::IND_SlvgTech, {{K::CaptureDesignsWith, static_cast(TechId::CCC_SpyBm)}}}, @@ -94,11 +103,14 @@ double AiBonusFor(AiBonusSlot slot, const AiBonusValues& v) { return 0.0; } +// The bonus is narrowed to float32 before it is added (the original stores the signed +// product to a 4-byte slot first), then the add and the store round again. void AddAiBonus(PlayerEconomyState& s, AiBonusSlot slot, double amount) { + const double v = static_cast(static_cast(amount)); switch (slot) { - case AiBonusSlot::Research: s.resMod += amount; break; - case AiBonusSlot::Admin: s.incMod += amount; break; - case AiBonusSlot::Factory: s.outMod += amount; break; + case AiBonusSlot::Research: s.resMod = AddF(s.resMod, v); break; + case AiBonusSlot::Admin: s.incMod = AddF(s.incMod, v); break; + case AiBonusSlot::Factory: s.outMod = AddF(s.outMod, v); break; } } @@ -106,22 +118,26 @@ void ApplyOne(PlayerEconomyState& s, const TechEffect& e, const ApplyContext& ct using K = EffectKind; switch (e.kind) { case K::AddConstructionMod: - for (double& c : s.conMod) c += e.value; + // The original writes the three words in order 0, 1, 2 off one loaded constant. + for (float& c : s.conMod) c = AddF(c, e.value); break; case K::AddConstructionModClass: - if (e.index >= 0 && e.index < 3) s.conMod[e.index] += e.value; + if (e.index >= 0 && e.index < 3) s.conMod[e.index] = AddF(s.conMod[e.index], e.value); break; case K::AddSavingsMod: - for (double& m : s.savMod) m += e.value; + for (float& m : s.savMod) m = AddF(m, e.value); break; - case K::AddOutputMod: s.outMod += e.value; break; - case K::MulOutputMod: s.outMod *= e.value; break; - case K::AddPopulationMod: s.popMod += e.value; break; - case K::AddTerraformMod: s.terraMod += e.value; break; - case K::AddSuitTolerance: s.suitTol += e.value; break; - case K::RaiseMaxOverharvest: s.maxOverharvest = std::max(s.maxOverharvest, e.value); break; - case K::AddMiningRate: s.miningRate += e.value; break; - case K::MulDefenceDamageMod: s.defenceDamageMod *= e.value; break; + case K::AddOutputMod: s.outMod = AddF(s.outMod, e.value); break; + case K::MulOutputMod: s.outMod = MulF(s.outMod, e.value); break; + case K::AddPopulationMod: s.popMod = AddF(s.popMod, e.value); break; + case K::AddTerraformMod: s.terraMod = AddF(s.terraMod, e.value); break; + case K::AddSuitTolerance: s.suitTol = AddF(s.suitTol, e.value); break; + case K::RaiseMaxOverharvest: + // A plain float32 max against the literal, not a widened compare. + s.maxOverharvest = std::max(s.maxOverharvest, static_cast(e.value)); + break; + case K::AddMiningRate: s.miningRate = AddF(s.miningRate, e.value); break; + case K::MulDefenceDamageMod: s.defenceDamageMod = MulF(s.defenceDamageMod, e.value); break; case K::SetFlag: if (e.index >= 0 && e.index < kPlayerFlagCount) s.flags[e.index] = true; break; @@ -129,15 +145,16 @@ void ApplyOne(PlayerEconomyState& s, const TechEffect& e, const ApplyContext& ct if (!s.rebelAI && e.index >= 0 && e.index < kPlayerFlagCount) s.flags[e.index] = true; break; case K::RaiseGateTraffic: { - double v = 0.0; + // Integer max against the config word, exactly as the branch does it. + int v = 0; if (ctx.tuning) v = e.index == 0 ? ctx.tuning->PERGATETRAFFIC_DRV_TpGate : ctx.tuning->PERGATETRAFFIC_DRV_GatAmp; s.perGateTraffic = std::max(s.perGateTraffic, v); break; } case K::SetFarCasting: - s.castRange = 10.0; - s.castEfficiency = 2.0; - s.castThreshold = 1.0; + s.castRange = 10.f; + s.castEfficiency = 2.f; + s.castThreshold = 1.f; // stored with FLD1 break; case K::AiTechBonus: if (s.aiBenefit) AddAiBonus(s, static_cast(e.index), AiBonusFor(static_cast(e.index), ctx.aiBonus)); @@ -155,18 +172,40 @@ void ApplyOne(PlayerEconomyState& s, const TechEffect& e, const ApplyContext& ct if (Sp(s.species) == e.index) out.grantedTech = static_cast(static_cast(e.value)); break; case K::CaptureDesignsWith: - if (s.HasResearched(static_cast(e.index))) s.flags[F(PlayerFlag::CaptureDesigns)] = true; - break; case K::NodeBoreParams: - if (e.index >= 0 && e.index < 3 && kNodeBoreRows[e.index][0] > s.nodeBoreParams[0]) { - for (int i = 0; i < 3; ++i) s.nodeBoreParams[i] = kNodeBoreRows[e.index][i]; - out.nodeBoreParamsChanged = true; - } + // Both are re-derived from the researched set in the tail of every completion, + // not applied by the completing tech's own branch. Their table entries stay so + // that EffectsOf() still documents what the tech does. break; case K::ReevaluateCivilianCaps: out.reevaluateCivilianCaps = true; break; } } +// The tail's node-bore step: the highest researched bore drive wins, and when none is +// researched the block does not exist at all (the original frees it). +void UpdateNodeBoreParams(PlayerEconomyState& s, TechApplyOutcome& out) { + static const struct { TechId id; int p[3]; } kRows[3] = { + {TechId::DRV_RAD, {95, 60, 5}}, + {TechId::DRV_REND, {65, 35, 4}}, + {TechId::DRV_RIP, {45, 15, 3}}, + }; + const bool had = s.hasNodeBoreParams; + const int before[3] = {s.nodeBoreParams[0], s.nodeBoreParams[1], s.nodeBoreParams[2]}; + s.hasNodeBoreParams = false; + for (const auto& row : kRows) { + if (!s.HasResearched(row.id)) continue; + s.hasNodeBoreParams = true; + for (int i = 0; i < 3; ++i) s.nodeBoreParams[i] = row.p[i]; + break; + } + if (!s.hasNodeBoreParams) { + s.nodeBoreParams[0] = s.nodeBoreParams[1] = s.nodeBoreParams[2] = 0; + } + out.nodeBoreParamsChanged = + had != s.hasNodeBoreParams || before[0] != s.nodeBoreParams[0] || + before[1] != s.nodeBoreParams[1] || before[2] != s.nodeBoreParams[2]; +} + } // namespace const std::vector& EffectsOf(TechId id) { @@ -192,6 +231,14 @@ void RebuildSpeciesTechFlags(PlayerEconomyState& s) { } s.speciesFlags[sp] = bits; } + // Second pass, in the same function in the original: a separate word accumulates + // "level-1 translation for species sp has been researched", one bit per species, the + // NPC race excluded. It is sticky -- the pass only ever ORs a bit in. + for (int sp = 0; sp < sots::sim::kSpeciesCount; ++sp) { + if (sots::sim::IsNpcSpecies(static_cast(sp))) continue; + if (s.speciesFlags[sp] & (1u << static_cast(XenoLevel::Translation1))) + s.translationKnownMask |= 1u << sp; + } } bool AiRebellionPossible(const PlayerEconomyState& s) { @@ -213,23 +260,47 @@ void SetAiBenefit(PlayerEconomyState& s, bool on, const ApplyContext& ctx) { } } -TechApplyOutcome ApplyTechEffect(PlayerEconomyState& s, TechId id, const ApplyContext& ctx) { +TechApplyOutcome ApplyTechCompletion(PlayerEconomyState& s, TechId id, const ApplyContext& ctx) { TechApplyOutcome out; - if (!IsValidTechId(id) || s.HasResearched(id)) return out; + if (!IsValidTechId(id)) return out; s.researched.set(static_cast(TechIdIndex(id))); out.applied = true; + // 1. the one matching branch of the id chain (the original is an if/else-if chain, so + // at most one tech's effects ever run). for (const TechEffect& e : EffectsOf(id)) ApplyOne(s, e, ctx, out); - // Every completion: re-derive the per-species xenotech bits and report the species - // whose temperance is held (their addiction is cured on owned systems). + // 2. the tail, which runs on every completion whatever the tech was. + const TechApplyOutcome tail = RunCompletionTail(s); + out.temperanceSpeciesMask = tail.temperanceSpeciesMask; + out.nodeBoreParamsChanged = tail.nodeBoreParamsChanged; + return out; +} + +TechApplyOutcome RunCompletionTail(PlayerEconomyState& s) { + TechApplyOutcome out; + out.applied = true; + UpdateNodeBoreParams(s, out); RebuildSpeciesTechFlags(s); + // The capture-designs pair test is a tail check on the researched set, not an effect of + // either tech: it fires on whichever completion first finds both present. + if (!s.flags[F(PlayerFlag::CaptureDesigns)] && s.HasResearched(TechId::CCC_SpyBm) && + s.HasResearched(TechId::IND_SlvgTech)) { + s.flags[F(PlayerFlag::CaptureDesigns)] = true; + } + // Every species whose temperance bit is now held: their addiction is cured on owned + // systems. Reported for every such species, not only a newly gained one. for (int sp = 0; sp < sots::sim::kSpeciesCount; ++sp) { if (s.speciesFlags[sp] & (1u << static_cast(XenoLevel::Temperance))) out.temperanceSpeciesMask |= 1u << sp; } return out; } +TechApplyOutcome ApplyTechEffect(PlayerEconomyState& s, TechId id, const ApplyContext& ctx) { + if (!IsValidTechId(id) || s.HasResearched(id)) return TechApplyOutcome{}; + return ApplyTechCompletion(s, id, ctx); +} + TechApplyOutcome ApplyTechEffectByName(PlayerEconomyState& s, std::string_view name, const ApplyContext& ctx) { // Node-track techs are keyed by name in the species table, not by id. for (int sp = 0; sp < sots::sim::kSpeciesCount; ++sp) { @@ -269,6 +340,31 @@ const char* const kDesignOptionNamesB[kDesignOptionCountB] = { "WEP_HvyPmsl", }; +// The same two tables as the executable carries them: {tech id, bit}, in bit order. The +// ids come straight from those tables; the names above are the catalog's, and the two agree +// on every id that was already known from an unrelated reader. +const TechId kDesignOptionIdsA[kDesignOptionCountA] = { + TechId::IND_REFCOAT, TechId::IND_IMPRFCT, TechId::IND_PLYALLOY, TechId::IND_MAGLAT, + TechId::IND_QRKRES, TechId::IND_ADMALY, TechId::IND_PREDGUN, TechId::SLD_DEF, + TechId::SLD_ERGAB, TechId::SLD_MKONE, TechId::SLD_MKTWO, TechId::SLD_MKTHREE, + TechId::SLD_MKFOUR, TechId::SLD_CLK, TechId::SLD_IMPCLK, TechId::SLD_INTANG, + TechId::WEP_VRFTECH, TechId::WEP_NUKEWHD, TechId::WEP_GMAWHD, TechId::WEP_FUSWHD, + TechId::WEP_AMWHD, TechId::WEP_NEUTRND, TechId::CCC_INTSENS, TechId::CCC_SNSJAM, + TechId::CCC_QNTCHAF, TechId::CCC_CMBTALG, TechId::CCC_HOLOTAC, TechId::CCC_ADVCNC, + TechId::CCC_AdvSens, TechId::DRV_MCROFUS, TechId::DRV_INCTHRST, TechId::DRV_SMLFUS, +}; + +const TechId kDesignOptionIdsB[kDesignOptionCountB] = { + TechId::DRV_NODE, TechId::DRV_NODFOC, TechId::DRV_NODPATH, TechId::DRV_STRWRP, + TechId::DRV_IMPSTWRP, TechId::DRV_FLICKER, TechId::DRV_HYPER, TechId::DRV_HYPRFLD, + TechId::DRV_WARP, TechId::SLD_MESSHLD, TechId::SLD_GRVSHLD, TechId::CCC_AIFRCON, + TechId::DRV_RIP, TechId::DRV_REND, TechId::DRV_RAD, TechId::BIO_CONNAN, + TechId::SLD_DISR, TechId::SLD_MAGNI, TechId::DRV_QNTCAP, TechId::CCC_FCCOM, + TechId::IND_HRDELEC, TechId::IND_TRKSTL, TechId::DRV_VDCTR, TechId::DRV_VDCRV, + TechId::DRV_VDMSTR, TechId::BIO_SMRTNAN, TechId::WEP_ACCAMP, TechId::WEP_MwMsl, + TechId::WEP_HvyPmsl, +}; + DesignOptionMasks ComputeDesignOptionMasks(const std::function& hasResearchedByName) { DesignOptionMasks m; for (int i = 0; i < kDesignOptionCountA; ++i) { @@ -280,4 +376,25 @@ DesignOptionMasks ComputeDesignOptionMasks(const std::function& hasResearched) { + DesignOptionMasks m; + for (int i = 0; i < kDesignOptionCountA; ++i) { + if (hasResearched(kDesignOptionIdsA[i])) m.a |= 1u << i; + } + for (int i = 0; i < kDesignOptionCountB; ++i) { + if (hasResearched(kDesignOptionIdsB[i])) m.b |= 1u << i; + } + return m; +} + +double AiRebellionOdds(TechId id) { + switch (id) { + case TechId::CCC_AI: + case TechId::CCC_AIAdmin: + case TechId::CCC_AIFac: return 0.1f; // (double)0.1f, as stored + case TechId::CCC_AIFRCON: return 0.2f; + default: return 0.0; + } +} + } // namespace sots::effects diff --git a/src/game/effects/tech_effects.h b/src/game/effects/tech_effects.h index 875eb29..7a2d348 100644 --- a/src/game/effects/tech_effects.h +++ b/src/game/effects/tech_effects.h @@ -74,26 +74,36 @@ const std::vector& EffectsOf(TechId id); // Everything a tech touches on the player. Defaults are the "no tech yet" values; the // caller seeds species-dependent starts (SuitTol) from its own tables. +// Every modifier below is a 4-byte float in the player object and every effect is applied +// as `field = (float)((double)field OP constant)` -- the x87 loads the float, combines it +// with a double literal and stores back through a float. Modelling them as `double` and +// rounding once at the end drifts from the original after a few techs, so they are floats +// here and `ApplyTechEffect` rounds at every step. See docs/B2.md. struct PlayerEconomyState { sots::sim::Species species = sots::sim::Species::Human; bool rebelAI = false; - double conMod[3] = {1.0, 1.0, 1.0}; // ConMod: construction cost per hull class - double savMod[3] = {1.0, 1.0, 1.0}; // SavMod - double outMod = 1.0; // OutMod - double popMod = 1.0; // PopMod - double terraMod = 1.0; // TerraMod - double suitTol = 0.0; // SuitTol: species start value + adaptation techs - double maxOverharvest = 0.0; // MaxOH - double miningRate = 0.0; // MinRate - double defenceDamageMod = 1.0; // pddm - double resMod = 1.0; // ResMod (AI research bonus lands here) - double incMod = 1.0; // IncMod - double castRange = 0.0; // CstR - double castEfficiency = 0.0; // CstE - double castThreshold = 0.0; // CstT - double perGateTraffic = 0.0; // PrGtTrf - int nodeBoreParams[3] = {0, 0, 0}; // Zuul node-bore parameters (meaning of the three not resolved) + float conMod[3] = {1.f, 1.f, 1.f}; // ConMod: construction cost per hull class + float savMod[3] = {1.f, 1.f, 1.f}; // SavMod + float outMod = 1.f; // OutMod + float popMod = 1.f; // PopMod + float terraMod = 1.f; // TerraMod + float suitTol = 0.f; // SuitTol: species start value + adaptation techs + float maxOverharvest = 0.f; // MaxOH + float miningRate = 0.f; // MinRate + float defenceDamageMod = 1.f; // pddm + float resMod = 1.f; // ResMod (AI research bonus lands here) + float incMod = 1.f; // IncMod + float castRange = 0.f; // CstR + float castEfficiency = 0.f; // CstE + float castThreshold = 0.f; // CstT + int perGateTraffic = 0; // PrGtTrf -- an int, raised by an integer max + + // Node-bore parameters. In the player object these live in a separately allocated + // 3-word block whose pointer is null while no bore drive is researched, so "absent" + // is a state of its own rather than three zeroes. + bool hasNodeBoreParams = false; + int nodeBoreParams[3] = {0, 0, 0}; bool flags[kPlayerFlagCount] = {}; bool aiBenefit = true; // AIBn: false after an AI rebellion @@ -101,18 +111,22 @@ struct PlayerEconomyState { unsigned hasVaccine = 0; // HasVac unsigned hasImmunity = 0; // HasImm unsigned speciesFlags[sots::sim::kSpeciesCount] = {}; // xenotech bits per target species + // Sticky "level-1 translation for this species has been researched" mask, one bit per + // Species (never the NPC race, never cleared). Re-derived alongside speciesFlags. + unsigned translationKnownMask = 0; std::bitset researched; bool Flag(PlayerFlag f) const { return flags[static_cast(f)]; } bool HasResearched(TechId id) const { return IsValidTechId(id) && researched.test(static_cast(TechIdIndex(id))); } }; -// Values of the three AI-benefit bonuses. The game reads them from a small table whose -// numbers are not recovered; the caller supplies them (0 = no bonus). +// Values of the three AI-benefit bonuses. The game reads them from a 6-row table in the +// executable ({tech id, rebellion odds, bonus value}); all three bonus values are 0.5 +// (B2, table dumped). The caller may still override them. struct AiBonusValues { - double research = 0; // added to ResMod - double admin = 0; // added to IncMod - double factory = 0; // added to OutMod + double research = 0.5; // added to ResMod (CCC_AI) + double admin = 0.5; // added to IncMod (CCC_AIAdmin) + double factory = 0.5; // added to OutMod (CCC_AIFac) }; struct ApplyContext { @@ -139,6 +153,20 @@ struct TechApplyOutcome { // node-bore "highest wins" rule. TechApplyOutcome ApplyTechEffect(PlayerEconomyState& s, TechId id, const ApplyContext& ctx); +// The completion callback itself, with no already-researched guard: exactly what the game +// runs every time a tech is marked researched, in the game's order -- the one matching +// branch of the id chain, then the tail (plague mask, design-option masks are the caller's, +// node-bore parameters, species flags, the Zuul grant, the capture-designs pair test, the +// temperance sweep). `ApplyTechEffect` is this plus the guard; a differential hook must use +// this one, because by the time the callback runs the tech is already marked researched. +TechApplyOutcome ApplyTechCompletion(PlayerEconomyState& s, TechId id, const ApplyContext& ctx); + +// Just the tail of a completion: the node-bore selection, the species flag words and their +// sticky translation mask, the capture-designs pair test and the temperance report. The +// game runs it for *every* tech marked researched, including the ~half of the data files +// that are not in the 196-name key space at all and so have no branch of their own. +TechApplyOutcome RunCompletionTail(PlayerEconomyState& s); + // Apply a completion by data-file name: resolves the id (case-insensitively), and also // handles the node-track techs, which are keyed by name in the species table rather than // by id. Returns the outcome; `applied` is false for names without a code effect. @@ -169,11 +197,23 @@ constexpr int kDesignOptionCountA = 32; constexpr int kDesignOptionCountB = 29; extern const char* const kDesignOptionNamesA[kDesignOptionCountA]; extern const char* const kDesignOptionNamesB[kDesignOptionCountB]; +// The same two tables keyed the way the game keys them: bit i is "tech id kDesignOptionIdsX[i] +// is researched". Recovered in B2 from the two {tech id, bit} tables in the executable. +extern const TechId kDesignOptionIdsA[kDesignOptionCountA]; +extern const TechId kDesignOptionIdsB[kDesignOptionCountB]; struct DesignOptionMasks { std::uint32_t a = 0; std::uint32_t b = 0; }; DesignOptionMasks ComputeDesignOptionMasks(const std::function& hasResearchedByName); +// By id -- what the executable actually does, and what a differential hook needs. +DesignOptionMasks ComputeDesignOptionMasks(const std::function& hasResearched); + +// Chance of an AI rebellion contributed by researching `id`, from the same 6-row table as +// the bonus values: 0.1 for the three AI techs, 0.2 for CCC_AIFRCON, 0 for everything else. +// The risk applies only while AiRebellionPossible(). CONFIDENCE: high (table dumped); +// how the odds are consumed is not modelled here. +double AiRebellionOdds(TechId id); } // namespace sots::effects diff --git a/src/game/effects/tech_id.cpp b/src/game/effects/tech_id.cpp index de6d5fe..6f68b57 100644 --- a/src/game/effects/tech_id.cpp +++ b/src/game/effects/tech_id.cpp @@ -60,7 +60,7 @@ TechId XenoTechId(XenoLevel level, sots::sim::Species target) { {142, false}, // Temperance {147, true}, // Subjugate {153, false}, // Accommodate - {158, true}, // Proliferate + {158, false}, // Proliferate -- five entries, not six (B2: read from the name table) }; const int k = static_cast(level); if (k < 0 || k >= kXenoLevelCount) return TechId::None; diff --git a/src/game/effects/tech_id.h b/src/game/effects/tech_id.h index 5a4ef23..210a258 100644 --- a/src/game/effects/tech_id.h +++ b/src/game/effects/tech_id.h @@ -6,11 +6,17 @@ // gate and bitmask is keyed on those ids; techs that are not in the list have no code // effect beyond what the data files say (prerequisites, section/weapon availability). // -// The list below is the part of that table recovered so far. Entries whose data-file -// name is known carry it; entries whose position is known but whose name is not are -// `Unresolved_NNN` (no code reads them, or their readers are combat/design side). The -// xenotech (XNC) block is named by role because the per-species data-file names are -// only partially recovered -- see docs/game-effects.md. +// The list below is the whole table, in position order, read out of the executable in B2: +// all 196 names, so `TechIdName` never returns nullptr and `TechIdFromName` resolves every +// tech the code can key on. Enum identifiers are the data-file names, except in the +// xenotech (XNC) block, where the role-based names are kept because `XenoTechId` is built +// on them and they say what the tech does; each still carries its real name as its string. +// +// This replaced an earlier partial reconstruction. Two of its guesses were wrong and are +// worth recording: the level-3 translation block's fifth entry is `XNC_DOMZUUL`, not a +// translation, and the **proliferate block has five entries, not six** -- it omits the +// Zuul like the other 5-entry families, and the two slots after it are the node-track +// techs. See docs/B2.md. #pragma once #include @@ -69,140 +75,140 @@ constexpr int kTechIdCount = 196; X(42, BIO_CONNAN, "BIO_CONNAN") \ X(43, BIO_UNIANTI, "BIO_UNIANTI") \ X(44, BIO_PLG, "BIO_PLG") \ - X(45, BIO_RetroPlague, nullptr) /* name inferred from its vaccine */ \ - X(46, BIO_BeastPlague, nullptr) \ - X(47, BIO_AssimilationPlague, nullptr) \ + X(45, BIO_RTPLG, "BIO_RTPLG") \ + X(46, BIO_BST, "BIO_BST") \ + X(47, BIO_ASPLG, "BIO_ASPLG") \ X(48, BIO_NANVIR, "BIO_NANVIR") \ X(49, DRN_CMBT, "DRN_CMBT") \ X(50, IND_STLTHARM, "IND_STLTHARM") \ X(51, DRV_PLSMFOC, "DRV_PLSMFOC") \ - X(52, Unresolved_052, nullptr) \ - X(53, Unresolved_053, nullptr) \ - X(54, Unresolved_054, nullptr) \ - X(55, Unresolved_055, nullptr) \ - X(56, Unresolved_056, nullptr) \ - X(57, Unresolved_057, nullptr) \ - X(58, Unresolved_058, nullptr) \ - X(59, Unresolved_059, nullptr) \ - X(60, Unresolved_060, nullptr) \ - X(61, Unresolved_061, nullptr) \ - X(62, Unresolved_062, nullptr) \ - X(63, Unresolved_063, nullptr) \ + X(52, DRV_FUSN, "DRV_FUSN") \ + X(53, DRV_ANTIMAT, "DRV_ANTIMAT") \ + X(54, IND_DREADCON, "IND_DREADCON") \ + X(55, DRV_FISSN, "DRV_FISSN") \ + X(56, DRV_NODE, "DRV_NODE") \ + X(57, DRV_NODFOC, "DRV_NODFOC") \ + X(58, DRV_NODPATH, "DRV_NODPATH") \ + X(59, DRV_HYPER, "DRV_HYPER") \ + X(60, DRV_WARP, "DRV_WARP") \ + X(61, DRV_STRWRP, "DRV_STRWRP") \ + X(62, DRV_IMPSTWRP, "DRV_IMPSTWRP") \ + X(63, DRV_FLICKER, "DRV_FLICKER") \ X(64, DRV_RIP, "DRV_RIP") \ X(65, DRV_REND, "DRV_REND") \ X(66, DRV_RAD, "DRV_RAD") \ - X(67, Unresolved_067, nullptr) \ - X(68, Unresolved_068, nullptr) \ - X(69, Unresolved_069, nullptr) \ - X(70, Unresolved_070, nullptr) \ + X(67, DRV_VDCTR, "DRV_VDCTR") \ + X(68, DRV_VDCRV, "DRV_VDCRV") \ + X(69, DRV_VDMSTR, "DRV_VDMSTR") \ + X(70, BIO_SPNDANI, "BIO_SPNDANI") \ X(71, SLD_INTANG, "SLD_INTANG") \ X(72, DRV_RECFISS, "DRV_RECFISS") \ - X(73, Unresolved_073, nullptr) \ + X(73, SLD_IMPCLK, "SLD_IMPCLK") \ X(74, CCC_ARMCOM, "CCC_ARMCOM") \ X(75, CCC_DATSYN, "CCC_DATSYN") \ X(76, CCC_BTLCMP, "CCC_BTLCMP") \ - X(77, WEP_BeamVariant_077, nullptr) /* combat beam variant */ \ - X(78, WEP_BeamVariant_078, nullptr) \ - X(79, WEP_CannonVariant_079, nullptr) /* combat cannon variant */ \ - X(80, WEP_CannonVariant_080, nullptr) \ - X(81, WEP_CannonVariant_081, nullptr) \ - X(82, WEP_BeamVariant_082, nullptr) \ - X(83, Unresolved_083, nullptr) \ - X(84, Unresolved_084, nullptr) \ - X(85, Unresolved_085, nullptr) \ - X(86, Unresolved_086, nullptr) \ - X(87, Unresolved_087, nullptr) \ - X(88, Unresolved_088, nullptr) \ - X(89, Unresolved_089, nullptr) \ - X(90, Unresolved_090, nullptr) \ - X(91, Unresolved_091, nullptr) \ - X(92, Unresolved_092, nullptr) \ + X(77, WEP_UVLAS, "WEP_UVLAS") \ + X(78, WEP_XRYLAS, "WEP_XRYLAS") \ + X(79, WEP_AMCAN, "WEP_AMCAN") \ + X(80, WEP_FUSCAN, "WEP_FUSCAN") \ + X(81, WEP_PLSMCAN, "WEP_PLSMCAN") \ + X(82, WEP_GRNLAS, "WEP_GRNLAS") \ + X(83, CCC_AIFRCON, "CCC_AIFRCON") \ + X(84, IND_PREDGUN, "IND_PREDGUN") \ + X(85, CCC_CMBTALG, "CCC_CMBTALG") \ + X(86, CCC_HOLOTAC, "CCC_HOLOTAC") \ + X(87, DRV_PLSFISS, "DRV_PLSFISS") \ + X(88, SLD_CLK, "SLD_CLK") \ + X(89, CCC_FTLCOM, "CCC_FTLCOM") \ + X(90, IND_MAGLAT, "IND_MAGLAT") \ + X(91, IND_PLYALLOY, "IND_PLYALLOY") \ + X(92, IND_REFCOAT, "IND_REFCOAT") \ X(93, CCC_HYPCOM, "CCC_HYPCOM") \ X(94, IND_TRKSTL, "IND_TRKSTL") \ X(95, IND_SPNLMNT, "IND_SPNLMNT") \ X(96, WEP_HCLAS, "WEP_HCLAS") \ X(97, WEP_PRTBM, "WEP_PRTBM") \ X(98, WEP_DSRPTR, "WEP_DSRPTR") \ - X(99, Unresolved_099, nullptr) \ - X(100, WEP_NUKMINE, nullptr) /* stem known, prefix inferred */ \ - X(101, WEP_FUSMINE, nullptr) \ - X(102, WEP_DFMSL, nullptr) \ - X(103, WEP_GSDRVR, nullptr) \ - X(104, WEP_MASDRVR, nullptr) \ - X(105, WEP_HVYDRVR, nullptr) \ + X(99, WEP_FUSWHD, "WEP_FUSWHD") \ + X(100, WEP_NUKMINE, "WEP_NUKMINE") \ + X(101, WEP_FUSMINE, "WEP_FUSMINE") \ + X(102, WEP_DFMSL, "WEP_DFMSL") \ + X(103, WEP_GSDRVR, "WEP_GSDRVR") \ + X(104, WEP_MASDRVR, "WEP_MASDRVR") \ + X(105, WEP_HVYDRVR, "WEP_HVYDRVR") \ X(106, WEP_VRFTECH, "WEP_VRFTECH") \ - X(107, WEP_PDTECH, nullptr) \ + X(107, WEP_PDTECH, "WEP_PDTECH") \ X(108, CCC_FTLBRDB, "CCC_FTLBRDB") \ - X(109, Unresolved_109, nullptr) \ + X(109, CCC_SNSJAM, "CCC_SNSJAM") \ X(110, CCC_INTSENS, "CCC_INTSENS") \ - X(111, Unresolved_111, nullptr) \ + X(111, SLD_DEF, "SLD_DEF") \ X(112, CCC_SPJAM, "CCC_SPJAM") \ X(113, CCC_TUNSENS, "CCC_TUNSENS") \ X(114, XNC_Translation1_Human, "CCC_TRNSHUM") \ - X(115, XNC_Translation1_Hiver, nullptr) \ - X(116, XNC_Translation1_Tarkas, nullptr) \ + X(115, XNC_Translation1_Hiver, "CCC_TRNSHVR") \ + X(116, XNC_Translation1_Tarkas, "CCC_TRNSTRK") \ X(117, XNC_Translation1_Liir, "CCC_TRNSLIR") \ - X(118, XNC_Translation1_Zuul, nullptr) \ - X(119, XNC_Translation1_Morrigi, nullptr) \ - X(120, XNC_Translation2_Human, nullptr) \ - X(121, XNC_Translation2_Hiver, nullptr) \ - X(122, XNC_Translation2_Tarkas, nullptr) \ - X(123, XNC_Translation2_Liir, nullptr) \ - X(124, XNC_Translation2_Zuul, nullptr) \ - X(125, XNC_Translation2_Morrigi, nullptr) \ - X(126, XNC_Translation3_Human, nullptr) \ - X(127, XNC_Translation3_Hiver, nullptr) \ - X(128, XNC_Translation3_Tarkas, nullptr) \ - X(129, XNC_Translation3_Liir, nullptr) \ - X(130, XNC_Translation3_Zuul, nullptr) \ - X(131, XNC_Translation3_Morrigi, nullptr) \ - X(132, XNC_Incorporate_Human, nullptr) \ - X(133, XNC_Incorporate_Hiver, nullptr) \ - X(134, XNC_Incorporate_Tarkas, nullptr) \ - X(135, XNC_Incorporate_Liir, nullptr) \ - X(136, XNC_Incorporate_Morrigi, nullptr) \ - X(137, XNC_Addict_Human, nullptr) \ - X(138, XNC_Addict_Hiver, nullptr) \ - X(139, XNC_Addict_Tarkas, nullptr) \ - X(140, XNC_Addict_Liir, nullptr) \ - X(141, XNC_Addict_Morrigi, nullptr) \ - X(142, XNC_Temperance_Human, nullptr) \ - X(143, XNC_Temperance_Hiver, nullptr) \ - X(144, XNC_Temperance_Tarkas, nullptr) \ - X(145, XNC_Temperance_Liir, nullptr) \ - X(146, XNC_Temperance_Morrigi, nullptr) \ - X(147, XNC_Subjugate_Human, nullptr) \ - X(148, XNC_Subjugate_Hiver, nullptr) \ - X(149, XNC_Subjugate_Tarkas, nullptr) \ - X(150, XNC_Subjugate_Liir, nullptr) \ - X(151, XNC_Subjugate_Zuul, nullptr) \ - X(152, XNC_Subjugate_Morrigi, nullptr) \ - X(153, XNC_Accommodate_Human, nullptr) \ - X(154, XNC_Accommodate_Hiver, nullptr) \ - X(155, XNC_Accommodate_Tarkas, nullptr) \ - X(156, XNC_Accommodate_Liir, nullptr) \ - X(157, XNC_Accommodate_Morrigi, nullptr) \ - X(158, XNC_Proliferate_Human, nullptr) \ - X(159, XNC_Proliferate_Hiver, nullptr) \ - X(160, XNC_Proliferate_Tarkas, nullptr) \ - X(161, XNC_Proliferate_Liir, nullptr) \ - X(162, XNC_Proliferate_Zuul, nullptr) \ - X(163, XNC_Proliferate_Morrigi, nullptr) \ - X(164, Unresolved_164, nullptr) \ - X(165, Unresolved_165, nullptr) \ - X(166, Unresolved_166, nullptr) \ - X(167, Unresolved_167, nullptr) \ - X(168, Unresolved_168, nullptr) \ - X(169, Unresolved_169, nullptr) \ - X(170, Unresolved_170, nullptr) \ + X(118, XNC_Translation1_Zuul, "CCC_TRNSZUL") \ + X(119, XNC_Translation1_Morrigi, "CCC_TRNSMORR") \ + X(120, XNC_Translation2_Human, "XNC_TRNSHUM2") \ + X(121, XNC_Translation2_Hiver, "XNC_TRNSHVR2") \ + X(122, XNC_Translation2_Tarkas, "XNC_TRNSTRK2") \ + X(123, XNC_Translation2_Liir, "XNC_TRNSLIR2") \ + X(124, XNC_Translation2_Zuul, "XNC_TRNSZUUL2") \ + X(125, XNC_Translation2_Morrigi, "XNC_TRNSMORR2") \ + X(126, XNC_Translation3_Human, "XNC_TRNSHUM3") \ + X(127, XNC_Translation3_Hiver, "XNC_TRNSHVR3") \ + X(128, XNC_Translation3_Tarkas, "XNC_TRNSTRK3") \ + X(129, XNC_Translation3_Liir, "XNC_TRNSLIR3") \ + X(130, XNC_Translation3_Zuul, "XNC_DOMZUUL") /* the Zuul slot of the level-3 block; the data name is not a "translation" */ \ + X(131, XNC_Translation3_Morrigi, "XNC_TRNSMORR3") \ + X(132, XNC_Incorporate_Human, "XNC_INCHUM") \ + X(133, XNC_Incorporate_Hiver, "XNC_INCHVR") \ + X(134, XNC_Incorporate_Tarkas, "XNC_INCTRK") \ + X(135, XNC_Incorporate_Liir, "XNC_INCLIR") \ + X(136, XNC_Incorporate_Morrigi, "XNC_INCMORR") \ + X(137, XNC_Addict_Human, "XNC_ADCTHUM") \ + X(138, XNC_Addict_Hiver, "XNC_ADCTHVR") \ + X(139, XNC_Addict_Tarkas, "XNC_ADCTTRK") \ + X(140, XNC_Addict_Liir, "XNC_ADCTLIR") \ + X(141, XNC_Addict_Morrigi, "XNC_ADCTMORR") \ + X(142, XNC_Temperance_Human, "XNC_TEMPHUM") \ + X(143, XNC_Temperance_Hiver, "XNC_TEMPHVR") \ + X(144, XNC_Temperance_Tarkas, "XNC_TEMPTRK") \ + X(145, XNC_Temperance_Liir, "XNC_TEMPLIR") \ + X(146, XNC_Temperance_Morrigi, "XNC_TEMPMORR") \ + X(147, XNC_Subjugate_Human, "XNC_SUBHUM") \ + X(148, XNC_Subjugate_Hiver, "XNC_SUBHVR") \ + X(149, XNC_Subjugate_Tarkas, "XNC_SUBTRK") \ + X(150, XNC_Subjugate_Liir, "XNC_SUBLIR") \ + X(151, XNC_Subjugate_Zuul, "XNC_SUBZUUL") \ + X(152, XNC_Subjugate_Morrigi, "XNC_SUBMORR") \ + X(153, XNC_Accommodate_Human, "XNC_ACCHUM") \ + X(154, XNC_Accommodate_Hiver, "XNC_ACCHVR") \ + X(155, XNC_Accommodate_Tarkas, "XNC_ACCTRK") \ + X(156, XNC_Accommodate_Liir, "XNC_ACCLIR") \ + X(157, XNC_Accommodate_Morrigi, "XNC_ACCMORR") \ + X(158, XNC_Proliferate_Human, "XNC_PROFHUM") \ + X(159, XNC_Proliferate_Hiver, "XNC_PROFHVR") \ + X(160, XNC_Proliferate_Tarkas, "XNC_PROFTRK") \ + X(161, XNC_Proliferate_Liir, "XNC_PROFLIR") \ + X(162, XNC_Proliferate_Morrigi, "XNC_PROFMORR") \ + X(163, CCC_NDTRKHUM, "CCC_NDTRKHUM") /* node-track techs: keyed by name in the species table, not by the effect chain */ \ + X(164, CCC_NDTRKZUL, "CCC_NDTRKZUL") \ + X(165, WEP_NUKES, "WEP_NUKES") \ + X(166, WEP_NUKEWHD, "WEP_NUKEWHD") \ + X(167, WEP_GMAWHD, "WEP_GMAWHD") \ + X(168, WEP_AMWHD, "WEP_AMWHD") \ + X(169, IND_ADMALY, "IND_ADMALY") \ + X(170, IND_IMPRFCT, "IND_IMPRFCT") \ X(171, IND_QRKRES, "IND_QRKRES") \ - X(172, Unresolved_172, nullptr) \ + X(172, SLD_ERGAB, "SLD_ERGAB") \ X(173, SLD_MKONE, "SLD_MKONE") \ X(174, SLD_MKTWO, "SLD_MKTWO") \ X(175, SLD_MKTHREE, "SLD_MKTHREE") \ - X(176, Unresolved_176, nullptr) \ + X(176, SLD_MKFOUR, "SLD_MKFOUR") \ X(177, WEP_NEUTRND, "WEP_NEUTRND") \ - X(178, Unresolved_178, nullptr) \ + X(178, CCC_QNTCHAF, "CCC_QNTCHAF") \ X(179, CCC_ADVCNC, "CCC_ADVCNC") \ X(180, DRV_MCROFUS, "DRV_MCROFUS") \ X(181, DRV_INCTHRST, "DRV_INCTHRST") \ @@ -217,9 +223,9 @@ constexpr int kTechIdCount = 196; X(190, IND_HRDELEC, "IND_HRDELEC") \ X(191, BIO_SMRTNAN, "BIO_SMRTNAN") \ X(192, WEP_ACCAMP, "WEP_ACCAMP") \ - X(193, Unresolved_193, nullptr) \ - X(194, Unresolved_194, nullptr) \ - X(195, Unresolved_195, nullptr) + X(193, DRN_BTLRDRS, "DRN_BTLRDRS") \ + X(194, DRN_ADVFRM, "DRN_ADVFRM") \ + X(195, IND_AdvDreadEng, "IND_AdvDreadEng") enum class TechId : int { #define SOTS_TECH_ID_ENUM(idx, name, str) name = kTechIdBase + idx, @@ -233,8 +239,7 @@ constexpr bool IsValidTechId(TechId id) { return IsValidTechId(static_cast( constexpr int TechIdIndex(TechId id) { return static_cast(id) - kTechIdBase; } constexpr TechId TechIdFromIndex(int index) { return static_cast(kTechIdBase + index); } -// Data-file name of a tech id, or nullptr when the position is known but the name is not -// (or the id is invalid). +// Data-file name of a tech id; nullptr only when the id is invalid. const char* TechIdName(TechId id); // Resolve a data-file name to its id, case-insensitively as the game does. Names not in @@ -256,10 +261,10 @@ enum class XenoLevel : int { constexpr int kXenoLevelCount = 9; // The xenotech of one family aimed at one species, or None where the game has no such -// tech (the NPC race for every family; the Zuul for Incorporate, Addict, Temperance and -// Accommodate). CONFIDENCE: high on the family order and block bases; medium on the -// compact species order inside a block; low on which species the four 5-entry blocks -// other than Incorporate omit (Zuul assumed). +// tech: the NPC race for every family, and the Zuul for Incorporate, Addict, Temperance, +// Accommodate and Proliferate. CONFIDENCE: high throughout -- the family order, the block +// bases, the species order inside a block (Human, Hiver, Tarkas, Liir, [Zuul,] Morrigi) +// and which blocks omit the Zuul are all read off the name table. TechId XenoTechId(XenoLevel level, sots::sim::Species target); // Data-file name of the tech that lets a player see one species' node-space traffic diff --git a/src/game/sim/tuning.h b/src/game/sim/tuning.h index c71dfb3..e286b50 100644 --- a/src/game/sim/tuning.h +++ b/src/game/sim/tuning.h @@ -68,8 +68,10 @@ struct TuningTable { double STUTTER_MAX_SPEED = 0; // ---- gates (read by the tech-effect layer) ---- - double PERGATETRAFFIC_DRV_TpGate = 0; // per-gate traffic capacity granted by the gate tech - double PERGATETRAFFIC_DRV_GatAmp = 0; // ... and by the amplifier tech (the higher wins) + // Both are integers in the executable: the tech callback does a signed integer max + // against the player's PrGtTrf word, not a float compare (B2, read from the branch). + int PERGATETRAFFIC_DRV_TpGate = 0; // per-gate traffic capacity granted by the gate tech + int PERGATETRAFFIC_DRV_GatAmp = 0; // ... and by the amplifier tech (the higher wins) }; } // namespace sots::sim diff --git a/src/shim/hooks/tech_effect_fields.cpp b/src/shim/hooks/tech_effect_fields.cpp new file mode 100644 index 0000000..2632ffe --- /dev/null +++ b/src/shim/hooks/tech_effect_fields.cpp @@ -0,0 +1,338 @@ +#include "shim/hooks/tech_effect_fields.h" + +#include +#include + +#include "generated/sots_addresses.h" + +namespace shim::hooks::techfx { + +using trace::Tv; +namespace tv = trace::tv; +namespace fx = sots::effects; + +namespace { + +namespace A = sots::addr; + +std::int32_t i32_at(const void* p, std::size_t off) { + std::int32_t v = 0; + std::memcpy(&v, static_cast(p) + off, sizeof v); + return v; +} +std::uint32_t u32_at(const void* p, std::size_t off) { + std::uint32_t v = 0; + std::memcpy(&v, static_cast(p) + off, sizeof v); + return v; +} +float f32_at(const void* p, std::size_t off) { + float v = 0; + std::memcpy(&v, static_cast(p) + off, sizeof v); + return v; +} +void* ptr_at(const void* p, std::size_t off) { + void* v = nullptr; + std::memcpy(&v, static_cast(p) + off, sizeof v); + return v; +} +std::uint8_t u8_at(const void* p, std::size_t off) { + return static_cast(p)[off]; +} +void put_i32(void* p, std::size_t off, std::int32_t v) { std::memcpy(static_cast(p) + off, &v, sizeof v); } +void put_u32(void* p, std::size_t off, std::uint32_t v) { std::memcpy(static_cast(p) + off, &v, sizeof v); } +void put_f32(void* p, std::size_t off, float v) { std::memcpy(static_cast(p) + off, &v, sizeof v); } +void put_u8(void* p, std::size_t off, std::uint8_t v) { static_cast(p)[off] = v; } +void put_ptr(void* p, std::size_t off, void* v) { std::memcpy(static_cast(p) + off, &v, sizeof v); } + +// One describer per field group, so a divergence names the field. + +Tv describe_suit(const void* p, std::size_t, unsigned) { + Tv s = tv::struct_(); + s.add("suit_tol", tv::f32(f32_at(p, 0))); + s.add("max_overharvest", tv::f32(f32_at(p, 4))); + return s; +} + +Tv describe_res_mod(const void* p, std::size_t, unsigned) { + Tv s = tv::struct_(); + s.add("res_mod", tv::f32(f32_at(p, 0))); + return s; +} + +Tv describe_abilities(const void* p, std::size_t, unsigned) { + Tv s = tv::struct_(); + s.add("reb_ai", tv::boolean(u8_at(p, 0x00) != 0)); + s.add("unk_fd", tv::u8(u8_at(p, 0x01))); + s.add("ai_benefit", tv::boolean(u8_at(p, 0x02) != 0)); + s.add("trade_allowed", tv::boolean(u8_at(p, 0x03) != 0)); + s.add("commerce_raiding", tv::boolean(u8_at(p, 0x04) != 0)); + s.add("view_intel", tv::boolean(u8_at(p, 0x05) != 0)); + s.add("grav_synth", tv::boolean(u8_at(p, 0x06) != 0)); + s.add("advanced_sensors", tv::boolean(u8_at(p, 0x07) != 0)); + s.add("arcology", tv::boolean(u8_at(p, 0x08) != 0)); + s.add("unk_105", tv::u8(u8_at(p, 0x09))); + s.add("unk_106", tv::u8(u8_at(p, 0x0a))); + s.add("unk_107", tv::u8(u8_at(p, 0x0b))); + return s; +} + +// +0x108 .. +0x15c. The five words this module does not model are emitted as raw u32 so a +// surprise write by the original shows up as a real divergence rather than going unseen. +Tv describe_modifiers(const void* p, std::size_t, unsigned) { + Tv s = tv::struct_(); + s.add("pddm", tv::f32(f32_at(p, 0x00))); + std::vector con; + for (int i = 0; i < 3; ++i) con.push_back(tv::f32(f32_at(p, 0x04 + 4u * i))); + s.add("con_mod", tv::list(std::move(con))); + std::vector sav; + for (int i = 0; i < 3; ++i) sav.push_back(tv::f32(f32_at(p, 0x10 + 4u * i))); + s.add("sav_mod", tv::list(std::move(sav))); + s.add("out_mod", tv::f32(f32_at(p, 0x1c))); + s.add("unk_128", tv::u32(u32_at(p, 0x20))); + s.add("unk_12c", tv::u32(u32_at(p, 0x24))); + s.add("pop_mod", tv::f32(f32_at(p, 0x28))); + s.add("terra_mod", tv::f32(f32_at(p, 0x2c))); + s.add("asteroid_mining", tv::boolean(u8_at(p, 0x30) != 0)); + s.add("unk_139_13b", tv::u32(u32_at(p, 0x30) >> 8)); + s.add("unk_13c", tv::u32(u32_at(p, 0x34))); + s.add("min_rate", tv::f32(f32_at(p, 0x38))); + s.add("unk_144", tv::u32(u32_at(p, 0x3c))); + s.add("per_gate_traffic", tv::i32(i32_at(p, 0x40))); // an int, not a float + s.add("unk_14c", tv::u32(u32_at(p, 0x44))); + s.add("cast_range", tv::f32(f32_at(p, 0x48))); + s.add("cast_efficiency", tv::f32(f32_at(p, 0x4c))); + s.add("cast_threshold", tv::f32(f32_at(p, 0x50))); + return s; +} + +Tv describe_masks(const void* p, std::size_t, unsigned) { + Tv s = tv::struct_(); + s.add("design_options_a", tv::u32(u32_at(p, 0))); + s.add("design_options_b", tv::u32(u32_at(p, 4))); + return s; +} + +Tv describe_translation(const void* p, std::size_t, unsigned) { + Tv s = tv::struct_(); + s.add("translation_known", tv::u32(u32_at(p, 0))); + return s; +} + +Tv describe_vaccines(const void* p, std::size_t, unsigned) { + Tv s = tv::struct_(); + s.add("has_vaccine", tv::u32(u32_at(p, 0))); + s.add("has_immunity", tv::u32(u32_at(p, 4))); + s.add("node_track_mask", tv::u32(u32_at(p, 8))); + return s; +} + +Tv describe_research_target(const void* p, std::size_t, unsigned) { + Tv s = tv::struct_(); + // A heap pointer, so only "still set" / "cleared" is meaningful across two runs. + s.add("research_target", tv::ptr(ptr_at(p, 0))); + s.add("research_target_set", tv::boolean(ptr_at(p, 0) != nullptr)); + return s; +} + +Tv describe_nodebore_ptr(const void* p, std::size_t, unsigned) { + Tv s = tv::struct_(); + // Pointer values are ignored by the default policy; `present` is the fact that matters, + // and it is deliberately NOT compared (see the header): `ours` cannot allocate the + // block, so a first bore-drive completion would diverge here for the wrong reason. + s.add("node_bore_block", tv::ptr(ptr_at(p, 0))); + return s; +} + +Tv describe_inc_mod(const void* p, std::size_t, unsigned) { + Tv s = tv::struct_(); + s.add("inc_mod", tv::f32(f32_at(p, 0))); + return s; +} + +Tv describe_capture(const void* p, std::size_t, unsigned) { + Tv s = tv::struct_(); + s.add("capture_designs", tv::boolean(u8_at(p, 0) != 0)); + s.add("unk_331_333", tv::u32(u32_at(p, 0) >> 8)); + return s; +} + +Tv describe_species_flags(const void* p, std::size_t, unsigned) { + Tv s = tv::struct_(); + std::vector f; + for (int i = 0; i < sots::sim::kSpeciesCount; ++i) f.push_back(tv::u32(u32_at(p, 4u * i))); + s.add("species_flags", tv::list(std::move(f))); + s.add("count", tv::u32(u32_at(p, 0x1c))); + return s; +} + +Tv describe_roll(const void* p, std::size_t, unsigned) { + Tv s = tv::struct_(); + s.add("research_roll_pending", tv::boolean(u8_at(p, 0) != 0)); + s.add("unk_3b5_3b7", tv::u32(u32_at(p, 0) >> 8)); + s.add("ai_rebellion", tv::ptr(ptr_at(p, 4))); + return s; +} + +Tv describe_nodebore(const void* p, std::size_t, unsigned) { + Tv s = tv::struct_(); + std::vector v; + for (int i = 0; i < 3; ++i) v.push_back(tv::i32(i32_at(p, 4u * i))); + s.add("node_bore_params", tv::list(std::move(v))); + return s; +} + +} // namespace + +const RegionDef kRegions[kRegionCount] = { + {"suit", A::ServerPlayer_off_SuitTol, 8, &describe_suit}, + {"res_mod", A::ServerPlayer_off_ResMod, 4, &describe_res_mod}, + {"abilities", A::ServerPlayer_off_RebAI, 12, &describe_abilities}, + {"modifiers", A::ServerPlayer_off_pddm, 0x54, &describe_modifiers}, + {"design_masks", A::ServerPlayer_off_TechMaskA, 8, &describe_masks}, + {"translation", A::ServerPlayer_off_TranslationKnown, 4, &describe_translation}, + {"vaccines", A::ServerPlayer_off_HasVac, 12, &describe_vaccines}, + {"research_target", A::ServerPlayer_off_ResearchTarget, 4, &describe_research_target}, + {"node_bore_ptr", A::ServerPlayer_off_NodeBore, 4, &describe_nodebore_ptr}, + {"inc_mod", A::ServerPlayer_off_IncMod, 4, &describe_inc_mod}, + {"capture_designs", A::ServerPlayer_off_CaptureDesigns, 4, &describe_capture}, + {"species_flags", A::ServerPlayer_off_SpeciesTechFlags, 0x20, &describe_species_flags}, + {"roll", A::ServerPlayer_off_ResearchRollPending, 8, &describe_roll}, + {"node_bore", 0, 12, &describe_nodebore}, // base is *(player + off_NodeBore) +}; + +std::size_t PlayerSpan() { return A::ServerPlayer_off_ResearchRollPending + 8; } + +Views Views::OverPlayer(void* player) { + Views v; + for (int i = 0; i < kRegionCount; ++i) { + v.base[i] = i == R_NODEBORE ? ptr_at(player, A::ServerPlayer_off_NodeBore) + : static_cast(player) + kRegions[i].off; + } + return v; +} + +// Seed our model from the pre-call field values. Everything the callback can write comes +// from the region copies; species (never written) comes from the live object. +fx::PlayerEconomyState ReadPlayerState(const Views& v_, sots::sim::Species species) { + fx::PlayerEconomyState s; + s.species = species; + + const void* suit = v_.base[R_SUIT]; + s.suitTol = f32_at(suit, 0); + s.maxOverharvest = f32_at(suit, 4); + + s.resMod = f32_at(v_.base[R_RESMOD], 0); + s.incMod = f32_at(v_.base[R_INCMOD], 0); + + const void* ab = v_.base[R_ABILITIES]; + s.rebelAI = u8_at(ab, 0x00) != 0; + s.aiBenefit = u8_at(ab, 0x02) != 0; + s.flags[static_cast(fx::PlayerFlag::TradeAllowed)] = u8_at(ab, 0x03) != 0; + s.flags[static_cast(fx::PlayerFlag::CommerceRaiding)] = u8_at(ab, 0x04) != 0; + s.flags[static_cast(fx::PlayerFlag::ViewIntel)] = u8_at(ab, 0x05) != 0; + s.flags[static_cast(fx::PlayerFlag::GravSynth)] = u8_at(ab, 0x06) != 0; + s.flags[static_cast(fx::PlayerFlag::AdvancedSensors)] = u8_at(ab, 0x07) != 0; + s.flags[static_cast(fx::PlayerFlag::Arcology)] = u8_at(ab, 0x08) != 0; + + const void* m = v_.base[R_MODIFIERS]; + s.defenceDamageMod = f32_at(m, 0x00); + for (int i = 0; i < 3; ++i) s.conMod[i] = f32_at(m, 0x04 + 4u * i); + for (int i = 0; i < 3; ++i) s.savMod[i] = f32_at(m, 0x10 + 4u * i); + s.outMod = f32_at(m, 0x1c); + s.popMod = f32_at(m, 0x28); + s.terraMod = f32_at(m, 0x2c); + s.flags[static_cast(fx::PlayerFlag::AsteroidMining)] = u8_at(m, 0x30) != 0; + s.miningRate = f32_at(m, 0x38); + s.perGateTraffic = i32_at(m, 0x40); + s.castRange = f32_at(m, 0x48); + s.castEfficiency = f32_at(m, 0x4c); + s.castThreshold = f32_at(m, 0x50); + + const void* v = v_.base[R_VACCINES]; + s.hasVaccine = u32_at(v, 0); + s.hasImmunity = u32_at(v, 4); + s.nodeTrackMask = u32_at(v, 8); + + s.translationKnownMask = u32_at(v_.base[R_TRANSLATION], 0); + s.flags[static_cast(fx::PlayerFlag::CaptureDesigns)] = + u8_at(v_.base[R_CAPTURE], 0) != 0; + + // Present exactly when the block exists: in compare mode that is the declared region, + // in replace mode the live pointer. + if (const void* nb = v_.base[R_NODEBORE]) { + s.hasNodeBoreParams = true; + for (int i = 0; i < 3; ++i) s.nodeBoreParams[i] = i32_at(nb, 4u * i); + } + + return s; +} + +void WritePlayerState(const Views& v_, const fx::PlayerEconomyState& s) { + void* suit = v_.base[R_SUIT]; + put_f32(suit, 0, s.suitTol); + put_f32(suit, 4, s.maxOverharvest); + + put_f32(v_.base[R_RESMOD], 0, s.resMod); + put_f32(v_.base[R_INCMOD], 0, s.incMod); + + void* ab = v_.base[R_ABILITIES]; + put_u8(ab, 0x02, s.aiBenefit ? 1 : 0); + put_u8(ab, 0x03, s.Flag(fx::PlayerFlag::TradeAllowed) ? 1 : 0); + put_u8(ab, 0x04, s.Flag(fx::PlayerFlag::CommerceRaiding) ? 1 : 0); + put_u8(ab, 0x05, s.Flag(fx::PlayerFlag::ViewIntel) ? 1 : 0); + put_u8(ab, 0x06, s.Flag(fx::PlayerFlag::GravSynth) ? 1 : 0); + put_u8(ab, 0x07, s.Flag(fx::PlayerFlag::AdvancedSensors) ? 1 : 0); + put_u8(ab, 0x08, s.Flag(fx::PlayerFlag::Arcology) ? 1 : 0); + + void* m = v_.base[R_MODIFIERS]; + put_f32(m, 0x00, s.defenceDamageMod); + for (int i = 0; i < 3; ++i) put_f32(m, 0x04 + 4u * i, s.conMod[i]); + for (int i = 0; i < 3; ++i) put_f32(m, 0x10 + 4u * i, s.savMod[i]); + put_f32(m, 0x1c, s.outMod); + put_f32(m, 0x28, s.popMod); + put_f32(m, 0x2c, s.terraMod); + put_u8(m, 0x30, s.Flag(fx::PlayerFlag::AsteroidMining) ? 1 : 0); + put_f32(m, 0x38, s.miningRate); + put_i32(m, 0x40, s.perGateTraffic); + put_f32(m, 0x48, s.castRange); + put_f32(m, 0x4c, s.castEfficiency); + put_f32(m, 0x50, s.castThreshold); + + void* v = v_.base[R_VACCINES]; + put_u32(v, 0, s.hasVaccine); + put_u32(v, 4, s.hasImmunity); + put_u32(v, 8, s.nodeTrackMask); + + put_u32(v_.base[R_TRANSLATION], 0, s.translationKnownMask); + put_u8(v_.base[R_CAPTURE], 0, s.Flag(fx::PlayerFlag::CaptureDesigns) ? 1 : 0); + + void* sf = v_.base[R_SPECIES_FLAGS]; + for (int i = 0; i < sots::sim::kSpeciesCount; ++i) put_u32(sf, 4u * i, s.speciesFlags[i]); + put_u32(sf, 0x1c, static_cast(sots::sim::kSpeciesCount)); + + // The block is only writable where it already exists; see the header. + void* nb = v_.base[R_NODEBORE]; + if (nb && s.hasNodeBoreParams) { + for (int i = 0; i < 3; ++i) put_i32(nb, 4u * i, s.nodeBoreParams[i]); + } +} + + +bool ClearResearchTargetIfMatched(const Views& v_, const void* def) { + void* rt = v_.base[R_RESEARCH_TARGET]; + void* roll = v_.base[R_ROLL]; + if (!rt || ptr_at(rt, 0) != def) return false; + const bool pending = roll && u8_at(roll, 0) != 0; + if (roll) put_u8(roll, 0, 0); + put_ptr(rt, 0, nullptr); // a pointer-sized zero: 4 bytes on the i386 target + return pending; +} + +void WriteDesignOptionMasks(const Views& v_, std::uint32_t a, std::uint32_t b) { + void* mk = v_.base[R_MASKS]; + if (!mk) return; + put_u32(mk, 0, a); + put_u32(mk, 4, b); +} + +} // namespace shim::hooks::techfx diff --git a/src/shim/hooks/tech_effect_fields.h b/src/shim/hooks/tech_effect_fields.h new file mode 100644 index 0000000..50cd519 --- /dev/null +++ b/src/shim/hooks/tech_effect_fields.h @@ -0,0 +1,80 @@ +// The byte-level adapter between a live `ServerPlayer` and game/effects' PlayerEconomyState, +// plus the region table and struct describers the B2 hook declares. +// +// Split out of the hook (as B1 split `budget_inputs`) so that every offset, every float32 +// round-trip and every field the tech-effect callback writes can be exercised on the host +// against a synthetic player buffer, without the game and without a VM window. The hook +// itself then only has to resolve the tech id and delegate to the game's tech tree. +// +// The regions do not cover the whole object: they are exactly the field groups the callback +// writes, one region each, so a divergence names the field. `Views` is the indirection that +// lets the same code run against the scratch copies (compare mode) and against the live +// object (replace mode). +#pragma once + +#include +#include + +#include "game/effects/tech_effects.h" +#include "game/sim/species.h" +#include "shim/trace/tracer.h" + +namespace shim::hooks::techfx { + +enum RegionId { + R_SUIT = 0, // SuitTol, MaxOH + R_RESMOD, // ResMod + R_ABILITIES, // RebAI .. harcc + R_MODIFIERS, // pddm .. CstT + R_MASKS, // design-option masks A / B + R_TRANSLATION, // sticky translation mask + R_VACCINES, // HasVac, HasImm, NPTrk + R_RESEARCH_TARGET, // ResT + R_NODEBORE_PTR, // pointer to the node-bore block + R_INCMOD, // IncMod + R_CAPTURE, // cdp + R_SPECIES_FLAGS, // flags[7] + count + R_ROLL, // pending plague-cure roll + AIRebellion* + R_NODEBORE, // the 3-word node-bore block itself (behind R_NODEBORE_PTR) + kRegionCount +}; + +struct RegionDef { + const char* name; + std::uint32_t off; // from the ServerPlayer base; meaningless for R_NODEBORE + std::size_t size; + trace::Tv (*describe)(const void* data, std::size_t size, unsigned inline_max); +}; + +extern const RegionDef kRegions[kRegionCount]; + +// The last byte the region table reaches, for one readability check up front. +std::size_t PlayerSpan(); + +// Where each region's bytes live for this call. A null entry means "not present": only +// R_NODEBORE is ever legitimately null (no bore drive researched yet). +struct Views { + void* base[kRegionCount] = {}; + + // Point every region at its offset inside a live (or synthetic) player object, and + // R_NODEBORE at whatever the block pointer holds. + static Views OverPlayer(void* player); +}; + +// Seed our model from the pre-call field values. `species` is read from the live object by +// the caller because no region covers it (the callback never writes it). +sots::effects::PlayerEconomyState ReadPlayerState(const Views& v, sots::sim::Species species); + +// Write back exactly the words the callback writes. The node-bore block is only written +// where it already exists. +void WritePlayerState(const Views& v, const sots::effects::PlayerEconomyState& s); + +// The callback's first act when the completing definition is the current research target: +// clear the target and the pending-roll byte. Returns whether the roll was pending (the +// roll itself draws randomness, so a differential run reports it instead of running it). +bool ClearResearchTargetIfMatched(const Views& v, const void* def); + +// The two design-option masks, written straight into R_MASKS. +void WriteDesignOptionMasks(const Views& v, std::uint32_t a, std::uint32_t b); + +} // namespace shim::hooks::techfx diff --git a/src/shim/hooks/tech_effects.cpp b/src/shim/hooks/tech_effects.cpp new file mode 100644 index 0000000..19b7175 --- /dev/null +++ b/src/shim/hooks/tech_effects.cpp @@ -0,0 +1,335 @@ +#include "shim/hooks/tech_effects.h" + +#include +#include +#include +#include +#include +#include +#include + +#if defined(_WIN32) +#define WIN32_LEAN_AND_MEAN +#include +#endif + +#include "game/effects/tech_effects.h" +#include "shim/hooks/tech_effect_fields.h" +#include "game/effects/tech_id.h" +#include "game/sim/species.h" +#include "game/sim/tuning.h" +#include "generated/sots_addresses.h" + +namespace shim::hooks { + +using trace::Tv; +namespace tv = trace::tv; +namespace fx = sots::effects; +namespace tfx = shim::hooks::techfx; + +namespace { + +namespace A = sots::addr; + +// The two game entry points `ours` leans on. Both are verified, read-only reads of the +// tech tree: the same delegation B3 makes to TechTree::Cost. +using IsTechFn = bool(SHIM_THISCALL*)(void* master, void* def, int techId); +using HasResearchedFn = bool(SHIM_THISCALL*)(void* tree, int techId); +// Reselects and (re)allocates the node-bore parameter block. Used only in replace mode, +// where `ours` has to leave the game in a consistent state and cannot allocate itself. +using UpdateBoreFn = void(SHIM_THISCALL*)(void* self); + +struct Env { + std::uintptr_t exe_base = 0; + void (*log_line)(const char*) = nullptr; + IsTechFn is_tech = nullptr; + HasResearchedFn has_researched = nullptr; + UpdateBoreFn update_bore = nullptr; + const int* gate_tpgate = nullptr; + const int* gate_gatamp = nullptr; +}; +Env g_env; + +void logf(const char* fmt, ...) { + if (!g_env.log_line) return; + char line[512]; + va_list ap; + va_start(ap, fmt); + std::vsnprintf(line, sizeof line, fmt, ap); + va_end(ap); + g_env.log_line(line); +} + +// ---- safe pointer chasing (the guard M2/B3 use) --------------------------------------- + +bool readable(const void* p, std::size_t n) { + if (!p) return false; + if (n == 0) return true; +#if defined(_WIN32) + const char* c = static_cast(p); + const char* const end = c + n; + while (c < end) { + MEMORY_BASIC_INFORMATION mbi; + if (!VirtualQuery(c, &mbi, sizeof mbi)) return false; + if (mbi.State != MEM_COMMIT) return false; + if (mbi.Protect & (PAGE_NOACCESS | PAGE_GUARD)) return false; + const DWORD ok = PAGE_READONLY | PAGE_READWRITE | PAGE_WRITECOPY | PAGE_EXECUTE_READ | + PAGE_EXECUTE_READWRITE | PAGE_EXECUTE_WRITECOPY; + if (!(mbi.Protect & ok)) return false; + c = static_cast(mbi.BaseAddress) + mbi.RegionSize; + } + return true; +#else + return true; +#endif +} + +// The x87 control word in force for the call. The modifier arithmetic is `fld dword; +// fadd qword; fstp dword`, so the precision-control field decides whether the middle step +// rounds to 53 or to 24 significand bits -- the same open question B3 records. +std::uint32_t fpu_control_word() { +#if defined(__i386__) || defined(__x86_64__) + unsigned short cw = 0; + __asm__ __volatile__("fnstcw %0" : "=m"(cw)); + return cw; +#else + return 0; +#endif +} + +// ---- per-call state --------------------------------------------------------------------- +// +// OnTechResearched CAN nest: the Zuul Cruiser-Construction branch grants boarding pods via +// SetResearched, which calls the callback again. `ours` never does that (it only reports +// the grant), so the statics below are only ever written by the outer invocation of the +// *hook*; a nested original call runs inside the original, not inside the hook. The depth +// counter makes that assumption checkable rather than assumed. + +struct CallState { + bool compare = false; + int region_of[tfx::kRegionCount] = {}; + tfx::Views views; // the scratch copies, once rebind() has resolved them +}; +CallState g_call; + +void reset_call() { + g_call = CallState{}; + for (int i = 0; i < tfx::kRegionCount; ++i) g_call.region_of[i] = -1; +} + +// The few reads `ours` makes straight off the live object -- fields the callback never +// writes, so "live" and "before" are the same value. +std::int32_t i32_at(const void* p, std::size_t off) { + std::int32_t v = 0; + std::memcpy(&v, static_cast(p) + off, sizeof v); + return v; +} +void* ptr_at(const void* p, std::size_t off) { + void* v = nullptr; + std::memcpy(&v, static_cast(p) + off, sizeof v); + return v; +} +std::uint8_t u8_at(const void* p, std::size_t off) { + return static_cast(p)[off]; +} + +// ---- tech identity ---------------------------------------------------------------------- + +void* master_tree_of(void* self) { + void* tree = ptr_at(self, A::ServerPlayer_off_TechTree); + if (!readable(tree, A::TechTree_off_Master + 4)) return nullptr; + return ptr_at(tree, A::TechTree_off_Master); +} + +// The TechId of a definition. +// +// TechDef's first word is NOT the TechId: MasterTechTree::IsTech maps `id - 10000` into the +// master table and compares TechDef pointers, while TechTree::HasResearched uses that same +// map to reach a TechDef and *then* uses the def's first word as an index into the node +// vector. So the first word is a node index in a different, larger key space, and the only +// way to get the TechId is the identity test the callback itself uses. 196 calls into a +// three-compare function, once per completion. +int resolve_tech_id(void* self, void* def) { + if (!readable(def, 4)) return -1; + void* master = master_tree_of(self); + if (!master || !g_env.is_tech) return -1; + for (int i = 0; i < fx::kTechIdCount; ++i) { + const int id = fx::kTechIdBase + i; + if (g_env.is_tech(master, def, id)) return id; + } + return -1; // a tech that is not in the 196-name table has no code effect +} + +// ---- our state <-> the player object ---------------------------------------------------- + +// The gate-traffic config words, read from the executable's own storage. +sots::sim::TuningTable gate_tuning() { + sots::sim::TuningTable t; + if (g_env.gate_tpgate) t.PERGATETRAFFIC_DRV_TpGate = *g_env.gate_tpgate; + if (g_env.gate_gatamp) t.PERGATETRAFFIC_DRV_GatAmp = *g_env.gate_gatamp; + return t; +} + +} // namespace + +// ---- descriptor --------------------------------------------------------------------------- + +void ServerPlayerOnTechResearchedHook::describe_args(std::vector& out, void* self, void* def, + bool silent) { + out.push_back(tv::ptr(self).named("player")); + const bool ok = readable(self, tfx::PlayerSpan()); + out.push_back(tv::i32(ok ? i32_at(self, A::ServerPlayer_off_PlyrIdx) : -1).named("player_index")); + out.push_back(tv::i32(ok ? i32_at(self, A::ServerPlayer_off_Species) : -1).named("species")); + out.push_back(tv::ptr(def).named("def")); + + const int id = ok ? resolve_tech_id(self, def) : -1; + out.push_back(tv::i32(id).named("tech_id")); + // The def's own first word, so a trace shows the two key spaces side by side (it is the + // node-vector index, not the TechId -- see resolve_tech_id). + out.push_back(tv::i32(readable(def, 4) ? i32_at(def, A::TechDef_off_TechId) : -1).named("def_node_index")); + const char* name = fx::IsValidTechId(id) ? fx::TechIdName(static_cast(id)) : nullptr; + out.push_back(tv::str(name).named("tech_name")); + out.push_back(tv::boolean(silent).named("silent")); + + out.push_back(tv::boolean(ok && ptr_at(self, A::ServerPlayer_off_ResearchTarget) == def) + .named("is_current_target")); + out.push_back(tv::i32(ok ? i32_at(self, A::ServerPlayer_off_OwnedSystems + 4) - + i32_at(self, A::ServerPlayer_off_OwnedSystems) + : 0) + .named("owned_systems_bytes")); + out.push_back(tv::boolean(ok && u8_at(self, A::ServerPlayer_off_RebAI) != 0).named("rebel_ai")); + out.push_back(tv::boolean(ok && u8_at(self, A::ServerPlayer_off_AIBn) != 0).named("ai_benefit_in")); + out.push_back(tv::i32(g_env.gate_tpgate ? *g_env.gate_tpgate : -1).named("gate_traffic_tpgate")); + out.push_back(tv::i32(g_env.gate_gatamp ? *g_env.gate_gatamp : -1).named("gate_traffic_gatamp")); + out.push_back(tv::u32(fpu_control_word()).named("fpu_cw")); +} + +void ServerPlayerOnTechResearchedHook::regions(std::vector& out, void* self, + void* def, bool silent) { + (void)def; + (void)silent; + reset_call(); + if (!readable(self, tfx::PlayerSpan())) throw std::runtime_error("ServerPlayer not readable"); + + const tfx::Views live = tfx::Views::OverPlayer(self); + for (int i = 0; i < tfx::kRegionCount; ++i) { + const tfx::RegionDef& d = tfx::kRegions[i]; + const void* base = live.base[i]; + // The node-bore block is declared only when it already exists: a region has to be + // snapshottable *before* the call, and the original allocates it on first use. + if (!readable(base, d.size)) continue; + trace::Region r; + r.name = d.name; + r.ptr = base; + r.size = d.size; + r.describe = d.describe; + g_call.region_of[i] = static_cast(out.size()); + out.push_back(r); + } +} + +ServerPlayerOnTechResearchedHook::Args ServerPlayerOnTechResearchedHook::rebind(trace::Scratch& s, + void* self, void* def, + bool silent) { + for (int i = 0; i < tfx::kRegionCount; ++i) { + g_call.views.base[i] = g_call.region_of[i] >= 0 + ? s.ptr(static_cast(g_call.region_of[i])) + : nullptr; + } + g_call.compare = true; + // `self` is passed through unchanged: `ours` only reads never-written fields off it + // (species, the tech tree) and takes everything else from the scratch copies above. + return Args(self, def, silent); +} + +void ServerPlayerOnTechResearchedHook::ours(void* self, void* def, bool silent) { + (void)silent; // only the events depend on it, and events are not reproduced + const bool compare = g_call.compare; + g_call.compare = false; // a replace-mode call must never inherit a stale mapping + if (!readable(self, tfx::PlayerSpan())) throw std::runtime_error("ServerPlayer not readable"); + const tfx::Views v = compare ? g_call.views : tfx::Views::OverPlayer(self); + if (!compare) reset_call(); + + const int raw_id = resolve_tech_id(self, def); + + // The pending plague-cure roll: the two words are cleared either way, but the roll + // itself draws from the strategic generator, so `ours` records it instead of running it. + const bool would_roll = tfx::ClearResearchTargetIfMatched(v, def); + + int species_index = i32_at(self, A::ServerPlayer_off_Species); + if (species_index < 0 || species_index >= sots::sim::kSpeciesCount) species_index = 0; + fx::PlayerEconomyState s = + tfx::ReadPlayerState(v, static_cast(species_index)); + + // The researched set, from the player's own tree. In compare mode the tree is the live + // one, i.e. the state the original's tail saw: the completing node is already marked. + void* tree = ptr_at(self, A::ServerPlayer_off_TechTree); + if (!tree || !g_env.has_researched) throw std::runtime_error("tech tree not available"); + for (int i = 0; i < fx::kTechIdCount; ++i) { + if (g_env.has_researched(tree, fx::kTechIdBase + i)) s.researched.set(static_cast(i)); + } + + fx::ApplyContext ctx; + sots::sim::TuningTable tuning = gate_tuning(); + ctx.tuning = &tuning; + + fx::TechApplyOutcome outcome; + if (fx::IsValidTechId(raw_id)) { + // The completion callback, not the guarded wrapper: by the time it runs the node is + // already state 4, so an already-researched guard would make it a no-op. + outcome = fx::ApplyTechCompletion(s, static_cast(raw_id), ctx); + } else { + // Roughly half the data files are not in the 196-name key space and so have no + // branch of their own -- but the callback still runs its whole tail for them. + outcome = fx::RunCompletionTail(s); + } + + // The node-track techs are keyed by name in the species table rather than by id; the + // callback resolves them with its own helper, which returns the species or -1. + for (int sp = 0; sp < sots::sim::kSpeciesCount; ++sp) { + const char* track = fx::NodeTrackTechName(static_cast(sp)); + if (track == nullptr) continue; + const fx::TechId tid = fx::TechIdFromName(track); + if (fx::IsValidTechId(tid) && static_cast(tid) == raw_id) s.nodeTrackMask |= 1u << sp; + } + + tfx::WritePlayerState(v, s); + + // The two design-option masks are a fresh OR over the researched set on every + // completion, which is why they are computed here rather than in the effects table. + const fx::DesignOptionMasks dm = fx::ComputeDesignOptionMasks( + [&](fx::TechId id) { return g_env.has_researched(tree, static_cast(id)); }); + tfx::WriteDesignOptionMasks(v, dm.a, dm.b); + + // Replace mode only: the node-bore block has to be allocated or freed to keep the game + // consistent, and `ours` has no allocator the game's runtime could free. Delegating to + // the game's own updater is the same read-only-helper delegation B3 makes for Cost. + if (!compare && g_env.update_bore) g_env.update_bore(self); + + // describe_args runs before the original (and so before `ours`), so the outcome cannot + // ride on the record. Completions are rare, so one audit line each is affordable and is + // where the un-compared consequences -- the system-side writes, the Zuul grant, the + // roll -- are visible at all. + logf("techfx: ours mode=%s id=%d granted=%d plague=0x%02x systems_ai=%d civcaps=%d " + "temperance=0x%02x bore_changed=%d bore_present=%d roll=%d", + compare ? "compare" : "replace", raw_id, static_cast(outcome.grantedTech), + outcome.plagueCuredMask, outcome.flagSystemsAI ? 1 : 0, + outcome.reevaluateCivilianCaps ? 1 : 0, outcome.temperanceSpeciesMask, + outcome.nodeBoreParamsChanged ? 1 : 0, v.base[tfx::R_NODEBORE] ? 1 : 0, + would_roll ? 1 : 0); +} + +void init_tech_effects(std::uintptr_t exe_base, void (*log_line)(const char* line)) { + g_env.exe_base = exe_base; + g_env.log_line = log_line; + g_env.is_tech = reinterpret_cast(exe_base + A::MasterTechTree_IsTech); + g_env.has_researched = reinterpret_cast(exe_base + A::TechTree_HasResearched); + g_env.update_bore = reinterpret_cast(exe_base + A::ServerPlayer_UpdateNodeBoreParams); + g_env.gate_tpgate = reinterpret_cast(exe_base + A::g_PERGATETRAFFIC_DRV_TpGate); + g_env.gate_gatamp = reinterpret_cast(exe_base + A::g_PERGATETRAFFIC_DRV_GatAmp); + logf("techfx: OnTechResearched hook ready (regions=%d, gate=%d/%d, fpu_cw=0x%04x)", tfx::kRegionCount, + g_env.gate_tpgate ? *g_env.gate_tpgate : -1, g_env.gate_gatamp ? *g_env.gate_gatamp : -1, + fpu_control_word()); +} + +} // namespace shim::hooks diff --git a/src/shim/hooks/tech_effects.h b/src/shim/hooks/tech_effects.h new file mode 100644 index 0000000..0239793 --- /dev/null +++ b/src/shim/hooks/tech_effects.h @@ -0,0 +1,61 @@ +// Hook descriptor for the hard-coded tech-effect callback (B2): +// +// Game::ServerPlayer::OnTechResearched(this, TechDef* def, bool silent) +// +// A verified __thiscall in sots_addresses.h (vft slot 4), so it goes through Hook<> with +// CallConv::Thiscall. TechTree::SetResearched calls it every time a tech is marked +// researched -- from the per-turn research pass (B3's hook), from the Zuul boarding-pod +// grant this very function makes, and from scenario grants. +// +// What it does, and therefore what this hook has to reproduce: an if/else-if chain over +// tech ids 10000..10031 (at most one branch runs) that writes the player's economy +// modifiers, followed by a tail that runs on *every* completion -- the plague-cure mask, +// the two design-option masks, the node-bore parameters, the per-species xenotech flag +// words and their sticky translation mask, the Zuul boarding-pod grant, the +// capture-designs pair test, and the temperance sweep. +// +// Region model: one region per field group of the player, each with a struct describer, +// so a divergence names the field (`side.modifiers.after.v.out_mod`) instead of a byte +// offset in one opaque blob. The regions do not cover the whole object, so `ours` reads +// every field the call can modify out of the scratch copies and everything else (species, +// RebAI, the tech tree) from the live player, where "live" and "before" are the same. +// +// What `ours` deliberately does NOT do, because compare mode must not touch live game +// state or consume randomness: +// * the completion events (EVENT_RESEARCH_COMPLETE / _UNDERBUDGET / _TEMPERANCE); +// * the pending plague-cure roll (it would draw from the real generator) -- the two +// words it guards are still cleared, and the record says whether it would have fired; +// * the writes to *other* objects: the owned systems' AI flag, the arcology civilian-cap +// re-evaluation, the addiction cure, and the plague clear on systems and ships; +// * TechTree::SetResearched for the Zuul boarding pods (it would mutate the tree). +// Each of those is reported in the record's `ours` fields instead, so a trace still shows +// what our layer decided even where the compare cannot check it. +#pragma once + +#include +#include +#include + +#include "shim/trace/hook.h" + +namespace shim::hooks { + +struct ServerPlayerOnTechResearchedHook { + static constexpr const char* name = "Game::ServerPlayer::OnTechResearched"; + static constexpr trace::CallConv conv = trace::CallConv::Thiscall; + using Ret = void; + // this (ServerPlayer*), def (TechDef*), silent + using Args = std::tuple; + + static void describe_args(std::vector& out, void* self, void* def, bool silent); + static void regions(std::vector& out, void* self, void* def, bool silent); + static Args rebind(trace::Scratch& s, void* self, void* def, bool silent); + static void ours(void* self, void* def, bool silent); + static trace::HookPolicy policy() { return trace::HookPolicy{}; } +}; + +// Process facts the hook needs (exe base for the RVAs, a line logger). Call once before +// installing. +void init_tech_effects(std::uintptr_t exe_base, void (*log_line)(const char* line)); + +} // namespace shim::hooks diff --git a/src/shim/main.cpp b/src/shim/main.cpp index 540a47a..2b775a9 100644 --- a/src/shim/main.cpp +++ b/src/shim/main.cpp @@ -19,6 +19,7 @@ #include "shim/hooks/compute_budget.h" #include "shim/hooks/global_consts.h" #include "shim/hooks/research.h" +#include "shim/hooks/tech_effects.h" #include "shim/trace/hook.h" #include "shim/trace/selftest.h" #include "shim/trace/tracer.h" @@ -152,6 +153,7 @@ using LoadFileHook = shim::trace::Hook; using WeaponInitHook = shim::trace::Hook; using SectionCtorHook = shim::trace::Hook; using ProcessResearchHook = shim::trace::Hook; +using OnTechResearchedHook = shim::trace::Hook; using ComputeBudgetHook = shim::trace::Hook; void InstallHooks(shim::trace::Tracer& tracer) { @@ -184,6 +186,11 @@ void InstallHooks(shim::trace::Tracer& tracer) { // B3: the per-turn research pass (verified thiscall). One call per player per turn. shim::hooks::init_research(exeBase, &ShimLogLine); InstallTemplateHook(tracer, exeBase, sots::addr::TechTree_ProcessResearch); + + // B2: the hard-coded tech-effect callback (verified thiscall, vft slot 4). Fires once + // per tech completion, so on most turns not at all. + shim::hooks::init_tech_effects(exeBase, &ShimLogLine); + InstallTemplateHook(tracer, exeBase, sots::addr::ServerPlayer_OnTechResearched); // B1: ServerPlayer::ComputeBudget (verified thiscall) -- the first behavioural compare. shim::hooks::init_compute_budget(&ShimLogLine); InstallTemplateHook(tracer, exeBase, sots::addr::ServerPlayer_ComputeBudget); @@ -229,6 +236,7 @@ void Shim_Init(HMODULE self) { WeaponInitHook::register_policy(tracer); SectionCtorHook::register_policy(tracer); ProcessResearchHook::register_policy(tracer); + OnTechResearchedHook::register_policy(tracer); char exeSha[65] = {}; if (!shim::trace::sha256_file(exePath, exeSha)) Log("trace: could not hash %s", exePath); if (tracer.open(SHIM_BUILD_ID, exeSha)) { diff --git a/tests/game_effects/test_effects.cpp b/tests/game_effects/test_effects.cpp index 41b4d19..0f08bf2 100644 --- a/tests/game_effects/test_effects.cpp +++ b/tests/game_effects/test_effects.cpp @@ -26,8 +26,13 @@ static void test_ids() { CHECK(TechIdFromIndex(64) == TechId::DRV_RIP); CHECK(std::string(TechIdName(TechId::IND_Waldo)) == "IND_Waldo"); - CHECK(TechIdName(TechId::Unresolved_052) == nullptr); + CHECK(std::string(TechIdName(TechId::DRV_FUSN)) == "DRV_FUSN"); CHECK(TechIdName(TechId::None) == nullptr); + // The table is complete: every position has a name, and every name resolves back. + for (int i = 0; i < kTechIdCount; ++i) { + const char* n = TechIdName(TechIdFromIndex(i)); + CHECK(n != nullptr && n[0] != '\0'); + } CHECK(TechIdFromName("IND_Waldo") == TechId::IND_Waldo); CHECK(TechIdFromName("ind_waldo") == TechId::IND_Waldo); // case-insensitive like the game CHECK(TechIdFromName("CCC_TRNSLIR") == TechId::XNC_Translation1_Liir); @@ -42,7 +47,7 @@ static void test_ids() { ++named; CHECK(TechIdFromName(n) == TechIdFromIndex(i)); } - CHECK_EQ(named, 89); // recovered data-file names so far; bump when more are resolved + CHECK_EQ(named, kTechIdCount); // the whole table is recovered // xenotech blocks CHECK(XenoTechId(XenoLevel::Translation1, Species::Human) == TechId::XNC_Translation1_Human); @@ -51,7 +56,7 @@ static void test_ids() { CHECK_EQ(static_cast(XenoTechId(XenoLevel::Incorporate, Species::Morrigi)), 10136); CHECK(XenoTechId(XenoLevel::Incorporate, Species::Zuul) == TechId::None); CHECK(XenoTechId(XenoLevel::Subjugate, Species::Zuul) == TechId::XNC_Subjugate_Zuul); - CHECK_EQ(static_cast(XenoTechId(XenoLevel::Proliferate, Species::Morrigi)), 10163); + CHECK_EQ(static_cast(XenoTechId(XenoLevel::Proliferate, Species::Morrigi)), 10162); CHECK(XenoTechId(XenoLevel::Temperance, Species::NPC) == TechId::None); CHECK(XenoTechId(XenoLevel::Translation3, Species::NPC) == TechId::None); @@ -65,56 +70,56 @@ static void test_industrial() { ApplyContext ctx; TechApplyOutcome o = ApplyTechEffect(s, TechId::IND_Waldo, ctx); CHECK(o.applied); - CHECK_NEAR(s.conMod[0], 0.90, 1e-12); - CHECK_NEAR(s.conMod[1], 0.90, 1e-12); - CHECK_NEAR(s.conMod[2], 0.90, 1e-12); - CHECK_NEAR(s.outMod, 1.15, 1e-12); + CHECK_NEAR(s.conMod[0], 0.90, 1e-6); + CHECK_NEAR(s.conMod[1], 0.90, 1e-6); + CHECK_NEAR(s.conMod[2], 0.90, 1e-6); + CHECK_NEAR(s.outMod, 1.15, 1e-6); CHECK(s.HasResearched(TechId::IND_Waldo)); o = ApplyTechEffect(s, TechId::IND_Waldo, ctx); // no double application CHECK(!o.applied); - CHECK_NEAR(s.outMod, 1.15, 1e-12); + CHECK_NEAR(s.outMod, 1.15, 1e-6); ApplyTechEffect(s, TechId::IND_CyberInt, ctx); // -0.05 / +0.20 - CHECK_NEAR(s.conMod[0], 0.85, 1e-12); - CHECK_NEAR(s.outMod, 1.35, 1e-12); + CHECK_NEAR(s.conMod[0], 0.85, 1e-6); + CHECK_NEAR(s.outMod, 1.35, 1e-6); ApplyTechEffect(s, TechId::IND_ExpSys, ctx); // -0.10 / +0.15 - CHECK_NEAR(s.conMod[2], 0.75, 1e-12); - CHECK_NEAR(s.outMod, 1.50, 1e-12); + CHECK_NEAR(s.conMod[2], 0.75, 1e-6); + CHECK_NEAR(s.outMod, 1.50, 1e-6); ApplyTechEffect(s, TechId::IND_OrbDry, ctx); // classes 1 and 2 only - CHECK_NEAR(s.conMod[0], 0.75, 1e-12); - CHECK_NEAR(s.conMod[1], 0.70, 1e-12); - CHECK_NEAR(s.conMod[2], 0.70, 1e-12); + CHECK_NEAR(s.conMod[0], 0.75, 1e-6); + CHECK_NEAR(s.conMod[1], 0.70, 1e-6); + CHECK_NEAR(s.conMod[2], 0.70, 1e-6); ApplyTechEffect(s, TechId::DRN_AdvRob, ctx); - CHECK_NEAR(s.conMod[0], 0.70, 1e-12); - CHECK_NEAR(s.conMod[1], 0.65, 1e-12); + CHECK_NEAR(s.conMod[0], 0.70, 1e-6); + CHECK_NEAR(s.conMod[1], 0.65, 1e-6); ApplyTechEffect(s, TechId::IND_OrbFound, ctx); - CHECK_NEAR(s.savMod[0], 0.95, 1e-12); - CHECK_NEAR(s.savMod[2], 0.95, 1e-12); + CHECK_NEAR(s.savMod[0], 0.95, 1e-6); + CHECK_NEAR(s.savMod[2], 0.95, 1e-6); ApplyTechEffect(s, TechId::IND_GravCon, ctx); // 1.80 ApplyTechEffect(s, TechId::IND_HvyPlat, ctx); // 1.90 - CHECK_NEAR(s.outMod, 1.90, 1e-12); + CHECK_NEAR(s.outMod, 1.90, 1e-6); ApplyTechEffect(s, TechId::IND_HrdStrct, ctx); // multiplicative - CHECK_NEAR(s.outMod, 1.71, 1e-12); - CHECK_NEAR(s.defenceDamageMod, 0.25, 1e-12); + CHECK_NEAR(s.outMod, 1.71, 1e-6); + CHECK_NEAR(s.defenceDamageMod, 0.25, 1e-6); // the order of additive and multiplicative effects matters PlayerEconomyState r; ApplyTechEffect(r, TechId::IND_HrdStrct, ctx); ApplyTechEffect(r, TechId::IND_GravCon, ctx); - CHECK_NEAR(r.outMod, 1.20, 1e-12); // 0.9 + 0.3, not 1.3 x 0.9 + CHECK_NEAR(r.outMod, 1.20, 1e-6); // 0.9 + 0.3, not 1.3 x 0.9 ApplyTechEffect(s, TechId::IND_AstMine, ctx); CHECK(s.Flag(PlayerFlag::AsteroidMining)); ApplyTechEffect(s, TechId::IND_MsMine, ctx); - CHECK_NEAR(s.maxOverharvest, 0.1, 0.0); + CHECK_NEAR(s.maxOverharvest, 0.1f, 0.0); CHECK_NEAR(s.miningRate, 1.0, 0.0); PlayerEconomyState q; - q.maxOverharvest = 0.3; + q.maxOverharvest = 0.3f; ApplyTechEffect(q, TechId::IND_MsMine, ctx); - CHECK_NEAR(q.maxOverharvest, 0.3, 0.0); // max(), not assignment + CHECK_NEAR(q.maxOverharvest, 0.3f, 0.0); // max(), not assignment ApplyTechEffect(s, TechId::CCC_AdvSens, ctx); CHECK(s.Flag(PlayerFlag::AdvancedSensors)); @@ -125,26 +130,26 @@ static void test_biology() { s.suitTol = 0.07; ApplyContext ctx; ApplyTechEffect(s, TechId::BIO_GnMod, ctx); - CHECK_NEAR(s.popMod, 1.10, 1e-12); + CHECK_NEAR(s.popMod, 1.10, 1e-6); ApplyTechEffect(s, TechId::BIO_AtmoAd, ctx); - CHECK_NEAR(s.suitTol, 0.82, 1e-12); - CHECK_NEAR(s.popMod, 1.16, 1e-12); - CHECK_NEAR(s.terraMod, 1.35, 1e-12); + CHECK_NEAR(s.suitTol, 0.82, 1e-6); + CHECK_NEAR(s.popMod, 1.16, 1e-6); + CHECK_NEAR(s.terraMod, 1.35, 1e-6); ApplyTechEffect(s, TechId::BIO_GrvAdpt, ctx); - CHECK_NEAR(s.suitTol, 2.32, 1e-12); - CHECK_NEAR(s.popMod, 1.26, 1e-12); - CHECK_NEAR(s.terraMod, 1.70, 1e-12); + CHECK_NEAR(s.suitTol, 2.32, 1e-6); + CHECK_NEAR(s.popMod, 1.26, 1e-6); + CHECK_NEAR(s.terraMod, 1.70, 1e-6); ApplyTechEffect(s, TechId::BIO_EnvTail, ctx); - CHECK_NEAR(s.popMod, 1.46, 1e-12); - CHECK_NEAR(s.terraMod, 2.15, 1e-12); + CHECK_NEAR(s.popMod, 1.46, 1e-6); + CHECK_NEAR(s.terraMod, 2.15, 1e-6); ApplyTechEffect(s, TechId::IND_EleNans, ctx); // +0.60 ApplyTechEffect(s, TechId::BIO_TerBac, ctx); // +0.45 ApplyTechEffect(s, TechId::IND_AtProc, ctx); // +0.50 - CHECK_NEAR(s.terraMod, 3.70, 1e-12); + CHECK_NEAR(s.terraMod, 3.70, 1e-6); TechApplyOutcome o = ApplyTechEffect(s, TechId::IND_ArcCon, ctx); CHECK(s.Flag(PlayerFlag::Arcology)); - CHECK_NEAR(s.popMod, 1.61, 1e-12); + CHECK_NEAR(s.popMod, 1.61, 1e-6); CHECK(o.reevaluateCivilianCaps); } @@ -184,29 +189,29 @@ static void test_ai() { PlayerEconomyState s; CHECK(s.aiBenefit); ApplyTechEffect(s, TechId::CCC_AI, ctx); - CHECK_NEAR(s.resMod, 1.2, 1e-12); + CHECK_NEAR(s.resMod, 1.2, 1e-6); ApplyTechEffect(s, TechId::CCC_AIAdmin, ctx); - CHECK_NEAR(s.incMod, 1.3, 1e-12); + CHECK_NEAR(s.incMod, 1.3, 1e-6); ApplyTechEffect(s, TechId::CCC_AIFac, ctx); - CHECK_NEAR(s.outMod, 1.4, 1e-12); + CHECK_NEAR(s.outMod, 1.4, 1e-6); SetAiBenefit(s, false, ctx); // rebellion: bonuses withdrawn - CHECK_NEAR(s.resMod, 1.0, 1e-12); - CHECK_NEAR(s.incMod, 1.0, 1e-12); - CHECK_NEAR(s.outMod, 1.0, 1e-12); + CHECK_NEAR(s.resMod, 1.0, 1e-6); + CHECK_NEAR(s.incMod, 1.0, 1e-6); + CHECK_NEAR(s.outMod, 1.0, 1e-6); SetAiBenefit(s, false, ctx); // idempotent - CHECK_NEAR(s.outMod, 1.0, 1e-12); + CHECK_NEAR(s.outMod, 1.0, 1e-6); SetAiBenefit(s, true, ctx); - CHECK_NEAR(s.resMod, 1.2, 1e-12); - CHECK_NEAR(s.outMod, 1.4, 1e-12); + CHECK_NEAR(s.resMod, 1.2, 1e-6); + CHECK_NEAR(s.outMod, 1.4, 1e-6); PlayerEconomyState off; // researched while off: nothing off.aiBenefit = false; ApplyTechEffect(off, TechId::CCC_AIFac, ctx); - CHECK_NEAR(off.outMod, 1.0, 1e-12); + CHECK_NEAR(off.outMod, 1.0, 1e-6); TechApplyOutcome o = ApplyTechEffect(off, TechId::CCC_AISlv, ctx); // slave AI: benefit back on CHECK(off.aiBenefit); - CHECK_NEAR(off.outMod, 1.4, 1e-12); + CHECK_NEAR(off.outMod, 1.4, 1e-6); CHECK(o.flagSystemsAI); CHECK(!AiRebellionPossible(off)); @@ -218,9 +223,23 @@ static void test_ai() { v.species = Species::NPC; CHECK(!AiRebellionPossible(v)); - PlayerEconomyState none; // unknown table values: 0 - ApplyTechEffect(none, TechId::CCC_AI, ApplyContext{}); + PlayerEconomyState none; // caller overrides to 0 + ApplyContext zero; + zero.aiBonus = AiBonusValues{0, 0, 0}; + ApplyTechEffect(none, TechId::CCC_AI, zero); CHECK_NEAR(none.resMod, 1.0, 0.0); + + // The table's own values, recovered in B2: 0.5 into each of the three slots. + PlayerEconomyState dflt; + ApplyTechEffect(dflt, TechId::CCC_AI, ApplyContext{}); + CHECK_NEAR(dflt.resMod, 1.5, 0.0); + ApplyTechEffect(dflt, TechId::CCC_AIAdmin, ApplyContext{}); + CHECK_NEAR(dflt.incMod, 1.5, 0.0); + ApplyTechEffect(dflt, TechId::CCC_AIFac, ApplyContext{}); + CHECK_NEAR(dflt.outMod, 1.5, 0.0); + CHECK_NEAR(AiRebellionOdds(TechId::CCC_AI), 0.1f, 0.0); + CHECK_NEAR(AiRebellionOdds(TechId::CCC_AIFRCON), 0.2f, 0.0); + CHECK_NEAR(AiRebellionOdds(TechId::CCC_AISlv), 0.0, 0.0); } static void test_flags_and_species() { @@ -306,8 +325,11 @@ static void test_xenotech() { o = ApplyTechEffect(s, TechId::XNC_Accommodate_Morrigi, ctx); // every completion reports temperance CHECK_EQ(s.speciesFlags[static_cast(Species::Morrigi)], 0x080u); CHECK_EQ(o.temperanceSpeciesMask, 1u << static_cast(Species::Hiver)); - ApplyTechEffect(s, TechId::XNC_Proliferate_Zuul, ctx); - CHECK_EQ(s.speciesFlags[static_cast(Species::Zuul)], 0x100u); + // Proliferate has no Zuul entry, so the Zuul word never gets bit 8. + ApplyTechEffect(s, TechId::XNC_Proliferate_Tarkas, ctx); + CHECK_EQ(s.speciesFlags[static_cast(Species::Tarkas)], 0x100u); + ApplyTechEffect(s, TechId::XNC_Subjugate_Zuul, ctx); + CHECK_EQ(s.speciesFlags[static_cast(Species::Zuul)], 0x040u); // rebuild from the researched set alone (load path) PlayerEconomyState l; @@ -321,7 +343,7 @@ static void test_by_name() { PlayerEconomyState s; TechApplyOutcome o = ApplyTechEffectByName(s, "ind_gravcon", ctx); CHECK(o.applied); - CHECK_NEAR(s.outMod, 1.30, 1e-12); + CHECK_NEAR(s.outMod, 1.30, 1e-6); o = ApplyTechEffectByName(s, "CCC_NDTRKHUM", ctx); CHECK(o.applied); CHECK_EQ(s.nodeTrackMask, 1u << static_cast(Species::Human)); @@ -329,18 +351,18 @@ static void test_by_name() { CHECK_EQ(s.nodeTrackMask, (1u << static_cast(Species::Human)) | (1u << static_cast(Species::Zuul))); o = ApplyTechEffectByName(s, "WEP_SOMETHING_DATA_ONLY", ctx); CHECK(!o.applied); - CHECK_NEAR(s.outMod, 1.30, 1e-12); + CHECK_NEAR(s.outMod, 1.30, 1e-6); o = ApplyTechEffectByName(s, "IND_SPNLMNT", ctx); // in the table, no effect CHECK(o.applied); CHECK(s.HasResearched(TechId::IND_SPNLMNT)); - CHECK_NEAR(s.outMod, 1.30, 1e-12); + CHECK_NEAR(s.outMod, 1.30, 1e-6); } static void test_table_shape() { // Techs with a strategic effect have entries; unresolved and gate-only ids do not. CHECK(!EffectsOf(TechId::IND_Waldo).empty()); CHECK_EQ(EffectsOf(TechId::IND_Waldo).size(), std::size_t{2}); - CHECK(EffectsOf(TechId::Unresolved_052).empty()); + CHECK(EffectsOf(TechId::DRV_FUSN).empty()); CHECK(EffectsOf(TechId::CCC_HYPCOM).empty()); CHECK(EffectsOf(TechId::None).empty()); int withEffects = 0; @@ -349,6 +371,17 @@ static void test_table_shape() { } CHECK_EQ(withEffects, 44); + // The proliferate block is five entries and the two slots after it are the node-track + // techs -- the earlier reconstruction had a six-entry block running over them. + CHECK(XenoTechId(XenoLevel::Proliferate, Species::Zuul) == TechId::None); + CHECK(XenoTechId(XenoLevel::Proliferate, Species::Morrigi) == TechId::XNC_Proliferate_Morrigi); + CHECK_EQ(static_cast(TechId::XNC_Proliferate_Morrigi), 10162); + CHECK_EQ(static_cast(TechId::CCC_NDTRKHUM), 10163); + CHECK_EQ(static_cast(TechId::CCC_NDTRKZUL), 10164); + CHECK(TechIdFromName(NodeTrackTechName(Species::Human)) == TechId::CCC_NDTRKHUM); + CHECK(TechIdFromName(NodeTrackTechName(Species::Zuul)) == TechId::CCC_NDTRKZUL); + CHECK(NodeTrackTechName(Species::Morrigi) == nullptr); + // design-option masks std::set have = {"IND_REFCOAT", "SLD_INTANG", "DRV_NODE", "WEP_HvyPmsl"}; DesignOptionMasks m = ComputeDesignOptionMasks([&](std::string_view n) { return have.count(std::string(n)) > 0; }); @@ -362,6 +395,190 @@ static void test_table_shape() { CHECK_EQ(m.b, 0x1fffffffu); } +// ---- B2: the corrections read off the completion callback's instruction stream -------- + +static void test_float32_state() { + ApplyContext ctx; + // The modifiers are float32 in the player object and every step rounds through float32. + // Six terraform techs in a row: accumulating in double and rounding once gives a + // different float from rounding at every step, which is what the original does. + PlayerEconomyState s; + const TechId chain[] = {TechId::BIO_AtmoAd, TechId::BIO_GrvAdpt, TechId::BIO_EnvTail, + TechId::BIO_TerBac, TechId::IND_AtProc, TechId::IND_EleNans}; + for (TechId id : chain) ApplyTechEffect(s, id, ctx); + + float stepwise = 1.f; + for (double k : {0.35f, 0.35f, 0.45f, 0.45f, 0.50f, 0.60f}) + stepwise = static_cast(static_cast(stepwise) + k); + CHECK(s.terraMod == stepwise); + + // The constants are widened float32 literals, not the exact decimals they look like. + CHECK(static_cast(0.05f) != 0.05); + CHECK(static_cast(0.45f) != 0.45); + CHECK(static_cast(0.9f) != 0.9); + CHECK(static_cast(0.75f) == 0.75); // exactly representable, unaffected + + // The multiplicative tech runs on the float32 that the additive ones left behind. + PlayerEconomyState m; + ApplyTechEffect(m, TechId::IND_Waldo, ctx); + ApplyTechEffect(m, TechId::IND_HrdStrct, ctx); + const float expect = static_cast( + static_cast(static_cast(1.0 + static_cast(0.15f))) * static_cast(0.9f)); + CHECK(m.outMod == expect); + CHECK(m.defenceDamageMod == 0.25f); +} + +static void test_gate_traffic_is_integer() { + sots::sim::TuningTable t; + t.PERGATETRAFFIC_DRV_TpGate = 5; + t.PERGATETRAFFIC_DRV_GatAmp = 12; + ApplyContext ctx = ctx_with_tuning(t); + + PlayerEconomyState s; + ApplyTechEffect(s, TechId::DRV_GatAmp, ctx); + CHECK_EQ(s.perGateTraffic, 12); + ApplyTechEffect(s, TechId::DRV_TpGate, ctx); + CHECK_EQ(s.perGateTraffic, 12); // integer max, the smaller key loses + + PlayerEconomyState r; + r.perGateTraffic = 20; + ApplyTechEffect(r, TechId::DRV_GatAmp, ctx); + CHECK_EQ(r.perGateTraffic, 20); // an already higher value is kept +} + +static void test_node_bore() { + ApplyContext ctx; + PlayerEconomyState s; + CHECK(!s.hasNodeBoreParams); + + TechApplyOutcome o = ApplyTechEffect(s, TechId::DRV_REND, ctx); + CHECK(o.nodeBoreParamsChanged); + CHECK(s.hasNodeBoreParams); + CHECK_EQ(s.nodeBoreParams[0], 65); + CHECK_EQ(s.nodeBoreParams[1], 35); + CHECK_EQ(s.nodeBoreParams[2], 4); + + // Highest wins, whatever the order: a lower drive researched afterwards changes nothing. + o = ApplyTechEffect(s, TechId::DRV_RIP, ctx); + CHECK(!o.nodeBoreParamsChanged); + CHECK_EQ(s.nodeBoreParams[0], 65); + o = ApplyTechEffect(s, TechId::DRV_RAD, ctx); + CHECK(o.nodeBoreParamsChanged); + CHECK_EQ(s.nodeBoreParams[0], 95); + CHECK_EQ(s.nodeBoreParams[1], 60); + CHECK_EQ(s.nodeBoreParams[2], 5); + + // The set is re-derived on every completion, not only on a bore tech's own. + PlayerEconomyState t; + t.researched.set(static_cast(TechIdIndex(TechId::DRV_RIP))); + ApplyTechEffect(t, TechId::IND_Waldo, ctx); + CHECK(t.hasNodeBoreParams); + CHECK_EQ(t.nodeBoreParams[0], 45); +} + +static void test_capture_designs_is_a_tail_check() { + ApplyContext ctx; + PlayerEconomyState s; + ApplyTechEffect(s, TechId::CCC_SpyBm, ctx); + CHECK(!s.Flag(PlayerFlag::CaptureDesigns)); + ApplyTechEffect(s, TechId::IND_SlvgTech, ctx); + CHECK(s.Flag(PlayerFlag::CaptureDesigns)); + + // Both already researched but the flag not yet set (a loaded save, say): the next + // completion of any tech at all sets it, because the test is in the tail. + PlayerEconomyState t; + t.researched.set(static_cast(TechIdIndex(TechId::CCC_SpyBm))); + t.researched.set(static_cast(TechIdIndex(TechId::IND_SlvgTech))); + CHECK(!t.Flag(PlayerFlag::CaptureDesigns)); + ApplyTechEffect(t, TechId::IND_HvyPlat, ctx); + CHECK(t.Flag(PlayerFlag::CaptureDesigns)); +} + +static void test_translation_known_mask() { + ApplyContext ctx; + PlayerEconomyState s; + CHECK_EQ(s.translationKnownMask, 0u); + ApplyTechEffect(s, XenoTechId(XenoLevel::Translation1, Species::Liir), ctx); + CHECK_EQ(s.translationKnownMask, 1u << static_cast(Species::Liir)); + ApplyTechEffect(s, XenoTechId(XenoLevel::Translation2, Species::Hiver), ctx); + CHECK_EQ(s.translationKnownMask, 1u << static_cast(Species::Liir)); // level 2 alone: no bit + ApplyTechEffect(s, XenoTechId(XenoLevel::Translation1, Species::Hiver), ctx); + CHECK_EQ(s.translationKnownMask, + (1u << static_cast(Species::Liir)) | (1u << static_cast(Species::Hiver))); + + // Sticky: clearing the researched bit and re-running the rebuild does not take it back. + s.researched.reset(static_cast(TechIdIndex(XenoTechId(XenoLevel::Translation1, Species::Liir)))); + RebuildSpeciesTechFlags(s); + CHECK(s.translationKnownMask & (1u << static_cast(Species::Liir))); + CHECK_EQ(s.speciesFlags[static_cast(Species::Liir)], 0u); +} + +static void test_tail_runs_for_unkeyed_techs() { + // A tech outside the 196-name key space has no branch, but the callback still runs its + // whole tail: the capture-designs pair test, the flag words and the bore selection. + PlayerEconomyState s; + s.researched.set(static_cast(TechIdIndex(TechId::CCC_SpyBm))); + s.researched.set(static_cast(TechIdIndex(TechId::IND_SlvgTech))); + s.researched.set(static_cast(TechIdIndex(TechId::DRV_REND))); + s.researched.set(static_cast(TechIdIndex(XenoTechId(XenoLevel::Translation1, Species::Liir)))); + const TechApplyOutcome o = RunCompletionTail(s); + CHECK(o.applied); + CHECK(s.Flag(PlayerFlag::CaptureDesigns)); + CHECK(s.hasNodeBoreParams && s.nodeBoreParams[0] == 65); + CHECK(o.nodeBoreParamsChanged); + CHECK_EQ(s.speciesFlags[static_cast(Species::Liir)], 1u); + CHECK_EQ(s.translationKnownMask, 1u << static_cast(Species::Liir)); +} + +static void test_completion_has_no_guard() { + ApplyContext ctx; + // The callback runs after the node is already marked researched, so it cannot carry an + // already-researched guard. ApplyTechEffect (the caller-facing wrapper) still does. + PlayerEconomyState s; + ApplyTechCompletion(s, TechId::IND_HvyPlat, ctx); + const float once = s.outMod; + TechApplyOutcome o = ApplyTechCompletion(s, TechId::IND_HvyPlat, ctx); + CHECK(o.applied); + CHECK(s.outMod != once); // applied a second time + + PlayerEconomyState g; + ApplyTechEffect(g, TechId::IND_HvyPlat, ctx); + const float g1 = g.outMod; + o = ApplyTechEffect(g, TechId::IND_HvyPlat, ctx); + CHECK(!o.applied); + CHECK(g.outMod == g1); +} + +static void test_design_option_ids() { + // The two tables key on tech ids in the executable; the by-name and by-id builders must + // therefore agree bit for bit. + for (int i = 0; i < kDesignOptionCountA; ++i) { + const char* n = TechIdName(kDesignOptionIdsA[i]); + CHECK(n != nullptr && std::string(n) == kDesignOptionNamesA[i]); + } + for (int i = 0; i < kDesignOptionCountB; ++i) { + const char* n = TechIdName(kDesignOptionIdsB[i]); + CHECK(n != nullptr && std::string(n) == kDesignOptionNamesB[i]); + } + // Anchors that were known from unrelated readers before the tables were dumped. + CHECK(kDesignOptionIdsA[28] == TechId::CCC_AdvSens); + CHECK(kDesignOptionIdsB[12] == TechId::DRV_RIP); + CHECK(kDesignOptionIdsB[15] == TechId::BIO_CONNAN); + CHECK(kDesignOptionIdsB[21] == TechId::IND_TRKSTL); + CHECK(kDesignOptionIdsB[28] == TechId::WEP_HvyPmsl); + + std::set have = {static_cast(TechId::IND_REFCOAT), static_cast(TechId::SLD_INTANG), + static_cast(TechId::DRV_NODE), static_cast(TechId::WEP_HvyPmsl)}; + DesignOptionMasks m = ComputeDesignOptionMasks( + [&](TechId id) { return have.count(static_cast(id)) > 0; }); + CHECK_EQ(m.a, (1u << 0) | (1u << 15)); + CHECK_EQ(m.b, (1u << 0) | (1u << 28)); + m = ComputeDesignOptionMasks([](TechId) { return true; }); + CHECK_EQ(m.a, 0xffffffffu); + CHECK_EQ(m.b, 0x1fffffffu); +} + + int main() { test_ids(); test_industrial(); @@ -373,5 +590,13 @@ int main() { test_xenotech(); test_by_name(); test_table_shape(); + test_float32_state(); + test_gate_traffic_is_integer(); + test_node_bore(); + test_capture_designs_is_a_tail_check(); + test_translation_known_mask(); + test_tail_runs_for_unkeyed_techs(); + test_completion_has_no_guard(); + test_design_option_ids(); return simtest::finish("test_effects"); } diff --git a/tests/shim_techfx/CMakeLists.txt b/tests/shim_techfx/CMakeLists.txt new file mode 100644 index 0000000..b740e43 --- /dev/null +++ b/tests/shim_techfx/CMakeLists.txt @@ -0,0 +1,7 @@ +# B2: the tech-effect field adapter (ServerPlayer offsets <-> game::effects state) and the +# region table / describers the OnTechResearched hook declares. +add_executable(shim_techfx_unit_tests unit_tests.cpp) +target_link_libraries(shim_techfx_unit_tests PRIVATE shim_techfx) +target_include_directories(shim_techfx_unit_tests PRIVATE ${CMAKE_SOURCE_DIR}/tests/game_sim) +target_compile_options(shim_techfx_unit_tests PRIVATE -Wall -Wextra -Werror) +add_test(NAME shim_techfx_unit COMMAND shim_techfx_unit_tests) diff --git a/tests/shim_techfx/unit_tests.cpp b/tests/shim_techfx/unit_tests.cpp new file mode 100644 index 0000000..daa4e23 --- /dev/null +++ b/tests/shim_techfx/unit_tests.cpp @@ -0,0 +1,387 @@ +// Host tests for the B2 field adapter: the offsets the tech-effect callback writes, the +// float32 round-trip through them, and the region table the hook declares. +// +// This is the coverage that does not need the game: the reference save completes a tech +// only now and then, so the compare on the VM validates whichever techs happen to finish, +// while every catalogued effect is exercised here against a synthetic ServerPlayer. + +#include "shim/hooks/tech_effect_fields.h" + +#include +#include +#include + +#include "check.h" // shared with tests/game_sim +#include "game/effects/tech_effects.h" +#include "game/effects/tech_id.h" +#include "generated/sots_addresses.h" + +namespace A = sots::addr; +namespace tfx = shim::hooks::techfx; +using namespace sots::effects; +using sots::sim::Species; + +namespace { + +// A synthetic player object: big enough for every declared region, plus a separate +// 3-word block for the node-bore parameters. +struct FakePlayer { + std::vector bytes; + int bore[3] = {0, 0, 0}; + + FakePlayer() : bytes(tfx::PlayerSpan() + 0x40, 0) {} + + void* base() { return bytes.data(); } + template + void put(std::uint32_t off, T v) { std::memcpy(bytes.data() + off, &v, sizeof v); } + template + T get(std::uint32_t off) const { T v{}; std::memcpy(&v, bytes.data() + off, sizeof v); return v; } + + // The node-bore block pointer is a 32-bit word in the game and a 64-bit one on this + // host, so writing a real pointer into the buffer would spill over the neighbouring + // field. The tests point the view at the block directly instead, which is what + // Views::OverPlayer would do on the target. + tfx::Views views(bool with_bore) { + tfx::Views v = tfx::Views::OverPlayer(bytes.data()); + v.base[tfx::R_NODEBORE] = with_bore ? bore : nullptr; + return v; + } + + // The "no tech yet" starting values a fresh player carries. + void seed_defaults(Species sp) { + std::fill(bytes.begin(), bytes.end(), 0); + put(A::ServerPlayer_off_Species, static_cast(sp)); + put(A::ServerPlayer_off_pddm, 1.f); + for (int i = 0; i < 3; ++i) put(A::ServerPlayer_off_ConMod + 4u * i, 1.f); + for (int i = 0; i < 3; ++i) put(A::ServerPlayer_off_SavMod + 4u * i, 1.f); + put(A::ServerPlayer_off_OutMod, 1.f); + put(A::ServerPlayer_off_PopMod, 1.f); + put(A::ServerPlayer_off_TerraMod, 1.f); + put(A::ServerPlayer_off_ResMod, 1.f); + put(A::ServerPlayer_off_IncMod, 1.f); + put(A::ServerPlayer_off_AIBn, 1); + } +}; + +ApplyContext plain_ctx(sots::sim::TuningTable& t) { + ApplyContext c; + c.tuning = &t; + return c; +} + +// Run one completion through the adapter exactly as the hook does. +TechApplyOutcome run_completion(FakePlayer& p, TechId id, const ApplyContext& ctx, + const std::vector& already = {}, bool with_bore = true) { + tfx::Views v = p.views(with_bore); + PlayerEconomyState s = tfx::ReadPlayerState( + v, static_cast(p.get(A::ServerPlayer_off_Species))); + for (TechId t : already) s.researched.set(static_cast(TechIdIndex(t))); + if (IsValidTechId(id)) s.researched.set(static_cast(TechIdIndex(id))); + TechApplyOutcome o = ApplyTechCompletion(s, id, ctx); + tfx::WritePlayerState(v, s); + return o; +} + +// ---- tests ------------------------------------------------------------------------------ + +void test_region_table() { + // Every region carries a name and a describer, and no two overlap. + for (int i = 0; i < tfx::kRegionCount; ++i) { + const tfx::RegionDef& d = tfx::kRegions[i]; + CHECK(d.name != nullptr && d.name[0] != '\0'); + CHECK(d.describe != nullptr); + CHECK(d.size > 0); + } + for (int i = 0; i < tfx::kRegionCount; ++i) { + if (i == tfx::R_NODEBORE) continue; // a separate allocation, not part of the object + for (int j = i + 1; j < tfx::kRegionCount; ++j) { + if (j == tfx::R_NODEBORE) continue; + const std::uint32_t a0 = tfx::kRegions[i].off, a1 = a0 + tfx::kRegions[i].size; + const std::uint32_t b0 = tfx::kRegions[j].off, b1 = b0 + tfx::kRegions[j].size; + CHECK(a1 <= b0 || b1 <= a0); + } + } + // The declared span must reach past the last region. + for (int i = 0; i < tfx::kRegionCount; ++i) { + if (i == tfx::R_NODEBORE) continue; + CHECK(tfx::kRegions[i].off + tfx::kRegions[i].size <= tfx::PlayerSpan()); + } + // The node-bore region is the only one that may legitimately be absent: on the target + // the block pointer is the 32-bit word at its offset, and a zero word means "no block". + FakePlayer p; + p.seed_defaults(Species::Human); + CHECK_EQ(p.get(A::ServerPlayer_off_NodeBore), 0u); + CHECK(p.views(true).base[tfx::R_NODEBORE] == p.bore); + CHECK(p.views(false).base[tfx::R_NODEBORE] == nullptr); +} + +void test_read_round_trip() { + FakePlayer p; + p.seed_defaults(Species::Morrigi); + p.put(A::ServerPlayer_off_SuitTol, 1.25f); + p.put(A::ServerPlayer_off_MaxOH, 0.5f); + p.put(A::ServerPlayer_off_ResMod, 1.75f); + p.put(A::ServerPlayer_off_IncMod, 2.5f); + p.put(A::ServerPlayer_off_RebAI, 1); + p.put(A::ServerPlayer_off_AIBn, 0); + p.put(A::ServerPlayer_off_hadvs, 1); + p.put(A::ServerPlayer_off_AMine, 1); + p.put(A::ServerPlayer_off_pddm, 0.75f); + p.put(A::ServerPlayer_off_ConMod + 4, 0.8f); + p.put(A::ServerPlayer_off_SavMod + 8, 0.6f); + p.put(A::ServerPlayer_off_OutMod, 3.25f); + p.put(A::ServerPlayer_off_PopMod, 1.125f); + p.put(A::ServerPlayer_off_TerraMod, 4.5f); + p.put(A::ServerPlayer_off_MinRate, 2.f); + p.put(A::ServerPlayer_off_PrGtTrf, 37); + p.put(A::ServerPlayer_off_CstR, 10.f); + p.put(A::ServerPlayer_off_CstR + 4, 2.f); + p.put(A::ServerPlayer_off_CstR + 8, 1.f); + p.put(A::ServerPlayer_off_HasVac, 0x03); + p.put(A::ServerPlayer_off_HasVac + 4, 0x05); + p.put(A::ServerPlayer_off_NPTrk, 0x21); + p.put(A::ServerPlayer_off_TranslationKnown, 0x09); + p.put(A::ServerPlayer_off_CaptureDesigns, 1); + p.bore[0] = 65; p.bore[1] = 35; p.bore[2] = 4; + + const PlayerEconomyState s = tfx::ReadPlayerState(p.views(true), Species::Morrigi); + CHECK(s.species == Species::Morrigi); + CHECK(s.suitTol == 1.25f); + CHECK(s.maxOverharvest == 0.5f); + CHECK(s.resMod == 1.75f); + CHECK(s.incMod == 2.5f); + CHECK(s.rebelAI); + CHECK(!s.aiBenefit); + CHECK(s.Flag(PlayerFlag::AdvancedSensors)); + CHECK(s.Flag(PlayerFlag::AsteroidMining)); + CHECK(s.Flag(PlayerFlag::CaptureDesigns)); + CHECK(s.defenceDamageMod == 0.75f); + CHECK(s.conMod[1] == 0.8f); + CHECK(s.savMod[2] == 0.6f); + CHECK(s.outMod == 3.25f); + CHECK(s.popMod == 1.125f); + CHECK(s.terraMod == 4.5f); + CHECK(s.miningRate == 2.f); + CHECK_EQ(s.perGateTraffic, 37); + CHECK(s.castRange == 10.f && s.castEfficiency == 2.f && s.castThreshold == 1.f); + CHECK_EQ(s.hasVaccine, 0x03u); + CHECK_EQ(s.hasImmunity, 0x05u); + CHECK_EQ(s.nodeTrackMask, 0x21u); + CHECK_EQ(s.translationKnownMask, 0x09u); + CHECK(s.hasNodeBoreParams); + CHECK_EQ(s.nodeBoreParams[0], 65); + CHECK_EQ(s.nodeBoreParams[2], 4); + + // Write it straight back out: nothing the callback writes may change. + FakePlayer q; + q.seed_defaults(Species::Morrigi); + tfx::WritePlayerState(q.views(true), s); + CHECK(q.get(A::ServerPlayer_off_SuitTol) == 1.25f); + CHECK(q.get(A::ServerPlayer_off_OutMod) == 3.25f); + CHECK_EQ(q.get(A::ServerPlayer_off_PrGtTrf), 37); + CHECK_EQ(q.get(A::ServerPlayer_off_TranslationKnown), 0x09u); + CHECK_EQ(q.bore[1], 35); + // The species-flag word count is part of the assignment the original makes. + CHECK_EQ(q.get(A::ServerPlayer_off_SpeciesTechFlags + 0x1c), + static_cast(sots::sim::kSpeciesCount)); +} + +// Every catalogued effect, applied to a synthetic player, byte-checked at the offsets the +// original writes. The values themselves are pinned in tests/game_effects; what is under +// test here is that they land in the right words in the right representation. +void test_every_effect_lands_in_the_right_word() { + sots::sim::TuningTable t; + t.PERGATETRAFFIC_DRV_TpGate = 7; + t.PERGATETRAFFIC_DRV_GatAmp = 19; + ApplyContext ctx = plain_ctx(t); + + int covered = 0; + for (int i = 0; i < kTechIdCount; ++i) { + const TechId id = TechIdFromIndex(i); + if (EffectsOf(id).empty()) continue; + ++covered; + + FakePlayer p; + p.seed_defaults(Species::Zuul); // the one species with an extra branch + run_completion(p, id, ctx); + + // Independently: the same completion on a plain state seeded the same way. + PlayerEconomyState want; + want.species = Species::Zuul; + want.hasNodeBoreParams = true; + want.researched.set(static_cast(i)); + ApplyTechCompletion(want, id, ctx); + + CHECK(p.get(A::ServerPlayer_off_SuitTol) == want.suitTol); + CHECK(p.get(A::ServerPlayer_off_MaxOH) == want.maxOverharvest); + CHECK(p.get(A::ServerPlayer_off_ResMod) == want.resMod); + CHECK(p.get(A::ServerPlayer_off_IncMod) == want.incMod); + CHECK(p.get(A::ServerPlayer_off_pddm) == want.defenceDamageMod); + for (int k = 0; k < 3; ++k) + CHECK(p.get(A::ServerPlayer_off_ConMod + 4u * k) == want.conMod[k]); + for (int k = 0; k < 3; ++k) + CHECK(p.get(A::ServerPlayer_off_SavMod + 4u * k) == want.savMod[k]); + CHECK(p.get(A::ServerPlayer_off_OutMod) == want.outMod); + CHECK(p.get(A::ServerPlayer_off_PopMod) == want.popMod); + CHECK(p.get(A::ServerPlayer_off_TerraMod) == want.terraMod); + CHECK(p.get(A::ServerPlayer_off_MinRate) == want.miningRate); + CHECK_EQ(p.get(A::ServerPlayer_off_PrGtTrf), want.perGateTraffic); + CHECK(p.get(A::ServerPlayer_off_CstR) == want.castRange); + CHECK(p.get(A::ServerPlayer_off_CstR + 4) == want.castEfficiency); + CHECK(p.get(A::ServerPlayer_off_CstR + 8) == want.castThreshold); + CHECK_EQ(p.get(A::ServerPlayer_off_HasVac), want.hasVaccine); + CHECK_EQ(p.get(A::ServerPlayer_off_HasVac + 4), want.hasImmunity); + CHECK_EQ(p.get(A::ServerPlayer_off_hadvs) != 0, + want.Flag(PlayerFlag::AdvancedSensors)); + CHECK_EQ(p.get(A::ServerPlayer_off_harcc) != 0, want.Flag(PlayerFlag::Arcology)); + CHECK_EQ(p.get(A::ServerPlayer_off_AMine) != 0, + want.Flag(PlayerFlag::AsteroidMining)); + CHECK_EQ(p.get(A::ServerPlayer_off_CnTrd) != 0, + want.Flag(PlayerFlag::TradeAllowed)); + CHECK_EQ(p.get(A::ServerPlayer_off_CnRad) != 0, + want.Flag(PlayerFlag::CommerceRaiding)); + CHECK_EQ(p.get(A::ServerPlayer_off_CnVItl) != 0, + want.Flag(PlayerFlag::ViewIntel)); + CHECK_EQ(p.get(A::ServerPlayer_off_hgs) != 0, want.Flag(PlayerFlag::GravSynth)); + CHECK_EQ(p.bore[0], want.nodeBoreParams[0]); + } + CHECK_EQ(covered, 44); +} + +// Spot checks with the constants written out by hand, so a silent change to the table or +// to the rounding shape fails here and not only in the aggregate above. +void test_exact_float32_bit_patterns() { + sots::sim::TuningTable t; + ApplyContext ctx = plain_ctx(t); + + FakePlayer p; + p.seed_defaults(Species::Human); + run_completion(p, TechId::IND_Waldo, ctx); + CHECK(p.get(A::ServerPlayer_off_OutMod) == + static_cast(1.0 + static_cast(0.15f))); + CHECK(p.get(A::ServerPlayer_off_ConMod) == + static_cast(1.0 - static_cast(0.10f))); + + // Multiplicative, on top of the additive one. + run_completion(p, TechId::IND_HrdStrct, ctx, {TechId::IND_Waldo}); + const float after_waldo = static_cast(1.0 + static_cast(0.15f)); + CHECK(p.get(A::ServerPlayer_off_OutMod) == + static_cast(static_cast(after_waldo) * static_cast(0.9f))); + CHECK(p.get(A::ServerPlayer_off_pddm) == 0.25f); + + // The suitability techs are exactly representable, so they must be exact. + FakePlayer q; + q.seed_defaults(Species::Liir); + q.put(A::ServerPlayer_off_SuitTol, 0.5f); + run_completion(q, TechId::BIO_AtmoAd, ctx); + CHECK(q.get(A::ServerPlayer_off_SuitTol) == 1.25f); + run_completion(q, TechId::BIO_GrvAdpt, ctx, {TechId::BIO_AtmoAd}); + CHECK(q.get(A::ServerPlayer_off_SuitTol) == 2.75f); + + // Far-casting writes three literals, one of them via FLD1. + FakePlayer r; + r.seed_defaults(Species::Morrigi); + run_completion(r, TechId::DRV_FarCast, ctx); + CHECK(r.get(A::ServerPlayer_off_CstR) == 10.f); + CHECK(r.get(A::ServerPlayer_off_CstR + 4) == 2.f); + CHECK(r.get(A::ServerPlayer_off_CstR + 8) == 1.f); +} + +void test_gate_traffic_and_bore_absent() { + sots::sim::TuningTable t; + t.PERGATETRAFFIC_DRV_TpGate = 7; + t.PERGATETRAFFIC_DRV_GatAmp = 19; + ApplyContext ctx = plain_ctx(t); + + FakePlayer p; + p.seed_defaults(Species::Hiver); + p.put(A::ServerPlayer_off_PrGtTrf, 12); + run_completion(p, TechId::DRV_TpGate, ctx); + CHECK_EQ(p.get(A::ServerPlayer_off_PrGtTrf), 12); // integer max keeps 12 + run_completion(p, TechId::DRV_GatAmp, ctx, {TechId::DRV_TpGate}); + CHECK_EQ(p.get(A::ServerPlayer_off_PrGtTrf), 19); + + // With no block attached the bore parameters have nowhere to go, and writing must not + // fault or scribble on the object. + FakePlayer q; + q.seed_defaults(Species::Zuul); + const std::vector before = q.bytes; + run_completion(q, TechId::DRV_RAD, ctx, {}, /*with_bore=*/false); + CHECK(q.bore[0] == 0 && q.bore[1] == 0 && q.bore[2] == 0); + CHECK(std::memcmp(before.data() + A::ServerPlayer_off_NodeBore, + q.bytes.data() + A::ServerPlayer_off_NodeBore, sizeof(void*)) == 0); +} + +void test_species_flags_and_translation() { + sots::sim::TuningTable t; + ApplyContext ctx = plain_ctx(t); + FakePlayer p; + p.seed_defaults(Species::Human); + run_completion(p, XenoTechId(XenoLevel::Translation1, Species::Tarkas), ctx); + CHECK_EQ(p.get(A::ServerPlayer_off_SpeciesTechFlags + + 4u * static_cast(Species::Tarkas)), + 1u); + CHECK_EQ(p.get(A::ServerPlayer_off_TranslationKnown), + 1u << static_cast(Species::Tarkas)); + + run_completion(p, XenoTechId(XenoLevel::Temperance, Species::Tarkas), ctx, + {XenoTechId(XenoLevel::Translation1, Species::Tarkas)}); + CHECK_EQ(p.get(A::ServerPlayer_off_SpeciesTechFlags + + 4u * static_cast(Species::Tarkas)), + 1u | (1u << static_cast(XenoLevel::Temperance))); +} + +void test_research_target_and_masks() { + FakePlayer p; + p.seed_defaults(Species::Human); + int def = 0; + p.put(A::ServerPlayer_off_ResearchTarget, &def); + p.put(A::ServerPlayer_off_ResearchRollPending, 1); + tfx::Views v = p.views(false); + + int other = 0; + CHECK(!tfx::ClearResearchTargetIfMatched(v, &other)); // a different definition + CHECK(p.get(A::ServerPlayer_off_ResearchTarget) == &def); + CHECK(tfx::ClearResearchTargetIfMatched(v, &def)); // the roll was pending + CHECK(p.get(A::ServerPlayer_off_ResearchTarget) == nullptr); + CHECK_EQ(p.get(A::ServerPlayer_off_ResearchRollPending), 0); + CHECK(!tfx::ClearResearchTargetIfMatched(v, nullptr) || + p.get(A::ServerPlayer_off_ResearchTarget) == nullptr); + + tfx::WriteDesignOptionMasks(v, 0xdeadbeefu, 0x1fffffffu); + CHECK_EQ(p.get(A::ServerPlayer_off_TechMaskA), 0xdeadbeefu); + CHECK_EQ(p.get(A::ServerPlayer_off_TechMaskA + 4), 0x1fffffffu); +} + +// The describers must name fields, not dump bytes: a diff has to be able to point at +// `side.modifiers.after.v.out_mod`. +void test_describers_name_fields() { + FakePlayer p; + p.seed_defaults(Species::Human); + p.put(A::ServerPlayer_off_OutMod, 1.5f); + p.put(A::ServerPlayer_off_PrGtTrf, 42); + const shim::trace::Tv tv = + tfx::kRegions[tfx::R_MODIFIERS].describe(p.bytes.data() + A::ServerPlayer_off_pddm, + tfx::kRegions[tfx::R_MODIFIERS].size, 256); + shim::trace::Buf b; + shim::trace::emit_tv(b, tv); + const std::string json = b.str(); + CHECK(json.find("\"out_mod\"") != std::string::npos); + CHECK(json.find("\"per_gate_traffic\"") != std::string::npos); + CHECK(json.find("\"con_mod\"") != std::string::npos); + CHECK(json.find("42") != std::string::npos); +} + +} // namespace + +int main() { + test_region_table(); + test_read_round_trip(); + test_every_effect_lands_in_the_right_word(); + test_exact_float32_bit_patterns(); + test_gate_traffic_and_bore_absent(); + test_species_flags_and_translation(); + test_research_target_and_masks(); + test_describers_name_fields(); + return simtest::finish("shim_techfx"); +}