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:
parent
9cd997da93
commit
4a6e11d1ce
17 changed files with 2345 additions and 285 deletions
|
|
@ -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
363
docs/B2.md
Normal 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.
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
338
src/shim/hooks/tech_effect_fields.cpp
Normal file
338
src/shim/hooks/tech_effect_fields.cpp
Normal 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
|
||||
80
src/shim/hooks/tech_effect_fields.h
Normal file
80
src/shim/hooks/tech_effect_fields.h
Normal 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
|
||||
335
src/shim/hooks/tech_effects.cpp
Normal file
335
src/shim/hooks/tech_effects.cpp
Normal 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
|
||||
61
src/shim/hooks/tech_effects.h
Normal file
61
src/shim/hooks/tech_effects.h
Normal 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
|
||||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
7
tests/shim_techfx/CMakeLists.txt
Normal file
7
tests/shim_techfx/CMakeLists.txt
Normal 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)
|
||||
387
tests/shim_techfx/unit_tests.cpp
Normal file
387
tests/shim_techfx/unit_tests.cpp
Normal 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");
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue