b2: OnTechResearched hook + 10 effects-table corrections (float32, xenotech block size, no already-researched guard, sticky mask, AI bonus values)

This commit is contained in:
alex 2026-09-08 00:01:04 -04:00
parent 9cd997da93
commit 4a6e11d1ce
17 changed files with 2345 additions and 285 deletions

View file

@ -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()

363
docs/B2.md Normal file
View file

@ -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.

View file

@ -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.

View file

@ -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 <cstdint>
@ -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<PlayerAid> (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

View file

@ -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<float>(static_cast<double>(a) + k); }
inline float MulF(float a, double k) { return static_cast<float>(static_cast<double>(a) * k); }
constexpr int F(PlayerFlag f) { return static_cast<int>(f); }
constexpr int Sp(Species s) { return static_cast<int>(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<TechEffect> effects;
@ -33,23 +42,23 @@ const std::vector<TableEntry>& Table() {
using K = EffectKind;
static const std::vector<TableEntry> 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<TableEntry>& 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<double>(static_cast<int>(TechId::IND_BrdPod))}}},
{TechId::CCC_SpyBm, {{K::CaptureDesignsWith, static_cast<int>(TechId::IND_SlvgTech)}}},
{TechId::IND_SlvgTech, {{K::CaptureDesignsWith, static_cast<int>(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<double>(static_cast<float>(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<float>(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<AiBonusSlot>(e.index), AiBonusFor(static_cast<AiBonusSlot>(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<TechId>(static_cast<int>(e.value));
break;
case K::CaptureDesignsWith:
if (s.HasResearched(static_cast<TechId>(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<TechEffect>& 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<Species>(sp))) continue;
if (s.speciesFlags[sp] & (1u << static_cast<int>(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<std::size_t>(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<int>(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<bool(std::string_view)>& hasResearchedByName) {
DesignOptionMasks m;
for (int i = 0; i < kDesignOptionCountA; ++i) {
@ -280,4 +376,25 @@ DesignOptionMasks ComputeDesignOptionMasks(const std::function<bool(std::string_
return m;
}
DesignOptionMasks ComputeDesignOptionMasks(const std::function<bool(TechId)>& 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

View file

@ -74,26 +74,36 @@ const std::vector<TechEffect>& 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<kTechIdCount> researched;
bool Flag(PlayerFlag f) const { return flags[static_cast<int>(f)]; }
bool HasResearched(TechId id) const { return IsValidTechId(id) && researched.test(static_cast<std::size_t>(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<bool(std::string_view)>& hasResearchedByName);
// By id -- what the executable actually does, and what a differential hook needs.
DesignOptionMasks ComputeDesignOptionMasks(const std::function<bool(TechId)>& 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

View file

@ -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<int>(level);
if (k < 0 || k >= kXenoLevelCount) return TechId::None;

View file

@ -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 <string_view>
@ -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<int>(
constexpr int TechIdIndex(TechId id) { return static_cast<int>(id) - kTechIdBase; }
constexpr TechId TechIdFromIndex(int index) { return static_cast<TechId>(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

View file

@ -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

View file

@ -0,0 +1,338 @@
#include "shim/hooks/tech_effect_fields.h"
#include <cstring>
#include <vector>
#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<const char*>(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<const char*>(p) + off, sizeof v);
return v;
}
float f32_at(const void* p, std::size_t off) {
float v = 0;
std::memcpy(&v, static_cast<const char*>(p) + off, sizeof v);
return v;
}
void* ptr_at(const void* p, std::size_t off) {
void* v = nullptr;
std::memcpy(&v, static_cast<const char*>(p) + off, sizeof v);
return v;
}
std::uint8_t u8_at(const void* p, std::size_t off) {
return static_cast<const std::uint8_t*>(p)[off];
}
void put_i32(void* p, std::size_t off, std::int32_t v) { std::memcpy(static_cast<char*>(p) + off, &v, sizeof v); }
void put_u32(void* p, std::size_t off, std::uint32_t v) { std::memcpy(static_cast<char*>(p) + off, &v, sizeof v); }
void put_f32(void* p, std::size_t off, float v) { std::memcpy(static_cast<char*>(p) + off, &v, sizeof v); }
void put_u8(void* p, std::size_t off, std::uint8_t v) { static_cast<std::uint8_t*>(p)[off] = v; }
void put_ptr(void* p, std::size_t off, void* v) { std::memcpy(static_cast<char*>(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<Tv> 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<Tv> 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<Tv> 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<Tv> 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<char*>(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<int>(fx::PlayerFlag::TradeAllowed)] = u8_at(ab, 0x03) != 0;
s.flags[static_cast<int>(fx::PlayerFlag::CommerceRaiding)] = u8_at(ab, 0x04) != 0;
s.flags[static_cast<int>(fx::PlayerFlag::ViewIntel)] = u8_at(ab, 0x05) != 0;
s.flags[static_cast<int>(fx::PlayerFlag::GravSynth)] = u8_at(ab, 0x06) != 0;
s.flags[static_cast<int>(fx::PlayerFlag::AdvancedSensors)] = u8_at(ab, 0x07) != 0;
s.flags[static_cast<int>(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<int>(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<int>(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<std::uint32_t>(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

View file

@ -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 <cstddef>
#include <cstdint>
#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

View file

@ -0,0 +1,335 @@
#include "shim/hooks/tech_effects.h"
#include <algorithm>
#include <cstdarg>
#include <cstdio>
#include <cstring>
#include <stdexcept>
#include <string>
#include <vector>
#if defined(_WIN32)
#define WIN32_LEAN_AND_MEAN
#include <windows.h>
#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<const char*>(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<const char*>(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<const char*>(p) + off, sizeof v);
return v;
}
void* ptr_at(const void* p, std::size_t off) {
void* v = nullptr;
std::memcpy(&v, static_cast<const char*>(p) + off, sizeof v);
return v;
}
std::uint8_t u8_at(const void* p, std::size_t off) {
return static_cast<const std::uint8_t*>(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<Tv>& 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<fx::TechId>(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<trace::Region>& 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<int>(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<std::size_t>(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<sots::sim::Species>(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<std::size_t>(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<fx::TechId>(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<sots::sim::Species>(sp));
if (track == nullptr) continue;
const fx::TechId tid = fx::TechIdFromName(track);
if (fx::IsValidTechId(tid) && static_cast<int>(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<int>(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<int>(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<IsTechFn>(exe_base + A::MasterTechTree_IsTech);
g_env.has_researched = reinterpret_cast<HasResearchedFn>(exe_base + A::TechTree_HasResearched);
g_env.update_bore = reinterpret_cast<UpdateBoreFn>(exe_base + A::ServerPlayer_UpdateNodeBoreParams);
g_env.gate_tpgate = reinterpret_cast<const int*>(exe_base + A::g_PERGATETRAFFIC_DRV_TpGate);
g_env.gate_gatamp = reinterpret_cast<const int*>(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

View file

@ -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 <cstdint>
#include <tuple>
#include <vector>
#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<void*, void*, bool>;
static void describe_args(std::vector<trace::Tv>& out, void* self, void* def, bool silent);
static void regions(std::vector<trace::Region>& 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

View file

@ -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<shim::hooks::GlobalConstsLoadFileHook>;
using WeaponInitHook = shim::trace::Hook<shim::hooks::WeaponDictionaryInitHook>;
using SectionCtorHook = shim::trace::Hook<shim::hooks::SectionDictionaryCtorHook>;
using ProcessResearchHook = shim::trace::Hook<shim::hooks::TechTreeProcessResearchHook>;
using OnTechResearchedHook = shim::trace::Hook<shim::hooks::ServerPlayerOnTechResearchedHook>;
using ComputeBudgetHook = shim::trace::Hook<shim::hooks::ComputeBudgetHook>;
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<shim::hooks::TechTreeProcessResearchHook>(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<shim::hooks::ServerPlayerOnTechResearchedHook>(tracer, exeBase, sots::addr::ServerPlayer_OnTechResearched);
// B1: ServerPlayer::ComputeBudget (verified thiscall) -- the first behavioural compare.
shim::hooks::init_compute_budget(&ShimLogLine);
InstallTemplateHook<shim::hooks::ComputeBudgetHook>(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)) {

View file

@ -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<int>(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<int>(XenoTechId(XenoLevel::Proliferate, Species::Morrigi)), 10163);
CHECK_EQ(static_cast<int>(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<int>(Species::Morrigi)], 0x080u);
CHECK_EQ(o.temperanceSpeciesMask, 1u << static_cast<int>(Species::Hiver));
ApplyTechEffect(s, TechId::XNC_Proliferate_Zuul, ctx);
CHECK_EQ(s.speciesFlags[static_cast<int>(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<int>(Species::Tarkas)], 0x100u);
ApplyTechEffect(s, TechId::XNC_Subjugate_Zuul, ctx);
CHECK_EQ(s.speciesFlags[static_cast<int>(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<int>(Species::Human));
@ -329,18 +351,18 @@ static void test_by_name() {
CHECK_EQ(s.nodeTrackMask, (1u << static_cast<int>(Species::Human)) | (1u << static_cast<int>(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<int>(TechId::XNC_Proliferate_Morrigi), 10162);
CHECK_EQ(static_cast<int>(TechId::CCC_NDTRKHUM), 10163);
CHECK_EQ(static_cast<int>(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<std::string> 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<float>(static_cast<double>(stepwise) + k);
CHECK(s.terraMod == stepwise);
// The constants are widened float32 literals, not the exact decimals they look like.
CHECK(static_cast<double>(0.05f) != 0.05);
CHECK(static_cast<double>(0.45f) != 0.45);
CHECK(static_cast<double>(0.9f) != 0.9);
CHECK(static_cast<double>(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<float>(
static_cast<double>(static_cast<float>(1.0 + static_cast<double>(0.15f))) * static_cast<double>(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<std::size_t>(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<std::size_t>(TechIdIndex(TechId::CCC_SpyBm)));
t.researched.set(static_cast<std::size_t>(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<int>(Species::Liir));
ApplyTechEffect(s, XenoTechId(XenoLevel::Translation2, Species::Hiver), ctx);
CHECK_EQ(s.translationKnownMask, 1u << static_cast<int>(Species::Liir)); // level 2 alone: no bit
ApplyTechEffect(s, XenoTechId(XenoLevel::Translation1, Species::Hiver), ctx);
CHECK_EQ(s.translationKnownMask,
(1u << static_cast<int>(Species::Liir)) | (1u << static_cast<int>(Species::Hiver)));
// Sticky: clearing the researched bit and re-running the rebuild does not take it back.
s.researched.reset(static_cast<std::size_t>(TechIdIndex(XenoTechId(XenoLevel::Translation1, Species::Liir))));
RebuildSpeciesTechFlags(s);
CHECK(s.translationKnownMask & (1u << static_cast<int>(Species::Liir)));
CHECK_EQ(s.speciesFlags[static_cast<int>(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<std::size_t>(TechIdIndex(TechId::CCC_SpyBm)));
s.researched.set(static_cast<std::size_t>(TechIdIndex(TechId::IND_SlvgTech)));
s.researched.set(static_cast<std::size_t>(TechIdIndex(TechId::DRV_REND)));
s.researched.set(static_cast<std::size_t>(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<int>(Species::Liir)], 1u);
CHECK_EQ(s.translationKnownMask, 1u << static_cast<int>(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<int> have = {static_cast<int>(TechId::IND_REFCOAT), static_cast<int>(TechId::SLD_INTANG),
static_cast<int>(TechId::DRV_NODE), static_cast<int>(TechId::WEP_HvyPmsl)};
DesignOptionMasks m = ComputeDesignOptionMasks(
[&](TechId id) { return have.count(static_cast<int>(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");
}

View file

@ -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)

View file

@ -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 <cstring>
#include <string>
#include <vector>
#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<unsigned char> bytes;
int bore[3] = {0, 0, 0};
FakePlayer() : bytes(tfx::PlayerSpan() + 0x40, 0) {}
void* base() { return bytes.data(); }
template <class T>
void put(std::uint32_t off, T v) { std::memcpy(bytes.data() + off, &v, sizeof v); }
template <class T>
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<std::int32_t>(A::ServerPlayer_off_Species, static_cast<int>(sp));
put<float>(A::ServerPlayer_off_pddm, 1.f);
for (int i = 0; i < 3; ++i) put<float>(A::ServerPlayer_off_ConMod + 4u * i, 1.f);
for (int i = 0; i < 3; ++i) put<float>(A::ServerPlayer_off_SavMod + 4u * i, 1.f);
put<float>(A::ServerPlayer_off_OutMod, 1.f);
put<float>(A::ServerPlayer_off_PopMod, 1.f);
put<float>(A::ServerPlayer_off_TerraMod, 1.f);
put<float>(A::ServerPlayer_off_ResMod, 1.f);
put<float>(A::ServerPlayer_off_IncMod, 1.f);
put<std::uint8_t>(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<TechId>& already = {}, bool with_bore = true) {
tfx::Views v = p.views(with_bore);
PlayerEconomyState s = tfx::ReadPlayerState(
v, static_cast<Species>(p.get<std::int32_t>(A::ServerPlayer_off_Species)));
for (TechId t : already) s.researched.set(static_cast<std::size_t>(TechIdIndex(t)));
if (IsValidTechId(id)) s.researched.set(static_cast<std::size_t>(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<std::uint32_t>(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<float>(A::ServerPlayer_off_SuitTol, 1.25f);
p.put<float>(A::ServerPlayer_off_MaxOH, 0.5f);
p.put<float>(A::ServerPlayer_off_ResMod, 1.75f);
p.put<float>(A::ServerPlayer_off_IncMod, 2.5f);
p.put<std::uint8_t>(A::ServerPlayer_off_RebAI, 1);
p.put<std::uint8_t>(A::ServerPlayer_off_AIBn, 0);
p.put<std::uint8_t>(A::ServerPlayer_off_hadvs, 1);
p.put<std::uint8_t>(A::ServerPlayer_off_AMine, 1);
p.put<float>(A::ServerPlayer_off_pddm, 0.75f);
p.put<float>(A::ServerPlayer_off_ConMod + 4, 0.8f);
p.put<float>(A::ServerPlayer_off_SavMod + 8, 0.6f);
p.put<float>(A::ServerPlayer_off_OutMod, 3.25f);
p.put<float>(A::ServerPlayer_off_PopMod, 1.125f);
p.put<float>(A::ServerPlayer_off_TerraMod, 4.5f);
p.put<float>(A::ServerPlayer_off_MinRate, 2.f);
p.put<std::int32_t>(A::ServerPlayer_off_PrGtTrf, 37);
p.put<float>(A::ServerPlayer_off_CstR, 10.f);
p.put<float>(A::ServerPlayer_off_CstR + 4, 2.f);
p.put<float>(A::ServerPlayer_off_CstR + 8, 1.f);
p.put<std::uint32_t>(A::ServerPlayer_off_HasVac, 0x03);
p.put<std::uint32_t>(A::ServerPlayer_off_HasVac + 4, 0x05);
p.put<std::uint32_t>(A::ServerPlayer_off_NPTrk, 0x21);
p.put<std::uint32_t>(A::ServerPlayer_off_TranslationKnown, 0x09);
p.put<std::uint8_t>(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<float>(A::ServerPlayer_off_SuitTol) == 1.25f);
CHECK(q.get<float>(A::ServerPlayer_off_OutMod) == 3.25f);
CHECK_EQ(q.get<std::int32_t>(A::ServerPlayer_off_PrGtTrf), 37);
CHECK_EQ(q.get<std::uint32_t>(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<std::uint32_t>(A::ServerPlayer_off_SpeciesTechFlags + 0x1c),
static_cast<std::uint32_t>(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<std::size_t>(i));
ApplyTechCompletion(want, id, ctx);
CHECK(p.get<float>(A::ServerPlayer_off_SuitTol) == want.suitTol);
CHECK(p.get<float>(A::ServerPlayer_off_MaxOH) == want.maxOverharvest);
CHECK(p.get<float>(A::ServerPlayer_off_ResMod) == want.resMod);
CHECK(p.get<float>(A::ServerPlayer_off_IncMod) == want.incMod);
CHECK(p.get<float>(A::ServerPlayer_off_pddm) == want.defenceDamageMod);
for (int k = 0; k < 3; ++k)
CHECK(p.get<float>(A::ServerPlayer_off_ConMod + 4u * k) == want.conMod[k]);
for (int k = 0; k < 3; ++k)
CHECK(p.get<float>(A::ServerPlayer_off_SavMod + 4u * k) == want.savMod[k]);
CHECK(p.get<float>(A::ServerPlayer_off_OutMod) == want.outMod);
CHECK(p.get<float>(A::ServerPlayer_off_PopMod) == want.popMod);
CHECK(p.get<float>(A::ServerPlayer_off_TerraMod) == want.terraMod);
CHECK(p.get<float>(A::ServerPlayer_off_MinRate) == want.miningRate);
CHECK_EQ(p.get<std::int32_t>(A::ServerPlayer_off_PrGtTrf), want.perGateTraffic);
CHECK(p.get<float>(A::ServerPlayer_off_CstR) == want.castRange);
CHECK(p.get<float>(A::ServerPlayer_off_CstR + 4) == want.castEfficiency);
CHECK(p.get<float>(A::ServerPlayer_off_CstR + 8) == want.castThreshold);
CHECK_EQ(p.get<std::uint32_t>(A::ServerPlayer_off_HasVac), want.hasVaccine);
CHECK_EQ(p.get<std::uint32_t>(A::ServerPlayer_off_HasVac + 4), want.hasImmunity);
CHECK_EQ(p.get<std::uint8_t>(A::ServerPlayer_off_hadvs) != 0,
want.Flag(PlayerFlag::AdvancedSensors));
CHECK_EQ(p.get<std::uint8_t>(A::ServerPlayer_off_harcc) != 0, want.Flag(PlayerFlag::Arcology));
CHECK_EQ(p.get<std::uint8_t>(A::ServerPlayer_off_AMine) != 0,
want.Flag(PlayerFlag::AsteroidMining));
CHECK_EQ(p.get<std::uint8_t>(A::ServerPlayer_off_CnTrd) != 0,
want.Flag(PlayerFlag::TradeAllowed));
CHECK_EQ(p.get<std::uint8_t>(A::ServerPlayer_off_CnRad) != 0,
want.Flag(PlayerFlag::CommerceRaiding));
CHECK_EQ(p.get<std::uint8_t>(A::ServerPlayer_off_CnVItl) != 0,
want.Flag(PlayerFlag::ViewIntel));
CHECK_EQ(p.get<std::uint8_t>(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<float>(A::ServerPlayer_off_OutMod) ==
static_cast<float>(1.0 + static_cast<double>(0.15f)));
CHECK(p.get<float>(A::ServerPlayer_off_ConMod) ==
static_cast<float>(1.0 - static_cast<double>(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<float>(1.0 + static_cast<double>(0.15f));
CHECK(p.get<float>(A::ServerPlayer_off_OutMod) ==
static_cast<float>(static_cast<double>(after_waldo) * static_cast<double>(0.9f)));
CHECK(p.get<float>(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<float>(A::ServerPlayer_off_SuitTol, 0.5f);
run_completion(q, TechId::BIO_AtmoAd, ctx);
CHECK(q.get<float>(A::ServerPlayer_off_SuitTol) == 1.25f);
run_completion(q, TechId::BIO_GrvAdpt, ctx, {TechId::BIO_AtmoAd});
CHECK(q.get<float>(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<float>(A::ServerPlayer_off_CstR) == 10.f);
CHECK(r.get<float>(A::ServerPlayer_off_CstR + 4) == 2.f);
CHECK(r.get<float>(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<std::int32_t>(A::ServerPlayer_off_PrGtTrf, 12);
run_completion(p, TechId::DRV_TpGate, ctx);
CHECK_EQ(p.get<std::int32_t>(A::ServerPlayer_off_PrGtTrf), 12); // integer max keeps 12
run_completion(p, TechId::DRV_GatAmp, ctx, {TechId::DRV_TpGate});
CHECK_EQ(p.get<std::int32_t>(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<unsigned char> 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<std::uint32_t>(A::ServerPlayer_off_SpeciesTechFlags +
4u * static_cast<int>(Species::Tarkas)),
1u);
CHECK_EQ(p.get<std::uint32_t>(A::ServerPlayer_off_TranslationKnown),
1u << static_cast<int>(Species::Tarkas));
run_completion(p, XenoTechId(XenoLevel::Temperance, Species::Tarkas), ctx,
{XenoTechId(XenoLevel::Translation1, Species::Tarkas)});
CHECK_EQ(p.get<std::uint32_t>(A::ServerPlayer_off_SpeciesTechFlags +
4u * static_cast<int>(Species::Tarkas)),
1u | (1u << static_cast<int>(XenoLevel::Temperance)));
}
void test_research_target_and_masks() {
FakePlayer p;
p.seed_defaults(Species::Human);
int def = 0;
p.put<void*>(A::ServerPlayer_off_ResearchTarget, &def);
p.put<std::uint8_t>(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<void*>(A::ServerPlayer_off_ResearchTarget) == &def);
CHECK(tfx::ClearResearchTargetIfMatched(v, &def)); // the roll was pending
CHECK(p.get<void*>(A::ServerPlayer_off_ResearchTarget) == nullptr);
CHECK_EQ(p.get<std::uint8_t>(A::ServerPlayer_off_ResearchRollPending), 0);
CHECK(!tfx::ClearResearchTargetIfMatched(v, nullptr) ||
p.get<void*>(A::ServerPlayer_off_ResearchTarget) == nullptr);
tfx::WriteDesignOptionMasks(v, 0xdeadbeefu, 0x1fffffffu);
CHECK_EQ(p.get<std::uint32_t>(A::ServerPlayer_off_TechMaskA), 0xdeadbeefu);
CHECK_EQ(p.get<std::uint32_t>(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<float>(A::ServerPlayer_off_OutMod, 1.5f);
p.put<std::int32_t>(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");
}