From 4abf654983cd610d29e6e1fa67d326d1e717262a Mon Sep 17 00:00:00 2001 From: alex Date: Tue, 8 Sep 2026 00:57:55 -0400 Subject: [PATCH] b4: colony + movement hooks; 22 formula corrections (growth curve has no capacity term, range margin +0.05f, ties-to-even rounding); 3 verified signatures --- CMakeLists.txt | 19 +- docs/B4.md | 346 +++++++++++++++++++ docs/game-sim.md | 86 +++-- include/generated/sots_addresses.h | 236 ++++++++++++- src/game/sim/colony.cpp | 477 ++++++++++++++++++++------ src/game/sim/colony.h | 406 +++++++++++++++++----- src/game/sim/movement.cpp | 338 ++++++++++++++----- src/game/sim/movement.h | 265 ++++++++++++--- src/game/sim/numeric.h | 24 +- src/game/sim/rng.h | 4 + src/shim/hooks/colony_inputs.cpp | 147 ++++++++ src/shim/hooks/colony_inputs.h | 106 ++++++ src/shim/hooks/colony_turn.cpp | 451 +++++++++++++++++++++++++ src/shim/hooks/colony_turn.h | 47 +++ src/shim/hooks/fleet_movement.cpp | 518 +++++++++++++++++++++++++++++ src/shim/hooks/fleet_movement.h | 66 ++++ src/shim/hooks/movement_inputs.cpp | 152 +++++++++ src/shim/hooks/movement_inputs.h | 105 ++++++ src/shim/hooks/research.cpp | 4 + src/shim/main.cpp | 16 + src/shim/shim.cfg.b4compare | 16 + src/shim/shim.cfg.b4scout | 23 ++ src/shim/shim.cfg.b4trace | 16 + tests/game_sim/check.h | 4 + tests/game_sim/test_colony.cpp | 250 +++++++++----- tests/game_sim/test_movement.cpp | 197 +++++++---- tests/shim_colony/CMakeLists.txt | 6 + tests/shim_colony/unit_tests.cpp | 263 +++++++++++++++ tests/shim_movement/CMakeLists.txt | 6 + tests/shim_movement/unit_tests.cpp | 173 ++++++++++ tools/build-shim.sh | 3 + 31 files changed, 4286 insertions(+), 484 deletions(-) create mode 100644 docs/B4.md create mode 100644 src/shim/hooks/colony_inputs.cpp create mode 100644 src/shim/hooks/colony_inputs.h create mode 100644 src/shim/hooks/colony_turn.cpp create mode 100644 src/shim/hooks/colony_turn.h create mode 100644 src/shim/hooks/fleet_movement.cpp create mode 100644 src/shim/hooks/fleet_movement.h create mode 100644 src/shim/hooks/movement_inputs.cpp create mode 100644 src/shim/hooks/movement_inputs.h create mode 100644 src/shim/shim.cfg.b4compare create mode 100644 src/shim/shim.cfg.b4scout create mode 100644 src/shim/shim.cfg.b4trace create mode 100644 tests/shim_colony/CMakeLists.txt create mode 100644 tests/shim_colony/unit_tests.cpp create mode 100644 tests/shim_movement/CMakeLists.txt create mode 100644 tests/shim_movement/unit_tests.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 23cca1e..b049ca5 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -51,6 +51,16 @@ 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) +# ---- B4 colony adapter: ServerSystem snapshot <-> game::sim colony turn (pure, host-tested) ---- +add_library(shim_colony STATIC src/shim/hooks/colony_inputs.cpp) +target_link_libraries(shim_colony PUBLIC shim_trace sots_game_sim) +target_compile_options(shim_colony PRIVATE -Wall -Wextra -Werror) + +# ---- B4 movement adapter: StarFleet snapshot <-> game::sim movement (pure, host-tested) ---- +add_library(shim_movement STATIC src/shim/hooks/movement_inputs.cpp) +target_link_libraries(shim_movement PUBLIC shim_trace sots_game_sim) +target_compile_options(shim_movement PRIVATE -Wall -Wextra -Werror) + if(WIN32) # ---- shim: proxy binkw32.dll that the original game loads (Phase 2 frontend) ---- add_library(minhook STATIC @@ -64,9 +74,12 @@ if(WIN32) 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) + src/shim/hooks/compute_budget.cpp + src/shim/hooks/colony_turn.cpp + src/shim/hooks/fleet_movement.cpp) target_link_libraries(shim_hooks PUBLIC shim_trace sots_addresses sots_game_config sots_game_sim - sots_game_effects mars_rng shim_budget shim_techfx) + sots_game_effects mars_rng shim_budget shim_techfx + shim_colony shim_movement) target_compile_options(shim_hooks PRIVATE -Wall -Wextra -Werror) add_library(binkw32 SHARED src/shim/main.cpp src/shim/binkw32.def) @@ -82,7 +95,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 shim_techfx) + 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 shim_colony shim_movement) if(EXISTS ${CMAKE_SOURCE_DIR}/tests/${_t}/CMakeLists.txt) add_subdirectory(tests/${_t}) endif() diff --git a/docs/B4.md b/docs/B4.md new file mode 100644 index 0000000..3fb068a --- /dev/null +++ b/docs/B4.md @@ -0,0 +1,346 @@ +# B4 — the colony turn and the fleet movement pass, old vs new + +**Status (2026-09-08): code complete, cross-built, 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\binkw32.dll` / `C:\SOTS\shimdist` were not +touched. Everything below is offline work plus what the *binary* says; the run list is at the +end. The build lives in its own tree (`/srv/re-lab/build/sots-engine-b4`) and its own dist +(`/srv/re-lab/shim/dist-b4`), not the shared ones. Build +`b4-final-20260908T0500Z`, exports 66 names identical to `binkw32.dll`. Host suite **30/30**. `tools/clean_room_check.sh` OK. + +Ghidra was available this round and was used at the end to write the verified prototypes back +into the shared project (`reva-server` stopped for the run and restarted afterwards). + +## What was hooked + +| hook name (record `hook`) | RVA | prototype (all now `[verified]`) | +|---|---|---| +| `Game::ServerSystem::ProcessTurn` | 0x003598e0 | `void (ServerSystem*)` — **no stack arguments** | +| `Game::StrategyServer::MoveFleet` | 0x003d9ee0 | `bool (StrategyServer*, StarFleet*, float dt)` | +| `Game::StrategyServer::ProcessFleetMovement` | 0x003da9a0 | `void (StrategyServer*)` | + +All three are `__thiscall` and go through `Hook<>` with `CallConv::Thiscall`. Sources: +`src/shim/hooks/colony_turn.{h,cpp}` and `src/shim/hooks/fleet_movement.{h,cpp}`, installed +from `src/shim/main.cpp` after the B1/B2/B3 hooks. The pure halves are +`src/shim/hooks/colony_inputs.{h,cpp}` (lib `shim_colony`, ctest `shim_colony_unit`) and +`src/shim/hooks/movement_inputs.{h,cpp}` (lib `shim_movement`, ctest `shim_movement_unit`) — +the B1 split, so the mapping is exhaustively testable without the VM. + +### How the prototypes were verified + +Every one was read off the instruction stream (`objdump -d` over the shipped exe) before it was +hooked, because M0's lesson is that a wrong `thiscall` prototype crashes the game: + +* `ServerSystem::ProcessTurn` ends in a **plain `ret`** and nothing in the body reads + `[ebp+8]`. The existing decompile shows a second parameter `void* stream`; that is a Ghidra + guess and it is wrong. Hooking it as a one-argument function would have corrupted the stack. +* `MoveFleet` ends in `ret 8`; `[ebp+8]` goes into ESI as the fleet and `[ebp+0xc]` is read + with `fld DWORD` — a 4-byte float. Each of the five call sites pushes its `dt` with + `push ecx; fstp DWORD PTR [esp]`. It returns `AL`, and the caller tests it. +* `ProcessFleetMovement` ends in a plain `ret` with `mov esi,ecx` and no stack reads. + +## The declared input boundary — say it out loud + +Both colony and movement targets are mostly *dispatchers*. Being honest about that is what +makes the compare mean anything. + +**`ServerSystem::ProcessTurn`.** The function body writes: the unowned infrastructure decay, +both pending bonus pools (through `ApplyInfraBonus` / `ApplyPopBonus`), `ntdev`, the +long-stability accrual, `TRes = 0`, `haltv[0..2] = false`, the two per-player countdown +sweeps, and the addiction morale events. Everything else is a callee. So the declared regions +are exactly + +> `infra`, `ibon`, `pbon`, `ntdev`, `tres`, `haltv`, `bats2`, `bats_mask`, `rcex`, +> `rcex_mask`, `rng` + +and **nothing else about the system is declared** — population, morale, resources, plague, +slaves and rebellion state are simply not regions, so the harness never compares them. They +are recorded in the `inputs` region for the trace instead. Two live values our side asks the +game for rather than re-deriving: `ServerSystem::IsStable` (the stability verdict) and +`ServerSystem::MaxPop` (the imperial capacity the accrual reads) — both read-only, the same +delegation B3 makes to `TechTree::Cost`. + +**`MoveFleet`.** Declared: `pos`, `prev_pos`, one region per ship's `range`, the generator, +and the return value. Not declared: the departure hook, the route revalidation, every arrival +handler, the waypoint list, the tanker top-up. **A call that arrives is expected to differ in +all of that**, and none of it is compared. + +**`ProcessFleetMovement`.** Declared: each player's gate-traffic word. That is the one thing +our side can honestly reproduce, because the original computes it at the very *end* of the +pass from the post-move fleet state — which is exactly the state `ours` reads. The pass +schedule itself is recorded in the arguments (and `sim::PlanFleetMovement` predicts the call +order so a trace can be checked against it) but is not compared, because reproducing it would +mean running `MoveFleet`. + +Neither `MoveFleet` nor `ProcessTurn` offers **replace** mode. Both say so in `shim.log` and +fall back to the original: our side models a slice, and feeding that slice to the game would +strand every arriving fleet or skip a colony's whole turn. + +### The RNG region, and why it has teeth here + +A colony turn's RNG consumption was swept function by function to call depth one: +`ProcessPlague`, the civilian growth pass and `ProcessSlaves` make **no draws at all**, and +neither does `ProcessTurn` itself. **`ProcessRebellion` is the only consumer**, and its count +is data-dependent — one `RandChance` per iteration of a 64-bit rebel counter, plus a +short-circuiting per-species roll loop, plus one outcome roll. + +So the generator is a declared region whose expected post-state on a system with no rebellion +is **bit-identical**, and any movement of it names the system whose rebellion fired. `ours` +seeds a `mars::rng::MT19937` from the pre-call snapshot, consumes the draws its own pass makes +(currently none) and writes the state back, exactly as B3 does. `next` is rebuilt against the +live generator address so the describer's index arithmetic reads the same on both sides. + +`MoveFleet` declares the generator too, because a type-5 waypoint draws from it: **one word on +a successful jump, two on a miss** (the second seeds the random scatter direction). + +## Corrections found by reading the binary + +Twenty-two, none of them tuned to make anything match. The ones that change behaviour: + +### Movement + +1. **The range grace margin is added, not subtracted.** The notes said + `range = MinRange(fleet) − 0.05`. The constant at `0x00a1d2c0` is `0x3d4ccccd` — `+0.05f`, + sign bit clear — and `MinRange` *adds* its float argument (`fadd DWORD PTR [ebp+8]`). The + correct expression is `range = float32(MinRange(fleet) + 0.05f)`: a grace fudge so a fleet + exactly at its range limit can still reach the target. Our old code stopped fleets 0.1 short. +2. **The out-of-range case zeroes the *range*, not the step.** The original asks for the + minimum range a second time with no margin and, when that is exactly zero, does + `fstp DWORD PTR [ebp+0x8]` — the range slot. `step` is untouched, and `step` is later the + **divisor** of the pass fraction, so zeroing it instead changes (or NaNs) the recursion. +3. **`move` has no floor at zero.** `move = min(min(range, step), distance)` in that order; a + negative minimum range moves the fleet backwards. The floor at zero exists only on each + ship's range in the fuel loop. +4. **`MinRange` seeds its accumulator with `FLT_MAX`,** so an empty fleet is unconstrained + rather than stranded, and it skips no ship — a range-exempt tanker still clamps the fleet. +5. **The probabilistic jump does not stop part-way along the vector.** On a miss the fleet is + placed at `dest + randomUnitVector x v` where `v = float32(roll x CstE)` — *scattered around + the destination* by exactly `v`, at the cost of a second RNG draw. The jump succeeds iff + `!(v > CstT)`, so equality arrives, and the pass fraction is 1.0 either way, so a type-5 + waypoint never recurses. +6. **Only waypoint type 3 reports a partial pass fraction.** `IsNodeWaypoint` is a 7-entry jump + table that is true for 3 alone — and the node-*line* case of the movement switch is type + **2**, which it does not accept. Everything else reports a full pass unless the move was + blocked, in which case the fraction is `clamp01(move / step)`. +7. **The recursion threshold is a widened float literal and the test is strict.** The constant + at `0x00a261f0` is an 8-byte double whose value is exactly `(double)0.9999f` = + 0.9998999834060669; `fraction == threshold` does **not** recurse, and the recursive `dt` is + `float32((1 − fraction) x dt)`. +8. **The stutter overlap rule is not a midpoint.** When `seg[i].end > seg[i+1].start` the + original sets *both* boundaries to `float32(seg[i].end + 0.5 x (seg[i].end − seg[i+1].start))` + — the mirror of the midpoint about `seg[i].end`, pushing the boundary **forward past both + chords** by half the overlap. Verified down to the ModRM byte, because the FSUB/FADD operand + order is the whole claim. Nothing is dropped and nothing is clipped back, so a chord + swallowed by its predecessor comes out **inverted** (`start > end`) and the step loop skips + it. We reproduce it, bug and all. +9. Smaller ones in the same pass: the chord parameters are clamped to `[0, length]` before the + drop test (without which the intersect routine's `±FLT_MAX` sentinels would poison the + list); the drop threshold is `fabs(start − end) <= 0.01f` (a float32 literal, and inclusive); + the sort is a real `std::sort` keyed on `start` alone, so ties are unordered. +10. **The pass schedule is a pursuit model, not a "departing / in-transit / other" split.** + Every fleet whose current waypoint targets another *fleet* is classified by the relation + between the two owners: relation 0 (no treaty) makes it a pursuer, anything else a + follower. Prey move half a turn, then pursuers move half a turn and a pursuer that arrives + retires itself *and its prey* from the rest of the schedule, then the surviving prey take + their second half, then everything unscheduled takes a full turn (an uncaught pursuer gets + a second half instead), then the followers take a full turn. A fleet that is only a + *follower's* target is not prey and takes a normal full turn. +11. **Gate traffic** is the sum of a **signed int16** at `fleet+0xc0` over fleets whose *front* + waypoint type is 4 or 5, indexed by the owner's own index word, and it is **assigned** to + each player rather than accumulated. Two things worth recording: the original accumulates + by `player->index` but writes back by the player's *position* in the server's vector, which + agree only while `players[j]->index == j`; and the accumulator is a fixed 32 ints with no + bounds check. +12. **The field the notes called `FPogn2` at `fleet+0xec` is `FPdpos`.** By the FlightPlan + layout `FPogn2` is at `+0xe0`; `+0xec` is the destination position, which is what the pass + writes. +13. **`STUTTER_MIN_SPEED == STUTTER_MAX_SPEED == 0.33` in the shipped data** (radius 2), so the + linear ramp collapses to a constant 0.33× inside any influence sphere and 1.0× outside. The + ramp is still implemented because the constants are data-file tunable, but a run against + shipped data cannot distinguish it from a binary in/out multiplier — worth knowing before + reading a clean compare as evidence for the ramp. + +### Colony + +14. **The population growth curve has no `pop / capacity` term.** The notes had + `g = clamp01((1 − clamp01(pop/cap))^EXP)`. The capacity is never passed into the growth + chain at all; the base of the power is a **suitability** term: + `base = 1 − clamp01(min(|ideal − clamp(suit, 0, 20)|, SuitTol) / SuitTol)`, and the + exponent is clamped into `[0.01f, 1000]` before a real `pow()`. Every modifier that follows + is gated on a strict `> 0` and stored back to a float32. The 50,000,000 cap is not here + either — it lives in the apply. +15. **The over-cap shrink runs only when the colony was *already* over the cap**, is computed + from the *old* population, and a colony that merely grows past the cap simply lands on it. +16. **The second `SYSTEMBONUS_MINTURNS` gate is `ntdev`, not `rbtn`.** Read off the two `cmp`s + at the head of `AccrueSystemBonus`. Our `SystemBonusInputs` field was named + `turnsSinceRebellion`; it is `turnsDeveloping`. +17. **Both bonus-apply helpers reset `ntdev` to zero on a colony that is not the owner's home + system.** That is a real feedback loop: a colony still absorbing a bonus never accumulates + the developing turns the accrual gate wants, so it fails the gate on the same turn. + `ApplyInfraBonus` also snaps `Infra` to **exactly 1.0f** when the pool covers the whole + remainder, and an unowned system *drops* its whole population pool. +18. **The output-rate normaliser pins the trade slider.** It takes a "pinned channel" argument + that every call site leaves null, which selects trade. Only trade is clamped into `[0, 1]`; + the other three are summed in float32 (trade excluded) and rescaled to `1 − trade`; the + all-zero fallback is an equal split over **three** channels, not four. Our version rescaled + all four symmetrically. +19. **The engine's "round" is `fistp`/`fild` — round to nearest, ties to EVEN.** Not + round-half-away-from-zero. 302.5 becomes 302. And `out[0]`, `out[3]` and the construction + points go through the *truncating* helper, not the rounding one. +20. **Every carrying capacity is rounded down to a multiple of ten** by the shared helper, and + `Size x 1e8` is an exact 64-bit *integer* product (the 1e8 is the immediate `0x05f5e100`); + only the modifier chain is floating point. The arcology bonus is 0 for slaves. +21. **The terraforming modifier is inside the point *count*, not only the yield** — a better + modifier needs proportionally fewer points, which is what keeps need and yield consistent — + the sign is `-1` only for `suit > ideal` strictly, and the apply clamps at the ideal from + whichever side it approached. `TerraformPointsNeeded` is a `ceil`, not a truncation. +22. Smaller ones: the slave death rate folds the hazard term into the base *before* the output + term (order matters when every step narrows to float32), an unowned system reports 1.0 + rather than 0, the worst plague at the system contributes an **additive** rate term, and + both `SLAVES_MIN/MAX_DEATHS` are disabled by *any* negative value with the result clamped + into `[0, slaves]`. `BuildQueue::ProcessTurn` returns the leftover points by value, a money + refusal **skips** the order rather than stopping the pass, and removal is a separate sweep + that unlinks every order at or below zero. + +### Float discipline + +Every constant was checked bit by bit, because B2 and B3 were both bitten here. + +| what | address | bits | value | kind | +|---|---|---|---|---| +| unowned infra decay | `0x009e9170` | `3f947ae140000000` | 0.019999999552965164 | **widened `0.02f`**, not the decimal | +| range grace | `0x00a1d2c0` | `3d4ccccd` | +0.05000000074505806 | float32 `0.05f`, **positive** | +| recursion threshold | `0x00a261f0` | `3fefff2e40000000` | 0.9998999834060669 | double whose value is `(double)0.9999f` | +| stutter chord drop | `0x009e3e14` | `3c23d70a` | 0.009999999776482582 | float32 `0.01f` | +| stutter overlap half | `0x009e20a0` | `3fe0000000000000` | 0.5 | **true double** | +| hazard band `+0.1` | `0x00a1a438` | `3fb999999999999a` | 0.1 | **true double**, not `(double)0.1f` | +| output-rate threshold | `0x009e22c8` | `3f1a36e2e0000000` | 9.999999747378752e-05 | widened `1e-4f` | +| infra divisor | `0x00a1f930` | `3f014d2f5dbb9cfa` | 3.3e-05 | **true double** | +| infra chain | — | — | `/500`, `x0.01`, `x1.65` | 0.01 and 1.65 are true doubles; the three steps are not folded | +| terraform yield | `0x009e62b8` | `3ff3333340000000` | 1.2000000476837158 | **widened `1.2f`** | +| terraform need | `0x00a1f928` | `3ffccccce0000000` | 1.8000000715255737 | `1.5 x (double)1.2f` | +| growth exponent floor | `0x009e3e14` | `3c23d70a` | 0.01f | shared with the chord drop | +| slave mod base / step | `0x009e3030` / `0x009e20d8` | `3f4ccccd` / `3fc99999a0000000` | 0.800000011920929 / 0.20000000298023224 | float32 `0.8f` / widened `0.2f` | + +`numeric.h` gained `F32()` (narrow-and-widen) and `RoundHalfEven()`; `rng.h`'s `IRandom` gained +`NextUInt32()` for the raw word the jump scatter takes. + +## What our side runs + +* `sots::sim::ProcessColonyTurn` (new, `game/sim/colony.{h,cpp}`) — the dispatcher's own writes, + in the original's order, plus `ColonyCountdowns` / `AddictionPhaseOf` and the corrected + `ApplyPopulationBonus` / `ApplyInfrastructureBonus` / `AccrueSystemBonus`. +* `sots::sim::StepFleet` (the shim adapter) over `ResolveMoveStep`, `AdvanceAlongDirection`, + `ConsumeShipRange`, `PassFraction` and `RollProbabilisticJump`. +* `sots::sim::GateTrafficTotals` and `sots::sim::PlanFleetMovement`. +* `sots::sim::NodeLineStep` and `BuildStutterSegments` are corrected and unit-tested but are + **not** wired into the hook: the node-line step needs the node graph walked from the live + server, which this milestone does not do. A node-line waypoint therefore records its type in + the trace and is left to the original — a declared gap, not a silent one. + +## Host tests + +`ctest` **30/30** (was 28). New coverage: + +* `game_sim_colony` — 200 checks. Rewritten around the corrected growth curve (the suitability + base, the `[0, 20]` clamp, the exponent clamp), the ties-to-even rounding, the pinned-trade + normaliser, the terraform modifier inside the point count, the infra/suit apply clamps, the + additive plague rate and the both-ends slave clamp. +* `game_sim_movement` — 144 checks. The `+0.05` grace, the range-zeroing stranded case, the + absence of a floor on `move`, the empty-fleet `FLT_MAX`, the scatter semantics of a failed + jump and its two draws, the type-3-only pass fraction, the strict recursion threshold, the + forward-pushed overlap boundary (including the inverted segment it produces), the pass + schedule with and without a catch, and the gate-traffic sum. +* `shim_colony_unit` — 62 checks. The snapshot round trip, the input mapping, a reference-save + shaped colony turn, the bonus/accrual interaction with the non-home `ntdev` reset, the + addiction sweep across all four phases plus temperance, the unowned colony, and the + countdown edges (index 15, the clamp, the skip-when-zero). +* `shim_movement_unit` — 56 checks. Straight steps, the exempt-tanker clamp, the arrival snap, + the node-waypoint fraction, the gate teleport, both jump outcomes, a held fleet, the gate + traffic and the schedule. + +## Gotchas + +1. **`ServerSystem::ProcessTurn` takes no arguments.** The decompile in the RE handoff shows a + second parameter. Trusting it would have pushed a garbage word and corrupted the stack — + exactly M0's failure mode. +2. **`MoveFleet` recurses into itself** for a multi-waypoint leg. `Hook<>` handles the nesting + (depth, call ids) and the inner call gets its own record, but the outer record's "after" + snapshot includes everything the recursion did — read a record's `depth` before comparing + two of them. +3. **Per-call state lives in statics** between `regions()` → `rebind()` → `ours()` (M1's + concession). Safe here because the turn pass is single-threaded, but `MoveFleet` *does* nest + — the compare path is still correct because the template runs `regions`/`rebind`/`ours` for + one call before the original's recursion can start, but do not add state that has to survive + the original's execution. +4. **`Region::name` is a `const char*` held for the whole call**, so the per-ship and per-player + name strings are `reserve`d once up front; a reallocation would dangle every name already + pushed. +5. **`ProcessFleetMovement`'s `ours` re-reads the fleet list.** The gate-traffic total is + computed by the original *after* the passes, so comparing against a pre-call snapshot would + diverge for the wrong reason. +6. A compare record for `ProcessTurn` is small (twelve regions), but the generator's 2496-byte + block is hashed rather than inlined — keep `trace.inline_max` at 256. +7. `MoveFleet` in `compare` is the busiest of the three; on the reference save that is a handful + of records, but on a large map it is every fleet times up to five passes times the recursion + depth. The `b4scout` config exists for exactly that reason: it leaves `MoveFleet` off. + +## What remains (needs the VM) + +The lane holding VM140 must be finished first; then, in this order: + +1. Deploy `/srv/re-lab/shim/dist-b4` (build `b4-final-20260908T0500Z`): `scp` it to + `C:\SOTS\shimdist-b4\` and run `deploy.ps1 -Dist C:\SOTS\shimdist-b4` — **a separate staging + directory from the shared `C:\SOTS\shimdist`**, so no other lane's dist is overwritten. +2. **Scout pass.** Copy `shim.cfg.b4scout` over `C:\SOTS\shim.cfg`, relaunch, load + `ref-turn2.sav`, press End Turn once, pull `C:\SOTS\shim.trace.jsonl` → + `b4-scout.jsonl`. `tracecmp.py` must exit 0 with 0 invalid records. `MoveFleet` is off in + this config, so the file stays small. **Read off it before going further:** + * `fpu_cw` on every record — expect `0x027f`; `0x007f` / `0x003f` means 24-bit x87 + precision and the float mapping needs the PC24 route (B3's open question). + * per `ServerSystem::ProcessTurn` record: `args.rng_left_in` minus `side.rng.after.left`. + **Expect 0 on every system.** A non-zero delta names a system whose `ProcessRebellion` + fired, and that system's compare record is then expected to diverge on `rng` and only on + `rng`. + * how many systems report `owned`, `stable`, a non-zero `ibon`/`pbon`, a non-empty + `civilians` list and a non-zero `bats2`/`rcex`. That is the coverage table for step 4. + * per `ProcessFleetMovement` record: the `fleet_state` list — how many fleets exist, how many + have waypoints, and whether any waypoint type is 4 or 5. If none is, gate traffic is + exercised in its zero branch only and must be reported that way. +3. **Trace pass.** Copy `shim.cfg.b4trace` (adds `MoveFleet`), relaunch, load `ref-turn2.sav`, + End Turn → `b4-trace-golden.jsonl`. Check the `MoveFleet` record sequence against + `sim::PlanFleetMovement`'s prediction: the fleet ids and `dt` values should appear in the + pass order documented above. A mismatch is a finding about the schedule, not about the step. +4. **Compare pass.** Copy `shim.cfg.b4compare`, relaunch, load `ref-turn2.sav`, End Turn → + `b4-compare.jsonl`. Expected: + * `ServerSystem::ProcessTurn` — **0 divergences on every system**, on all twelve regions. + The reference save is a turn-2 two-empire game, so `infra`/`ibon`/`pbon` are probably + exercised in their no-op branches and `bats2`/`rcex` in their all-zero branch; say which + regions actually carried a value rather than implying the rest passed. + * `MoveFleet` — 0 divergences on `pos`, `prev_pos` and every `ship[i].range` for a call that + did **not** arrive. A call that arrived is expected to match on those too (the snap is a + verbatim copy) but the RNG and the undeclared arrival state are the original's; a + divergence on `pos` after an arrival is a real finding. + * `ProcessFleetMovement` — 0 divergences on every `gate_traffic[i]`. + Any other diff is a real finding: report it, do not tune the formula. +5. Restore the previous `shim.cfg` (`hooks=trace`) and leave the game at the main menu, as + M1/M2/B1/B3 left it. + +**Not done, and worth saying:** + +* **Replace mode is not offered** for any of the three hooks, so there is no End-Turn oracle + result for this milestone. That is a deliberate consequence of the input boundary, not an + omission — the strongest available evidence here is the compare plus the RNG post-state. +* **Sub-paths the reference save will not exercise**, and which must therefore be reported as + untested rather than passed: plague, rebellion (and with it every RNG draw in a colony turn), + slaves, terraforming (the reference colony sits at its ideal), the addiction sweep at any + phase, the unowned-system infrastructure decay, a non-home colony's `ntdev` reset, the + probabilistic jump, the gate teleport, node-line travel, and a stranded fleet. A save with a + plague, a rebelling colony, a Hiver gate network and a Zuul node bore would exercise most of + them and is the natural next workload. +* The **node-line step** is modelled and unit-tested but not hooked (see "What our side runs"), + and under shipped data the stutter ramp is a constant anyway (correction 13). +* `ComputeOutputFromRates` was read instruction by instruction and every correction is folded + into `game/sim/colony`, but it is **not hooked**: it repairs damaged ships in orbit as a side + effect (`0x00751590` with its estimate flag clear), so a compare hook cannot run it on a + scratch copy of the system without isolating the ships too. That is its own milestone. diff --git a/docs/game-sim.md b/docs/game-sim.md index ba26e3b..d50e5d8 100644 --- a/docs/game-sim.md +++ b/docs/game-sim.md @@ -7,8 +7,10 @@ come in through a `TuningTable` (`tuning.h`) whose fields are the data-file keys nothing in the module hard-codes a shipped value. Numeric conventions (`numeric.h`): `Ftol`/`Ftoi64` truncate toward zero like the original -float-to-int helper; `RoundToInt` rounds half away from zero; the treasury is a saturating -32-bit int clamped to +/-2,000,000,000. +float-to-int helper; **`RoundHalfEven` is the engine's "round"** -- `fistp`/`fild`, i.e. round +to nearest with ties to *even*, not away from zero (B4 correction); `F32` narrows to float32 +and back, which the colony and movement formulas do at every named store; the treasury is a +saturating 32-bit int clamped to +/-2,000,000,000. Build/test: `tests/game_sim/build_and_run.sh` (plain g++, `-Wall -Wextra -Werror`), or `-DSOTS_GAME_SIM_TESTS=ON` once `src/game/sim` is added to the root CMake. The real-data @@ -66,59 +68,73 @@ Confidence legend — **high**: formula verified in the RE notes against the cod | function | formula | confidence | |---|---|---| | `SpeciesTechFlags` | per-species xenotech bits in family order: translation 1/2/3 (bits 0–2), incorporate 3, addict 4, temperance 5, subjugate 6, accommodate 7, proliferate 8; `FromBits` unpacks a flag word | high on the order | -| `HazardModifier` | `clamp01(1 − |suit − ideal| / (SuitTol + 0.1))` — linear, no exponent; SuitTol is the species start value plus the adaptation techs (+0.75 atmospheric, +1.5 gravitational); the caller passes 1 when it holds the accommodate xenotech for the species or is the rebel AI | high — read with its 0.1 constant | -| `CarryingCapacity` | `ftoi64(Size x 1e8 x groupMult x speciesFactor x crossSpecies x hazard) + arcology (1e8 imperial / 2e8 civilian)`; clamp to group max; `x INDSYS_IMPERIAL_POPULATION_MOD` for NPC owners; NPC species or uninhabitable → 0 | high | -| `PopulationGrowthDelta` | `g = clamp01((1 − clamp01(pop/cap))^EXP)`; if g > 0: `x MOD x PopMod x hazard/species x groupMult (if > 0)`; `delta = ftoi64(pop x g)`, min 1 when g > 0, max 50,000,000; blockade → 0 | high | -| `ApplyImperialGrowth` | over cap: shrink by `min(5e7, pop − cap)` but not below `min(pop, 100)`; else `min(cap, pop + delta)` | medium — shrink floor read from a terse note | -| `InfrastructurePointsNeeded` / `InfrastructureGain` | `ceil((1 − infra)/3.3e-5)`; `points x (1/500) x 0.01 x 1.65 = points x 3.3e-5` (≈30,300 points for 0→1) | high | -| `DecayUnownedInfrastructure` | `max(0, infra − 0.02)` | high | -| `TerraformPointsNeeded` / `TerraformDelta` | `|ideal − suit| / (1.5 x 1.2 / 20000)`; `points x 1.5 x 1.2 x TerraMod x sign / 20000` toward the ideal | high | -| `SlaveDeathRate` | `(SRs x BYOUTPUT + |ideal − suit| x BYHAZARD + DEATH_RATE) x ((translation1 ? 0.8 : 1) − 0.2 translation2 − 0.2 translation3)` | high | -| `SlaveDeaths` | `clamp(ftoi64(slaves x rate), MIN_DEATHS, MAX_DEATHS)`, `MAX −1` = uncapped, never more than present | high | -| `NormaliseOutputRates` | negatives → 0; terraform → 0 at ideal; infra → 0 when full; rescale to Σ 1, equal split when all zero | high (the tiny positive threshold is treated as 0) | +| `HazardModifier` | `clamp01(1 − |suit − ideal| / (SuitTol + 0.1))` — linear, no exponent, all in double; the 0.1 is a **true double**, not a widened float, the clamp is low end first then high end, and a zero band at the ideal yields NaN rather than 1 | high — read with its constant's exact bits | +| `CarryingCapacity` | `quantise(ftoi64((Size x 1e8) x (hazard x (groupMult x speciesFactor x crossSpecies))))` + arcology (1e8 imperial / 2e8 civilian, **0 for slaves**); clamp to group max; `x INDSYS_IMPERIAL_POPULATION_MOD` and re-quantise for NPC owners. `Size x 1e8` is an exact **64-bit integer** product; `quantise` rounds down to a multiple of 10 above 10, so every capacity is a multiple of ten (B4) | high | +| `PopulationGrowthFraction` / `PopulationGrowthDelta` | **B4: there is no `pop/cap` term** — the capacity never enters the growth chain. `base = 1 − clamp01(min(|ideal − clamp(suit, 0, 20)|, SuitTol) / SuitTol)`; `g = clamp01(pow(base, clamp(EXP, 0.01f, 1000)))`; then `x MOD x PopMod x extraFactor x groupMult`, each gated on a strict `> 0` and each stored back to float32. `delta = trunc(pop x g)`, forced to 1 only when that truncates to 0 with `g > 0`; blockade or empty group → 0. The 50,000,000 cap lives in the apply | high — read instruction by instruction | +| `ApplyImperialGrowth` | delta capped at 50,000,000 when non-negative; `new <= cap` → new; `new > cap` but `pop <= cap` → exactly `cap` (**no shrink**); both over cap → `max(pop − min(|cap − pop|, 5e7), min(pop, 100))`; floored at 0. The shrink is computed from the **old** population | high — read branch by branch | +| `InfrastructurePointsNeeded` / `InfrastructureGain` / `ApplyInfrastructureDelta` | `ceil((1 − infra)/3.3e-5)` with 3.3e-5 a **true double**; the gain is `points/500 x 0.01 x 1.65` evaluated in that order on the x87, clamped at 0 and narrowed to float32 once — deliberately **not** folded into one `x3.3e-5`; the apply is a no-op at 1 and clamps there otherwise | high | +| `DecayUnownedInfrastructure` | `float32(infra − (double)0.02f)`, then `result <= 0 → 0`. The image holds the **widened float literal** 0.019999999552965164, not the decimal 0.02 | high | +| `TerraformPointsNeeded` / `TerraformDelta` / `ApplyTerraformDelta` | `ceil(|float32(ideal − suit)| / |TerraMod x 1.8000000715255737 / 20000|)` — **the terraforming modifier is inside the point count**, so a better modifier needs fewer points; `float32(points x 1.5 x (double)1.2f x TerraMod x sign / 20000)` with `sign = −1` only for `suit > ideal` strictly; the apply clamps at the ideal from whichever side it approached | high | +| `SlaveDeathRate` | `((|ideal − suit| x BYHAZARD + DEATH_RATE) + SRs x BYOUTPUT) x mod` — the hazard term joins the base **first**, and every step is stored back to a float32; `mod = (translation1 ? 0.8f : 1) − 0.2 t2 − 0.2 t3` with no clamp; an unowned system reports 1.0, not 0 | high | +| `SlaveDeaths` | `ftoi64(slaves x (rate + plagueRate))` — the worst plague at the system adds an **additive** rate term — then MIN/MAX_DEATHS, each disabled by **any** negative value, then clamped into `[0, slaves]` | high | +| `NormaliseOutputRates` | **B4: the trade slider is pinned.** Terraform → 0 at the ideal (exact `==`); infra → 0 when `float32(ibon + infra) >= 1`; any channel `<= (double)1e-4f` → 0; **only trade** is clamped to `[0, 1]`; the other three are summed in float32 and rescaled to `(r/Σ) x (1 − trade)`; an exactly-zero sum seeds them with 1e-4f — an equal split over **three** channels, not four | high — read step by step | | `MoraleOutputMultiplier` | `>= INCREASE_OUTPUT → x INCREASE_MOD`; `<= DECREASE_OUTPUT → x DECREASE_MOD` | high | -| `TotalSystemOutput` | `round(base x morale x (1 + STATION_BONUS_IMPERIAL_OUTPUT x stations) x addiction x ScOutMod x RebOutMod x techOut x sys.OutMod x OutMod)` | high on the chain; the base-from-population term is an input (unresolved) | -| `SplitOutput` | `round(total x rate)` per channel | high | -| `ConstructionPoints` | `round(cons x (1 + STATION_BONUS_SHIPCON x stations))` | high | -| `SplitLeftover` | unspent construction over trade/terraform/infra by their rates, or `1 / (suit != ideal) / (infra != 1)` when construction was the only slider | medium | +| `TotalSystemOutput` | `roundHalfEven(base x morale x (1 + STATION_BONUS_IMPERIAL_OUTPUT x stations) x addiction x ScOutMod x RebOutMod x techOut x sys.OutMod x OutMod)`, kept as a double for the channel splits | high on the chain and the rounding mode; the base-from-population term is an input (unresolved) | +| `SplitOutput` | `roundHalfEven(total x rate)` per channel, each kept as a double | high | +| `ConstructionPoints` | `trunc(cons x (1 + STATION_BONUS_SHIPCON x stations))` — **truncating**, not rounding | high | +| `SplitLeftover` | unspent construction over trade/terraform/infra by their rates, or `1 / (suit != ideal) / (infra != 1)` when the construction rate is **exactly 1**; each share is rounded half-to-even independently, so the three need not add back up to the leftover | medium on the weights, high on the rounding | | `SuitabilityCostMod` | `min(|ideal − suit|, SuitTol)`; 0 for the rebel AI; 20 when unowned — the tolerance techs cap the money cost as well as widening the habitable band | high | | `SystemMoneyIncome` | `t = (trade − fmod(trade, 5)) x 5` (whole five-point blocks, five money each); `t += imperial + civilian + slave population income`; `t *= speciesIncomeFactor`; `t *= IncMod`; `t *= serverIncomeMod x difficultyIncomeMult`; `cost = speciesCostFactor x suitCostMod x 10000 x 1.5`; `money = ftol(t − cost)` | high on the chain and constants; the three population-income terms are inputs (their group-income tables, and the addiction factor inside them, are not modelled) | -| `ApplyPopulationBonus` / `ApplyInfrastructureBonus` | `pop += min(bonus, cap − pop)`; `infra += min(bonus, 1 − infra)`; bonus reduced by the same | high | -| `AccrueSystemBonus` | gated on stable, owned > MINTURNS, no rebellion > MINTURNS; `target = eligible ? ftol(max(POPBONUS, 0) x cap) : 0`; `pbon += min(max(ftol(POPBONUS_INC x cap), 0), max(target − pbon, 0))`; `ibon += min(max(INFRABONUS_INC, 0), max((eligible ? INFRABONUS : 0) − ibon, 0))`; Zuul are never eligible. The `*_HOME` keys are only read when a home system's bonus is initialised (not modelled) | high — increment, target and gating read with constants | -| `ProcessBuildQueue` | FIFO: `points < conleft → conleft −= points, stop`; else complete, `points −= conleft`, charge money cost, continue | high | +| `ApplyPopulationBonus` / `ApplyInfrastructureBonus` | `pop += min(cap − pop, bonus)` with an **unowned system dropping the whole pool** and an at-or-over-cap colony short-circuiting; `infra += min(bonus, float32(1 − infra))`, snapping to **exactly 1.0** when the pool covers the remainder; bonus reduced by the same. Either firing on a colony that is not the owner's home system **resets `ntdev` to 0**, which then costs it the system-bonus gate that same turn | high | +| `AccrueSystemBonus` | gated on stable, owned > MINTURNS, and **`ntdev` > MINTURNS (not the rebellion turn — B4 correction)**; `target = eligible ? ftol(max(POPBONUS, 0) x cap) : 0`; `pbon += min(max(ftol(POPBONUS_INC x cap), 0), max(target − pbon, 0))`; `ibon += min(max(INFRABONUS_INC, 0), max((eligible ? INFRABONUS : 0) − ibon, 0))`; Zuul are never eligible. The `*_HOME` keys are only read when a home system's bonus is initialised (not modelled) | high — increment, target and gating read with constants | +| `ProcessBuildQueue` | FIFO: `conleft > points → conleft −= points, points = 0, stop`; else charge the money cost — **a refusal skips that order and continues** rather than stopping — then `points −= conleft`, `conleft = 0`, continue. The leftover is the return value, and removal is a separate sweep that unlinks every order at or below zero | high | + +| `CountdownFor` / `SetCountdown` / `TickCountdowns` | the system's `Bats2` and `rcex` words carry a 4-bit countdown per player at bits `[4i, 4i+4)`, with a companion 32-bit "someone is counting" mask. Each turn every counter ticks down by one and a player whose counter is **already** zero loses its mask bit; index >= 15 has no nibble and is skipped entirely, and the whole sweep is skipped when the counter word is zero | high | +| `AddictionPhaseOf` | `adt[species] == 0` → none; else `elapsed = turn − adt`, `elapsed > PHASE3_START` → terminal, `elapsed > PHASE2_START` → established, else onset. Both comparisons strict | high | +| `ProcessColonyTurn` | the whole per-system pass, restricted to what `ServerSystem::ProcessTurn` writes itself: unowned infra decay → both bonus pools (either resets `ntdev` on a non-home colony) → `ntdev` ++/reset → `AccrueSystemBonus` → `TRes = 0` → `haltv` cleared → both countdown sweeps → the addiction sweep, which raises morale event `0x1b` (−1) under temperance, `0x1c` (+1) at onset, `0x1d` (−2) at terminal, and **nothing at all** in the established phase. Consumes no RNG; see `docs/B4.md` for the input boundary | high on every step; the callees are not modelled | ## Movement (`movement.h`) | function | formula | confidence | |---|---|---| -| pass schedule | departing/in-transit sets: two `dt = 0.5` passes; everything else one `dt = 1.0` pass | medium — constants established, bucketing semantics not fully | -| `StraightStep` | `speed x dt` | high | +| `PlanFleetMovement` | the turn's schedule is a **pursuit model**, not a departing/in-transit split: a fleet whose current waypoint targets another fleet is a *pursuer* when the two owners have no relation and a *follower* otherwise. Prey move `dt 0.5`; pursuers move `dt 0.5` and a pursuer that arrives retires itself and its prey; surviving prey take a second `0.5`; everything unscheduled takes `1.0` (an uncaught pursuer takes another `0.5`); followers take `1.0` | high — read pass by pass | +| `IsGateTransitWaypoint` / `IsNodeWaypoint` | type 4 or 5, and type **3 only**, respectively. The node-*line* case of the movement switch is type 2, which `IsNodeWaypoint` does not accept | high | +| `GateTrafficTotals` | per player, the sum of a **signed int16** per fleet whose front waypoint is a gate transit; assigned, not accumulated, so it resets every turn | high | +| `StraightStep` | `float32(speed x dt)` | high | | `NodeLineSpeed` | `speed x ((STUTTER_MAX − STUTTER_MIN) x (dist / INFLUENCE_RADIUS) + STUTTER_MIN)` — no clamp; only evaluated for chords inside a sphere, so `dist <= radius` by construction | high | | `DistPointToSegment` | closest-point distance with the projection parameter clamped to [0, 1] | high | -| `BuildStutterSegments` | the travel line is intersected with every system's influence sphere; each chord (clipped to the line) becomes a segment, chords shorter than 0.01 are dropped, segments are sorted by start and overlaps are split at the midpoint of the overlap; the speed factor of a segment is `NodeLineSpeed(1, closest approach of the whole chord)` — per segment, not per position | high on chord/clip/drop/sort and per-chord speed; medium on the overlap rule (midpoint split; a chord swallowed by an earlier one is dropped) | -| `AdvanceAlongNodeLine` | piecewise integration: segment speed inside a sphere, plain `nodeSpeed` in the gaps, never past the line end | medium — written from the per-segment description | -| `ResolveMoveStep` | `range = minShipRange − 0.05`; no range at all and `range < distance` → step 0 (stranded); `move = min(step, range, distance)` ≥ 0; arrival when `move == distance` | high | -| `ConsumeShipRange` | `max(0, range − moved)` unless exempt | high | -| `RemainingPassTime` | `fraction < 0.9999 ? (1 − fraction) x dt : 0` (multi-waypoint recursion) | high | -| `RollProbabilisticJump` | `roll = rand01() x castEfficiency`; `roll > castThreshold` → stop at fraction `roll`; else arrive | medium — identity of the two player fields inferred | +| `SegmentSphereIntersect` / `BuildStutterSegments` | chord parameters scaled to world distance and **clamped to `[0, length]`**; a chord with `|start − end| <= 0.01f` is dropped; `std::sort` ascending by `start` alone (ties unordered); then **one forward pass over adjacent pairs** sets *both* boundaries of an overlap to `float32(end_i + 0.5 x (end_i − start_{i+1}))` — the mirror of the midpoint, pushed forward past both chords. Nothing is dropped or clipped back, so a swallowed chord comes out **inverted** and the step loop skips it. Reproduced verbatim, bug and all | high — read down to the ModRM byte | +| `NodeLineStep` | walk the segments in ascending order at plain `nodeSpeed` between influence spheres and at the segment's own constant speed inside one, until the line ends or `dt` runs out; arrival is `time < dt` or `|along − length|` under one float epsilon, and on arrival the destination is copied verbatim. **Under shipped data `STUTTER_MIN_SPEED == STUTTER_MAX_SPEED == 0.33`, so the ramp collapses to a constant 0.33x inside any sphere** | high — the loop was reconstructed instruction by instruction | +| `FleetMinShipRange` / `ResolveMoveStep` | `range = float32(minShipRange + 0.05f)` — the grace margin is **added** (B4 correction; the constant's sign bit is clear and the helper adds it). If `distance > range` and the un-biased minimum is exactly 0, the **range** is zeroed — not the step, which stays the divisor of the pass fraction. `move = min(min(range, step), distance)` in that order, with **no floor at zero**; arrival when `move == distance`, after which the position is copied from the destination verbatim. An empty fleet's minimum range is `FLT_MAX`, and no ship is excluded — a range-exempt tanker still clamps the fleet | high | +| `AdvanceAlongDirection` / `ConsumeShipRange` | `pos + unit(dest − pos) x move`, each component narrowed to float32 after the multiply and before the add; `max(0, float32(range − moved))` unless the ship carries the range-exempt flag, in which case it pays nothing | high | +| `PassFraction` / `RemainingPassTime` | a full pass unless the waypoint is type 3, which reports `clamp01(distance/step)`; a blocked move reports `clamp01(move/step)`. Recursion iff `fraction < 0.9998999834060669` **strictly** — an 8-byte constant whose value is exactly `(double)0.9999f` — with `dt' = float32((1 − fraction) x dt)` | high | +| `RollProbabilisticJump` | `v = float32(rand01() x CstE)`; **arrives iff `!(v > CstT)`** (equality arrives). On a miss the fleet is placed at `dest + randomUnitVector x v` — *scattered around* the destination by `v`, not advanced a fraction along the vector (B4 correction) — and the random direction costs a **second** draw, so a jump consumes 1 word on success and 2 on a miss. The pass fraction is 1.0 either way, so a type-5 waypoint never recurses. No fuel is charged and no distance clamp applies | high on the arithmetic and the draw count; medium on the field identities | ## Low-confidence list (flagged in headers) None. The five formulas that were low (bankruptcy limits, bankruptcy stamping, hazard curve, money tail, system-bonus increment) are pinned; each header carries a one-line -rationale. Items still at **medium**: `TechCostMultiplier` (which three species techs -count), `RollLabAccident` (odds-from-boost function), `ApplyImperialGrowth` shrink floor, -`SplitLeftover`, `TradeRouteGrossIncome` multiplier truncation order, the movement pass -bucketing, the probabilistic-jump field identities, the stutter overlap rule and -`AdvanceAlongNodeLine`, and the meaning of the budget's secondary-manager slot. +rationale. B4 promoted five more to **high** by reading them instruction by instruction — +`ApplyImperialGrowth`'s shrink, the movement pass schedule, the stutter overlap rule, the +node-line step and the probabilistic jump's arithmetic. Items still at **medium**: +`TechCostMultiplier` (which three species techs count), `RollLabAccident` (odds-from-boost +function), `SplitLeftover`'s weights, `TradeRouteGrossIncome` multiplier truncation order, +the identity of the two probabilistic-jump player fields, and the meaning of the budget's +secondary-manager slot. Tech effects on the player's modifiers (`OutMod`, `PopMod`, `TerraMod`, `SuitTol`, ...) live in `game/effects` — see `game-effects.md`. Not modelled here (out of scope for pure formulas, or unresolved): base output from population, the per-group population income tables (inputs to `SystemMoneyIncome`), -civilian seeding rules, morale event deltas, rebellion rolls, plague, the -research-boost → accident-odds function, the fleet speed (`FPsp2`) derivation from -engine `ftlspeed`/`nodespeed`, gate traffic capacity, the home-system bonus +civilian seeding rules, the morale-event *application* (`ProcessColonyTurn` reports the +addiction events it would raise, but not what the owner does with them), rebellion rolls, +plague, the research-boost → accident-odds function, the fleet speed (`FPsp2`) derivation +from engine `ftlspeed`/`nodespeed`, gate traffic capacity, the home-system bonus initialisation, and the game-setup handicap multipliers (output / income / research) that feed `techIncomeMult` and friends. + +`ComputeOutputFromRates` was read in full for B4 and every correction is folded into the +table above, but the function itself is **not** side-effect free — it repairs damaged ships +in orbit — so it has no compare hook; see `docs/B4.md`. The unspent terraforming points +cascade into the money channel, an edge the split functions here do not yet carry. diff --git a/include/generated/sots_addresses.h b/include/generated/sots_addresses.h index 7ef76c0..001b753 100644 --- a/include/generated/sots_addresses.h +++ b/include/generated/sots_addresses.h @@ -1,5 +1,5 @@ // GENERATED — do not edit. Facts about Sword of the Stars.exe (GOG 1.8.1). -// Source: sots-re ghidra/addresses.json @ 8a6c0e8, generated 2026-09-08 by tools/gen_addresses.py +// Source: sots-re ghidra/addresses.json @ 535d1c3, generated 2026-09-08 by tools/gen_addresses.py // Runtime address = (uintptr_t)GetModuleHandle(NULL) + RVA (the exe is ASLR-relocated). #pragma once #include @@ -295,10 +295,6 @@ constexpr uint32_t ServerSystem_CalcSuitMod = 0x003484d0; constexpr uint32_t ServerSystem_AccrueSystemBonus = 0x0034d4f0; // thiscall int (ServerSystem* this, float frac) [verified] constexpr uint32_t ServerSystem_SystemBonusPopTarget = 0x0034b5a0; -// cdecl void (Vector3* posOut, bool* arrivedOut, float nodespeed, float dt, void* lines, Vector3* from, Vector3* to) [unverified] -constexpr uint32_t NodeLine_Step = 0x00305510; -// cdecl void (vector* segsOut, vector* systems, Vector3* from, Vector3* to) [unverified] -constexpr uint32_t NodeLine_BuildStutterSegments = 0x00305280; // data struct { const char* name; int x; }[196] /* TechId = 10000+i */ [verified] constexpr uint32_t g_TechIdNames = 0x005ff9e4; // data const char*[116] [verified] @@ -429,5 +425,235 @@ constexpr uint32_t ServerPlayer_SpeciesOfTranslationTech = 0x0040e410; constexpr uint32_t AITechRow = 0x00290f70; // data int -- number of rows in g_AITechValueTable (6) [verified] constexpr uint32_t g_AITechValueCount = 0x006ea2ec; +// offset void* -- the StrategyServer subobject pointer; StrategyServer = *(void**)(player+8) - 4. Its +0x16c is the Mars::RNG* the strategic sim draws from, and its +8 the event manager [verified] +constexpr uint32_t ServerPlayer_off_ServerLink = 0x00000008; +// offset Mars::RNG* -- the strategic generator (same object TechTree::ProcessResearch is handed) [verified] +constexpr uint32_t StrategyServer_off_RNG = 0x0000016c; +// fastcall void (ServerPlayer* this) -- odds = ResearchEventOdds(this, this->ResT); draws EXACTLY ONE NextFloat from StrategyServer's generator, unconditionally, then fires 0x00889d60 when odds > roll. Called from OnTechResearched when the completing def is the current research target and the pending-roll byte at +0x3b4 is set. This is the extra RNG draw B3 observed on a completion [verified] +constexpr uint32_t ServerPlayer_RollResearchEvent = 0x0048df20; +// thiscall float (ServerPlayer* this, TechDef* def) -- 0 when def is null; a plague-family path via 0x00535480, else the AI-rebellion path via AITechRow (odds column) gated on !NPC. Read-only, makes no draw [verified] +constexpr uint32_t ServerPlayer_ResearchEventOdds = 0x00420380; +// thiscall void (ServerSystem* this) /* NO stack arguments -- plain RET, nothing reads [ebp+8]; Ghidra's decompile shows a spurious second parameter. Body order: unowned Infra decay -> ApplyInfraBonus -> ApplyPopBonus -> independent pop drift -> IsStable/ntdev -> AccrueSystemBonus -> ProcessPlague -> ProcessBuildQueue -> imperial growth -> civilian growth -> AdjustResources(-out[2]) -> TRes=0 -> RefuelInOrbit(1) -> Bats2 tick -> rcex tick -> haltv[0..2]=false -> ProcessSlaves -> ProcessRebellion -> addiction sweep. Consumes no RNG itself; ProcessPlague / civilian growth / ProcessSlaves are draw-free to depth 1, and ProcessRebellion is the only consumer */ [verified] +constexpr uint32_t ServerSystem_ProcessTurn = 0x003598e0; +// thiscall bool (StrategyServer* this, StarFleet* fleet, float dt) /* RET 8; dt is a 4-byte float pushed with fstp DWORD [esp]; returns AL, true only when the fleet ends exactly on a waypoint destination at the deepest recursion level. range = MinRange(fleet, +0.05f) -- ADDED, not subtracted; out of range with MinRange(0)==0 zeroes the RANGE, not the step; move = min(min(range, step), distance) with no floor at 0; recursion iff fraction < (double)0.9999f with dt' = float32((1-fraction)*dt) */ [verified] +constexpr uint32_t StrategyServer_MoveFleet = 0x003d9ee0; +// thiscall void (StrategyServer* this) /* no stack args, plain RET. Pursuit schedule, not a departing/in-transit split: classify every fleet whose current waypoint targets a fleet by owner relation (0 = pursuer, else follower); pass1 prey dt 0.5, pass2 pursuers dt 0.5 (an arrival retires the pair), pass3 remaining prey dt 0.5, pass4 everything unscheduled dt 1.0 (an uncaught pursuer gets 0.5), pass5 followers dt 1.0. Then FPdpos, gate traffic, OnFleetArrived, and clears flag 0x100 on every fleet. Makes no RNG draw of its own */ [verified] +constexpr uint32_t StrategyServer_ProcessFleetMovement = 0x003da9a0; +// thiscall float (StarFleet* this, float bias) /* RET 4; min over ships of ship->Range seeded with FLT_MAX (an empty fleet is unconstrained), then + bias, narrowed to float32. No ship is skipped -- range-exempt tankers still clamp the fleet */ [verified] +constexpr uint32_t StarFleet_MinRange = 0x002ff6a0; +// thiscall bool (StarFleet* this, uint32 mask) /* (flags & mask) == mask, flags at fleet+0x10c */ [verified] +constexpr uint32_t StarFleet_HasAllFlags = 0x002fe1d0; +// thiscall void (StarFleet* this, uint32 mask, bool on) /* the only two writers of fleet+0x10c in the image */ [verified] +constexpr uint32_t StarFleet_SetFlag = 0x002fe1a0; +// thiscall void* (StarFleet* this) /* no args; NULL when the waypoint vector is empty or the front waypoint's target id does not resolve in the entity hash at fleet->galaxy(+0x10)+0x80 */ [verified] +constexpr uint32_t StarFleet_ResolveWaypoint = 0x00301390; +// cdecl bool (int waypointType) /* 7-entry jump table: true only for 4 and 5; false default */ [verified] +constexpr uint32_t IsGateTransitWaypoint = 0x0016e6e0; +// cdecl bool (int waypointType) /* 7-entry jump table: true only for 3. NOTE the node-LINE case of the movement switch is type 2, which this does NOT accept */ [verified] +constexpr uint32_t IsNodeWaypoint = 0x0016e720; +// thiscall void (StrategyServer* this, StarFleet* fleet, void* dest) /* waypoint type 5. v = float32(NextFloat() * player->CstE); arrives iff !(v > player->CstT) (equality arrives); on a miss the fleet is placed at dest + randomUnitVector * v -- scattered AROUND the destination by v, not advanced a fraction along the vector -- which costs a second raw draw. 1 draw on success, 2 on a miss */ [verified] +constexpr uint32_t ProbabilisticJump = 0x003b6700; +// cdecl void (Vector3* posOut, bool* arrivedOut, float nodespeed, float dt, vector* systems, const Vector3* from, const Vector3* to) /* builds the stutter segments, reverses them and pops from the back (so ascending by start); plain nodespeed between spheres, a constant per-segment speed inside one; arrived = (time < dt) || |along - length| < FLT_EPSILON, and on arrival the destination is copied verbatim */ [verified] +constexpr uint32_t NodeLine_Step = 0x00305510; +// cdecl void (vector* out, vector* systems, const Vector3* from, const Vector3* to) /* chord parameters scaled to world distance and clamped to [0, length]; a chord with |start-end| <= 0.01f is dropped; std::sort ascending by start; then ONE forward pass over adjacent pairs sets BOTH boundaries of an overlap to end_i + 0.5*(end_i - start_{i+1}) -- the mirror of the midpoint, pushed forward past both chords. Nothing is dropped or clipped back, so a swallowed chord comes out inverted */ [verified] +constexpr uint32_t NodeLine_BuildStutterSegments = 0x00305280; +// cdecl bool (float* tNear, float* tFar, const Sphere4* s, const Vector3* from, const Vector3* to) /* wraps the quadratic solver 0x008a62c0; rejects a sphere the segment does not reach and reports an open end as -FLT_MAX / +FLT_MAX */ [verified] +constexpr uint32_t SegmentSphereIntersect = 0x004a64f0; +// cdecl float (const Vector3* p, const Vector3* a, const Vector3* b) /* projection parameter clamped to [0,1]. NOTE 0x008c8eb0 is a varargs formatter, not this */ [verified] +constexpr uint32_t DistPointToSegment = 0x004e8eb0; +// thiscall int (ServerSystem* this, ServerPlayer* p, int flag) /* RET 8 -- (player, flag), NOT (species, groupType). Species is derived from p->Species and the group type is hard-coded to 0, which makes the cross-species and INDSYS branches dead in this specialisation. Returns the int64 result clamped to [0, INT32_MAX], low dword only */ [verified] +constexpr uint32_t ServerSystem_MaxPop = 0x0034ab20; +// thiscall int64 (ServerSystem* this, int groupType, int species, ServerPlayer* p, float* suitOverride) /* RET 0x10 */ [verified] +constexpr uint32_t ServerSystem_MaxPopGeneric = 0x0034a4a0; +// thiscall bool (ServerSystem* this) /* false without an owner, with Infra < 1.0, or with Suit != IdealSuit */ [verified] +constexpr uint32_t ServerSystem_IsStable = 0x0034ad90; +// thiscall void (ServerSystem* this) /* returns early unless ibon > 0 and Infra < 1; resets ntdev to 0 when the system is not the owner's home system; applied = min(ibon, float32(1 - Infra)); Infra becomes EXACTLY 1.0f when applied == the remainder, else float32(Infra + applied); ibon -= applied */ [verified] +constexpr uint32_t ServerSystem_ApplyInfraBonus = 0x00346780; +// thiscall void (ServerSystem* this) /* nothing unless pbon > 0; an unowned system drops the whole pool; returns when Pop >= MaxPop; resets ntdev for a non-home system; Pop += min(cap - Pop, pbon); pbon -= same. Pop and pbon are int32 */ [verified] +constexpr uint32_t ServerSystem_ApplyPopBonus = 0x0034b510; +// thiscall void (ServerSystem* this, float delta) /* no-op once Infra >= 1; clamps to 1 and re-normalises the system's own output rates when it lands there */ [verified] +constexpr uint32_t ServerSystem_ApplyInfraDelta = 0x00348270; +// thiscall void (ServerSystem* this, float delta) /* clamps at the ideal from whichever side it approached, so suitability never overshoots; re-normalises the rates when it lands exactly on the ideal */ [verified] +constexpr uint32_t ServerSystem_ApplySuitDelta = 0x003481c0; +// thiscall void (ServerSystem* this, int out[12], float rates[7]) /* RET 8. `out` is 12 dwords of MIXED type: 0 total (truncated), 1 strip-mined resources, 2 resources consumed, 3 money, 7 gross construction, 8 points to the queue, 9 points SPENT on repairs, 10 float32 infra delta, 11 float32 suitability delta. `rates` is copied to the stack first, so the caller's struct is untouched. NOT side-effect free: it repairs damaged ships in orbit via 0x00751590(points, estimateOnly=0) */ [verified] +constexpr uint32_t ServerSystem_ComputeOutputFromRates = 0x00351bb0; +// cdecl void (float rates[7], ServerSystem* sys, float* pinned) /* `pinned` defaults to &rates[0] (trade) and every call site passes NULL or that. Suppress terraform at the ideal (exact ==) and infra when float32(ibon+Infra) >= 1; zero any channel <= (double)1e-4f; clamp ONLY the pinned channel to [0,1]; sum the other three in float32; an exactly-zero sum seeds them with 1e-4f (honouring the suppressions); each becomes (r/sum)*(1-pinned) */ [verified] +constexpr uint32_t ServerSystem_NormaliseOutputRates = 0x00347390; +// thiscall void (ServerSystem* this, int* resourcesConsumedOut) /* computes the turn's output vector, writes out[2] (resources consumed) through the pointer, applies out[11] via ApplySuitDelta and out[10] via ApplyInfraDelta, and feeds out[8] to BuildQueue::ProcessTurn. Increments TnsOH while SRoh > 0 && out[1] > 0 */ [verified] +constexpr uint32_t ServerSystem_ProcessBuildQueue = 0x00352500; +// thiscall int (BuildQueue* this, ServerSystem* sys, int points) /* RET 8; `points` is BY VALUE and the leftover is the RETURN value. An order needing more than what is left absorbs everything and stops the pass; a design that costs money asks this->vft[9](sys, (int64)cost) and a refusal SKIPS that order and continues rather than stopping. Removal is a separate sweep afterwards that unlinks every order with conleft <= 0 */ [verified] +constexpr uint32_t BuildQueue_ProcessTurn = 0x00490d50; +// thiscall void (ServerSystem* this, int delta, int mode) /* RET 8; ProcessTurn passes -out[2] with mode 0 when the owner has AMine, else mode 3. Mode 3 takes it all from Res; mode 0 splits it proportionally over Res / ARes2 / MRes */ [verified] +constexpr uint32_t ServerSystem_AdjustResources = 0x00345f30; +// thiscall int64 (ServerSystem* this, int groupType, int species) /* RET 8; 0 without an owner or when haltv[groupType] is set */ [verified] +constexpr uint32_t ServerSystem_PopGrowthDelta = 0x00348100; +// thiscall float (ServerPlayer* p, int groupType, int species, float suit, float factor) /* the growth curve. base = 1 - clamp01(min(|ideal - clamp(suit,0,20)|, SuitTol) / SuitTol) -- there is NO pop/capacity term anywhere; g = clamp01(pow(base, clamp(POPULATION_GROWTH_EXP, 0.01f, 1000))) then x MOD x PopMod x factor x groupdef[+4], each gated on a strict > 0 and each stored back to a float32 */ [verified] +constexpr uint32_t PopGrowthFraction = 0x00136fb0; +// thiscall float (ServerSystem* this, int species) /* RET 4; 1.0 when unowned. mod = (bit0 ? 0.8f : 1) - 0.2*bit1 - 0.2*bit2, no clamp; rate = ((|Ideal-Suit| x BYHAZARD + DEATH_RATE) + SRs x BYOUTPUT) x mod, every step narrowed to float32 */ [verified] +constexpr uint32_t ServerSystem_SlaveDeathRate = 0x0034b110; +// thiscall void (ServerSystem* this) /* the worst plague's rate is ADDED to the death rate; SLAVES_MIN/MAX_DEATHS are each disabled by ANY negative value; deaths are clamped into [0, adjusted slave count]. Consumes no RNG */ [verified] +constexpr uint32_t ServerSystem_ProcessSlaves = 0x003537b0; +// thiscall void (ServerSystem* this) /* consumes no RNG, at depth 1 */ [verified] +constexpr uint32_t ServerSystem_ProcessPlague = 0x00356a90; +// thiscall void (ServerSystem* this) /* the ONLY RNG consumer in a colony turn, and its draw count is data-dependent: one RandChance per iteration of a 64-bit rebel counter (0x0074fbe0), a short-circuiting per-species roll loop (0x00753c60), one outcome roll (0x00756350) and one 0.2f continuation roll */ [verified] +constexpr uint32_t ServerSystem_ProcessRebellion = 0x003583b0; +// thiscall void (ServerSystem* this) /* consumes no RNG, at depth 1 */ [verified] +constexpr uint32_t ServerSystem_GrowCivilianPops = 0x00354220; +// stdcall bool (RNG* this /*ecx, the object*/, float p) /* p <= 0 -> false and p >= 1 -> true, both WITHOUT a draw; otherwise exactly one NextFloat and `r < p` */ [verified] +constexpr uint32_t RNG_Chance = 0x004e6dd0; +// thiscall void (ServerPlayer* this, MoraleEvent* ev, ServerSystem* sys) /* the actual apply. 0x00752a10 is only the MoraleEvent constructor; the per-species delta is carried in ev->deltas[species] (int[7] at ev+0x18), not as an argument */ [verified] +constexpr uint32_t ServerPlayer_AddMoraleEvent = 0x00439d60; +// thiscall MoraleEvent* (MoraleEvent* this) /* 0x50 bytes: {vptr, ints (+0x10 = event id), vptr2 @+0x14, int deltas[7] @+0x18, std::string name @+0x34} */ [verified] +constexpr uint32_t MoraleEvent_ctor = 0x00352a10; +// offset StrategyServer* owner (the RAW base; the RNG accessor uses owner-4) [verified] +constexpr uint32_t ServerSystem_off_Owner = 0x00000010; +// offset int Idx [verified] +constexpr uint32_t ServerSystem_off_Idx = 0x0000005c; +// offset int Size (1..10); the capacity base is Size * 100000000 as an exact int64 product [verified] +constexpr uint32_t ServerSystem_off_Size = 0x00000060; +// offset float Suit [verified] +constexpr uint32_t ServerSystem_off_Suit = 0x00000064; +// offset int Res [verified] +constexpr uint32_t ServerSystem_off_Res = 0x00000068; +// offset int ARes2 [verified] +constexpr uint32_t ServerSystem_off_ARes2 = 0x0000006c; +// offset int MRes [verified] +constexpr uint32_t ServerSystem_off_MRes = 0x00000070; +// offset int TRes -- zeroed every turn by ProcessTurn [verified] +constexpr uint32_t ServerSystem_off_TRes = 0x00000074; +// offset bool haltv[3] -- growth halt per group; cleared every turn by ProcessTurn [verified] +constexpr uint32_t ServerSystem_off_haltv = 0x00000078; +// offset float OutMod [verified] +constexpr uint32_t ServerSystem_off_OutMod = 0x0000007c; +// offset int TAcq (turn acquired) [verified] +constexpr uint32_t ServerSystem_off_TAcq = 0x00000080; +// offset StarSystem::OutputRates (0x1c): float SRt, SRsc, SRtf, SRi, SRoh, SRs; int SRnr [verified] +constexpr uint32_t ServerSystem_off_Rates = 0x00000088; +// offset float SRoh -- the over-harvest slider (Rates + 0x10) [verified] +constexpr uint32_t ServerSystem_off_SRoh = 0x00000098; +// offset BuildQueue* [verified] +constexpr uint32_t ServerSystem_off_BuildQueue = 0x000000a4; +// offset std::string Name (0x1c, MSVC SSO; capacity word at +0xbc) [verified] +constexpr uint32_t ServerSystem_off_Name = 0x000000a8; +// offset bool Abdn [verified] +constexpr uint32_t ServerSystem_off_Abdn = 0x000000c4; +// offset int64 Bats2 -- player i's 4-bit battle-recent counter at bits [4i, 4i+4), i < 15 [verified] +constexpr uint32_t ServerSystem_off_Bats2 = 0x000000f0; +// offset int64 rcex -- the same shape for the explored/recon-recent counter [verified] +constexpr uint32_t ServerSystem_off_Rcex = 0x000000f8; +// offset ServerPlayer* PID (null = unowned) [verified] +constexpr uint32_t ServerSystem_off_PID = 0x00000100; +// offset Morale cm (0x20: vptr + int[7]) [verified] +constexpr uint32_t ServerSystem_off_Morale = 0x0000011c; +// offset std::vector [verified] +constexpr uint32_t ServerSystem_off_Fleets = 0x0000016c; +// offset int Pop (imperial) [verified] +constexpr uint32_t ServerSystem_off_Pop = 0x0000018c; +// offset float Infra [verified] +constexpr uint32_t ServerSystem_off_Infra = 0x00000190; +// offset int pbon -- the pending population bonus (int32, not a float) [verified] +constexpr uint32_t ServerSystem_off_pbon = 0x00000194; +// offset float ibon -- the pending infrastructure bonus [verified] +constexpr uint32_t ServerSystem_off_ibon = 0x00000198; +// offset Population Pop2 (civilians): {vptr, vector @+4}, 24-byte entries {?, int type @+4, int species @+8, int64 count @+0x10} [verified] +constexpr uint32_t ServerSystem_off_Pop2 = 0x000001a0; +// offset Population pbon2 -- the civilian analogue of pbon [verified] +constexpr uint32_t ServerSystem_off_pbon2 = 0x000001b4; +// offset IndependenceInfo* indi (int indsp at +4) [verified] +constexpr uint32_t ServerSystem_off_Indi = 0x000001c8; +// offset int rbfl -- rebelling-species bitmask [verified] +constexpr uint32_t ServerSystem_off_rbfl = 0x000001dc; +// offset int adt[7] -- per-species addiction start turn; 0 means never addicted [verified] +constexpr uint32_t ServerSystem_off_adt = 0x000001e4; +// offset uint32 -- bit i is cleared when player i's Bats2 counter reaches 0 [verified] +constexpr uint32_t ServerSystem_off_BatsMask = 0x000002a0; +// offset uint32 -- the same for rcex [verified] +constexpr uint32_t ServerSystem_off_RcexMask = 0x000002a4; +// offset int TnsOH (turns over-harvesting) [verified] +constexpr uint32_t ServerSystem_off_TnsOH = 0x000002b8; +// offset int ntdev -- turns developing; ++ when IsStable, reset to 0 otherwise AND by either bonus-apply helper on a non-home system. This is the second SYSTEMBONUS_MINTURNS gate, not rbtn [verified] +constexpr uint32_t ServerSystem_off_ntdev = 0x000002c4; +// offset int rbtn [verified] +constexpr uint32_t ServerSystem_off_rbtn = 0x000002cc; +// offset int ModCount (the turn counter), relative to the RAW server base a ServerSystem's +0x10 points at [verified] +constexpr uint32_t StrategyServer_off_ModCount = 0x00000008; +// offset std::vector (begin @+0x50, end @+0x54); numPlayers = (end-begin)>>2 [verified] +constexpr uint32_t StrategyServer_off_Players = 0x00000050; +// offset std::vector (begin @+0x64, end @+0x68) -- one flat global list, not per player [verified] +constexpr uint32_t StrategyServer_off_Fleets = 0x00000064; +// offset 16-bucket hash of entity id -> object, hashed by (key & 0xF); 0x008b9240 is the lookup [verified] +constexpr uint32_t StrategyServer_off_EntityHash = 0x00000084; +// offset Mars::RNG* relative to the RAW base (== the -4-adjusted base's +0x16c) [verified] +constexpr uint32_t StrategyServer_off_RNGPtr = 0x00000168; +// offset std::set> cleared at the head of ProcessFleetMovement [verified] +constexpr uint32_t StrategyServer_off_ArrivedSet = 0x00000204; +// offset std::set> that MoveFleet fills for fleets still in motion; OnFleetArrived takes the set difference [verified] +constexpr uint32_t StrategyServer_off_InMotionSet = 0x00000214; +// offset int id (also the entity-hash key) [verified] +constexpr uint32_t StarFleet_off_Id = 0x00000004; +// offset the object whose +0x80 holds the entity hash the waypoint target is resolved in [verified] +constexpr uint32_t StarFleet_off_Galaxy = 0x00000010; +// offset Vector3 Pos [verified] +constexpr uint32_t StarFleet_off_Pos = 0x00000018; +// offset Vector3 PrvPos -- set to the ENTRY position when the fleet moved [verified] +constexpr uint32_t StarFleet_off_PrvPos = 0x0000004c; +// offset ServerPlayer* owner [verified] +constexpr uint32_t StarFleet_off_PID = 0x00000058; +// offset Location*; kind at Location+0x14 (0 system, 1 fleet, 2 point) [verified] +constexpr uint32_t StarFleet_off_Location = 0x000000a0; +// offset std::vector (begin @+0xa4, end @+0xa8) [verified] +constexpr uint32_t StarFleet_off_Ships = 0x000000a4; +// offset int16 -- SIGNED, summed into the owner's gate traffic [verified] +constexpr uint32_t StarFleet_off_GateTraffic = 0x000000c0; +// offset std::vector _Myfirst (proxy @+0xc4, last @+0xcc, end @+0xd0) [verified] +constexpr uint32_t StarFleet_off_Waypoints = 0x000000c8; +// offset float FPsp2 -- the fleet's strategic speed [verified] +constexpr uint32_t StarFleet_off_Speed = 0x000000d8; +// offset Vector3 -- set after every pass to the current waypoint target's position. The notes called this FPogn2; by the FlightPlan layout it is FPdpos [verified] +constexpr uint32_t StarFleet_off_DestPos = 0x000000ec; +// offset uint32; bit 0x100 = held this turn (cleared for every fleet at the end of ProcessFleetMovement), bit 0x2 cleared in the destination pass, bit 0x1 set by MoveFleet when the fleet moved [verified] +constexpr uint32_t StarFleet_off_Flags = 0x0000010c; +// offset sizeof(Waypoint) -- pinned by the divide-by-28 reciprocal multiply in the iterator arithmetic [verified] +constexpr uint32_t Waypoint_stride = 0x0000001c; +// offset int -- the destination entity id [verified] +constexpr uint32_t Waypoint_off_Target = 0x00000004; +// offset int -- 2 node line, 3 node route, 4 gate teleport, 5 probabilistic jump, otherwise a straight run [verified] +constexpr uint32_t Waypoint_off_Type = 0x00000008; +// offset uint64 flag pair; bit 0x1000 exempts the ship from movement fuel and marks it a tanker [verified] +constexpr uint32_t StarShip_off_Flags = 0x00000018; +// offset float Range -- remaining strategic range [verified] +constexpr uint32_t StarShip_off_Range = 0x00000020; +// offset float -- the cap the refuel helper clamps Range to [verified] +constexpr uint32_t StarShip_off_MaxRange = 0x00000078; +// offset ServerSystem* -- the bonus-apply helpers skip the ntdev reset for this system [verified] +constexpr uint32_t ServerPlayer_off_HomeSystem = 0x0000002c; +// offset int GTraf -- assigned (not accumulated) once per turn [verified] +constexpr uint32_t ServerPlayer_off_GateTraffic = 0x0000014c; +// offset float CstE -- the probabilistic jump's efficiency [verified] +constexpr uint32_t ServerPlayer_off_CstE = 0x00000154; +// offset float CstT -- the probabilistic jump's threshold [verified] +constexpr uint32_t ServerPlayer_off_CstT = 0x00000158; +// data int** -- the GlobalConst pointer slot; the storage word is *slot [verified] +constexpr uint32_t g_ptr_SYSTEMBONUS_MINTURNS = 0x006eca10; +// data float** -- pointer slot [verified] +constexpr uint32_t g_ptr_SYSTEMBONUS_POPBONUS = 0x006eca18; +// data float** -- pointer slot [verified] +constexpr uint32_t g_ptr_SYSTEMBONUS_INFRABONUS = 0x006eca20; +// data float** -- pointer slot [verified] +constexpr uint32_t g_ptr_SYSTEMBONUS_POPBONUS_INC = 0x006eca38; +// data float** -- pointer slot [verified] +constexpr uint32_t g_ptr_SYSTEMBONUS_INFRABONUS_INC = 0x006eca40; +// data int** -- pointer slot (image default 10) [verified] +constexpr uint32_t g_ptr_ADDICTION_PHASE2_START = 0x006eca58; +// data int** -- pointer slot (image default 15) [verified] +constexpr uint32_t g_ptr_ADDICTION_PHASE3_START = 0x006eca60; +// data float -- 0.05f, a hard-coded literal in .data, NOT a GlobalConst; both ServerSystem::ProcessTurn calls to the independent pop-drift helper pass it [verified] +constexpr uint32_t g_IndependentPopDriftRate = 0x006eca80; +// data float** -- pointer slot; storage 0x00b212cc (shipped value 2) [verified] +constexpr uint32_t g_ptr_STUTTER_SYSTEM_INFLUENCE_RADIUS = 0x006ebc44; +// data float** -- pointer slot; storage 0x00b212d0 (shipped value 0.33) [verified] +constexpr uint32_t g_ptr_STUTTER_MIN_SPEED = 0x006ebc48; +// data float** -- pointer slot; storage 0x00b212d4 (shipped value 0.33). NOTE min == max in the shipped data, so the stutter ramp collapses to a constant 0.33x inside any influence sphere [verified] +constexpr uint32_t g_ptr_STUTTER_MAX_SPEED = 0x006ebc4c; } // namespace sots::addr diff --git a/src/game/sim/colony.cpp b/src/game/sim/colony.cpp index d423ab7..9048e06 100644 --- a/src/game/sim/colony.cpp +++ b/src/game/sim/colony.cpp @@ -12,115 +12,225 @@ constexpr std::int64_t kMaxPopStep = 50000000; } double HazardModifier(double suitability, double idealSuitability, double suitTolerance) { + // Every step is double: the 0.1 in the image is a true double (0x3fb999999999999a), + // not a widened float, and nothing here is narrowed. The clamp is applied low end + // first, then high end, which is what lets a NaN fall through unchanged. const double band = suitTolerance + 0.1; - if (band <= 0) return suitability == idealSuitability ? 1.0 : 0.0; - return Clamp01(1.0 - std::fabs(suitability - idealSuitability) / band); + const double v = 1.0 - std::fabs(suitability - idealSuitability) / band; + if (!(v == v)) return v; // 0/0 when the band is zero and the planet is at the ideal + const double lo = v > 0.0 ? v : 0.0; + return lo < 1.0 ? lo : 1.0; +} + +std::int64_t QuantiseCapacity(std::int64_t v) { + // The capacity helper rounds down to a whole ten -- but only above ten, so small values + // and negatives pass through untouched. + return v > 10 ? (v / 10) * 10 : v; } std::int64_t CarryingCapacity(const CapacityInputs& in, const TuningTable& t) { if (IsNpcSpecies(in.species)) return 0; if (!in.speciesCanLive) return 0; - double cap = static_cast(in.planetSize) * 1e8 * in.groupCapacityMult * - in.speciesGrowthFactor * - (in.ownerIsDifferentSpecies ? in.crossSpeciesMod : 1.0) * in.hazardMod; - std::int64_t result = Ftoi64(cap); + // `Size x 1e8` is an exact 64-bit *integer* product (the 1e8 is the immediate + // 0x05f5e100), and the whole floating-point factor is formed first, in this order. + const std::int64_t base = static_cast(in.planetSize) * 100000000LL; + const double factor = in.hazardMod * (in.groupCapacityMult * in.speciesGrowthFactor * + (in.ownerIsDifferentSpecies ? in.crossSpeciesMod : 1.0)); + std::int64_t result = QuantiseCapacity(Ftoi64(static_cast(base) * factor)); if (in.arcologyTech) { if (in.group == PopGroup::Imperial) result += 100000000; else if (in.group == PopGroup::Civilian) result += 200000000; + // Slaves get nothing: the helper falls through both decrements and returns 0. } if (in.groupMaxEnabled) result = std::min(result, in.groupMax); - if (in.ownerIsNpc) result = Ftoi64(static_cast(result) * t.INDSYS_IMPERIAL_POPULATION_MOD); + if (in.ownerIsNpc) { + result = QuantiseCapacity( + Ftoi64(static_cast(result) * t.INDSYS_IMPERIAL_POPULATION_MOD)); + } return result; } +double GrowthSuitabilityDistance(double suit, double ideal, double tolerance, + bool accommodated) { + if (accommodated) return 0.0; + const double clamped = F32(ClampT(suit, 0.0, 20.0)); + const double d = F32(std::fabs(F32(ideal - clamped))); + return tolerance < d ? tolerance : d; +} + +double PopulationGrowthFraction(const GrowthInputs& in, const TuningTable& t) { + const double d = GrowthSuitabilityDistance(in.suitability, in.idealSuitability, + in.suitTolerance, in.accommodated); + const double q = F32(d / in.suitTolerance); // 0/0 when the tolerance is zero: NaN + const double base = F32(1.0 - Clamp01(q)); + const double exponent = ClampT(t.POPULATION_GROWTH_EXP, kGrowthExponentMin, kGrowthExponentMax); + double g = F32(std::pow(base, exponent)); + g = F32(Clamp01(g)); + if (!(g > 0)) return g; // zero, or a NaN that must survive + if (t.POPULATION_GROWTH_MOD > 0) g = F32(g * t.POPULATION_GROWTH_MOD); + if (in.playerPopMod > 0) g = F32(g * in.playerPopMod); + if (in.extraFactor > 0) g = F32(g * in.extraFactor); + if (in.groupGrowthMult > 0) g = F32(g * in.groupGrowthMult); + return g; +} + std::int64_t PopulationGrowthDelta(const GrowthInputs& in, const TuningTable& t) { if (in.blockaded) return 0; - if (in.capacity <= 0) return 0; - const double fill = Clamp01(static_cast(in.pop) / static_cast(in.capacity)); - double g = Clamp01(std::pow(1.0 - fill, t.POPULATION_GROWTH_EXP)); - if (g > 0) { - g *= t.POPULATION_GROWTH_MOD; - g *= in.playerPopMod; - g *= in.hazardSpeciesFactor; - g *= in.flaggedByPlayer ? in.flaggedFactor : 1.0; - if (in.groupGrowthMult > 0) g *= in.groupGrowthMult; - } - std::int64_t delta = Ftoi64(static_cast(in.pop) * g); - if (g > 0) delta = std::max(1, delta); - return std::min(delta, kMaxPopStep); + if (in.pop <= 0) return 0; + const double g = PopulationGrowthFraction(in, t); + if (g == 0.0) return 0; + const std::int64_t delta = Ftoi64(static_cast(in.pop) * g); + if (delta != 0) return delta; + // A truncated-to-zero delta becomes exactly 1, but only for a strictly positive + // fraction -- a NaN fraction leaves the colony alone. + return g > 0 ? 1 : 0; } std::int64_t ApplyImperialGrowth(std::int64_t pop, std::int64_t capacity, std::int64_t delta) { - if (pop > capacity) { - const std::int64_t floor = std::min(pop, 100); - const std::int64_t shrunk = pop - std::min(kMaxPopStep, pop - capacity); - return std::max(shrunk, floor); + if (delta >= 0 && delta > kMaxPopulationStep) delta = kMaxPopulationStep; + const std::int64_t grown = pop + delta; + std::int64_t result; + if (grown <= capacity) { + result = grown; + } else if (pop <= capacity) { + result = capacity; // growing past the cap simply lands on it: no shrink + } else { + std::int64_t over = capacity > pop ? capacity - pop : pop - capacity; // |cap - pop| + if (over >= kMaxPopulationStep) over = kMaxPopulationStep; + const std::int64_t floorValue = pop >= 100 ? 100 : pop; + const std::int64_t shrunk = pop - over; + result = shrunk > floorValue ? shrunk : floorValue; } - return std::min(capacity, pop + delta); + return result < 0 ? 0 : result; } -int InfrastructurePointsNeeded(double infra) { - if (infra >= 1.0) return 0; - return static_cast(std::ceil((1.0 - infra) / kInfraPerPoint)); +double InfrastructurePointsNeeded(double infra) { + // A real ceil() on the double quotient; the result stays a double and is compared + // against the (also double) point pool, so nothing is truncated on the way. + return std::ceil((1.0 - infra) / kInfraPerPoint); } -double InfrastructureGain(int points) { - return (static_cast(points) / 500.0) * 0.01 * 1.65; +double InfrastructureGain(double points) { + // Three separate 80-bit steps in this exact order, then a clamp at zero and one + // narrowing to float32 on the store. Folding them into one x3.3e-5 multiply is not + // bit-identical. + const double v = points / 500.0 * 0.01 * 1.65; + return F32(v > 0.0 ? v : 0.0); } -double ApplyInfrastructurePoints(double infra, int pool, int* pointsUsed) { - const int spend = std::max(0, std::min(pool, InfrastructurePointsNeeded(infra))); - if (pointsUsed) *pointsUsed = spend; - return infra + InfrastructureGain(spend); +double ApplyInfrastructurePoints(double infra, double pool, double* pointsUnused) { + const double spend = std::min(pool, InfrastructurePointsNeeded(infra)); + if (pointsUnused) *pointsUnused = std::max(pool - spend, 0.0); + return InfrastructureGain(spend); +} + +double ApplyInfrastructureDelta(double infra, double delta) { + if (infra >= 1.0) return infra; // the apply is a no-op once the colony is built out + const double v = delta + infra; + return F32(v > 1.0 ? 1.0 : v); } double DecayUnownedInfrastructure(double infra) { - return std::max(0.0, infra - 0.02); + // The image holds `(double)0.02f`, not the decimal 0.02, and `Infra` is a 4-byte float, so + // the difference is rounded to single precision before the floor test. The floor itself is + // `result <= 0 -> 0`, i.e. an exact zero also takes the zero branch. + const double v = F32(infra - kUnownedInfraDecay); + return v > 0.0 ? v : 0.0; } -int TerraformPointsNeeded(double suit, double ideal) { - return Ftol(std::fabs(ideal - suit) / kTerraformPerPoint); +double TerraformPointsNeeded(double suit, double ideal, double terraMod) { + // The per-point yield -- terraforming modifier included -- is folded into the *need*, + // so a player with a better modifier needs proportionally fewer points. The gap is + // narrowed to float32 before the fabs. + const double gap = std::fabs(F32(ideal - suit)); + const double per = std::fabs(terraMod * kTerraformNeedFactor / 20000.0); + return std::ceil(gap / per); } -double TerraformDelta(int points, double terraMod, double suit, double ideal) { - const double sign = suit > ideal ? -1.0 : 1.0; - return static_cast(points) * 1.5 * 1.2 * terraMod * sign / 20000.0; +double TerraformDelta(double points, double terraMod, double suit, double ideal) { + const double sign = suit > ideal ? -1.0 : 1.0; // strictly greater; at the ideal, +1 + return F32(points * 1.5 * kTerraform12 * terraMod * sign / 20000.0); +} + +double ApplyTerraformDelta(double suit, double delta, double ideal) { + const double v = suit + delta; + // The apply clamps on whichever side it approached the ideal from, so suitability + // never overshoots. + if (suit < ideal) return F32(v > ideal ? ideal : v); + if (suit > ideal) return F32(v < ideal ? ideal : v); + return suit; } double SlaveDeathRate(double slaveOutputRate, double suit, double ideal, - const SpeciesTechFlags& flags, const TuningTable& t) { - const double base = slaveOutputRate * t.SLAVES_DEATH_RATE_BYOUTPUT + - std::fabs(ideal - suit) * t.SLAVES_DEATH_RATE_BYHAZARD + - t.SLAVES_DEATH_RATE; - double mod = flags.translation1 ? 0.8 : 1.0; - if (flags.translation2) mod -= 0.2; - if (flags.translation3) mod -= 0.2; - return base * mod; + const SpeciesTechFlags& flags, const TuningTable& t, bool owned) { + if (!owned) return 1.0; // an unowned system reports the full rate, not zero + // The mod chain: 0.8f replaces 1 outright for the first translation tech, then each of + // the next two subtracts (double)0.2f. There is no clamp at zero. + double mod = flags.translation1 ? kSlaveModBase : 1.0; + if (flags.translation2) mod = F32(mod - kSlaveModStep); + if (flags.translation3) mod = F32(mod - kSlaveModStep); + // Term order matters because every step is stored back to a float32: the hazard term is + // added to the base first, and only then the output term. + const double base = F32(t.SLAVES_DEATH_RATE); + const double hazard = F32(std::fabs(F32(ideal - suit))); + double v = F32(F32(hazard * t.SLAVES_DEATH_RATE_BYHAZARD) + base); + v = F32(v + F32(slaveOutputRate * t.SLAVES_DEATH_RATE_BYOUTPUT)); + return F32(v * mod); } -std::int64_t SlaveDeaths(std::int64_t slaves, double rate, const TuningTable& t) { - std::int64_t d = Ftoi64(static_cast(slaves) * rate); - d = std::max(d, t.SLAVES_MIN_DEATHS); - if (t.SLAVES_MAX_DEATHS >= 0) d = std::min(d, t.SLAVES_MAX_DEATHS); - return std::max(0, std::min(d, slaves)); +std::int64_t SlaveDeaths(std::int64_t slaves, double rate, double plagueRate, + const TuningTable& t) { + // The worst plague at the system contributes an *additive* term to the rate, not a + // multiplier -- easy to miss, and it is the only path by which a plague kills slaves. + std::int64_t d = Ftoi64(static_cast(slaves) * (rate + plagueRate)); + // Either bound is disabled by ANY negative value, not specifically by -1. + if (t.SLAVES_MIN_DEATHS >= 0 && d < t.SLAVES_MIN_DEATHS) d = t.SLAVES_MIN_DEATHS; + if (t.SLAVES_MAX_DEATHS >= 0 && d > t.SLAVES_MAX_DEATHS) d = t.SLAVES_MAX_DEATHS; + if (d < 0) d = 0; + if (d > slaves) d = slaves; + return d; } OutputRates NormaliseOutputRates(const OutputRates& raw, bool suitAtIdeal, bool infraFull) { - OutputRates r; - r.trade = std::max(0.0, raw.trade); - r.construction = std::max(0.0, raw.construction); - r.terraform = suitAtIdeal ? 0.0 : std::max(0.0, raw.terraform); - r.infra = infraFull ? 0.0 : std::max(0.0, raw.infra); - const double sum = r.trade + r.construction + r.terraform + r.infra; - if (sum <= 0) { - r.trade = r.construction = r.terraform = r.infra = 0.25; - return r; + OutputRates r = raw; + // Suppression first: a planet already at its ideal cannot terraform, and a colony + // whose infrastructure plus pending bonus has reached 1 cannot build more. + if (suitAtIdeal) r.terraform = 0.0; + if (infraFull) r.infra = 0.0; + + // A slider at or below the threshold counts as off. The threshold is the widened + // float literal (double)1e-4f. + if (!(r.trade > kOutputRateThreshold)) r.trade = 0.0; + if (!(r.construction > kOutputRateThreshold)) r.construction = 0.0; + if (!(r.infra > kOutputRateThreshold)) r.infra = 0.0; + if (!(r.terraform > kOutputRateThreshold)) r.terraform = 0.0; + + // Only the pinned channel is clamped into [0, 1]; the other three are left alone + // because they are about to be rescaled anyway. + r.trade = Clamp01(r.trade); + + // The sum excludes the pinned channel, and it is rounded to float32 at every step. + double sum = 0; + sum = F32(sum + r.construction); + sum = F32(sum + r.infra); + sum = F32(sum + r.terraform); + if (sum == 0.0) { + // The all-zero fallback seeds the three unpinned channels with the threshold value + // itself -- respecting the two suppressions -- and re-sums. + r.construction = kOutputRateThreshold; + r.infra = infraFull ? 0.0 : kOutputRateThreshold; + r.terraform = suitAtIdeal ? 0.0 : kOutputRateThreshold; + sum = 0; + sum = F32(sum + r.construction); + sum = F32(sum + r.infra); + sum = F32(sum + r.terraform); + if (sum == 0.0) return r; // everything suppressed: nothing left to rescale } - r.trade /= sum; - r.construction /= sum; - r.terraform /= sum; - r.infra /= sum; + const double share = 1.0 - r.trade; + r.construction = F32(F32(r.construction / sum) * share); + r.infra = F32(F32(r.infra / sum) * share); + r.terraform = F32(F32(r.terraform / sum) * share); return r; } @@ -130,48 +240,56 @@ double MoraleOutputMultiplier(int morale, const TuningTable& t) { return 1.0; } -int TotalSystemOutput(const OutputModifiers& m, const TuningTable& t) { +double TotalSystemOutput(const OutputModifiers& m, const TuningTable& t) { double v = m.baseOutput; v *= MoraleOutputMultiplier(m.morale, t); v *= 1.0 + t.STATION_BONUS_IMPERIAL_OUTPUT * m.stations; if (m.addictionPhase3) v *= t.ADDICTION_OUTPUT_MOD; v *= m.scOutMod * m.rebOutMod * m.techOutMod * m.systemOutMod * m.playerOutMod; - return RoundToInt(v); + // Rounded half-to-even and kept as a double: the channel splits multiply this value, + // and only the reported slot 0 truncates it to an int. + return RoundHalfEven(v); } -OutputSplit SplitOutput(int total, const OutputRates& rates) { +OutputSplit SplitOutput(double total, const OutputRates& rates) { + // Each channel is rounded half-to-even independently and stays a double. OutputSplit s; - s.trade = RoundToInt(total * rates.trade); - s.construction = RoundToInt(total * rates.construction); - s.terraform = RoundToInt(total * rates.terraform); - s.infra = RoundToInt(total * rates.infra); + s.trade = RoundHalfEven(total * rates.trade); + s.construction = RoundHalfEven(rates.construction * total); + s.terraform = RoundHalfEven(rates.terraform * total); + s.infra = RoundHalfEven(rates.infra * total); return s; } -int ConstructionPoints(int constructionShare, int stations, const TuningTable& t) { - return RoundToInt(constructionShare * (1.0 + t.STATION_BONUS_SHIPCON * stations)); +int ConstructionPoints(double constructionShare, int stations, const TuningTable& t) { + // Truncating, not rounding -- this slot goes through the float-to-int helper. + return Ftol(constructionShare * (1.0 + t.STATION_BONUS_SHIPCON * stations)); } -OutputSplit SplitLeftover(int leftover, const OutputRates& rates, bool suitAtIdeal, bool infraFull) { +OutputSplit SplitLeftover(double leftover, const OutputRates& rates, bool suitAtIdeal, + bool infraFull) { double wt, wf, wi; - if (rates.construction >= 1.0) { + // The "construction was the only slider" case is an exact equality against 1, not a + // >= test: after normalisation a pure-construction colony has exactly 1 there. + if (rates.construction == 1.0) { wt = 1.0; wf = suitAtIdeal ? 0.0 : 1.0; wi = infraFull ? 0.0 : 1.0; } else { - wt = std::max(0.0, rates.trade); - wf = std::max(0.0, rates.terraform); - wi = std::max(0.0, rates.infra); + wt = rates.trade; + wf = rates.terraform; + wi = rates.infra; } const double sum = wt + wf + wi; OutputSplit s; if (sum <= 0 || leftover <= 0) { - s.trade = std::max(0, leftover); + s.trade = std::max(0.0, leftover); return s; } - s.trade = RoundToInt(leftover * wt / sum); - s.terraform = RoundToInt(leftover * wf / sum); - s.infra = RoundToInt(leftover * wi / sum); + // Independently rounded, so the three need not add back up to the leftover. + s.trade = RoundHalfEven(wt * leftover / sum); + s.terraform = RoundHalfEven(wf * leftover / sum); + s.infra = RoundHalfEven(wi * leftover / sum); return s; } @@ -195,24 +313,43 @@ int SystemMoneyIncome(const SystemMoneyInputs& in) { return Ftol(t - cost); } -void ApplyPopulationBonus(std::int64_t& pop, std::int64_t capacity, std::int64_t& pendingBonus) { - const std::int64_t room = std::max(0, capacity - pop); - const std::int64_t applied = std::max(0, std::min(pendingBonus, room)); +BonusApplyResult ApplyPopulationBonus(std::int64_t& pop, std::int64_t capacity, + std::int64_t& pendingBonus, bool owned, bool homeSystem) { + BonusApplyResult r; + if (pendingBonus <= 0) return r; + if (!owned) { // an unowned system keeps no pool at all + pendingBonus = 0; + return r; + } + if (pop >= capacity) return r; + r.resetTurnsDeveloping = !homeSystem; + const std::int64_t applied = std::min(capacity - pop, pendingBonus); pop += applied; pendingBonus -= applied; + r.applied = applied != 0; + return r; } -void ApplyInfrastructureBonus(double& infra, double& pendingBonus) { - const double applied = std::max(0.0, std::min(pendingBonus, 1.0 - infra)); - infra += applied; - pendingBonus -= applied; +BonusApplyResult ApplyInfrastructureBonus(double& infra, double& pendingBonus, bool homeSystem) { + BonusApplyResult r; + if (!(pendingBonus > 0)) return r; + if (infra >= 1.0) return r; + r.resetTurnsDeveloping = !homeSystem; + const double room = F32(1.0 - infra); + const double applied = std::min(pendingBonus, room); + // The original stores the literal 1.0 rather than the sum when the pool covers the whole + // remainder, so a colony topped up this way lands exactly on 1 with no rounding residue. + infra = applied == room ? 1.0 : F32(infra + applied); + pendingBonus = F32(pendingBonus - applied); + r.applied = true; + return r; } void AccrueSystemBonus(const SystemBonusInputs& in, std::int64_t& popBonus, double& infraBonus, const TuningTable& t) { if (!in.stable) return; if (in.turnsOwned <= t.SYSTEMBONUS_MINTURNS) return; - if (in.turnsSinceRebellion <= t.SYSTEMBONUS_MINTURNS) return; + if (in.turnsDeveloping <= t.SYSTEMBONUS_MINTURNS) return; const double cap = static_cast(in.capacity); const std::int64_t popTarget = @@ -220,28 +357,158 @@ void AccrueSystemBonus(const SystemBonusInputs& in, std::int64_t& popBonus, doub const std::int64_t popInc = Ftol(t.SYSTEMBONUS_POPBONUS_INC * cap); popBonus += std::min(std::max(popInc, 0), std::max(popTarget - popBonus, 0)); - const double infraTarget = in.ownerSpeciesEligible ? t.SYSTEMBONUS_INFRABONUS : 0.0; + // The original reads the infrastructure target through a helper that already returns 0 + // for an ineligible owner, and clamps it at 0 before differencing. + const double infraTarget = + std::max(0.0, in.ownerSpeciesEligible ? t.SYSTEMBONUS_INFRABONUS : 0.0); infraBonus += std::min(std::max(t.SYSTEMBONUS_INFRABONUS_INC, 0.0), std::max(infraTarget - infraBonus, 0.0)); } BuildQueueResult ProcessBuildQueue(std::vector& queue, int points) { BuildQueueResult r; - std::size_t completed = 0; - for (BuildOrder& o : queue) { - if (points < o.constructionLeft) { - o.constructionLeft -= points; - points = 0; - break; + if (points > 0) { + for (BuildOrder& o : queue) { + if (o.constructionLeft > points) { + o.constructionLeft -= points; + points = 0; + break; + } + if (o.moneyCost > 0) { + if (!o.moneyAvailable) continue; // refused: skip this order, keep going + r.moneyCharged = SaturatingAdd(r.moneyCharged, o.moneyCost); + } + points -= o.constructionLeft; + o.constructionLeft = 0; + r.completedOrderIds.push_back(o.orderId); } - points -= o.constructionLeft; - o.constructionLeft = 0; - if (o.moneyCost > 0) r.moneyCharged = SaturatingAdd(r.moneyCharged, o.moneyCost); - r.completedOrderIds.push_back(o.orderId); - ++completed; } - queue.erase(queue.begin(), queue.begin() + static_cast(completed)); + // The removal sweep is separate and unconditional: every order at or below zero goes, + // including one that was already finished before this turn. + queue.erase(std::remove_if(queue.begin(), queue.end(), + [](const BuildOrder& o) { return o.constructionLeft <= 0; }), + queue.end()); r.pointsLeft = points; return r; } +// --------------------------------------------------------------------------------------- +// Countdown nibbles +// --------------------------------------------------------------------------------------- + +int CountdownFor(const ColonyCountdowns& c, int player) { + if (player < 0 || player >= kMaxCountdownPlayers) return 0; + return static_cast((c.counters >> (4 * player)) & 0xfULL); +} + +void SetCountdown(ColonyCountdowns& c, int player, int value) { + if (player < 0 || player >= kMaxCountdownPlayers) return; // the original refuses too + const std::uint64_t v = static_cast(ClampT(value, 0, 15)); + const std::uint64_t mask = 0xfULL << (4 * player); + c.counters = (c.counters & ~mask) | (v << (4 * player)); +} + +void TickCountdowns(ColonyCountdowns& c, int playerCount) { + if (c.counters == 0) return; // the whole sweep is skipped when nothing is counting + for (int i = 0; i < playerCount; ++i) { + if (i >= kMaxCountdownPlayers) continue; // no nibble exists; neither word is touched + const int v = CountdownFor(c, i); + if (v != 0) SetCountdown(c, i, v - 1); + else c.active &= ~(1u << i); + } +} + +// --------------------------------------------------------------------------------------- +// Addiction +// --------------------------------------------------------------------------------------- + +AddictionPhase AddictionPhaseOf(int startTurn, int currentTurn, int phase2Start, int phase3Start) { + if (startTurn == 0) return AddictionPhase::None; + const int elapsed = currentTurn - startTurn; + if (elapsed > phase3Start) return AddictionPhase::Terminal; + if (elapsed > phase2Start) return AddictionPhase::Established; + return AddictionPhase::Onset; +} + +// --------------------------------------------------------------------------------------- +// The per-system turn pass +// --------------------------------------------------------------------------------------- + +ColonyTurnResult ProcessColonyTurn(ColonyTurnState& s, const ColonyTurnInputs& in, + const TuningTable& t) { + ColonyTurnResult r; + + // 1. An unowned system's infrastructure rots. An owned one is left alone here; its + // infrastructure moves in the output pass, which is a different phase of the turn. + if (!in.owned) s.infra = DecayUnownedInfrastructure(s.infra); + + // 2. The two pending bonus pools are drained into the colony, owned or not. Either one + // resets the turns-developing counter when it fires on a colony that is not the + // owner's home system -- so a colony that is still absorbing a bonus never reaches + // the system-bonus gate, which is a real feedback loop and easy to miss. + const BonusApplyResult ib = ApplyInfrastructureBonus(s.infra, s.infraBonus, in.homeSystem); + const BonusApplyResult pb = + ApplyPopulationBonus(s.pop, in.imperialCapacity, s.popBonus, in.owned, in.homeSystem); + if (in.owned && (ib.resetTurnsDeveloping || pb.resetTurnsDeveloping)) s.turnsDeveloping = 0; + + // (an independent colony's imperial<->civilian drift runs here; not modelled) + + // 3. Turns-developing: incremented while the colony is stable, reset the moment it is not. + if (in.stable) ++s.turnsDeveloping; + else s.turnsDeveloping = 0; + + // 4. Long-stability bonus accrual, which reads the ntdev just written above. + if (in.owned) { + SystemBonusInputs b; + b.stable = in.stable; + b.turnsOwned = in.turnsOwned; + b.turnsDeveloping = s.turnsDeveloping; + b.ownerSpeciesEligible = in.ownerSpeciesEligible; + b.capacity = in.imperialCapacity; + AccrueSystemBonus(b, s.popBonus, s.infraBonus, t); + } + + // (plague, the build queue, imperial and civilian growth, the resource debit and the + // in-orbit refuel run here; all of them are the declared input boundary) + + // 5. The turn's resource total is consumed and reset. + s.totalResources = 0; + + // 6. Growth halts expire every turn: a blockade has to be re-asserted. + s.growthHalted[0] = s.growthHalted[1] = s.growthHalted[2] = false; + + // 7. The two per-player countdowns tick. + TickCountdowns(s.battles, in.playerCount); + TickCountdowns(s.recon, in.playerCount); + + // (slaves and rebellion run here; not modelled) + + // 8. Addiction. Only an owned, non-independent colony sweeps it, and only species that + // actually have civilians here. Temperance suppresses the addiction outright; otherwise + // the onset and terminal phases each raise their own morale event and the established + // phase (2) raises none at all. + if (in.owned && !in.independent) { + for (int i = 0; i < kSpeciesCount; ++i) { + if (!in.civilianPresent[i]) continue; + if (in.temperance[i]) { + r.moraleEvents.push_back({i, kMoraleEventAddictionSuppressed, -1}); + continue; + } + switch (AddictionPhaseOf(in.addictionStart[i], in.currentTurn, in.addictionPhase2Start, + in.addictionPhase3Start)) { + case AddictionPhase::Onset: + r.moraleEvents.push_back({i, kMoraleEventAddictionOnset, +1}); + break; + case AddictionPhase::Terminal: + r.moraleEvents.push_back({i, kMoraleEventAddictionTerminal, -2}); + break; + case AddictionPhase::None: + case AddictionPhase::Established: + break; + } + } + } + + return r; +} + } // namespace sots::sim diff --git a/src/game/sim/colony.h b/src/game/sim/colony.h index 6b506f1..8575f5f 100644 --- a/src/game/sim/colony.h +++ b/src/game/sim/colony.h @@ -2,6 +2,7 @@ // point conversion, slave deaths, output split, build-queue consumption. #pragma once +#include #include #include @@ -72,10 +73,17 @@ struct CapacityInputs { bool ownerIsNpc = false; // owner species is the independent race }; -// cap = ftoi64(Size x 1e8 x groupMult x speciesFactor x crossSpecies x hazard) -// + arcology flat bonus; clamped to the group max; x INDSYS_IMPERIAL_POPULATION_MOD -// when the owner is the NPC race. The NPC species itself, and a species that cannot -// live there, get 0. CONFIDENCE: high on the shape and the special cases. +// Round a capacity down to a whole ten -- but only above ten. B4 finding: every carrying +// capacity goes through this, so a colony's cap is always a multiple of 10 in practice. +// CONFIDENCE: high -- read from the helper's truncate/divide/multiply sequence. +std::int64_t QuantiseCapacity(std::int64_t v); + +// cap = quantise(ftoi64((Size x 1e8) x (hazard x (groupMult x speciesFactor x crossSpecies)))) +// + arcology flat bonus (1e8 imperial / 2e8 civilian, **0 for slaves**); clamped to +// the group max; x INDSYS_IMPERIAL_POPULATION_MOD and re-quantised when the owner is +// the NPC race. The NPC species itself, and a species that cannot live there, get 0. +// `Size x 1e8` is an exact 64-bit integer product; only the modifier chain is floating +// point. CONFIDENCE: high on the shape, the order and the special cases. std::int64_t CarryingCapacity(const CapacityInputs& in, const TuningTable& t); // --------------------------------------------------------------------------------------- @@ -84,70 +92,143 @@ std::int64_t CarryingCapacity(const CapacityInputs& in, const TuningTable& t); struct GrowthInputs { std::int64_t pop = 0; - std::int64_t capacity = 0; - bool blockaded = false; // growth halted at this system - double playerPopMod = 1.0; // PopMod - double hazardSpeciesFactor = 1.0; + bool blockaded = false; // growth halted at this system for this group + double suitability = 0; // the planet's Suit + double idealSuitability = 0; // the growing species' ideal + double suitTolerance = 0; // the owner's SuitTol + bool accommodated = false; // the owner ignores suitability entirely (RebAI etc.) + double playerPopMod = 1.0; // PopMod, applied only when > 0 + double extraFactor = 1.0; // the caller's per-call factor, applied only when > 0 double groupGrowthMult = 0.0; // per-group-type growth column; applied only if > 0 - bool flaggedByPlayer = false; // system in the player's special-growth mask - double flaggedFactor = 1.0; // replaces the implicit 1 for flagged systems }; -// Logistic growth fraction and the resulting delta: -// g = clamp01( (1 - clamp01(pop/cap)) ^ POPULATION_GROWTH_EXP ) -// if g > 0: g x= POPULATION_GROWTH_MOD x PopMod x hazard/species factor -// x groupGrowthMult (if > 0) -// delta = ftoi64(pop x g), at least 1 when g > 0, at most 50,000,000. -// A blockaded system yields 0. CONFIDENCE: high. +// The clamp the engine puts on the growth exponent before calling pow(). +constexpr double kGrowthExponentMin = 0.009999999776482582; // (double)0.01f +constexpr double kGrowthExponentMax = 1000.0; + +// Suitability distance the growth curve is built from: +// min( |ideal - clamp(suit, 0, 20)| , tolerance ) +// clamped through float32 at every step, and 0 when the owner is accommodated. The 0..20 +// clamp on the planet's suitability is easy to miss and matters at the extremes. +// CONFIDENCE: high. +double GrowthSuitabilityDistance(double suit, double ideal, double tolerance, bool accommodated); + +// Growth fraction for one population group. +// +// B4 CORRECTION -- the curve is **not** logistic in population. There is no `pop / capacity` +// term anywhere in the chain; the capacity is never even passed in. The base of the power +// is a *suitability* term: +// base = 1 - clamp01( GrowthSuitabilityDistance(...) / tolerance ) +// g = clamp01( pow(base, clamp(POPULATION_GROWTH_EXP, 0.01f, 1000)) ) +// if g > 0: g x= POPULATION_GROWTH_MOD (if > 0) x PopMod (if > 0) +// x extraFactor (if > 0) x groupGrowthMult (if > 0) +// Every guard is a strict `> 0` and every intermediate is stored back to a float32 slot. +// A zero tolerance makes the quotient 0/0, which propagates NaN through the whole chain -- +// a real hazard, reproduced rather than papered over. CONFIDENCE: high. +double PopulationGrowthFraction(const GrowthInputs& in, const TuningTable& t); + +// The per-turn delta: `trunc(pop x g)`, or exactly 1 when that truncates to 0 while g is +// strictly positive. A group with no population, or a blockaded one, yields 0. There is no +// 50,000,000 cap here -- that lives in the apply. CONFIDENCE: high. std::int64_t PopulationGrowthDelta(const GrowthInputs& in, const TuningTable& t); -// Apply growth to an imperial population: grow toward the cap, or if already over the -// cap shrink by at most 50,000,000 per turn but never below min(previous pop, 100). -// CONFIDENCE: medium (the shrink floor is read from a terse note). +// The largest population step a colony can take in one turn, up or down. +constexpr std::int64_t kMaxPopulationStep = 50000000; + +// Apply growth to an imperial population: +// delta = min(delta, 50,000,000) when delta >= 0 +// new = pop + delta +// new <= cap -> new +// new > cap and pop <= cap -> exactly cap (no shrink at all) +// new > cap and pop > cap -> max(pop - min(|cap - pop|, 50,000,000), min(pop, 100)) +// finally, floored at 0 +// B4 correction: the shrink is computed from the *old* population and only runs when the +// colony was already over the cap; a colony that merely grows past the cap simply lands on +// it. CONFIDENCE: high -- read branch by branch. std::int64_t ApplyImperialGrowth(std::int64_t pop, std::int64_t capacity, std::int64_t delta); // --------------------------------------------------------------------------------------- // Infrastructure and terraforming // --------------------------------------------------------------------------------------- -constexpr double kInfraPerPoint = 3.3e-5; // (1/500) x 0.01 x 1.65 -constexpr double kTerraformPerPoint = 1.5 * 1.2 / 20000.0; +// The infrastructure divisor is a TRUE double 3.3e-05 in the image (not a widened float). +constexpr double kInfraPerPoint = 3.3e-5; -// Points needed to bring infrastructure from `infra` to 1.0. CONFIDENCE: high. -int InfrastructurePointsNeeded(double infra); +// The two terraforming factors are widened *float* literals, and they differ: the "points +// needed" side uses 1.8000000715255737 (= 1.5 x (double)1.2f, stored as one constant), +// the yield side multiplies by the exact double 1.5 and then by (double)1.2f. The two +// therefore agree exactly, which is what keeps need and yield consistent. +constexpr double kTerraform12 = 1.2000000476837158; // (double)1.2f +constexpr double kTerraformNeedFactor = 1.8000000715255737; // 1.5 x (double)1.2f -// Infrastructure gained from spending `points`: points x 3.3e-5. CONFIDENCE: high. -double InfrastructureGain(int points); +// Points needed to bring infrastructure from `infra` to 1.0: ceil((1 - infra) / 3.3e-5). +// A real ceil on the double quotient. CONFIDENCE: high. +double InfrastructurePointsNeeded(double infra); -// Spend from a pool toward full infrastructure; returns the new value and reports the -// points used. CONFIDENCE: high. -double ApplyInfrastructurePoints(double infra, int pool, int* pointsUsed); +// Infrastructure gained from spending `points`: `points / 500 x 0.01 x 1.65` evaluated in +// that order on the x87, clamped at zero and narrowed to float32 once on the store. The +// arithmetic is deliberately not folded into a single x3.3e-5. CONFIDENCE: high. +double InfrastructureGain(double points); -// Unowned systems lose 0.02 infrastructure per turn, floored at 0. CONFIDENCE: high. +// Spend from a pool toward full infrastructure; returns the *delta* and reports the +// points the pool has left (which cascade into the terraforming pool). CONFIDENCE: high. +double ApplyInfrastructurePoints(double infra, double pool, double* pointsUnused); + +// Add an infrastructure delta to a colony: a no-op once infrastructure is at 1, clamped +// to 1 otherwise, stored as float32. CONFIDENCE: high. +double ApplyInfrastructureDelta(double infra, double delta); + +// Per-turn infrastructure loss of an unowned system. The image's constant is the widened +// float literal `(double)0.02f` = 0.019999999552965164, not the decimal 0.02 (B4). +constexpr double kUnownedInfraDecay = 0.019999999552965164; + +// Unowned systems lose `kUnownedInfraDecay` infrastructure per turn: the difference is stored +// back into a 4-byte float and then floored, `result <= 0 -> 0`. CONFIDENCE: high -- read with +// its constant's exact bits and the fcomp against the .rdata zero. double DecayUnownedInfrastructure(double infra); -// Points needed to terraform from `suit` to `ideal`: |ideal - suit| / (1.8 / 20000). -// CONFIDENCE: high. -int TerraformPointsNeeded(double suit, double ideal); +// Points needed to terraform from `suit` to `ideal`: +// ceil( |float32(ideal - suit)| / |TerraMod x 1.8000000715255737 / 20000| ) +// B4 correction: the player's terraforming modifier is inside the *need*, not only the +// yield -- a better modifier needs proportionally fewer points, and that is what keeps +// need and yield consistent. CONFIDENCE: high. +double TerraformPointsNeeded(double suit, double ideal, double terraMod); -// Suitability change from spending `points`: points x 1.8 x TerraMod / 20000, signed -// toward the ideal. CONFIDENCE: high. -double TerraformDelta(int points, double terraMod, double suit, double ideal); +// Suitability change from spending `points`: +// float32( points x 1.5 x (double)1.2f x TerraMod x sign / 20000 ) +// with `sign` = -1 only when `suit > ideal` strictly (a planet already at the ideal takes +// +1). CONFIDENCE: high. +double TerraformDelta(double points, double terraMod, double suit, double ideal); + +// Add a suitability delta, clamped so the planet stops at the ideal from whichever side +// it approached; stored as float32. CONFIDENCE: high. +double ApplyTerraformDelta(double suit, double delta, double ideal); // --------------------------------------------------------------------------------------- // Slaves // --------------------------------------------------------------------------------------- -// Per-turn slave death rate: -// (slaveOutputRate x BYOUTPUT + |ideal - suit| x BYHAZARD + DEATH_RATE) x mod -// mod = (translation1 ? 0.8 : 1) - 0.2 x translation2 - 0.2 x translation3 -// CONFIDENCE: high. -double SlaveDeathRate(double slaveOutputRate, double suit, double ideal, - const SpeciesTechFlags& flags, const TuningTable& t); +// The two xenotech factors of the slave-death modifier, as the image holds them. +constexpr double kSlaveModBase = 0.800000011920929; // float32 0.8f, replaces the 1 +constexpr double kSlaveModStep = 0.20000000298023224; // (double)0.2f, subtracted -// Deaths = clamp(ftoi64(slaves x rate), MIN_DEATHS, MAX_DEATHS); MAX -1 means no cap. -// Never more than the slaves present. CONFIDENCE: high. -std::int64_t SlaveDeaths(std::int64_t slaves, double rate, const TuningTable& t); +// Per-turn slave death rate: +// mod = (translation1 ? 0.8f : 1) - 0.2 x translation2 - 0.2 x translation3 [no clamp] +// rate = ((|ideal - suit| x BYHAZARD + DEATH_RATE) + SRs x BYOUTPUT) x mod +// B4 correction: the hazard term is folded into the base *before* the output term, and every +// step is stored back to a float32; an unowned system reports 1.0, not 0. CONFIDENCE: high. +double SlaveDeathRate(double slaveOutputRate, double suit, double ideal, + const SpeciesTechFlags& flags, const TuningTable& t, bool owned = true); + +// Deaths this turn: +// d = ftoi64(slaves x (rate + plagueRate)) +// then MIN_DEATHS/MAX_DEATHS, each **disabled by any negative value**, then clamped into +// [0, slaves]. +// B4 correction: the worst plague at the system contributes an *additive* rate term -- that +// is the only path by which a plague kills slaves -- and both bounds are clamps, not just +// the maximum. CONFIDENCE: high. +std::int64_t SlaveDeaths(std::int64_t slaves, double rate, double plagueRate, + const TuningTable& t); // --------------------------------------------------------------------------------------- // Output split @@ -160,10 +241,26 @@ struct OutputRates { double infra = 0; // SRi }; -// Normalise the player's output sliders: negatives -> 0; terraform -> 0 when the planet -// is at the ideal; infra -> 0 when infra + pending bonus >= 1; rescale to sum 1, or an -// equal split when everything is zero. CONFIDENCE: high (the small positive threshold -// below which a slider counts as zero is treated as 0). +// A slider at or below this counts as off, and it is also the value the all-zero fallback +// seeds the unpinned channels with. The image holds the widened float literal +// `(double)1e-4f`. +constexpr double kOutputRateThreshold = 9.999999747378752e-05; + +// Normalise the player's output sliders. +// +// B4 CORRECTION -- this is not a symmetric "rescale all four to sum 1". The routine takes a +// *pinned* channel, and every caller in the game passes the trade slider (explicitly or by +// leaving the argument null, which selects it). So: +// * terraform -> 0 when suitability is exactly at the ideal (an exact ==, no epsilon); +// infra -> 0 when float32(infra + pending bonus) >= 1; +// * any channel at or below (double)1e-4f -> 0; +// * **only the trade slider** is clamped into [0, 1]; +// * the sum covers construction + infra + terraform only, accumulated in float32; +// * an exactly-zero sum seeds those three with 1e-4f (still honouring the two +// suppressions) and re-sums -- an equal split over three channels, not four; +// * each of the three becomes `(rate / sum) x (1 - trade)`, float32 at both stores. +// Trade comes out unchanged. CONFIDENCE: high -- read step by step, including the pinned +// argument's default and the float32 accumulation. OutputRates NormaliseOutputRates(const OutputRates& raw, bool suitAtIdeal, bool infraFull); struct OutputModifiers { @@ -182,29 +279,34 @@ struct OutputModifiers { // decrease threshold x DECREASE_MOD, otherwise x1. CONFIDENCE: high. double MoraleOutputMultiplier(int morale, const TuningTable& t); -// total = round(base x morale x (1 + STATION_BONUS_IMPERIAL_OUTPUT x stations) -// x addiction x ScOutMod x RebOutMod x techOut x sys.OutMod x OutMod) -// CONFIDENCE: high on the multiplier chain; the base-output-from-population term is an -// input here because its own formula is not resolved. -int TotalSystemOutput(const OutputModifiers& m, const TuningTable& t); +// total = roundHalfEven(base x morale x (1 + STATION_BONUS_IMPERIAL_OUTPUT x stations) +// x addiction x ScOutMod x RebOutMod x techOut x sys.OutMod x OutMod) +// B4 correction: the engine's "round" is `fistp`/`fild` -- round to nearest, ties to EVEN -- +// and the result stays a double that the channel splits multiply; only the reported total +// slot truncates it. CONFIDENCE: high on the multiplier chain and the rounding mode; the +// base-output-from-population term is an input because its own formula is not resolved. +double TotalSystemOutput(const OutputModifiers& m, const TuningTable& t); struct OutputSplit { - int trade = 0; - int construction = 0; - int terraform = 0; - int infra = 0; + double trade = 0; + double construction = 0; + double terraform = 0; + double infra = 0; }; -// Round(total x rate) for each channel. CONFIDENCE: high. -OutputSplit SplitOutput(int total, const OutputRates& rates); +// roundHalfEven(total x rate) for each channel, each kept as a double. CONFIDENCE: high. +OutputSplit SplitOutput(double total, const OutputRates& rates); -// Construction points after the shipyard station bonus. CONFIDENCE: high. -int ConstructionPoints(int constructionShare, int stations, const TuningTable& t); +// Construction points after the shipyard station bonus. **Truncating**, not rounding. +// CONFIDENCE: high. +int ConstructionPoints(double constructionShare, int stations, const TuningTable& t); // Redistribute unspent construction points L over trade/terraform/infra. Weights are the -// normalised rates of those three channels, or 1 / (suit != ideal) / (infra != 1) when -// construction was the only slider. CONFIDENCE: medium. -OutputSplit SplitLeftover(int leftover, const OutputRates& rates, bool suitAtIdeal, bool infraFull); +// normalised rates of those three channels, or 1 / (suit != ideal) / (infra != 1) when the +// construction rate is exactly 1. Each share is rounded half-to-even independently, so the +// three need not add back up to L. CONFIDENCE: medium on the weights, high on the rounding. +OutputSplit SplitLeftover(double leftover, const OutputRates& rates, bool suitAtIdeal, + bool infraFull); // Suitability term of the money cost: how far the planet is from the owner species' // ideal, capped by the owner's SuitTol (so the tolerance techs also cap the cost). @@ -241,24 +343,46 @@ int SystemMoneyIncome(const SystemMoneyInputs& in); // System bonus and build queue // --------------------------------------------------------------------------------------- -// Apply a pending population bonus: pop += min(bonus, cap - pop); bonus -= same. -// CONFIDENCE: high. -void ApplyPopulationBonus(std::int64_t& pop, std::int64_t capacity, std::int64_t& pendingBonus); +// Both bonus-pool helpers reset the system's turns-developing counter when they fire on a +// colony that is not the owner's home system. That is reported rather than written, so the +// helpers stay pure; the turn pass applies it. +struct BonusApplyResult { + bool applied = false; // the pool actually moved + bool resetTurnsDeveloping = false; // the non-home `ntdev = 0` the original performs +}; -// Apply a pending infrastructure bonus: infra += min(bonus, 1 - infra); bonus -= same. -// CONFIDENCE: high. -void ApplyInfrastructureBonus(double& infra, double& pendingBonus); +// Apply a pending population bonus. Nothing happens unless `bonus > 0`; an unowned system +// simply drops the whole pool. Otherwise `applied = min(bonus, cap - pop)` with the colony +// already at or over the cap short-circuiting, `pop += applied`, `bonus -= applied`. +// The population and the capacity are 32-bit in the original (it takes only the low word of +// the capacity), which matters only for colonies past 2^31. CONFIDENCE: high -- read +// instruction by instruction, including the unowned drop and the home-system test. +BonusApplyResult ApplyPopulationBonus(std::int64_t& pop, std::int64_t capacity, + std::int64_t& pendingBonus, bool owned = true, + bool homeSystem = true); + +// Apply a pending infrastructure bonus. Nothing happens unless `bonus > 0` and +// `infra < 1`; then `applied = min(bonus, 1 - infra)` and `infra` becomes **exactly 1.0** +// when the pool covers the whole remainder (the original stores the literal, not the sum), +// otherwise `infra + applied`. Every value is rounded to float32. `bonus -= applied`. +// CONFIDENCE: high -- read instruction by instruction, including the exact-1.0 snap. +BonusApplyResult ApplyInfrastructureBonus(double& infra, double& pendingBonus, + bool homeSystem = true); struct SystemBonusInputs { bool stable = false; int turnsOwned = 0; // current turn - acquisition turn - int turnsSinceRebellion = 0; + // Turns the colony has been *developing* (the system's `ntdev` counter, bumped by this + // same pass just before the accrual and reset to 0 whenever the system is not stable). + // B4 correction: the second gate is `ntdev`, not the rebellion turn `rbtn` -- read off + // the two `cmp ... , SYSTEMBONUS_MINTURNS` at the head of the original. + int turnsDeveloping = 0; bool ownerSpeciesEligible = true; // ConstantsOf(owner).systemBonusEligible (Zuul: false) std::int64_t capacity = 0; // imperial carrying capacity }; // Accrue the long-stability bonus when stable, owned for more than SYSTEMBONUS_MINTURNS -// and free of rebellion for as long: +// and developing for as long: // target = eligible ? ftol(max(POPBONUS, 0) x cap) : 0 // popBonus += min(max(ftol(POPBONUS_INC x cap), 0), max(target - popBonus, 0)) // infraBonus += min(max(INFRABONUS_INC, 0), max((eligible ? INFRABONUS : 0) - infraBonus, 0)) @@ -274,18 +398,146 @@ struct BuildOrder { int constructionCost = 0; // con int constructionLeft = 0; // conleft int moneyCost = 0; // charged to the owner on completion (0 = free) + bool moneyAvailable = true; // the owner's answer to the charge; false skips the order }; struct BuildQueueResult { std::vector completedOrderIds; int moneyCharged = 0; - int pointsLeft = 0; // construction points not consumed by any order + int pointsLeft = 0; // the function's return value: points not consumed by any order }; -// FIFO consumption of construction points: an order that needs more than what is left -// absorbs everything and stops the pass; otherwise it completes, its remaining need is -// deducted and the next order is considered. Completed orders are removed. -// CONFIDENCE: high. +// FIFO consumption of construction points. +// points <= 0 -> nothing happens, the points are returned unchanged +// conleft > points -> `conleft -= points`, `points = 0`, **stop** +// otherwise -> if the design costs money, ask the owner to pay; a refusal +// SKIPS this order and moves to the next one rather than +// stopping the pass. On success build the ship, +// `points -= conleft`, `conleft = 0`, continue. +// Removal is a separate sweep afterwards that unlinks **every** order with `conleft <= 0`, +// including ones that were already at zero before this turn. More than one order can +// complete in a turn. CONFIDENCE: high -- B4 corrected the money-refusal path (continue, not +// stop), the removal sweep's predicate, and that the leftover is the return value. BuildQueueResult ProcessBuildQueue(std::vector& queue, int points); +// --------------------------------------------------------------------------------------- +// Per-player countdown nibbles (`Bats2`, `rcex`) +// --------------------------------------------------------------------------------------- +// +// A system carries two 64-bit words in which each player owns a 4-bit counter: `Bats2` (a +// battle cooldown) and `rcex` (a recon cooldown). Alongside each sits a 32-bit "someone is +// counting" mask with one bit per player. The per-turn pass ticks every counter down by one +// and clears the mask bit of a player whose counter has already reached zero. +// +// Only the first 15 players have a nibble: the original skips index >= 15 entirely (it neither +// ticks nor clears), which is why the mask is a separate word. A counter is clamped to 15 on +// write. CONFIDENCE: high -- read instruction by instruction, including the >= 15 guard. + +constexpr int kMaxCountdownPlayers = 15; + +struct ColonyCountdowns { + std::uint64_t counters = 0; // player i's counter occupies bits [4i, 4i+4) + std::uint32_t active = 0; // the companion mask, bit i per player +}; + +int CountdownFor(const ColonyCountdowns& c, int player); +void SetCountdown(ColonyCountdowns& c, int player, int value); // clamped to [0, 15] + +// One turn of decay over `playerCount` players. A zero counter clears the player's mask bit; +// a non-zero one is written back one lower. Players at or past index 15 are untouched, and +// the whole sweep is skipped when the counter word is already zero. CONFIDENCE: high. +void TickCountdowns(ColonyCountdowns& c, int playerCount); + +// --------------------------------------------------------------------------------------- +// Addiction phases and their morale events +// --------------------------------------------------------------------------------------- + +// Morale-event ids raised by the addiction sweep, with the per-species morale delta each +// carries (the deltas come from the event-id table the original inlines). +constexpr int kMoraleEventAddictionSuppressed = 0x1b; // delta -1: the owner has temperance +constexpr int kMoraleEventAddictionOnset = 0x1c; // delta +1: phase 1 +constexpr int kMoraleEventAddictionTerminal = 0x1d; // delta -2: phase 3 + +enum class AddictionPhase : int { None = 0, Onset = 1, Established = 2, Terminal = 3 }; + +// Phase of one species' addiction at this system. `startTurn` is the system's `adt[species]` +// stamp; a zero stamp means the species was never addicted here. +// 0 -> None +// elapsed > PHASE3_START -> Terminal, elapsed > PHASE2_START -> Established, else Onset +// Both comparisons are strict. CONFIDENCE: high. +AddictionPhase AddictionPhaseOf(int startTurn, int currentTurn, int phase2Start, int phase3Start); + +struct MoraleEventRaised { + int species = 0; + int eventId = 0; + int moraleDelta = 0; +}; + +// --------------------------------------------------------------------------------------- +// The per-system turn pass (`ServerSystem::ProcessTurn`) +// --------------------------------------------------------------------------------------- +// +// The original is a dispatcher: most of a colony turn happens in callees (plague, imperial +// and civilian growth, resources, refuelling, slaves, rebellion, the build queue). What this +// function reproduces is the set of words the dispatcher writes *itself*, plus the two +// bonus-pool helpers whose arithmetic is already modelled above. Everything else is an input +// boundary -- see docs/B4.md for the list and why each entry is there. + +struct ColonyTurnState { + double infra = 0; // Infra + double infraBonus = 0; // ibon, the pending infrastructure bonus + std::int64_t pop = 0; // Pop (imperial); overwritten later in the turn by growth + std::int64_t popBonus = 0; // pbon, the pending population bonus + int turnsDeveloping = 0; // ntdev + int totalResources = 0; // TRes, zeroed every turn + bool growthHalted[3] = {false, false, false}; // haltv, cleared every turn + ColonyCountdowns battles; // Bats2 + its mask + ColonyCountdowns recon; // rcex + its mask +}; + +struct ColonyTurnInputs { + bool owned = false; // PID != null + bool independent = false; // indi != null (an independent colony record exists) + bool homeSystem = true; // this is the owner's home system + bool stable = false; // IsStable() + int playerCount = 0; // players in the game (bounds the countdown sweep) + int currentTurn = 0; // StrategyServer ModCount + int turnsOwned = 0; // ModCount - TAcq + bool ownerSpeciesEligible = true; // the owner's species may accrue system bonuses + std::int64_t imperialCapacity = 0; // MaxPop for the imperial group + + // Addiction sweep, one entry per species slot. + bool civilianPresent[kSpeciesCount] = {}; // civilians (incl. pending bonus) > 0 + bool temperance[kSpeciesCount] = {}; // the owner holds the temperance xenotech + int addictionStart[kSpeciesCount] = {}; // adt[species] + int addictionPhase2Start = 0; // ADDICTION_PHASE2_START + int addictionPhase3Start = 0; // ADDICTION_PHASE3_START +}; + +struct ColonyTurnResult { + std::vector moraleEvents; // from the addiction sweep, in species order + int rngDraws = 0; // words this pass consumes from the server generator +}; + +// One `ServerSystem::ProcessTurn`, restricted to what the function body writes itself: +// 1. unowned system: `Infra -= UNOWNED_INFRA_DECAY`, floored at 0 (`Infra <= 0` -> 0); +// 2. `ApplyInfrastructureBonus` then `ApplyPopulationBonus` (both run even when unowned); +// 2b. either bonus firing on a colony that is not the owner's home system resets `ntdev`; +// 3. `ntdev` ++ when stable, else reset to 0; +// 4. `AccrueSystemBonus` (gated on owned/stable/turnsOwned/ntdev); +// 5. `TRes = 0`; +// 6. `haltv[0..2] = false`; +// 7. the two countdown sweeps; +// 8. the addiction sweep, which raises one morale event per addicted species. +// The order above is the original's. Steps 3-4 read `stable`, which is a callee's verdict and +// therefore an input here. The pass consumes no RNG at all -- and neither do ProcessPlague, +// the civilian growth pass or ProcessSlaves, all three swept to call depth one and provably +// draw-free. **The only RNG consumer in a colony turn is ProcessRebellion**, and its draw +// count is data dependent (one per million rebels in the counting pass, plus a +// short-circuiting per-species roll), so a system with no rebellion must leave the generator +// untouched -- which is what makes the generator a usable declared region here. +// CONFIDENCE: high on every step listed; the sub-passes themselves are not modelled. +ColonyTurnResult ProcessColonyTurn(ColonyTurnState& s, const ColonyTurnInputs& in, + const TuningTable& t); + } // namespace sots::sim diff --git a/src/game/sim/movement.cpp b/src/game/sim/movement.cpp index 1e1fca1..27cfde8 100644 --- a/src/game/sim/movement.cpp +++ b/src/game/sim/movement.cpp @@ -2,6 +2,9 @@ #include #include +#include +#include +#include #include "game/sim/numeric.h" @@ -10,6 +13,9 @@ namespace sots::sim { namespace { constexpr double kMinChordLength = 0.01; +// The epsilon the engine's vector normalise uses: below it the direction is zeroed. +constexpr double kNormaliseEpsilon = 1.1920928955078125e-07; // 2^-23, as a float32 literal + double Dot(const Vec3& a, const Vec3& b) { return a.x * b.x + a.y * b.y + a.z * b.z; } Vec3 Sub(const Vec3& a, const Vec3& b) { return Vec3{a.x - b.x, a.y - b.y, a.z - b.z}; } Vec3 Lerp(const Vec3& a, const Vec3& b, double f) { @@ -36,7 +42,10 @@ double DistPointToSegment(const Vec3& p, const Vec3& a, const Vec3& b) { return Distance(p, Lerp(a, b, u)); } -double StraightStep(double speed, double dt) { return speed * dt; } +bool IsGateTransitWaypoint(int waypointType) { return waypointType == 4 || waypointType == 5; } +bool IsNodeWaypoint(int waypointType) { return waypointType == 3; } + +double StraightStep(double speed, double dt) { return F32(speed * dt); } double NodeLineSpeed(double nodeSpeed, double distToLineSystem, const TuningTable& t) { double ratio = 0.0; @@ -46,135 +55,308 @@ double NodeLineSpeed(double nodeSpeed, double distToLineSystem, const TuningTabl return nodeSpeed * ((t.STUTTER_MAX_SPEED - t.STUTTER_MIN_SPEED) * ratio + t.STUTTER_MIN_SPEED); } +bool SegmentSphereIntersect(const Vec3& from, const Vec3& to, const Vec3& centre, double radius, + double* tNear, double* tFar) { + constexpr double kFltMax = 3.4028234663852886e38; + const Vec3 d = Sub(to, from); + const double a = Dot(d, d); + if (a <= kNormaliseEpsilon) { + *tNear = *tFar = kFltMax; + return false; + } + const Vec3 fc = Sub(from, centre); + const double b = 2.0 * Dot(d, fc); + const double c = Dot(centre, centre) + Dot(from, from) - 2.0 * Dot(from, centre) - radius * radius; + const double disc = b * b - 4.0 * a * c; + double t0, t1; + if (std::fabs(disc) < kNormaliseEpsilon) { + t0 = t1 = -b / (2.0 * a); + if (!(t0 >= 0.0 && t0 <= 1.0)) { + *tNear = *tFar = kFltMax; + return false; + } + } else if (disc < 0) { + *tNear = *tFar = kFltMax; + return false; + } else { + const double root = std::sqrt(disc); + t0 = (-b - root) / (2.0 * a); + t1 = (-b + root) / (2.0 * a); + } + // Reject a sphere the segment does not reach, and report an open end as the sentinel + // the caller's clamp turns into the corresponding line end. + if (t1 < 0.0) { + *tNear = *tFar = -kFltMax; + return false; + } + if (t0 > 1.0) { + *tNear = *tFar = kFltMax; + return false; + } + *tNear = t0 < 0.0 ? -kFltMax : t0; + *tFar = t1 > 1.0 ? kFltMax : t1; + return true; +} + std::vector BuildStutterSegments(const Vec3& from, const Vec3& to, const std::vector& systems, const TuningTable& t) { std::vector segs; const double radius = t.STUTTER_SYSTEM_INFLUENCE_RADIUS; const Vec3 d = Sub(to, from); - const double len2 = Dot(d, d); - if (radius <= 0 || len2 <= 0) return segs; - const double len = std::sqrt(len2); + const double len = F32(std::sqrt(F32(Dot(d, d)))); + if (len <= 0) return segs; - // Chord of the line inside each sphere, as [start, end] distances along the line. for (std::size_t i = 0; i < systems.size(); ++i) { - const Vec3 fc = Sub(from, systems[i]); - const double b = 2.0 * Dot(d, fc); - const double c = Dot(fc, fc) - radius * radius; - const double disc = b * b - 4.0 * len2 * c; - if (disc < 0) continue; - const double root = std::sqrt(disc); - const double u0 = Clamp01((-b - root) / (2.0 * len2)); - const double u1 = Clamp01((-b + root) / (2.0 * len2)); + double t0 = 0, t1 = 0; + if (!SegmentSphereIntersect(from, to, systems[i], radius, &t0, &t1)) continue; StutterSegment s; - s.start = u0 * len; - s.end = u1 * len; + s.start = ClampT(F32(t0 * len), 0.0, len); + s.end = ClampT(F32(t1 * len), 0.0, len); s.systemIndex = static_cast(i); - if (s.end - s.start < kMinChordLength) continue; + if (std::fabs(s.start - s.end) <= kMinStutterChord) continue; segs.push_back(s); } std::sort(segs.begin(), segs.end(), [](const StutterSegment& a, const StutterSegment& b) { return a.start < b.start; }); - // Split overlaps at the midpoint of the overlap; a chord entirely inside the - // previous one disappears. - std::vector merged; - for (const StutterSegment& s : segs) { - if (!merged.empty() && s.start < merged.back().end) { - const double mid = 0.5 * (s.start + merged.back().end); - if (s.end <= mid) continue; - merged.back().end = mid; - StutterSegment cut = s; - cut.start = mid; - merged.push_back(cut); - } else { - merged.push_back(s); - } + // The overlap pass, reproduced exactly -- see the header. One forward sweep over + // adjacent pairs, both boundaries set to the same value, nothing removed. + for (std::size_t i = 0; i + 1 < segs.size(); ++i) { + if (!(segs[i].end > segs[i + 1].start)) continue; + const double x = F32(segs[i].end + 0.5 * (segs[i].end - segs[i + 1].start)); + segs[i].end = x; + segs[i + 1].start = x; } - // Per-segment profile: closest approach of the whole chord to its system. - for (StutterSegment& s : merged) { + // Per-segment profile: closest approach of the (post-merge) chord to its system. The + // original computes this inside the step loop, after the merge, which is where the + // absence of a clamp on dist/radius can bite. + for (StutterSegment& s : segs) { const Vec3 a = Lerp(from, to, s.start / len); const Vec3 b = Lerp(from, to, s.end / len); - const double dist = DistPointToSegment(systems[static_cast(s.systemIndex)], a, b); - s.speedFactor = NodeLineSpeed(1.0, dist, t); + const double dist = F32(DistPointToSegment(systems[static_cast(s.systemIndex)], a, b)); + s.speedFactor = F32(NodeLineSpeed(1.0, dist, t)); } - return merged; + return segs; } -double AdvanceAlongNodeLine(double along, double dt, double nodeSpeed, double lineLength, - const std::vector& segments) { - if (nodeSpeed <= 0 || dt <= 0) return std::min(along, lineLength); - double s = along; - double timeLeft = dt; - while (timeLeft > 0 && s < lineLength) { - // Speed at s, and the distance until it may change. - double factor = 1.0; - double until = lineLength; - for (const StutterSegment& seg : segments) { - if (s >= seg.start && s < seg.end) { - factor = seg.speedFactor; - until = seg.end; - break; - } - if (seg.start > s) { - until = std::min(until, seg.start); +NodeLineStepResult NodeLineStep(double nodeSpeed, double dt, double lineLength, + const std::vector& segments) { + NodeLineStepResult r; + if (!(dt > 0.0)) return r; // dt <= 0: nothing moves, and `time < dt` is false + + bool ranOut = false; + for (const StutterSegment& s : segments) { + if (s.start >= r.along) { + if (r.time >= dt) break; + const double tt = r.time + (s.start - r.along) / nodeSpeed; // plain speed in the gap + if (tt > dt) { + r.along = r.along + (dt - r.time) * nodeSpeed; + r.time = dt; + ranOut = true; break; } + r.time = tt; + r.along = s.start; } - const double v = nodeSpeed * factor; - if (v <= 0) break; - const double reach = v * timeLeft; - const double gap = until - s; - if (reach < gap) { - s += reach; - timeLeft = 0; - } else { - s = until; - timeLeft -= gap / v; + const double v = F32(s.speedFactor * nodeSpeed); + if (s.end < r.along) continue; // swallowed or inverted by the overlap pass + if (dt <= r.time) break; + const double tt = r.time + (s.end - r.along) / v; + if (tt > dt) { + r.along = r.along + (dt - r.time) * v; + r.time = dt; + ranOut = true; + break; + } + r.time = tt; + r.along = s.end; + if (r.time >= dt) break; + } + + // Segments exhausted with time to spare: run straight to the end of the line. + if (!ranOut && r.time < dt) { + if (!(lineLength < r.along) && dt > r.time) { + const double tt = r.time + (lineLength - r.along) / nodeSpeed; + if (tt <= dt) { + r.time = tt; + r.along = lineLength; + } else { + r.along += nodeSpeed * (dt - r.time); + r.time = dt; + } } } - return std::min(s, lineLength); + + r.arrived = r.time < dt || std::fabs(r.along - lineLength) < kNormaliseEpsilon; + return r; +} + +// --------------------------------------------------------------------------------------- +// Range, clamping and arrival +// --------------------------------------------------------------------------------------- + +double FleetMinShipRange(const std::vector& shipRanges, double bias) { + // The accumulator starts at FLT_MAX, so an empty fleet is unconstrained rather than + // stranded -- worth knowing, because the stranded test compares against exactly 0. + double best = static_cast(std::numeric_limits::max()); + for (double r : shipRanges) best = std::min(best, r); + return F32(best + bias); } MoveStepResult ResolveMoveStep(double step, double minShipRange, double distance) { MoveStepResult r; - const double range = minShipRange - 0.05; - if (range < distance && minShipRange == 0.0) { - step = 0.0; - r.outOfFuel = true; + double range = F32(minShipRange + kRangeGrace); + if (distance > range) { + // The original asks for the minimum range a second time, with no grace margin, and + // takes the whole budget away when it is exactly zero. It zeroes the *range*, not + // the step: the step is still the divisor of the pass fraction. + if (minShipRange == 0.0) { + range = 0.0; + r.stranded = true; + } } - double move = std::min(step, std::min(range, distance)); - if (move < 0) move = 0; - r.moved = move; - r.fraction = step > 0 ? move / step : 1.0; - r.arrived = distance <= 0 || move == distance; + const double move = std::min(std::min(range, step), distance); + r.range = range; + r.moved = move; // deliberately not floored at 0 + r.arrived = move == distance; return r; } +Vec3 AdvanceAlongDirection(const Vec3& pos, const Vec3& dest, double move) { + const Vec3 delta = Sub(dest, pos); + const double len = std::sqrt(Dot(delta, delta)); + if (!(len > kNormaliseEpsilon)) return pos; // the direction is zeroed; nothing moves + const Vec3 dir{delta.x / len, delta.y / len, delta.z / len}; + return Vec3{F32(pos.x + F32(dir.x * move)), F32(pos.y + F32(dir.y * move)), + F32(pos.z + F32(dir.z * move))}; +} + double ConsumeShipRange(double shipRange, double moved, bool exempt) { if (exempt) return shipRange; - return std::max(0.0, shipRange - moved); + const double v = F32(shipRange - moved); + return v < 0.0 ? 0.0 : v; +} + +// --------------------------------------------------------------------------------------- +// Pass fraction +// --------------------------------------------------------------------------------------- + +double PassFraction(int waypointType, double distance, double step) { + if (!IsNodeWaypoint(waypointType)) return 1.0; + if (step == 0) return 1.0; // the clamp of an infinity/NaN quotient lands at the bound + return Clamp01(F32(distance / step)); +} + +double BlockedPassFraction(double moved, double step) { + if (step == 0) return 1.0; + return Clamp01(F32(moved / step)); } double RemainingPassTime(double fraction, double dt) { - if (fraction < 0.9999) return (1.0 - fraction) * dt; + if (fraction < kPassCompleteFraction) return F32((1.0 - fraction) * dt); return 0.0; } +// --------------------------------------------------------------------------------------- +// Probabilistic jump +// --------------------------------------------------------------------------------------- + JumpResult RollProbabilisticJump(double castEfficiency, double castThreshold, IRandom& rng) { JumpResult r; r.roll = rng.NextFloat(); - const double scaled = static_cast(r.roll) * castEfficiency; - if (scaled > castThreshold) { + r.draws = 1; + // The efficiency is read as a float32 and the product is stored back to a float32 slot + // before the comparison, so both narrowings are real. + const double v = F32(static_cast(r.roll) * F32(castEfficiency)); + if (v > castThreshold) { r.arrived = false; - r.stopFraction = Clamp01(scaled); + r.scatter = v; + rng.NextUInt32(); // the random direction the miss is scattered along + r.draws = 2; } else { r.arrived = true; - r.stopFraction = 1.0; + r.scatter = 0; } return r; } +// --------------------------------------------------------------------------------------- +// The turn's pass schedule +// --------------------------------------------------------------------------------------- + +std::vector PlanFleetMovement(const std::vector& fleets) { + std::set pursuers, prey, followers, resolved; + std::map preyOf; + for (const FleetMovementEntry& f : fleets) { + if (f.targetFleetId == 0) continue; + if (f.relation != 0) { + followers.insert(f.fleetId); + } else { + pursuers.insert(f.fleetId); + prey.insert(f.targetFleetId); + preyOf[f.fleetId] = f.targetFleetId; + } + } + + std::vector out; + // Pass 1: the prey move half a turn. + for (int id : prey) out.push_back({id, kHalfStep, 1}); + + // Pass 2: every pursuer (of the *original* set) re-validates and moves half a turn. A + // pursuer that arrives retires itself and its prey from the rest of the schedule. + const std::set pursuersAtPass2 = pursuers; + for (int id : pursuersAtPass2) { + out.push_back({id, kHalfStep, 2}); + const FleetMovementEntry* e = nullptr; + for (const FleetMovementEntry& f : fleets) + if (f.fleetId == id) e = &f; + if (!e || !e->caught) continue; + const int target = preyOf[id]; + resolved.insert(id); + resolved.insert(target); + pursuers.erase(id); + prey.erase(target); + followers.erase(target); + } + + // Pass 3: the prey that got away take their second half turn. + for (int id : prey) out.push_back({id, kHalfStep, 3}); + + // Pass 4: everyone else, in fleet order. A pursuer whose chase failed is still in the + // pursuer set and gets a half turn; anything already scheduled is skipped. + for (const FleetMovementEntry& f : fleets) { + const int id = f.fleetId; + if (resolved.count(id)) continue; + if (pursuers.count(id)) { + out.push_back({id, kHalfStep, 4}); + continue; + } + if (prey.count(id)) continue; + if (followers.count(id)) continue; + out.push_back({id, kFullStep, 4}); + } + + // Pass 5: the followers take a full turn. + for (int id : followers) out.push_back({id, kFullStep, 5}); + return out; +} + +// --------------------------------------------------------------------------------------- +// Gate traffic +// --------------------------------------------------------------------------------------- + +std::vector GateTrafficTotals(const std::vector& fleets, int playerCount) { + std::vector totals(static_cast(std::max(playerCount, 0)), 0); + for (const GateTrafficEntry& f : fleets) { + if (f.waypointType < 0) continue; // no waypoints: the fleet is not in transit + if (!IsGateTransitWaypoint(f.waypointType)) continue; + if (f.ownerIndex < 0 || f.ownerIndex >= static_cast(totals.size())) continue; + totals[static_cast(f.ownerIndex)] += f.traffic; + } + return totals; +} + } // namespace sots::sim diff --git a/src/game/sim/movement.h b/src/game/sim/movement.h index 58be1fb..9211a06 100644 --- a/src/game/sim/movement.h +++ b/src/game/sim/movement.h @@ -1,6 +1,8 @@ -// Movement: the per-pass fleet step, node-line speed profile, probabilistic jumps. +// Movement: the per-pass fleet step, node-line speed profile, probabilistic jumps, +// the turn's pass schedule and the per-player gate-traffic total. #pragma once +#include #include #include "game/sim/rng.h" @@ -22,20 +24,42 @@ Vec3 AdvanceToward(const Vec3& pos, const Vec3& dest, double amount); // parameter is clamped to [0, 1]). CONFIDENCE: high. double DistPointToSegment(const Vec3& p, const Vec3& a, const Vec3& b); -// Fractions of a turn each movement pass advances. The server runs the departing/ -// in-transit sets in two half-steps and the remaining fleets in one full step. -// CONFIDENCE: medium (the bucketing semantics are not fully resolved; the constants are). +// --------------------------------------------------------------------------------------- +// Waypoints +// --------------------------------------------------------------------------------------- + +// A waypoint is 28 bytes: {?, int targetId @+4, int type @+8, NodeRoute @+0xc}. Only the +// type matters to the movement step. CONFIDENCE: high -- the stride is pinned by the +// divide-by-28 reciprocal multiply the iterator arithmetic uses. +constexpr int kWaypointStride = 28; + +enum class WaypointKind : int { + Straight = 0, // any type not listed below: step = speed x dt + NodeLine = 2, // node-line travel with the stutter profile + NodeRoute = 3, // the only kind that reports a partial pass fraction + GateTeleport = 4, // arrive at once + ProbabilisticJump = 5, // random scatter around the destination +}; + +// The two waypoint predicates the movement code branches on. Both are 7-entry jump tables +// with a false default, so a type outside 0..6 is false. +// gate transit: type 4 or 5 -- what the gate-traffic total counts +// node: type 3 only -- what makes a straight step report a partial fraction +// CONFIDENCE: high -- read off both jump tables entry by entry. Note that the node-*line* +// case of the movement switch is type 2, which `IsNodeWaypoint` does NOT accept. +bool IsGateTransitWaypoint(int waypointType); +bool IsNodeWaypoint(int waypointType); + +// --------------------------------------------------------------------------------------- +// One movement step +// --------------------------------------------------------------------------------------- + +// The fraction of a turn each of the five movement passes advances. constexpr double kHalfStep = 0.5; constexpr double kFullStep = 1.0; -enum class WaypointKind : int { - Straight = 0, // any type not listed below: step = speed x dt - NodeLine = 2, // node-line travel with the stutter profile - GateTeleport = 4, // arrive at once - ProbabilisticJump = 5, // random scatter along the vector -}; - -// Straight-line step for one pass: speed x dt. CONFIDENCE: high. +// Straight-line step for one pass: speed x dt, rounded to float32 (both operands are +// 4-byte floats and the product is stored back to one). CONFIDENCE: high. double StraightStep(double speed, double dt); // Node-line speed inside a system's influence sphere: @@ -50,6 +74,7 @@ double NodeLineSpeed(double nodeSpeed, double distToLineSystem, const TuningTabl // One stretch of a node line that lies inside a system's influence sphere, measured as // distances along the line from its start. `speedFactor` is the profile multiplier for // the whole stretch (closest approach of the chord, not re-evaluated per position). +// The element is 12 bytes in the original: {float start, float end, System* system}. struct StutterSegment { double start = 0; double end = 0; @@ -57,56 +82,212 @@ struct StutterSegment { double speedFactor = 1.0; }; +// The minimum length a chord must have to survive, `0.01f` as the image holds it. The +// test is `fabs(start - end) <= threshold`, so an exactly-0.01 chord is dropped. +constexpr double kMinStutterChord = 0.009999999776482582; // (double)0.01f + +// Where the travel segment enters and leaves one system's influence sphere, as parameters +// on [0, 1] along the segment. Returns false when the sphere is missed entirely or lies +// wholly behind/ahead of the segment; a grazing (tangent) hit is accepted only when its +// parameter is inside [0, 1]. An entry before the start (or an exit past the end) is +// reported as -FLT_MAX (or +FLT_MAX), which the caller's clamp turns into 0 (or the line +// length). CONFIDENCE: high -- read from the quadratic solver and its wrapper. +bool SegmentSphereIntersect(const Vec3& from, const Vec3& to, const Vec3& centre, double radius, + double* tNear, double* tFar); + // Intersect the travel line with every system's sphere of radius -// STUTTER_SYSTEM_INFLUENCE_RADIUS: each chord becomes a segment (clipped to the line), -// chords shorter than 0.01 are dropped, the segments are sorted by start, and where two -// overlap the boundary is placed at the midpoint of the overlap. CONFIDENCE: high on the -// chord/clip/drop/sort steps; medium on the exact overlap rule (midpoint split assumed; -// a chord swallowed whole by an earlier one is dropped). +// STUTTER_SYSTEM_INFLUENCE_RADIUS. Each hit becomes a segment whose bounds are the chord +// parameters scaled to world distance along the line and clamped to [0, length]; chords of +// 0.01 or less are dropped; the survivors are sorted ascending by `start` (a real +// `std::sort`, so ties are unordered); then a single forward pass over adjacent pairs +// resolves overlaps. +// +// B4 CORRECTION -- the overlap rule is not a midpoint. When `seg[i].end > seg[i+1].start` +// the original sets **both** boundaries to +// x = float32(seg[i].end + 0.5 x (seg[i].end - seg[i+1].start)) +// i.e. the mirror of the midpoint about `seg[i].end`, which pushes the boundary *forward* +// past both chords by half the overlap. Nothing is dropped and nothing is clipped back, so +// a chord swallowed by its predecessor comes out inverted (`start > end`) and the step +// loop skips it. This is reproduced verbatim, bug and all. CONFIDENCE: high -- read down +// to the ModRM byte, because the FSUB/FADD operand order is the whole claim. std::vector BuildStutterSegments(const Vec3& from, const Vec3& to, const std::vector& systems, const TuningTable& t); -// Advance a distance `along` the line by `dt` turns at `nodeSpeed`, using the segment -// speeds inside influence spheres and plain nodeSpeed between them. Returns the new -// distance along the line, never past `lineLength`. CONFIDENCE: medium (piecewise -// integration written from the per-segment description). -double AdvanceAlongNodeLine(double along, double dt, double nodeSpeed, double lineLength, - const std::vector& segments); +struct NodeLineStepResult { + double along = 0; // distance covered along the line + double time = 0; // turns consumed + bool arrived = false; // the far end was reached +}; + +// One node-line step: walk the segments in ascending order, moving at plain `nodeSpeed` +// between influence spheres and at the segment's own constant speed inside one, until +// either the line ends or `dt` runs out. Arrival is `time < dt` or `|along - length|` under +// one float epsilon, and on arrival the caller copies the destination position verbatim +// rather than extrapolating. CONFIDENCE: high -- the loop was reconstructed instruction by +// instruction, including the "segment entirely behind us" skip and the straight run that +// finishes the line when the segments are exhausted. +NodeLineStepResult NodeLineStep(double nodeSpeed, double dt, double lineLength, + const std::vector& segments); + +// --------------------------------------------------------------------------------------- +// Range, clamping and arrival +// --------------------------------------------------------------------------------------- + +// The grace margin added to the fleet's minimum ship range before the range clamp. +// B4 correction: the constant in the image is `+0.05f` (0x3d4ccccd, sign bit clear) and +// the range helper *adds* its argument, so the margin is added, not subtracted. +constexpr double kRangeGrace = 0.05000000074505806; // (double)0.05f + +// The slowest tank in the fleet: min over ships of the ship's remaining range, plus a +// bias, rounded to float32. An **empty fleet reports FLT_MAX + bias** (the accumulator's +// seed), and no ship is excluded -- range-exempt ships (tankers) still clamp the fleet. +// CONFIDENCE: high -- read instruction by instruction, including the seed. +double FleetMinShipRange(const std::vector& shipRanges, double bias); struct MoveStepResult { - double moved = 0; // distance actually covered this pass - double fraction = 1.0; // moved / step (1 when the step was zero) - bool arrived = false; // reached the destination exactly - bool outOfFuel = false; // the fleet has no range and could not move + double moved = 0; // distance actually covered this pass + double range = 0; // the range budget after the stranded check + bool arrived = false; // the step landed exactly on the destination + bool stranded = false; // the fleet has no range left and could not cover the distance }; // Clamp a pass's step against the fleet's range and the remaining distance: -// range = minShipRange - 0.05 -// if the fleet has no range at all and range < distance: step = 0 (stranded) -// move = min(step, range, distance), never negative; arrival when move == distance. -// CONFIDENCE: high. +// range = float32(minShipRange + 0.05f) +// if (distance > range && minShipRange == 0) range = 0 -- the *range* is zeroed +// move = min(min(range, step), distance) -- in that order +// arrived iff move == distance (exact float comparison; the original then snaps the +// position onto the destination by a raw copy, so the later arrival test is exact) +// There is **no floor at zero** on `move`: a negative minimum range moves the fleet +// backwards. `step` is NOT modified -- it stays the divisor of the pass fraction. +// CONFIDENCE: high -- B4 corrected the sign of the grace margin, which value is zeroed, +// and the absence of the floor. MoveStepResult ResolveMoveStep(double step, double minShipRange, double distance); -// Remaining strategic range of one ship after moving; ships flagged as range-exempt -// (e.g. tankers, in-system) are not charged. Floors at 0. CONFIDENCE: high. +// New position after a step that did not arrive: `pos + unit(dest - pos) x move`, with +// each component rounded to float32 after the multiply and before the add. When the step +// did arrive the original copies the destination's words verbatim, which `AdvanceToward` +// reproduces. CONFIDENCE: high. +Vec3 AdvanceAlongDirection(const Vec3& pos, const Vec3& dest, double move); + +// Remaining strategic range of one ship after moving. Ships carrying the range-exempt +// flag (tankers and tenders, bit 0x1000) are skipped entirely -- they pay nothing and are +// topped back up by the refuelling pass. Everyone else loses `moved`, floored at 0. +// CONFIDENCE: high. double ConsumeShipRange(double shipRange, double moved, bool exempt); -// Time left in the turn after a pass consumed `fraction` of its step: passes below -// 0.9999 recurse with the rest. Returns 0 when the pass is considered complete. +// --------------------------------------------------------------------------------------- +// Pass fraction and the multi-waypoint recursion +// --------------------------------------------------------------------------------------- + +// The recursion threshold, an 8-byte constant whose value is exactly (double)0.9999f. +constexpr double kPassCompleteFraction = 0.9998999834060669; + +// How much of the pass the step consumed. A gate teleport, a probabilistic jump and an +// arrival all report a full pass; a node waypoint (type 3) reports clamp01(distance/step); +// everything else reports a full pass. When the move was blocked short of the destination +// the caller instead reports clamp01(move/step). CONFIDENCE: high. +double PassFraction(int waypointType, double distance, double step); +double BlockedPassFraction(double moved, double step); + +// Time left in the turn after a pass consumed `fraction` of its step. The comparison is +// strict -- a fraction equal to the threshold does not recurse -- and the remaining dt is +// rounded to float32 before the recursive call, which is what the original passes. // CONFIDENCE: high. double RemainingPassTime(double fraction, double dt); +// --------------------------------------------------------------------------------------- +// Probabilistic jump (waypoint type 5) +// --------------------------------------------------------------------------------------- + struct JumpResult { - bool arrived = false; // exact arrival at the destination - double stopFraction = 0; // when not arrived: how far along the vector the fleet stopped - float roll = 0; + bool arrived = false; // the jump landed exactly on the destination + float roll = 0; // the unit draw + double scatter = 0; // when it misses: the distance from the destination + int draws = 0; // RNG words consumed: 1 on success, 2 on a miss }; -// Probabilistic jump (waypoint type 5): roll = rand01() x castEfficiency; if roll exceeds -// the cast threshold the fleet stops at fraction `roll` along the vector, otherwise it -// arrives exactly. One RNG draw. CONFIDENCE: medium (the identity of the two player -// fields as efficiency/threshold is inferred; the arithmetic is established). +// Waypoint type 5 -- the Zuul node bore / Morrigi gravity cast. +// roll = rand01() (one draw) +// v = float32(roll x castEfficiency) (player CstE) +// arrives iff !(v > castThreshold) (player CstT; equality arrives) +// on a miss the fleet is placed at `dest + randomUnitVector x v` -- it is scattered +// *around* the destination by exactly `v`, and the random direction costs a **second** +// raw draw. It is not a partial advance along the travel vector. +// The pass fraction is 1.0 either way, so a type-5 waypoint never recurses. +// CONFIDENCE: high -- B4 read the whole callee; the previous "stop at fraction roll along +// the vector" reading was wrong. JumpResult RollProbabilisticJump(double castEfficiency, double castThreshold, IRandom& rng); +// --------------------------------------------------------------------------------------- +// The turn's pass schedule (`StrategyServer::ProcessFleetMovement`) +// --------------------------------------------------------------------------------------- +// +// The schedule is a pursuit model, not a "departing / in transit / other" split. Every +// fleet whose current waypoint targets another *fleet* is classified by the relation +// between the two owners: relation 0 (no treaty -- hostile or unknown) makes it a pursuer, +// anything else makes it a follower. Then: +// +// pass 1 every prey fleet moves half a turn +// pass 2 every pursuer re-validates its intercept and moves half a turn; a pursuer that +// arrives this half retires itself and its prey from the schedule +// pass 3 every prey still on the list moves its second half turn +// pass 4 every other fleet moves a full turn -- except a pursuer that failed to catch +// anything, which gets a second half turn instead +// pass 5 every follower moves a full turn +// +// so a caught pursuer/prey pair moves 0.5, an escaping prey 1.0, an uncaught pursuer +// 0.5 + 0.5, and everyone else 1.0. CONFIDENCE: high -- read pass by pass. + +enum class FleetRole : int { + Other = 0, // no fleet target, or the target could not be resolved + Pursuer = 1, // targets a fleet whose owner it has no relation with (relation 0) + Prey = 2, // the target of at least one pursuer + Follower = 3, // targets a fleet whose owner it does have a relation with +}; + +// One fleet as the schedule sees it. +struct FleetMovementEntry { + int fleetId = 0; + int targetFleetId = 0; // 0 when the current waypoint does not resolve to a fleet + int relation = 0; // owner-to-owner relation with the target's owner + bool caught = false; // filled by the caller: did the pursuer's half step arrive +}; + +// One scheduled call: which fleet, and the dt it is moved by. +struct MovementPass { + int fleetId = 0; + double dt = 0; + int pass = 0; // 1..5, the pass that issued it +}; + +// Build the call schedule. `caught` is consulted only for pursuers and only for the +// second half of the schedule, because the original decides it from the return value of +// the pass-2 call; a caller replaying a trace fills it in from what actually happened. +// CONFIDENCE: high on the order and the dt of every pass. +std::vector PlanFleetMovement(const std::vector& fleets); + +// --------------------------------------------------------------------------------------- +// Gate traffic +// --------------------------------------------------------------------------------------- + +// The accumulator the gate-traffic pass uses is a fixed 32 ints with no bounds check. +constexpr int kMaxGateTrafficPlayers = 32; + +struct GateTrafficEntry { + int ownerIndex = 0; // the owning player's index word + int waypointType = -1; // the current waypoint's type; -1 when there are none + std::int16_t traffic = 0; // the fleet's gate-traffic contribution, a SIGNED int16 +}; + +// Per-player gate traffic for the turn: for every fleet with a waypoint whose type is a +// gate transit (4 or 5), add the fleet's signed 16-bit traffic word to its owner's slot. +// The result is **assigned** to each player, not accumulated, so it resets every turn. +// +// Note the original indexes the accumulator by the player's own index word while writing +// it back by the player's position in the server's player vector; the two agree only while +// `players[j]->index == j`. This function returns the accumulator, indexed by owner index, +// and leaves that identity to the caller. CONFIDENCE: high. +std::vector GateTrafficTotals(const std::vector& fleets, int playerCount); + } // namespace sots::sim diff --git a/src/game/sim/numeric.h b/src/game/sim/numeric.h index f5f6687..6e292b3 100644 --- a/src/game/sim/numeric.h +++ b/src/game/sim/numeric.h @@ -27,11 +27,31 @@ inline std::int64_t Ftoi64(double v) { return static_cast(v); } -// Round-half-away-from-zero to int (used for the per-system output split). -inline int RoundToInt(double v) { return Ftol(std::round(v)); } +// The engine's "round" helper is `fistp` followed by `fild`: it rounds with the x87's +// current mode, which MSVC leaves at round-to-nearest, ties-to-EVEN, and hands the result +// back as a float rather than an int. Ties therefore go to the even neighbour, not away +// from zero -- 0.5 rounds to 0 and 1.5 rounds to 2. (B4 correction; the module previously +// used round-half-away-from-zero here.) +inline double RoundHalfEven(double v) { + if (!(v == v)) return v; + return std::nearbyint(v); // the default FE_TONEAREST mode is ties-to-even +} + +// The same rounding, delivered as an int for the callers that immediately truncate it. +inline int RoundToInt(double v) { return Ftol(RoundHalfEven(v)); } inline double Clamp01(double v) { return v < 0 ? 0 : (v > 1 ? 1 : v); } +// Narrow to float32 and widen back. The engine keeps most colony and movement scalars in +// 4-byte floats and does the arithmetic on the x87 stack, so every store back to such a +// field rounds to single precision; a formula that skips that step drifts. +inline double F32(double v) { return static_cast(static_cast(v)); } + +// A float32 literal as the image holds it. The compiler widened these decimals once, at +// compile time, so `0.02` in the disassembly is really 0.019999999552965164; using the exact +// decimal rounds differently at a truncation or comparison boundary. +inline constexpr double WidenedFloatLiteral(float v) { return static_cast(v); } + template inline T ClampT(T v, T lo, T hi) { return v < lo ? lo : (v > hi ? hi : v); } diff --git a/src/game/sim/rng.h b/src/game/sim/rng.h index f10d5b2..52e73af 100644 --- a/src/game/sim/rng.h +++ b/src/game/sim/rng.h @@ -20,6 +20,10 @@ struct IRandom { // Uniform integer in [0, n] INCLUSIVE (mask-and-reject; the mask covers n, not // n - 1). n == 0 returns 0 and still consumes one word. virtual std::uint32_t NextIntInclusive(std::uint32_t n) = 0; + // The raw tempered word, no mapping. A handful of sites take it directly -- the + // random-unit-vector helper a failed probabilistic jump uses is one -- and a + // reimplementation has to consume the same word to stay on the same stream. + virtual std::uint32_t NextUInt32() = 0; }; } // namespace sots::sim diff --git a/src/shim/hooks/colony_inputs.cpp b/src/shim/hooks/colony_inputs.cpp new file mode 100644 index 0000000..829a8da --- /dev/null +++ b/src/shim/hooks/colony_inputs.cpp @@ -0,0 +1,147 @@ +#include "shim/hooks/colony_inputs.h" + +#include +#include +#include +#include + +namespace shim::hooks { + +using sots::sim::ColonyTurnInputs; +using sots::sim::ColonyTurnState; +using sots::sim::TuningTable; +using trace::Tv; +namespace tv = trace::tv; + +TuningTable ToTuningTable(const ColonyTuning& t) { + TuningTable out; + out.SYSTEMBONUS_MINTURNS = t.SYSTEMBONUS_MINTURNS; + out.SYSTEMBONUS_POPBONUS = t.SYSTEMBONUS_POPBONUS; + out.SYSTEMBONUS_POPBONUS_INC = t.SYSTEMBONUS_POPBONUS_INC; + out.SYSTEMBONUS_INFRABONUS = t.SYSTEMBONUS_INFRABONUS; + out.SYSTEMBONUS_INFRABONUS_INC = t.SYSTEMBONUS_INFRABONUS_INC; + return out; +} + +ColonyTurnState ToColonyTurnState(const ColonySnapshot& s) { + ColonyTurnState st; + st.infra = s.infra; + st.infraBonus = s.infraBonus; + st.pop = s.pop; + st.popBonus = s.popBonus; + st.turnsDeveloping = s.turnsDeveloping; + st.totalResources = s.totalResources; + for (int i = 0; i < 3; ++i) st.growthHalted[i] = s.growthHalted[i] != 0; + st.battles.counters = s.battles; + st.battles.active = s.battlesMask; + st.recon.counters = s.recon; + st.recon.active = s.reconMask; + return st; +} + +ColonyTurnInputs ToColonyTurnInputs(const ColonySnapshot& s) { + ColonyTurnInputs in; + in.owned = s.owned; + in.independent = s.independent; + in.homeSystem = s.homeSystem; + in.stable = s.stable; + in.playerCount = s.playerCount; + in.currentTurn = s.currentTurn; + in.turnsOwned = s.currentTurn - s.turnAcquired; + in.ownerSpeciesEligible = s.ownerSpeciesEligible; + in.imperialCapacity = s.imperialCapacity; + in.addictionPhase2Start = s.addictionPhase2Start; + in.addictionPhase3Start = s.addictionPhase3Start; + for (int i = 0; i < kColonySpeciesSlots; ++i) { + in.civilianPresent[i] = s.civilianPresent[i] != 0; + in.temperance[i] = s.temperance[i] != 0; + in.addictionStart[i] = s.addictionStart[i]; + } + return in; +} + +void ApplyColonyTurnState(const ColonyTurnState& st, ColonySnapshot& s) { + s.infra = static_cast(st.infra); + s.infraBonus = static_cast(st.infraBonus); + s.pop = static_cast(st.pop); + s.popBonus = static_cast(st.popBonus); + s.turnsDeveloping = st.turnsDeveloping; + s.totalResources = st.totalResources; + for (int i = 0; i < 3; ++i) s.growthHalted[i] = st.growthHalted[i] ? 1 : 0; + s.battles = st.battles.counters; + s.battlesMask = st.battles.active; + s.recon = st.recon.counters; + s.reconMask = st.recon.active; +} + +namespace { +Tv species_list(const std::uint8_t* v) { + std::vector items; + for (int i = 0; i < kColonySpeciesSlots; ++i) items.push_back(tv::boolean(v[i] != 0)); + return tv::list(std::move(items)); +} +Tv species_ints(const std::int32_t* v) { + std::vector items; + for (int i = 0; i < kColonySpeciesSlots; ++i) items.push_back(tv::i32(v[i])); + return tv::list(std::move(items)); +} +} // namespace + +Tv DescribeColonySnapshot(const void* data, std::size_t size, unsigned) { + Tv s = tv::struct_(); + if (size < sizeof(ColonySnapshot)) return s; + ColonySnapshot c; + std::memcpy(&c, data, sizeof c); + s.add("system", tv::i32(c.systemIndex)); + s.add("owner", tv::i32(c.ownerIndex)); + s.add("species", tv::i32(c.ownerSpecies)); + s.add("owned", tv::boolean(c.owned)); + s.add("independent", tv::boolean(c.independent)); + s.add("home", tv::boolean(c.homeSystem)); + s.add("stable", tv::boolean(c.stable)); + s.add("players", tv::i32(c.playerCount)); + s.add("turn", tv::i32(c.currentTurn)); + s.add("turn_acquired", tv::i32(c.turnAcquired)); + s.add("infra", tv::f32(c.infra)); + s.add("ibon", tv::f32(c.infraBonus)); + s.add("pop", tv::i32(c.pop)); + s.add("pbon", tv::i32(c.popBonus)); + s.add("ntdev", tv::i32(c.turnsDeveloping)); + s.add("tres", tv::i32(c.totalResources)); + std::vector halt; + for (int i = 0; i < 3; ++i) halt.push_back(tv::boolean(c.growthHalted[i] != 0)); + s.add("haltv", tv::list(std::move(halt))); + s.add("bats2", tv::u64(c.battles)); + s.add("bats_mask", tv::u32(c.battlesMask)); + s.add("rcex", tv::u64(c.recon)); + s.add("rcex_mask", tv::u32(c.reconMask)); + s.add("adt", species_ints(c.addictionStart)); + s.add("civilians", species_list(c.civilianPresent)); + s.add("temperance", species_list(c.temperance)); + s.add("phase2", tv::i32(c.addictionPhase2Start)); + s.add("phase3", tv::i32(c.addictionPhase3Start)); + s.add("capacity", tv::i64(c.imperialCapacity)); + s.add("bonus_eligible", tv::boolean(c.ownerSpeciesEligible)); + s.add("size", tv::i32(c.planetSize)); + s.add("suit", tv::f32(c.suitability)); + s.add("res", tv::i32(c.resources)); + s.add("rbfl", tv::i32(c.rebelMask)); + s.add("rng_left_in", tv::i32(c.rngLeftIn)); + s.add("fpu_cw", tv::u32(c.fpuControlWord)); + s.add("queue_depth", tv::i32(c.buildQueueDepth)); + return s; +} + +Tv DescribeMoraleEvents(const sots::sim::ColonyTurnResult& r) { + std::vector items; + for (const sots::sim::MoraleEventRaised& e : r.moraleEvents) { + Tv one = tv::struct_(); + one.add("species", tv::i32(e.species)); + one.add("event", tv::i32(e.eventId)); + one.add("delta", tv::i32(e.moraleDelta)); + items.push_back(std::move(one)); + } + return tv::list(std::move(items)); +} + +} // namespace shim::hooks diff --git a/src/shim/hooks/colony_inputs.h b/src/shim/hooks/colony_inputs.h new file mode 100644 index 0000000..391cdf1 --- /dev/null +++ b/src/shim/hooks/colony_inputs.h @@ -0,0 +1,106 @@ +// B4 adapter: the ServerSystem state `ServerSystem::ProcessTurn` reads and writes <-> our +// game::sim colony call. +// +// The hook (src/shim/hooks/colony_turn.cpp) snapshots a ServerSystem into a ColonySnapshot +// using only the field offsets in the generated header. This file is the pure half: it turns +// a snapshot into sim::ColonyTurnState + sim::ColonyTurnInputs and describes both for the +// trace. It has no OS or game dependencies, so it builds and is unit-tested on the host. +// +// INPUT BOUNDARY. `ServerSystem::ProcessTurn` is a dispatcher: most of a colony turn happens +// in callees this milestone does not model. What our side reproduces is the set of words the +// dispatcher writes *itself*, plus the two bonus-pool helpers and the system-bonus accrual +// whose arithmetic game/sim already carries. Everything else -- the plague pass, imperial and +// civilian growth, the resource debit, the in-orbit refuel, slaves, rebellion, and the morale +// events the addiction sweep raises -- is an input: those fields are not declared regions, so +// the harness never compares them, and the trace records them as arguments instead. +// +// The one live value our side asks the game for is the imperial carrying capacity, which the +// accrual reads. `ServerSystem::MaxPop` is read-only, so the hook calls the game's own copy on +// the live system rather than re-deriving a capacity from species and group tables that this +// milestone does not model -- the same delegation B3 makes to `TechTree::Cost`. +#pragma once + +#include + +#include "game/sim/colony.h" +#include "shim/trace/emitter.h" + +namespace shim::hooks { + +constexpr int kColonySpeciesSlots = 7; + +// Everything the adapter needs out of a ServerSystem, its owner and the server. +struct ColonySnapshot { + // identity + std::int32_t systemIndex = 0; + std::int32_t ownerIndex = -1; // -1 when unowned + std::int32_t ownerSpecies = -1; + bool owned = false; + bool independent = false; + bool homeSystem = false; + bool stable = false; // the game's own IsStable verdict + std::int32_t playerCount = 0; + std::int32_t currentTurn = 0; // ModCount + std::int32_t turnAcquired = 0; // TAcq + + // the mutable colony words the pass owns + float infra = 0; + float infraBonus = 0; + std::int32_t pop = 0; + std::int32_t popBonus = 0; + std::int32_t turnsDeveloping = 0; + std::int32_t totalResources = 0; + std::uint8_t growthHalted[3] = {0, 0, 0}; + std::uint64_t battles = 0; + std::uint32_t battlesMask = 0; + std::uint64_t recon = 0; + std::uint32_t reconMask = 0; + + // the addiction sweep's inputs + std::int32_t addictionStart[kColonySpeciesSlots] = {}; + std::uint8_t civilianPresent[kColonySpeciesSlots] = {}; + std::uint8_t temperance[kColonySpeciesSlots] = {}; + std::int32_t addictionPhase2Start = 0; + std::int32_t addictionPhase3Start = 0; + + // measured live rather than re-derived (see the header comment) + std::int64_t imperialCapacity = 0; + bool ownerSpeciesEligible = true; + + // context that is recorded but never compared + std::int32_t planetSize = 0; + float suitability = 0; + std::int32_t resources = 0; + std::int32_t rebelMask = 0; + std::int32_t rngLeftIn = 0; + std::uint32_t fpuControlWord = 0; + std::int32_t buildQueueDepth = -1; +}; + +// Pinned: the snapshot is memcpy'd into a declared region, so a padding surprise would shift +// every field the describer reports (M2's +0x14 lesson). +static_assert(sizeof(ColonySnapshot) % 8 == 0, "ColonySnapshot has no tail padding surprise"); + +// The tuning keys the modelled steps read. The shim fills these from the loaded globals; a +// host test fills them directly. +struct ColonyTuning { + int SYSTEMBONUS_MINTURNS = 0; + double SYSTEMBONUS_POPBONUS = 0; + double SYSTEMBONUS_POPBONUS_INC = 0; + double SYSTEMBONUS_INFRABONUS = 0; + double SYSTEMBONUS_INFRABONUS_INC = 0; +}; + +sots::sim::TuningTable ToTuningTable(const ColonyTuning& t); +sots::sim::ColonyTurnState ToColonyTurnState(const ColonySnapshot& s); +sots::sim::ColonyTurnInputs ToColonyTurnInputs(const ColonySnapshot& s); + +// Write a finished state back over a snapshot, so the hook can lay the result out into the +// scratch regions field by field. +void ApplyColonyTurnState(const sots::sim::ColonyTurnState& st, ColonySnapshot& s); + +// Trace describers. +trace::Tv DescribeColonySnapshot(const void* data, std::size_t size, unsigned inline_max); +trace::Tv DescribeMoraleEvents(const sots::sim::ColonyTurnResult& r); + +} // namespace shim::hooks diff --git a/src/shim/hooks/colony_turn.cpp b/src/shim/hooks/colony_turn.cpp new file mode 100644 index 0000000..cb2a405 --- /dev/null +++ b/src/shim/hooks/colony_turn.cpp @@ -0,0 +1,451 @@ +#include "shim/hooks/colony_turn.h" + +#include +#include +#include +#include +#include + +#if defined(_WIN32) +#define WIN32_LEAN_AND_MEAN +#include +#endif + +#include "game/sim/species.h" +#include "generated/sots_addresses.h" +#include "mars/rng/mt19937.h" +#include "shim/hooks/colony_inputs.h" + +namespace shim::hooks { + +using trace::Tv; +namespace tv = trace::tv; + +namespace { + +namespace A = sots::addr; + +constexpr std::size_t kRngSize = A::RNG_size; // 0x9cc +constexpr int kMtWords = mars::rng::MT19937::N; +constexpr std::size_t kPopGroupStride = 0x18; // {?, int type @+4, int species @+8, int64 @+0x10} +constexpr std::size_t kMaxPopGroups = 4096; + +using MaxPopFn = int(SHIM_THISCALL*)(void* sys, void* player, int flag); +using IsStableFn = bool(SHIM_THISCALL*)(void* sys); + +struct Env { + std::uintptr_t exe_base = 0; + void (*log_line)(const char*) = nullptr; + MaxPopFn max_pop = nullptr; + IsStableFn is_stable = 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); +} + +bool readable(const void* p, std::size_t n) { + if (!p) return false; + if (n == 0) return true; +#if defined(_WIN32) + const char* c = static_cast(p); + const char* const end = c + n; + while (c < end) { + MEMORY_BASIC_INFORMATION mbi; + if (!VirtualQuery(c, &mbi, sizeof mbi)) return false; + if (mbi.State != MEM_COMMIT) return false; + if (mbi.Protect & (PAGE_NOACCESS | PAGE_GUARD)) return false; + const DWORD ok = PAGE_READONLY | PAGE_READWRITE | PAGE_WRITECOPY | PAGE_EXECUTE_READ | + PAGE_EXECUTE_READWRITE | PAGE_EXECUTE_WRITECOPY; + if (!(mbi.Protect & ok)) return false; + c = static_cast(mbi.BaseAddress) + mbi.RegionSize; + } + return true; +#else + return true; +#endif +} + +template +T peek(const void* base, std::size_t off) { + T v{}; + std::memcpy(&v, static_cast(base) + off, sizeof v); + return v; +} +void* ptr_at(const void* base, std::size_t off) { return peek(base, off); } + +// A GlobalConst is reached through a pointer slot in .data: the slot holds the address of the +// storage word the loader fills from the data file. +template +T global_const(std::uintptr_t slot_rva, T fallback) { + if (!g_env.exe_base) return fallback; + void** slot = reinterpret_cast(g_env.exe_base + slot_rva); + if (!readable(slot, sizeof(void*))) return fallback; + void* storage = *slot; + if (!readable(storage, sizeof(T))) return fallback; + T v{}; + std::memcpy(&v, storage, sizeof v); + return v; +} + +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 +} + +// The RNG the colony turn would draw from, reached through the raw server base a +// ServerSystem's owner pointer holds. +void* rng_of(void* server) { + if (!readable(server, A::StrategyServer_off_RNGPtr + 4)) return nullptr; + void* r = ptr_at(server, A::StrategyServer_off_RNGPtr); + return readable(r, kRngSize) ? r : nullptr; +} + +// Sum the int64 counts of one group type/species across a Population object. +std::int64_t population_of(const void* pop, int groupType, int species) { + if (!readable(pop, 0xc)) return 0; + const char* begin = static_cast(ptr_at(pop, 0x4)); + const char* end = static_cast(ptr_at(pop, 0x8)); + if (!begin || !end || end < begin) return 0; + const std::size_t n = static_cast(end - begin) / kPopGroupStride; + if (n > kMaxPopGroups || !readable(begin, n * kPopGroupStride)) return 0; + std::int64_t sum = 0; + for (std::size_t i = 0; i < n; ++i) { + const char* e = begin + i * kPopGroupStride; + if (peek(e, 0x4) != groupType) continue; + if (peek(e, 0x8) != species) continue; + sum += peek(e, 0x10); + } + return sum; +} + +// ---- per-call state ------------------------------------------------------------------------ +// +// The turn pass is single-threaded and ProcessTurn never nests, so the mapping regions() +// builds is handed to rebind()/ours() in statics (M1's concession). Do not copy this into a +// re-entrant hook. + +struct CallState { + bool compare = false; + ColonySnapshot snap; + ColonyTuning tuning; + std::uintptr_t rng_base = 0; + std::vector mt_before; + std::int32_t left_before = 0; + bool have_rng = false; + // scratch pointers, filled by rebind + void* s_infra = nullptr; + void* s_ibon = nullptr; + void* s_pbon = nullptr; + void* s_ntdev = nullptr; + void* s_tres = nullptr; + void* s_haltv = nullptr; + void* s_bats = nullptr; + void* s_bats_mask = nullptr; + void* s_rcex = nullptr; + void* s_rcex_mask = nullptr; + void* s_rng = nullptr; +}; +CallState g_call; + +void capture(void* sys) { + CallState c; + if (!readable(sys, A::ServerSystem_off_ntdev + 4)) { + g_call = c; + return; + } + ColonySnapshot& s = c.snap; + s.systemIndex = peek(sys, A::ServerSystem_off_Idx); + s.planetSize = peek(sys, A::ServerSystem_off_Size); + s.suitability = peek(sys, A::ServerSystem_off_Suit); + s.resources = peek(sys, A::ServerSystem_off_Res); + s.rebelMask = peek(sys, A::ServerSystem_off_rbfl); + s.infra = peek(sys, A::ServerSystem_off_Infra); + s.infraBonus = peek(sys, A::ServerSystem_off_ibon); + s.pop = peek(sys, A::ServerSystem_off_Pop); + s.popBonus = peek(sys, A::ServerSystem_off_pbon); + s.turnsDeveloping = peek(sys, A::ServerSystem_off_ntdev); + s.totalResources = peek(sys, A::ServerSystem_off_TRes); + for (int i = 0; i < 3; ++i) + s.growthHalted[i] = peek(sys, A::ServerSystem_off_haltv + std::size_t(i)); + s.battles = peek(sys, A::ServerSystem_off_Bats2); + s.battlesMask = peek(sys, A::ServerSystem_off_BatsMask); + s.recon = peek(sys, A::ServerSystem_off_Rcex); + s.reconMask = peek(sys, A::ServerSystem_off_RcexMask); + s.turnAcquired = peek(sys, A::ServerSystem_off_TAcq); + for (int i = 0; i < kColonySpeciesSlots; ++i) + s.addictionStart[i] = + peek(sys, A::ServerSystem_off_adt + std::size_t(i) * 4); + + void* owner = ptr_at(sys, A::ServerSystem_off_PID); + s.owned = owner != nullptr; + s.independent = ptr_at(sys, A::ServerSystem_off_Indi) != nullptr; + if (readable(owner, A::ServerPlayer_off_SpeciesTechFlags + 4 * kColonySpeciesSlots)) { + s.ownerIndex = peek(owner, A::ServerPlayer_off_PlyrIdx); + s.ownerSpecies = peek(owner, A::ServerPlayer_off_Species); + s.homeSystem = ptr_at(owner, A::ServerPlayer_off_HomeSystem) == sys; + for (int i = 0; i < kColonySpeciesSlots; ++i) { + const std::uint32_t bits = peek( + owner, A::ServerPlayer_off_SpeciesTechFlags + std::size_t(i) * 4); + s.temperance[i] = (bits >> 5) & 1u; + } + if (s.ownerSpecies >= 0 && s.ownerSpecies < kColonySpeciesSlots) { + s.ownerSpeciesEligible = + sots::sim::ConstantsOf(static_cast(s.ownerSpecies)) + .systemBonusEligible; + } + } + + // The addiction sweep only looks at species that actually have civilians here, counting + // the pending civilian bonus as present too -- the same 64-bit sum the original guards on. + const char* pop2 = static_cast(sys) + A::ServerSystem_off_Pop2; + const char* pbon2 = static_cast(sys) + A::ServerSystem_off_pbon2; + for (int i = 0; i < kColonySpeciesSlots; ++i) { + const std::int64_t n = population_of(pop2, 1, i) + population_of(pbon2, 1, i); + s.civilianPresent[i] = n > 0 ? 1 : 0; + } + + void* server = ptr_at(sys, A::ServerSystem_off_Owner); + if (readable(server, A::StrategyServer_off_Players + 8)) { + s.currentTurn = peek(server, A::StrategyServer_off_ModCount); + const char* pb = static_cast(ptr_at(server, A::StrategyServer_off_Players)); + const char* pe = + static_cast(ptr_at(server, A::StrategyServer_off_Players + 4)); + if (pb && pe && pe >= pb) s.playerCount = static_cast((pe - pb) / 4); + void* rng = rng_of(server); + if (rng) { + c.rng_base = reinterpret_cast(rng); + c.left_before = peek(rng, A::RNG_off_Left); + c.mt_before.resize(static_cast(kMtWords)); + std::memcpy(c.mt_before.data(), static_cast(rng) + A::RNG_off_State, + c.mt_before.size() * 4); + c.have_rng = true; + s.rngLeftIn = c.left_before; + } + } + + // Two values our side asks the game for rather than re-deriving: the read-only stability + // verdict and the imperial carrying capacity the accrual reads. + if (g_env.is_stable) s.stable = g_env.is_stable(sys); + if (g_env.max_pop && owner) s.imperialCapacity = g_env.max_pop(sys, owner, 0); + + void* queue = ptr_at(sys, A::ServerSystem_off_BuildQueue); + s.buildQueueDepth = readable(queue, 0x18) ? peek(queue, 0x14) : -1; + + s.addictionPhase2Start = global_const(A::g_ptr_ADDICTION_PHASE2_START, 10); + s.addictionPhase3Start = global_const(A::g_ptr_ADDICTION_PHASE3_START, 15); + s.fpuControlWord = fpu_control_word(); + + c.tuning.SYSTEMBONUS_MINTURNS = global_const(A::g_ptr_SYSTEMBONUS_MINTURNS, 0); + c.tuning.SYSTEMBONUS_POPBONUS = global_const(A::g_ptr_SYSTEMBONUS_POPBONUS, 0.f); + c.tuning.SYSTEMBONUS_POPBONUS_INC = global_const(A::g_ptr_SYSTEMBONUS_POPBONUS_INC, 0.f); + c.tuning.SYSTEMBONUS_INFRABONUS = global_const(A::g_ptr_SYSTEMBONUS_INFRABONUS, 0.f); + c.tuning.SYSTEMBONUS_INFRABONUS_INC = + global_const(A::g_ptr_SYSTEMBONUS_INFRABONUS_INC, 0.f); + + g_call = c; +} + +// ---- describers ----------------------------------------------------------------------------- + +Tv describe_f32(const void* p, std::size_t, unsigned) { + Tv s = tv::struct_(); + s.add("v", tv::f32(peek(p, 0))); + return s; +} +Tv describe_i32(const void* p, std::size_t, unsigned) { + Tv s = tv::struct_(); + s.add("v", tv::i32(peek(p, 0))); + return s; +} +Tv describe_u32(const void* p, std::size_t, unsigned) { + Tv s = tv::struct_(); + s.add("v", tv::u32(peek(p, 0))); + return s; +} +Tv describe_u64(const void* p, std::size_t, unsigned) { + Tv s = tv::struct_(); + s.add("v", tv::u64(peek(p, 0))); + return s; +} +Tv describe_haltv(const void* p, std::size_t, unsigned) { + Tv s = tv::struct_(); + const std::uint8_t* b = static_cast(p); + for (int i = 0; i < 3; ++i) { + char key[4] = {'h', static_cast('0' + i), 0, 0}; + s.add(key, tv::boolean(b[i] != 0)); + } + return s; +} +Tv describe_rng(const void* p, std::size_t, unsigned inline_max) { + Tv s = tv::struct_(); + s.add("vptr", tv::ptr(ptr_at(p, 0))); + s.add("mt", tv::bytes(static_cast(p) + A::RNG_off_State, + static_cast(kMtWords) * 4, inline_max)); + s.add("left", tv::i32(peek(p, A::RNG_off_Left))); + const std::uintptr_t next = reinterpret_cast(ptr_at(p, A::RNG_off_Next)); + const std::uintptr_t base = g_call.rng_base + A::RNG_off_State; + std::int64_t index = -1; + if (g_call.rng_base && next >= base) index = static_cast((next - base) / 4); + s.add("next_index", tv::i64(index)); + return s; +} + +void add_region(std::vector& out, const char* name, void* sys, std::size_t off, + std::size_t size, Tv (*describe)(const void*, std::size_t, unsigned)) { + trace::Region r; + r.name = name; + r.ptr = static_cast(sys) + off; + r.size = size; + r.describe = describe; + out.push_back(r); +} + +} // namespace + +void init_colony_turn(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.max_pop = reinterpret_cast(exe_base + A::ServerSystem_MaxPop); + g_env.is_stable = reinterpret_cast(exe_base + A::ServerSystem_IsStable); +} + +void ServerSystemProcessTurnHook::describe_args(std::vector& out, void* self) { + out.push_back(tv::ptr(self).named("system")); + // The snapshot is the whole input record; it doubles as the `inputs` region below, but the + // harness never compares arguments, so the interesting context lives here. + out.push_back(tv::i32(g_call.snap.systemIndex).named("system_index")); + out.push_back(tv::i32(g_call.snap.ownerIndex).named("owner")); + out.push_back(tv::i32(g_call.snap.rngLeftIn).named("rng_left_in")); + out.push_back(tv::u32(g_call.snap.fpuControlWord).named("fpu_cw")); +} + +void ServerSystemProcessTurnHook::regions(std::vector& out, void* self) { + capture(self); + if (!self) return; + + // Only the words `sim::ProcessColonyTurn` models are declared. Everything the callees + // write -- population, morale, resources, slaves, rebellion state -- is deliberately left + // undeclared so the harness never compares it (docs/B4.md, "The declared input boundary"). + add_region(out, "infra", self, A::ServerSystem_off_Infra, 4, &describe_f32); + add_region(out, "ibon", self, A::ServerSystem_off_ibon, 4, &describe_f32); + add_region(out, "pbon", self, A::ServerSystem_off_pbon, 4, &describe_i32); + add_region(out, "ntdev", self, A::ServerSystem_off_ntdev, 4, &describe_i32); + add_region(out, "tres", self, A::ServerSystem_off_TRes, 4, &describe_i32); + add_region(out, "haltv", self, A::ServerSystem_off_haltv, 3, &describe_haltv); + add_region(out, "bats2", self, A::ServerSystem_off_Bats2, 8, &describe_u64); + add_region(out, "bats_mask", self, A::ServerSystem_off_BatsMask, 4, &describe_u32); + add_region(out, "rcex", self, A::ServerSystem_off_Rcex, 8, &describe_u64); + add_region(out, "rcex_mask", self, A::ServerSystem_off_RcexMask, 4, &describe_u32); + + if (g_call.have_rng) { + trace::Region r; + r.name = "rng"; + r.ptr = reinterpret_cast(g_call.rng_base); + r.size = kRngSize; + r.describe = &describe_rng; + out.push_back(r); + } + + trace::Region in; + in.name = "inputs"; + in.ptr = &g_call.snap; + in.size = sizeof(ColonySnapshot); + in.describe = &DescribeColonySnapshot; + out.push_back(in); +} + +ServerSystemProcessTurnHook::Args ServerSystemProcessTurnHook::rebind(trace::Scratch& s, + void* self) { + g_call.compare = true; + std::size_t i = 0; + auto next = [&s, &i](std::size_t want) -> void* { + return i < s.count() && s.size(i) >= want ? s.ptr(i++) : (++i, nullptr); + }; + g_call.s_infra = next(4); + g_call.s_ibon = next(4); + g_call.s_pbon = next(4); + g_call.s_ntdev = next(4); + g_call.s_tres = next(4); + g_call.s_haltv = next(3); + g_call.s_bats = next(8); + g_call.s_bats_mask = next(4); + g_call.s_rcex = next(8); + g_call.s_rcex_mask = next(4); + g_call.s_rng = g_call.have_rng ? next(kRngSize) : nullptr; + return Args(self); +} + +void ServerSystemProcessTurnHook::ours(void* self) { + using H = trace::Hook; + const bool compare = g_call.compare; + g_call.compare = false; + if (H::mode == trace::Mode::Replace) { + // Replace is not offered for this hook: our side models the dispatcher's own writes + // and none of the callees, so feeding it to the game would silently skip a colony's + // whole turn. Run the original instead and say so once. + static bool warned = false; + if (!warned) { + warned = true; + logf("colony: replace mode is not supported for ServerSystem::ProcessTurn; " + "falling back to the original"); + } + if (H::original) H::original(self); + return; + } + if (!compare) return; + + ColonySnapshot s = g_call.snap; + const sots::sim::TuningTable t = ToTuningTable(g_call.tuning); + sots::sim::ColonyTurnState st = ToColonyTurnState(s); + const sots::sim::ColonyTurnInputs in = ToColonyTurnInputs(s); + const sots::sim::ColonyTurnResult r = sots::sim::ProcessColonyTurn(st, in, t); + ApplyColonyTurnState(st, s); + + auto put = [](void* p, const void* src, std::size_t n) { + if (p) std::memcpy(p, src, n); + }; + put(g_call.s_infra, &s.infra, 4); + put(g_call.s_ibon, &s.infraBonus, 4); + put(g_call.s_pbon, &s.popBonus, 4); + put(g_call.s_ntdev, &s.turnsDeveloping, 4); + put(g_call.s_tres, &s.totalResources, 4); + put(g_call.s_haltv, s.growthHalted, 3); + put(g_call.s_bats, &s.battles, 8); + put(g_call.s_bats_mask, &s.battlesMask, 4); + put(g_call.s_rcex, &s.recon, 8); + put(g_call.s_rcex_mask, &s.reconMask, 4); + + // The generator: seed from the pre-call snapshot, consume what our pass says it consumes + // (nothing, since ProcessRebellion is not modelled) and write the state back. A moved + // post-state is then a finding that names the system whose rebellion fired. + if (g_call.s_rng && g_call.have_rng) { + mars::rng::MT19937 gen(0); + gen.load_state(g_call.mt_before.data(), g_call.left_before); + for (int k = 0; k < r.rngDraws; ++k) gen.next_u32(); + char* dst = static_cast(g_call.s_rng); + std::memcpy(dst + A::RNG_off_State, gen.state(), + static_cast(kMtWords) * 4); + const std::int32_t left = gen.left(); + std::memcpy(dst + A::RNG_off_Left, &left, 4); + // `next` is rebuilt against the LIVE generator address so the describer's index + // arithmetic reads the same on both sides; it is never dereferenced. + void* next = reinterpret_cast(g_call.rng_base + A::RNG_off_State + + static_cast(kMtWords - left) * 4); + std::memcpy(dst + A::RNG_off_Next, &next, sizeof next); + } +} + +} // namespace shim::hooks diff --git a/src/shim/hooks/colony_turn.h b/src/shim/hooks/colony_turn.h new file mode 100644 index 0000000..1acc24f --- /dev/null +++ b/src/shim/hooks/colony_turn.h @@ -0,0 +1,47 @@ +// Hook descriptor for the per-system colony turn (B4): +// +// Game::ServerSystem::ProcessTurn(this) +// +// A verified `__thiscall` with **no stack arguments** and no return value, so it goes through +// Hook<> with CallConv::Thiscall. It is called once per system per turn from +// StrategyServer::ProcessTurn -- about 28 calls on the reference save's End Turn. +// +// Why it is worth a compare even though it is a dispatcher: the words the function body writes +// itself are a real, self-contained slice of a colony turn (the unowned infrastructure decay, +// both bonus pools, the long-stability accrual, the turn's resource reset, the growth-halt +// clear and the two per-player countdown sweeps), and they are exactly the ones our +// `sim::ProcessColonyTurn` models. Everything the callees write is left undeclared, so the +// harness never compares it. +// +// The generator is a declared region as well. A colony turn's only RNG consumer is +// ProcessRebellion (ProcessPlague, the civilian growth pass and ProcessSlaves were each swept +// to call depth one and are draw-free), so on a system with no rebellion the post-call state +// must be **identical** -- and any movement of it names the system whose rebellion fired. +#pragma once + +#include +#include +#include + +#include "shim/trace/hook.h" + +namespace shim::hooks { + +struct ServerSystemProcessTurnHook { + static constexpr const char* name = "Game::ServerSystem::ProcessTurn"; + static constexpr trace::CallConv conv = trace::CallConv::Thiscall; + using Ret = void; + using Args = std::tuple; // this (ServerSystem*) + + static void describe_args(std::vector& out, void* self); + static void regions(std::vector& out, void* self); + static Args rebind(trace::Scratch& s, void* self); + static void ours(void* self); + 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_colony_turn(std::uintptr_t exe_base, void (*log_line)(const char* line)); + +} // namespace shim::hooks diff --git a/src/shim/hooks/fleet_movement.cpp b/src/shim/hooks/fleet_movement.cpp new file mode 100644 index 0000000..1bce7bc --- /dev/null +++ b/src/shim/hooks/fleet_movement.cpp @@ -0,0 +1,518 @@ +#include "shim/hooks/fleet_movement.h" + +#include +#include +#include +#include +#include +#include + +#if defined(_WIN32) +#define WIN32_LEAN_AND_MEAN +#include +#endif + +#include "generated/sots_addresses.h" +#include "mars/rng/mt19937.h" +#include "shim/hooks/movement_inputs.h" + +namespace shim::hooks { + +using trace::Tv; +namespace tv = trace::tv; + +namespace { + +namespace A = sots::addr; + +constexpr std::size_t kRngSize = A::RNG_size; +constexpr int kMtWords = mars::rng::MT19937::N; +constexpr std::size_t kMaxPlayers = 64; + +using ResolveWaypointFn = void*(SHIM_THISCALL*)(void* fleet); +using RelationFn = int(SHIM_THISCALL*)(void* player, void* other); + +struct Env { + std::uintptr_t exe_base = 0; + void (*log_line)(const char*) = nullptr; + ResolveWaypointFn resolve_waypoint = 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); +} + +bool readable(const void* p, std::size_t n) { + if (!p) return false; + if (n == 0) return true; +#if defined(_WIN32) + const char* c = static_cast(p); + const char* const end = c + n; + while (c < end) { + MEMORY_BASIC_INFORMATION mbi; + if (!VirtualQuery(c, &mbi, sizeof mbi)) return false; + if (mbi.State != MEM_COMMIT) return false; + if (mbi.Protect & (PAGE_NOACCESS | PAGE_GUARD)) return false; + const DWORD ok = PAGE_READONLY | PAGE_READWRITE | PAGE_WRITECOPY | PAGE_EXECUTE_READ | + PAGE_EXECUTE_READWRITE | PAGE_EXECUTE_WRITECOPY; + if (!(mbi.Protect & ok)) return false; + c = static_cast(mbi.BaseAddress) + mbi.RegionSize; + } + return true; +#else + return true; +#endif +} + +template +T peek(const void* base, std::size_t off) { + T v{}; + std::memcpy(&v, static_cast(base) + off, sizeof v); + return v; +} +void* ptr_at(const void* base, std::size_t off) { return peek(base, off); } + +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 +} + +void* rng_of(void* server) { + if (!readable(server, A::StrategyServer_off_RNGPtr + 4)) return nullptr; + void* r = ptr_at(server, A::StrategyServer_off_RNGPtr); + return readable(r, kRngSize) ? r : nullptr; +} + +// The front waypoint of a fleet, or null when the vector is empty. +const void* front_waypoint(const void* fleet, std::int32_t* count) { + *count = 0; + if (!readable(fleet, A::StarFleet_off_Flags + 4)) return nullptr; + const char* first = static_cast(ptr_at(fleet, A::StarFleet_off_Waypoints)); + const char* last = static_cast(ptr_at(fleet, A::StarFleet_off_Waypoints + 4)); + if (!first || !last || last <= first) return nullptr; + const std::size_t n = static_cast(last - first) / A::Waypoint_stride; + if (n > 4096 || !readable(first, A::Waypoint_stride)) return nullptr; + *count = static_cast(n); + return first; +} + +std::vector fleet_ships(const void* fleet) { + std::vector ships; + void** begin = static_cast(ptr_at(fleet, A::StarFleet_off_Ships)); + void** end = static_cast(ptr_at(fleet, A::StarFleet_off_Ships + 4)); + if (!begin || !end || end < begin) return ships; + const std::size_t n = static_cast(end - begin); + if (n > kMaxTracedShips * 8 || !readable(begin, n * sizeof(void*))) return ships; + ships.assign(begin, begin + n); + return ships; +} + +std::vector server_players(void* server) { + std::vector out; + if (!readable(server, A::StrategyServer_off_Players + 8)) return out; + void** begin = static_cast(ptr_at(server, A::StrategyServer_off_Players)); + void** end = static_cast(ptr_at(server, A::StrategyServer_off_Players + 4)); + if (!begin || !end || end < begin) return out; + const std::size_t n = static_cast(end - begin); + if (n > kMaxPlayers || !readable(begin, n * sizeof(void*))) return out; + out.assign(begin, begin + n); + return out; +} + +std::vector server_fleets(void* server) { + std::vector out; + if (!readable(server, A::StrategyServer_off_Fleets + 8)) return out; + void** begin = static_cast(ptr_at(server, A::StrategyServer_off_Fleets)); + void** end = static_cast(ptr_at(server, A::StrategyServer_off_Fleets + 4)); + if (!begin || !end || end < begin) return out; + const std::size_t n = static_cast(end - begin); + if (n > kMaxTracedFleets || !readable(begin, n * sizeof(void*))) return out; + out.assign(begin, begin + n); + return out; +} + +// ---- per-call state ------------------------------------------------------------------------ + +struct MoveState { + bool compare = false; + FleetStepSnapshot snap; + double step = 0; + std::uintptr_t rng_base = 0; + std::vector mt_before; + std::int32_t left_before = 0; + bool have_rng = false; + void* s_pos = nullptr; + void* s_prev = nullptr; + void* s_rng = nullptr; + std::vector s_ship; + std::vector names; // stable storage for Region::name +}; +MoveState g_move; + +struct PfmState { + bool compare = false; + std::vector fleets; + std::vector players; + std::vector scratch; + std::vector names; + std::int32_t playerCount = 0; +}; +PfmState g_pfm; + +Tv describe_i32(const void* p, std::size_t, unsigned) { + Tv s = tv::struct_(); + s.add("v", tv::i32(peek(p, 0))); + return s; +} +Tv describe_f32(const void* p, std::size_t, unsigned) { + Tv s = tv::struct_(); + s.add("v", tv::f32(peek(p, 0))); + return s; +} +Tv describe_vec3(const void* p, std::size_t, unsigned) { + Tv s = tv::struct_(); + s.add("x", tv::f32(peek(p, 0))); + s.add("y", tv::f32(peek(p, 4))); + s.add("z", tv::f32(peek(p, 8))); + return s; +} +Tv describe_rng(const void* p, std::size_t, unsigned inline_max) { + Tv s = tv::struct_(); + s.add("mt", tv::bytes(static_cast(p) + A::RNG_off_State, + static_cast(kMtWords) * 4, inline_max)); + s.add("left", tv::i32(peek(p, A::RNG_off_Left))); + return s; +} + +struct ShimRandom final : sots::sim::IRandom { + mars::rng::MT19937 gen; + unsigned draws = 0; + float NextFloat() override { + ++draws; + return gen.next_float(); + } + std::uint32_t NextIntInclusive(std::uint32_t n) override { + ++draws; + return gen.next_int_inclusive(n); + } + std::uint32_t NextUInt32() override { + ++draws; + return gen.next_u32(); + } +}; + +void capture_move(void* self, void* fleet, float dt) { + MoveState m; + FleetStepSnapshot& s = m.snap; + s.dt = dt; + s.fpuControlWord = fpu_control_word(); + if (!readable(fleet, A::StarFleet_off_Flags + 4)) { + g_move = std::move(m); + return; + } + s.fleetId = peek(fleet, A::StarFleet_off_Id); + s.speed = peek(fleet, A::StarFleet_off_Speed); + s.heldFlag = (peek(fleet, A::StarFleet_off_Flags) & 0x100u) ? 1 : 0; + for (int i = 0; i < 3; ++i) + s.pos[i] = peek(fleet, A::StarFleet_off_Pos + std::size_t(i) * 4); + + void* owner = ptr_at(fleet, A::StarFleet_off_PID); + if (readable(owner, A::ServerPlayer_off_CstT + 4)) { + s.ownerIndex = peek(owner, A::ServerPlayer_off_PlyrIdx); + s.castEfficiency = peek(owner, A::ServerPlayer_off_CstE); + s.castThreshold = peek(owner, A::ServerPlayer_off_CstT); + } + + const void* wp = front_waypoint(fleet, &s.waypointCount); + if (wp) s.waypointType = peek(wp, A::Waypoint_off_Type); + + if (g_env.resolve_waypoint) { + void* dest = g_env.resolve_waypoint(fleet); + if (readable(dest, 0x24)) { + s.destKind = peek(dest, 0x14); + for (int i = 0; i < 3; ++i) s.dest[i] = peek(dest, 0x18 + std::size_t(i) * 4); + } + } + + const std::vector ships = fleet_ships(fleet); + s.shipCount = static_cast(ships.size()); + for (std::size_t i = 0; i < ships.size() && i < kMaxTracedShips; ++i) { + if (!readable(ships[i], A::StarShip_off_MaxRange + 4)) continue; + s.ships[i].range = peek(ships[i], A::StarShip_off_Range); + const std::uint32_t lo = peek(ships[i], A::StarShip_off_Flags); + s.ships[i].exempt = (lo & 0x1000u) ? 1 : 0; + } + + void* rng = rng_of(self); + if (rng) { + m.rng_base = reinterpret_cast(rng); + m.left_before = peek(rng, A::RNG_off_Left); + m.mt_before.resize(static_cast(kMtWords)); + std::memcpy(m.mt_before.data(), static_cast(rng) + A::RNG_off_State, + m.mt_before.size() * 4); + m.have_rng = true; + s.rngLeftIn = m.left_before; + } + + // A node-line waypoint's step comes from the stutter profile, which needs the node graph; + // this milestone does not walk it, so the node-line case is reported and left to the + // original. Every other case is `speed x dt`. + m.step = sots::sim::StraightStep(s.speed, dt); + g_move = std::move(m); +} + +void capture_pfm(void* self) { + PfmState p; + p.players = server_players(self); + p.playerCount = static_cast(p.players.size()); + for (void* f : server_fleets(self)) { + FleetSummary fs; + if (!readable(f, A::StarFleet_off_Flags + 4)) continue; + fs.fleetId = peek(f, A::StarFleet_off_Id); + fs.gateTraffic = peek(f, A::StarFleet_off_GateTraffic); + void* owner = ptr_at(f, A::StarFleet_off_PID); + if (readable(owner, A::ServerPlayer_off_PlyrIdx + 4)) + fs.ownerIndex = peek(owner, A::ServerPlayer_off_PlyrIdx); + std::int32_t count = 0; + const void* wp = front_waypoint(f, &count); + if (wp) fs.waypointType = peek(wp, A::Waypoint_off_Type); + p.fleets.push_back(fs); + } + g_pfm = std::move(p); +} + +} // namespace + +void init_fleet_movement(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.resolve_waypoint = + reinterpret_cast(exe_base + A::StarFleet_ResolveWaypoint); +} + +// ---- MoveFleet ------------------------------------------------------------------------------- + +void StrategyServerMoveFleetHook::describe_args(std::vector& out, void* self, void* fleet, + float dt) { + out.push_back(tv::ptr(self).named("server")); + out.push_back(tv::ptr(fleet).named("fleet")); + out.push_back(tv::f32(dt).named("dt")); + out.push_back(tv::i32(g_move.snap.fleetId).named("fleet_id")); + out.push_back(tv::i32(g_move.snap.ownerIndex).named("owner")); + out.push_back(tv::i32(g_move.snap.waypointType).named("wpt_type")); + out.push_back(tv::i32(g_move.snap.waypointCount).named("wpt_count")); + out.push_back(tv::i32(g_move.snap.destKind).named("dest_kind")); + out.push_back(tv::f32(g_move.snap.speed).named("speed")); + out.push_back(tv::i32(g_move.snap.rngLeftIn).named("rng_left_in")); + out.push_back(tv::u32(g_move.snap.fpuControlWord).named("fpu_cw")); +} + +Tv StrategyServerMoveFleetHook::describe_ret(bool r) { return tv::boolean(r); } + +void StrategyServerMoveFleetHook::regions(std::vector& out, void* self, + void* fleet, float dt) { + capture_move(self, fleet, dt); + if (!readable(fleet, A::StarFleet_off_Flags + 4)) return; + + trace::Region pos; + pos.name = "pos"; + pos.ptr = static_cast(fleet) + A::StarFleet_off_Pos; + pos.size = 12; + pos.describe = &describe_vec3; + out.push_back(pos); + + trace::Region prev; + prev.name = "prev_pos"; + prev.ptr = static_cast(fleet) + A::StarFleet_off_PrvPos; + prev.size = 12; + prev.describe = &describe_vec3; + out.push_back(prev); + + // One region per ship's remaining range, named so a diff points at the ship rather than + // at a byte offset. `names` is sized once so no push_back can dangle a Region::name. + const std::vector ships = fleet_ships(fleet); + const std::size_t n = ships.size() < kMaxTracedShips ? ships.size() : kMaxTracedShips; + g_move.names.clear(); + g_move.names.reserve(n); + for (std::size_t i = 0; i < n; ++i) { + char buf[32]; + std::snprintf(buf, sizeof buf, "ship[%u].range", static_cast(i)); + g_move.names.emplace_back(buf); + } + for (std::size_t i = 0; i < n; ++i) { + if (!readable(ships[i], A::StarShip_off_Range + 4)) continue; + trace::Region r; + r.name = g_move.names[i].c_str(); + r.ptr = static_cast(ships[i]) + A::StarShip_off_Range; + r.size = 4; + r.describe = &describe_f32; + out.push_back(r); + } + + if (g_move.have_rng) { + trace::Region r; + r.name = "rng"; + r.ptr = reinterpret_cast(g_move.rng_base); + r.size = kRngSize; + r.describe = &describe_rng; + out.push_back(r); + } +} + +StrategyServerMoveFleetHook::Args StrategyServerMoveFleetHook::rebind(trace::Scratch& s, + void* self, void* fleet, + float dt) { + g_move.compare = true; + g_move.s_pos = s.count() > 0 && s.size(0) >= 12 ? s.ptr(0) : nullptr; + g_move.s_prev = s.count() > 1 && s.size(1) >= 12 ? s.ptr(1) : nullptr; + g_move.s_ship.clear(); + std::size_t i = 2; + for (; i < s.count() && s.size(i) == 4; ++i) g_move.s_ship.push_back(s.ptr(i)); + g_move.s_rng = i < s.count() && s.size(i) >= kRngSize ? s.ptr(i) : nullptr; + return Args(self, fleet, dt); +} + +bool StrategyServerMoveFleetHook::ours(void* self, void* fleet, float dt) { + using H = trace::Hook; + const bool compare = g_move.compare; + g_move.compare = false; + if (H::mode == trace::Mode::Replace) { + // Replace is not offered: our side reproduces the step arithmetic but none of the + // arrival handling, so feeding it to the game would strand every arriving fleet. + static bool warned = false; + if (!warned) { + warned = true; + logf("movement: replace mode is not supported for MoveFleet; " + "falling back to the original"); + } + return H::original ? H::original(self, fleet, dt) : false; + } + if (!compare) return false; + + ShimRandom rng; + if (g_move.have_rng) rng.gen.load_state(g_move.mt_before.data(), g_move.left_before); + const FleetStepResult r = StepFleet(g_move.snap, g_move.step, rng); + + if (g_move.s_pos) { + for (int i = 0; i < 3; ++i) { + const float v = static_cast(r.pos[i]); + std::memcpy(static_cast(g_move.s_pos) + i * 4, &v, 4); + } + } + // The previous position is only written when the fleet actually moved, and it takes the + // ENTRY position -- not the new one. + if (g_move.s_prev && r.moved != 0 && !(r.pos[0] == g_move.snap.pos[0] && + r.pos[1] == g_move.snap.pos[1] && + r.pos[2] == g_move.snap.pos[2])) { + for (int i = 0; i < 3; ++i) { + const float v = static_cast(g_move.snap.pos[i]); + std::memcpy(static_cast(g_move.s_prev) + i * 4, &v, 4); + } + } + for (std::size_t i = 0; i < g_move.s_ship.size() && i < r.shipRanges.size(); ++i) { + const float v = static_cast(r.shipRanges[i]); + std::memcpy(g_move.s_ship[i], &v, 4); + } + if (g_move.s_rng && g_move.have_rng) { + char* dst = static_cast(g_move.s_rng); + std::memcpy(dst + A::RNG_off_State, rng.gen.state(), + static_cast(kMtWords) * 4); + const std::int32_t left = rng.gen.left(); + std::memcpy(dst + A::RNG_off_Left, &left, 4); + void* next = reinterpret_cast(g_move.rng_base + A::RNG_off_State + + static_cast(kMtWords - left) * 4); + std::memcpy(dst + A::RNG_off_Next, &next, sizeof next); + } + return r.arrived; +} + +// ---- ProcessFleetMovement -------------------------------------------------------------------- + +void StrategyServerProcessFleetMovementHook::describe_args(std::vector& out, void* self) { + out.push_back(tv::ptr(self).named("server")); + out.push_back(tv::i32(g_pfm.playerCount).named("players")); + out.push_back(tv::i32(static_cast(g_pfm.fleets.size())).named("fleets")); + std::vector summary; + for (const FleetSummary& f : g_pfm.fleets) { + Tv one = tv::struct_(); + one.add("id", tv::i32(f.fleetId)); + one.add("owner", tv::i32(f.ownerIndex)); + one.add("wpt_type", tv::i32(f.waypointType)); + one.add("gate_traffic", tv::i16(f.gateTraffic)); + summary.push_back(std::move(one)); + } + out.push_back(tv::list(std::move(summary)).named("fleet_state")); +} + +void StrategyServerProcessFleetMovementHook::regions(std::vector& out, + void* self) { + // Captured BEFORE the original runs, so the argument record shows the pre-move fleet + // state; `ours` re-captures afterwards, because that is when the original sums the + // traffic. + capture_pfm(self); + g_pfm.names.clear(); + g_pfm.names.reserve(g_pfm.players.size()); + for (std::size_t i = 0; i < g_pfm.players.size(); ++i) { + char buf[32]; + std::snprintf(buf, sizeof buf, "gate_traffic[%u]", static_cast(i)); + g_pfm.names.emplace_back(buf); + } + for (std::size_t i = 0; i < g_pfm.players.size(); ++i) { + if (!readable(g_pfm.players[i], A::ServerPlayer_off_GateTraffic + 4)) continue; + trace::Region r; + r.name = g_pfm.names[i].c_str(); + r.ptr = static_cast(g_pfm.players[i]) + A::ServerPlayer_off_GateTraffic; + r.size = 4; + r.describe = &describe_i32; + out.push_back(r); + } +} + +StrategyServerProcessFleetMovementHook::Args +StrategyServerProcessFleetMovementHook::rebind(trace::Scratch& s, void* self) { + g_pfm.compare = true; + g_pfm.scratch.clear(); + for (std::size_t i = 0; i < s.count(); ++i) + if (s.size(i) == 4) g_pfm.scratch.push_back(s.ptr(i)); + return Args(self); +} + +void StrategyServerProcessFleetMovementHook::ours(void* self) { + using H = trace::Hook; + const bool compare = g_pfm.compare; + g_pfm.compare = false; + if (H::mode == trace::Mode::Replace) { + static bool warned = false; + if (!warned) { + warned = true; + logf("movement: replace mode is not supported for ProcessFleetMovement; " + "falling back to the original"); + } + if (H::original) H::original(self); + return; + } + if (!compare) return; + + // Re-read the fleets: the original computes the gate-traffic total at the very end of the + // pass, from the state the fleets are in after they have moved. + capture_pfm(self); + const std::vector totals = GateTraffic(g_pfm.fleets, g_pfm.playerCount); + for (std::size_t i = 0; i < g_pfm.scratch.size(); ++i) { + const std::int32_t v = i < totals.size() ? totals[i] : 0; + std::memcpy(g_pfm.scratch[i], &v, 4); + } +} + +} // namespace shim::hooks diff --git a/src/shim/hooks/fleet_movement.h b/src/shim/hooks/fleet_movement.h new file mode 100644 index 0000000..5a881a6 --- /dev/null +++ b/src/shim/hooks/fleet_movement.h @@ -0,0 +1,66 @@ +// Hook descriptors for the strategic movement pass (B4): +// +// Game::StrategyServer::MoveFleet(this, fleet, dt) one movement step for one fleet +// Game::StrategyServer::ProcessFleetMovement(this) the turn's schedule + gate traffic +// +// Both prototypes are verified from the instruction stream (see docs/B4.md), so both go +// through Hook<> with CallConv::Thiscall. +// +// MoveFleet fires five times per turn per participating fleet at most, and recurses into +// itself for a multi-waypoint leg -- the template handles the nesting (depth, call ids), and +// the inner call gets its own record. +// +// declared regions: pos (3 floats), prev_pos (3 floats), one per ship's range, and the +// generator (a type-5 waypoint draws from it); plus the return value. +// input boundary: the departure hook, the route revalidation, every arrival handler, the +// waypoint list itself and the tanker top-up. A call that arrives is +// expected to differ in all of that, none of which is declared. +// +// ProcessFleetMovement runs once per turn. +// +// declared regions: each player's gate-traffic word -- which the original computes at the +// very end of the pass from the post-move fleet state, exactly the state +// our side reads, so the two are genuinely comparable. +// input boundary: everything else. The pass schedule is recorded in the arguments (ours +// predicts the call order so a trace can be checked against it) but is not +// itself compared, because reproducing it would mean running MoveFleet. +#pragma once + +#include +#include +#include + +#include "shim/trace/hook.h" + +namespace shim::hooks { + +struct StrategyServerMoveFleetHook { + static constexpr const char* name = "Game::StrategyServer::MoveFleet"; + static constexpr trace::CallConv conv = trace::CallConv::Thiscall; + using Ret = bool; + using Args = std::tuple; // this, fleet, dt + + static void describe_args(std::vector& out, void* self, void* fleet, float dt); + static trace::Tv describe_ret(bool r); + static void regions(std::vector& out, void* self, void* fleet, float dt); + static Args rebind(trace::Scratch& s, void* self, void* fleet, float dt); + static bool ours(void* self, void* fleet, float dt); + static trace::HookPolicy policy() { return trace::HookPolicy{}; } +}; + +struct StrategyServerProcessFleetMovementHook { + static constexpr const char* name = "Game::StrategyServer::ProcessFleetMovement"; + static constexpr trace::CallConv conv = trace::CallConv::Thiscall; + using Ret = void; + using Args = std::tuple; // this + + static void describe_args(std::vector& out, void* self); + static void regions(std::vector& out, void* self); + static Args rebind(trace::Scratch& s, void* self); + static void ours(void* self); + static trace::HookPolicy policy() { return trace::HookPolicy{}; } +}; + +void init_fleet_movement(std::uintptr_t exe_base, void (*log_line)(const char* line)); + +} // namespace shim::hooks diff --git a/src/shim/hooks/movement_inputs.cpp b/src/shim/hooks/movement_inputs.cpp new file mode 100644 index 0000000..5edd53d --- /dev/null +++ b/src/shim/hooks/movement_inputs.cpp @@ -0,0 +1,152 @@ +#include "shim/hooks/movement_inputs.h" + +#include +#include +#include + +#include "game/sim/numeric.h" + +namespace shim::hooks { + +using sots::sim::JumpResult; +using sots::sim::MoveStepResult; +using sots::sim::Vec3; +using trace::Tv; +namespace tv = trace::tv; + +namespace { +Vec3 vec_of(const double v[3]) { return Vec3{v[0], v[1], v[2]}; } +void store(double out[3], const Vec3& v) { + out[0] = v.x; + out[1] = v.y; + out[2] = v.z; +} +} // namespace + +std::vector ShipRanges(const FleetStepSnapshot& s) { + std::vector r; + const int n = s.shipCount < kMaxTracedShips ? s.shipCount : kMaxTracedShips; + for (int i = 0; i < n; ++i) r.push_back(s.ships[i].range); + return r; +} + +FleetStepResult StepFleet(const FleetStepSnapshot& s, double step, sots::sim::IRandom& rng) { + FleetStepResult r; + const Vec3 pos = vec_of(s.pos); + const Vec3 dest = vec_of(s.dest); + store(r.pos, pos); + r.shipRanges = ShipRanges(s); + if (s.heldFlag != 0 || s.waypointType < 0) return r; // held, or no waypoints: no move + + switch (s.waypointType) { + case static_cast(sots::sim::WaypointKind::ProbabilisticJump): { + const JumpResult j = sots::sim::RollProbabilisticJump(s.castEfficiency, + s.castThreshold, rng); + r.rngDraws = j.draws; + r.fraction = 1.0; // a type-5 waypoint never recurses + if (j.arrived) { + store(r.pos, dest); + r.arrived = true; + } else { + // The miss scatters the fleet around the destination in a random direction. + // The direction is a second draw whose mapping this milestone does not model, + // so the position is left where it was and the scatter distance is reported. + r.moved = j.scatter; + } + return r; // no fuel is charged and no clamp applies + } + case static_cast(sots::sim::WaypointKind::GateTeleport): + store(r.pos, dest); + r.arrived = true; + r.fraction = 1.0; + return r; // no fuel, no clamp + default: + break; + } + + r.distance = sots::sim::Distance(pos, dest); + const double minRange = sots::sim::FleetMinShipRange(r.shipRanges, 0.0); + const MoveStepResult m = sots::sim::ResolveMoveStep(step, minRange, r.distance); + r.moved = m.moved; + r.range = m.range; + r.arrived = m.arrived; + r.stranded = m.stranded; + store(r.pos, m.arrived ? dest : sots::sim::AdvanceAlongDirection(pos, dest, m.moved)); + + for (int i = 0; i < static_cast(r.shipRanges.size()); ++i) { + r.shipRanges[static_cast(i)] = + sots::sim::ConsumeShipRange(r.shipRanges[static_cast(i)], m.moved, + s.ships[i].exempt != 0); + } + r.fraction = sots::sim::PassFraction(s.waypointType, r.distance, step); + return r; +} + +std::vector GateTraffic(const std::vector& fleets, int playerCount) { + std::vector e; + e.reserve(fleets.size()); + for (const FleetSummary& f : fleets) { + sots::sim::GateTrafficEntry g; + g.ownerIndex = f.ownerIndex; + g.waypointType = f.waypointType; + g.traffic = f.gateTraffic; + e.push_back(g); + } + return sots::sim::GateTrafficTotals(e, playerCount); +} + +std::vector PassSchedule(const std::vector& fleets) { + std::vector e; + e.reserve(fleets.size()); + for (const FleetSummary& f : fleets) { + sots::sim::FleetMovementEntry x; + x.fleetId = f.fleetId; + x.targetFleetId = f.targetFleetId; + x.relation = f.relation; + e.push_back(x); + } + return sots::sim::PlanFleetMovement(e); +} + +Tv DescribeFleetStep(const void* data, std::size_t size, unsigned) { + Tv s = tv::struct_(); + if (size < sizeof(FleetStepSnapshot)) return s; + FleetStepSnapshot f; + std::memcpy(&f, data, sizeof f); + s.add("fleet", tv::i32(f.fleetId)); + s.add("owner", tv::i32(f.ownerIndex)); + s.add("wpt_type", tv::i32(f.waypointType)); + s.add("wpt_count", tv::i32(f.waypointCount)); + s.add("dest_kind", tv::i32(f.destKind)); + s.add("dt", tv::f32(f.dt)); + s.add("speed", tv::f32(f.speed)); + std::vector p, d; + for (int i = 0; i < 3; ++i) p.push_back(tv::f32(static_cast(f.pos[i]))); + for (int i = 0; i < 3; ++i) d.push_back(tv::f32(static_cast(f.dest[i]))); + s.add("pos", tv::list(std::move(p))); + s.add("dest", tv::list(std::move(d))); + s.add("cst_e", tv::f32(f.castEfficiency)); + s.add("cst_t", tv::f32(f.castThreshold)); + s.add("ships", tv::i32(f.shipCount)); + s.add("held", tv::i32(f.heldFlag)); + s.add("rng_left_in", tv::i32(f.rngLeftIn)); + s.add("fpu_cw", tv::u32(f.fpuControlWord)); + const int n = f.shipCount < kMaxTracedShips ? f.shipCount : kMaxTracedShips; + std::vector ranges; + for (int i = 0; i < n; ++i) { + Tv one = tv::struct_(); + one.add("range", tv::f32(f.ships[i].range)); + one.add("exempt", tv::boolean(f.ships[i].exempt != 0)); + ranges.push_back(std::move(one)); + } + s.add("ship_range", tv::list(std::move(ranges))); + return s; +} + +Tv DescribeGateTraffic(const std::vector& totals) { + std::vector items; + for (int v : totals) items.push_back(tv::i32(v)); + return tv::list(std::move(items)); +} + +} // namespace shim::hooks diff --git a/src/shim/hooks/movement_inputs.h b/src/shim/hooks/movement_inputs.h new file mode 100644 index 0000000..bd67f66 --- /dev/null +++ b/src/shim/hooks/movement_inputs.h @@ -0,0 +1,105 @@ +// B4 adapter: the StarFleet state the movement pass reads and writes <-> our game::sim +// movement calls. Pure half of `src/shim/hooks/fleet_movement.cpp`; no OS or game +// dependencies, so it builds and is unit-tested on the host. +// +// Two hooks share it: +// +// Game::StrategyServer::MoveFleet one movement step for one fleet +// Game::StrategyServer::ProcessFleetMovement the turn's pass schedule and gate traffic +// +// INPUT BOUNDARY for MoveFleet. Our side reproduces the step arithmetic: the waypoint-type +// switch, the range clamp, the position update, the per-ship fuel charge and the pass +// fraction. It does **not** reproduce the departure hook, the arrival handlers (entering a +// system, the arrival event, the next-leg pathability check), the route revalidation or the +// tanker top-up. So the declared regions are the fleet's position, its previous position and +// each ship's range -- the words the arithmetic owns -- and nothing else. A call that arrives +// is expected to differ in everything the arrival handlers touch, none of which is declared. +// +// INPUT BOUNDARY for ProcessFleetMovement. The pass schedule itself is the original's; our +// side reproduces only the per-player gate-traffic total, which the original computes at the +// end of the pass from the post-move fleet state -- exactly the state `ours` reads. So the +// declared regions are the players' gate-traffic words, and the pass structure is recorded in +// the arguments for the trace to check by eye. +#pragma once + +#include +#include + +#include "game/sim/movement.h" +#include "shim/trace/emitter.h" + +namespace shim::hooks { + +constexpr int kMaxTracedShips = 64; +constexpr int kMaxTracedFleets = 512; + +// One ship as the fuel loop sees it. +struct ShipSnapshot { + float range = 0; + std::uint8_t exempt = 0; // flag 0x1000: tankers and tenders pay no movement fuel + std::uint8_t pad[3] = {0, 0, 0}; +}; +static_assert(sizeof(ShipSnapshot) == 8, "ShipSnapshot is two words"); + +// Everything the MoveFleet adapter needs. +struct FleetStepSnapshot { + std::int32_t fleetId = 0; + std::int32_t ownerIndex = -1; + std::int32_t waypointType = -1; // -1 when the waypoint vector is empty + std::int32_t waypointCount = 0; + std::int32_t destKind = -1; // Location+0x14: 0 system, 1 fleet, 2 point + float dt = 0; + float speed = 0; // FPsp2 + double pos[3] = {0, 0, 0}; + double dest[3] = {0, 0, 0}; + float castEfficiency = 0; // player CstE, for a type-5 waypoint + float castThreshold = 0; // player CstT + std::int32_t shipCount = 0; + std::int32_t heldFlag = 0; // flags & 0x100 at entry + std::int32_t rngLeftIn = 0; + std::uint32_t fpuControlWord = 0; + ShipSnapshot ships[kMaxTracedShips]; +}; + +// What the step produced, for the trace and for the scratch write-back. +struct FleetStepResult { + double pos[3] = {0, 0, 0}; + double moved = 0; + double range = 0; + double distance = 0; + double fraction = 1.0; + bool arrived = false; + bool stranded = false; + int rngDraws = 0; + std::vector shipRanges; +}; + +// One fleet as the pass schedule and the gate-traffic total see it. +struct FleetSummary { + std::int32_t fleetId = 0; + std::int32_t ownerIndex = -1; + std::int32_t targetFleetId = 0; + std::int32_t relation = 0; + std::int32_t waypointType = -1; + std::int16_t gateTraffic = 0; + std::int16_t pad = 0; +}; + +std::vector ShipRanges(const FleetStepSnapshot& s); + +// The whole step, in our own code: the waypoint-type switch, the range clamp, the position +// update, the fuel charge and the pass fraction. `rng` is only consulted for a type-5 +// waypoint. Straight and node-line steps both arrive here with `speed x dt` as the step; the +// node-line profile is computed by the caller because it needs the system list. +FleetStepResult StepFleet(const FleetStepSnapshot& s, double step, sots::sim::IRandom& rng); + +// The turn's per-player gate-traffic totals, from the post-move fleet state. +std::vector GateTraffic(const std::vector& fleets, int playerCount); + +// The call schedule the pass would issue, for the trace to compare against the record order. +std::vector PassSchedule(const std::vector& fleets); + +trace::Tv DescribeFleetStep(const void* data, std::size_t size, unsigned inline_max); +trace::Tv DescribeGateTraffic(const std::vector& totals); + +} // namespace shim::hooks diff --git a/src/shim/hooks/research.cpp b/src/shim/hooks/research.cpp index d0300f3..b0efe55 100644 --- a/src/shim/hooks/research.cpp +++ b/src/shim/hooks/research.cpp @@ -235,6 +235,10 @@ struct ShimRandom final : sots::sim::IRandom { ++draws; return gen.next_int_inclusive(n); } + std::uint32_t NextUInt32() override { + ++draws; + return gen.next_u32(); + } }; } // namespace diff --git a/src/shim/main.cpp b/src/shim/main.cpp index 2b775a9..6a00597 100644 --- a/src/shim/main.cpp +++ b/src/shim/main.cpp @@ -16,7 +16,9 @@ #include "MinHook.h" #include "generated/sots_addresses.h" #include "shim/hooks/dictionaries.h" +#include "shim/hooks/colony_turn.h" #include "shim/hooks/compute_budget.h" +#include "shim/hooks/fleet_movement.h" #include "shim/hooks/global_consts.h" #include "shim/hooks/research.h" #include "shim/hooks/tech_effects.h" @@ -155,6 +157,9 @@ using SectionCtorHook = shim::trace::Hook; using OnTechResearchedHook = shim::trace::Hook; using ComputeBudgetHook = shim::trace::Hook; +using ColonyTurnHook = shim::trace::Hook; +using MoveFleetHook = shim::trace::Hook; +using FleetMovementHook = shim::trace::Hook; void InstallHooks(shim::trace::Tracer& tracer) { const uintptr_t exeBase = reinterpret_cast(GetModuleHandleA(nullptr)); @@ -194,6 +199,14 @@ void InstallHooks(shim::trace::Tracer& tracer) { // B1: ServerPlayer::ComputeBudget (verified thiscall) -- the first behavioural compare. shim::hooks::init_compute_budget(&ShimLogLine); InstallTemplateHook(tracer, exeBase, sots::addr::ServerPlayer_ComputeBudget); + + // B4: the per-system colony turn (once per system per turn) and the two movement entry + // points. All three are verified thiscall prototypes with no stack-argument surprises. + shim::hooks::init_colony_turn(exeBase, &ShimLogLine); + InstallTemplateHook(tracer, exeBase, sots::addr::ServerSystem_ProcessTurn); + shim::hooks::init_fleet_movement(exeBase, &ShimLogLine); + InstallTemplateHook(tracer, exeBase, sots::addr::StrategyServer_MoveFleet); + InstallTemplateHook(tracer, exeBase, sots::addr::StrategyServer_ProcessFleetMovement); } // ---- lifecycle ----------------------------------------------------------------------------- @@ -237,6 +250,9 @@ void Shim_Init(HMODULE self) { SectionCtorHook::register_policy(tracer); ProcessResearchHook::register_policy(tracer); OnTechResearchedHook::register_policy(tracer); + ColonyTurnHook::register_policy(tracer); + MoveFleetHook::register_policy(tracer); + FleetMovementHook::register_policy(tracer); char exeSha[65] = {}; if (!shim::trace::sha256_file(exePath, exeSha)) Log("trace: could not hash %s", exePath); if (tracer.open(SHIM_BUILD_ID, exeSha)) { diff --git a/src/shim/shim.cfg.b4compare b/src/shim/shim.cfg.b4compare new file mode 100644 index 0000000..5063a24 --- /dev/null +++ b/src/shim/shim.cfg.b4compare @@ -0,0 +1,16 @@ +# B4 — colony + fleet movement in compare mode. Copy over C:\SOTS\shim.cfg. +# Every hook from an earlier lane is off so the trace holds nothing but this milestone. +hooks=trace +hook.Shim::SelfTest::Fill=off +hook.Mars::GlobalConsts::LoadFile=off +hook.Game::WeaponDictionary::Init=off +hook.Game::SectionDictionary::SectionDictionary=off +hook.Game::ServerPlayer::ComputeBudget=off +hook.Game::TechTree::ProcessResearch=off +hook.Game::ServerPlayer::OnTechResearched=off +hook.Game::ServerSystem::ProcessTurn=compare +hook.Game::StrategyServer::MoveFleet=compare +hook.Game::StrategyServer::ProcessFleetMovement=compare +trace.path=C:\SOTS\shim.trace.jsonl +trace.inline_max=256 +trace.flush=always diff --git a/src/shim/shim.cfg.b4scout b/src/shim/shim.cfg.b4scout new file mode 100644 index 0000000..e33c23b --- /dev/null +++ b/src/shim/shim.cfg.b4scout @@ -0,0 +1,23 @@ +# B4 — reconnaissance pass: ServerSystem::ProcessTurn and the two movement entry points in +# trace mode, with MoveFleet OFF so the record count stays small and the pass structure of +# ProcessFleetMovement is legible on its own. Copy over C:\SOTS\shim.cfg. +# +# Read off this trace before running b4trace/b4compare: +# * fpu_cw on every record (expect 0x027f; 0x007f/0x003f means 24-bit x87 precision) +# * per ServerSystem::ProcessTurn record: rng_left_in minus side.rng.after.left +# -- 0 on a system with no plague, no rebellion roll and no new civilian group +# * per ProcessFleetMovement record: the bucket sizes and the per-player gate traffic +hooks=trace +hook.Shim::SelfTest::Fill=off +hook.Mars::GlobalConsts::LoadFile=off +hook.Game::WeaponDictionary::Init=off +hook.Game::SectionDictionary::SectionDictionary=off +hook.Game::ServerPlayer::ComputeBudget=off +hook.Game::TechTree::ProcessResearch=off +hook.Game::ServerPlayer::OnTechResearched=off +hook.Game::ServerSystem::ProcessTurn=trace +hook.Game::StrategyServer::MoveFleet=off +hook.Game::StrategyServer::ProcessFleetMovement=trace +trace.path=C:\SOTS\shim.trace.jsonl +trace.inline_max=256 +trace.flush=always diff --git a/src/shim/shim.cfg.b4trace b/src/shim/shim.cfg.b4trace new file mode 100644 index 0000000..5c561d0 --- /dev/null +++ b/src/shim/shim.cfg.b4trace @@ -0,0 +1,16 @@ +# B4 — colony + fleet movement in trace mode. Copy over C:\SOTS\shim.cfg. +# Every hook from an earlier lane is off so the trace holds nothing but this milestone. +hooks=trace +hook.Shim::SelfTest::Fill=off +hook.Mars::GlobalConsts::LoadFile=off +hook.Game::WeaponDictionary::Init=off +hook.Game::SectionDictionary::SectionDictionary=off +hook.Game::ServerPlayer::ComputeBudget=off +hook.Game::TechTree::ProcessResearch=off +hook.Game::ServerPlayer::OnTechResearched=off +hook.Game::ServerSystem::ProcessTurn=trace +hook.Game::StrategyServer::MoveFleet=trace +hook.Game::StrategyServer::ProcessFleetMovement=trace +trace.path=C:\SOTS\shim.trace.jsonl +trace.inline_max=256 +trace.flush=always diff --git a/tests/game_sim/check.h b/tests/game_sim/check.h index c18b089..a0c03d0 100644 --- a/tests/game_sim/check.h +++ b/tests/game_sim/check.h @@ -51,6 +51,10 @@ struct ScriptedRng final : sots::sim::IRandom { const std::uint32_t v = ints[ii++]; return n == 0xffffffffu ? v : v % (n + 1); // scripted value folded into [0, n] } + std::uint32_t NextUInt32() override { + if (ii >= ints.size()) { std::fprintf(stderr, "ScriptedRng: int script exhausted\n"); ++failures(); return 0; } + return ints[ii++]; + } std::size_t floatDraws() const { return fi; } std::size_t intDraws() const { return ii; } }; diff --git a/tests/game_sim/test_colony.cpp b/tests/game_sim/test_colony.cpp index 86958ec..1a4ed05 100644 --- a/tests/game_sim/test_colony.cpp +++ b/tests/game_sim/test_colony.cpp @@ -1,6 +1,7 @@ #include "game/sim/colony.h" #include "check.h" +#include "game/sim/numeric.h" using namespace sots::sim; @@ -77,13 +78,14 @@ static void test_capacity() { static void test_growth() { TuningTable t = tuning(); + // B4: the curve is driven by suitability, not by how full the colony is. `tol` is the + // owner's SuitTol and doubles as the divisor, so d/tol is the fraction of the habitable + // band the planet is off by. GrowthInputs g; - g.capacity = 1000000; - - g.pop = 0; // empty colony still grows by 1 - CHECK_EQ(PopulationGrowthDelta(g, t), std::int64_t{1}); - - g.pop = 500000; // (1-0.5)^2 = 0.25; x1.2 = 0.3; x5e5 = 150000 + g.suitTolerance = 1.0; + g.idealSuitability = 1.0; + g.suitability = 0.5; // half a band off: base 0.5, ^2 = 0.25, x1.2 = 0.3 + g.pop = 500000; CHECK_EQ(PopulationGrowthDelta(g, t), std::int64_t{150000}); g.playerPopMod = 0.5; // 75000 @@ -91,102 +93,177 @@ static void test_growth() { g.playerPopMod = 1.0; g.groupGrowthMult = 2.0; // 300000 CHECK_EQ(PopulationGrowthDelta(g, t), std::int64_t{300000}); - g.groupGrowthMult = 0.0; // zero column is ignored + g.groupGrowthMult = 0.0; // a zero column is ignored, not applied CHECK_EQ(PopulationGrowthDelta(g, t), std::int64_t{150000}); - g.flaggedByPlayer = true; - g.flaggedFactor = 0.0; - CHECK_EQ(PopulationGrowthDelta(g, t), std::int64_t{0}); // g becomes 0 -> no minimum 1 - g.flaggedByPlayer = false; + g.extraFactor = 0.0; // so is a zero extra factor + CHECK_EQ(PopulationGrowthDelta(g, t), std::int64_t{150000}); + g.extraFactor = 1.0; - g.pop = 1000000; // at capacity: no growth - CHECK_EQ(PopulationGrowthDelta(g, t), std::int64_t{0}); - g.pop = 2000000; // over capacity clamps to 0 growth + g.pop = 0; // an empty group does not grow at all CHECK_EQ(PopulationGrowthDelta(g, t), std::int64_t{0}); + g.pop = 1; // trunc(1 x 0.3) == 0 -> forced to 1 + CHECK_EQ(PopulationGrowthDelta(g, t), std::int64_t{1}); + g.pop = 500000; - g.pop = 1000000000; - g.capacity = 2000000000; // 0.3 x 1e9 = 3e8 -> capped at 5e7 - CHECK_EQ(PopulationGrowthDelta(g, t), std::int64_t{50000000}); + g.suitability = 1.0; // exactly at the ideal: base 1 + CHECK_EQ(PopulationGrowthDelta(g, t), std::int64_t{600000}); // 1 x 1.2 x 5e5 + g.suitability = 0.0; // a whole band off: base 0 -> no growth + CHECK_EQ(PopulationGrowthDelta(g, t), std::int64_t{0}); + g.suitability = -5.0; // clamped up to 0 first, so still a full band + CHECK_EQ(PopulationGrowthDelta(g, t), std::int64_t{0}); + g.suitability = 0.5; + + g.accommodated = true; // suitability ignored entirely: base 1 + CHECK_EQ(PopulationGrowthDelta(g, t), std::int64_t{600000}); + g.accommodated = false; g.blockaded = true; CHECK_EQ(PopulationGrowthDelta(g, t), std::int64_t{0}); g.blockaded = false; - g.capacity = 0; - CHECK_EQ(PopulationGrowthDelta(g, t), std::int64_t{0}); + + // the suitability distance clamps the planet's own value into [0, 20] before the + // difference, and is itself capped by the tolerance + CHECK_NEAR(GrowthSuitabilityDistance(25.0, 0.0, 100.0, false), 20.0, 1e-6); + CHECK_NEAR(GrowthSuitabilityDistance(-3.0, 5.0, 100.0, false), 5.0, 1e-6); + CHECK_NEAR(GrowthSuitabilityDistance(0.0, 5.0, 2.0, false), 2.0, 1e-6); + CHECK_NEAR(GrowthSuitabilityDistance(0.0, 5.0, 2.0, true), 0.0, 0.0); + + // the exponent is clamped into [0.01f, 1000] before pow() + TuningTable big = t; + big.POPULATION_GROWTH_EXP = 100000.0; + TuningTable capped = t; + capped.POPULATION_GROWTH_EXP = 1000.0; + CHECK_NEAR(PopulationGrowthFraction(g, big), PopulationGrowthFraction(g, capped), 0.0); + TuningTable tiny = t; + tiny.POPULATION_GROWTH_EXP = 1e-9; + TuningTable floored = t; + floored.POPULATION_GROWTH_EXP = kGrowthExponentMin; + CHECK_NEAR(PopulationGrowthFraction(g, tiny), PopulationGrowthFraction(g, floored), 0.0); CHECK_EQ(ApplyImperialGrowth(500000, 1000000, 150000), std::int64_t{650000}); - CHECK_EQ(ApplyImperialGrowth(999999, 1000000, 5), std::int64_t{1000000}); + CHECK_EQ(ApplyImperialGrowth(999999, 1000000, 5), std::int64_t{1000000}); // lands on the cap CHECK_EQ(ApplyImperialGrowth(2000000, 1000000, 0), std::int64_t{1000000}); CHECK_EQ(ApplyImperialGrowth(1000000000, 100000000, 0), std::int64_t{950000000}); // shrink capped CHECK_EQ(ApplyImperialGrowth(50, 10, 0), std::int64_t{50}); // floor min(pop, 100) CHECK_EQ(ApplyImperialGrowth(500, 10, 0), std::int64_t{100}); + // the 50,000,000 cap is on the delta, and it lives in the apply, not in the fraction + CHECK_EQ(ApplyImperialGrowth(1000, 2000000000, 100000000), std::int64_t{50001000}); + CHECK_EQ(ApplyImperialGrowth(1000, 2000000000, -100), std::int64_t{900}); } static void test_infra_terraform() { - CHECK_EQ(InfrastructurePointsNeeded(0.0), 30304); // ceil(30303.03) - CHECK_EQ(InfrastructurePointsNeeded(1.0), 0); - CHECK_NEAR(InfrastructureGain(500), 0.0165, 1e-12); - CHECK_NEAR(InfrastructureGain(1000), 0.033, 1e-12); - int used = -1; - double infra = ApplyInfrastructurePoints(0.5, 100000, &used); - CHECK_EQ(used, 15152); // ceil(0.5 / 3.3e-5) - CHECK(infra >= 1.0 && infra < 1.0001); - infra = ApplyInfrastructurePoints(0.5, 1000, &used); - CHECK_EQ(used, 1000); - CHECK_NEAR(infra, 0.533, 1e-12); - CHECK_NEAR(DecayUnownedInfrastructure(0.5), 0.48, 1e-12); + CHECK_NEAR(InfrastructurePointsNeeded(0.0), 30304.0, 0.0); // ceil(30303.03) + CHECK_NEAR(InfrastructurePointsNeeded(1.0), 0.0, 0.0); + CHECK_NEAR(InfrastructureGain(500), 0.0165, 1e-7); + CHECK_NEAR(InfrastructureGain(1000), 0.033, 1e-7); + CHECK_NEAR(InfrastructureGain(-100), 0.0, 0.0); // clamped at zero + double unused = -1; + double delta = ApplyInfrastructurePoints(0.5, 100000, &unused); + CHECK_NEAR(unused, 100000.0 - 15152.0, 0.0); // ceil(0.5 / 3.3e-5) + CHECK_NEAR(ApplyInfrastructureDelta(0.5, delta), 1.0, 0.0); // clamped to exactly 1 + delta = ApplyInfrastructurePoints(0.5, 1000, &unused); + CHECK_NEAR(unused, 0.0, 0.0); + CHECK_NEAR(ApplyInfrastructureDelta(0.5, delta), 0.533, 1e-7); + CHECK_NEAR(ApplyInfrastructureDelta(1.0, 0.5), 1.0, 0.0); // already built out: no-op + CHECK_NEAR(DecayUnownedInfrastructure(0.5), 0.48, 1e-7); CHECK_NEAR(DecayUnownedInfrastructure(0.01), 0.0, 0.0); + // the decay constant is the widened float literal, not the decimal 0.02 + CHECK(DecayUnownedInfrastructure(0.5) != 0.5 - 0.02); - CHECK_EQ(TerraformPointsNeeded(0.5, 0.8), 3333); // 0.3 / 9e-5 - CHECK_EQ(TerraformPointsNeeded(0.8, 0.5), 3333); - CHECK_EQ(TerraformPointsNeeded(0.5, 0.5), 0); - CHECK_NEAR(TerraformDelta(1000, 1.0, 0.5, 0.8), 0.09, 1e-12); - CHECK_NEAR(TerraformDelta(1000, 1.0, 0.8, 0.5), -0.09, 1e-12); - CHECK_NEAR(TerraformDelta(1000, 2.0, 0.5, 0.8), 0.18, 1e-12); + // the terraforming modifier is inside the point count, and the result is a ceil + CHECK_NEAR(TerraformPointsNeeded(0.5, 0.8, 1.0), 3334.0, 0.0); + CHECK_NEAR(TerraformPointsNeeded(0.8, 0.5, 1.0), 3334.0, 0.0); + CHECK_NEAR(TerraformPointsNeeded(0.5, 0.8, 2.0), 1667.0, 0.0); // twice the modifier, half the points + CHECK_NEAR(TerraformPointsNeeded(0.5, 0.5, 1.0), 0.0, 0.0); + CHECK_NEAR(TerraformDelta(1000, 1.0, 0.5, 0.8), 0.09, 1e-7); + CHECK_NEAR(TerraformDelta(1000, 1.0, 0.8, 0.5), -0.09, 1e-7); + CHECK_NEAR(TerraformDelta(1000, 1.0, 0.5, 0.5), 0.09, 1e-7); // at the ideal the sign is +1 + CHECK_NEAR(TerraformDelta(1000, 2.0, 0.5, 0.8), 0.18, 1e-7); CHECK_NEAR(TerraformDelta(0, 2.0, 0.5, 0.8), 0.0, 0.0); + // suitability stops at the ideal from whichever side it came + CHECK_NEAR(ApplyTerraformDelta(0.5, 0.09, 0.8), 0.59, 1e-7); + CHECK_NEAR(ApplyTerraformDelta(0.75, 0.09, 0.8), 0.8, 1e-7); + CHECK_NEAR(ApplyTerraformDelta(0.85, -0.09, 0.8), 0.8, 1e-7); + CHECK_NEAR(ApplyTerraformDelta(0.8, 0.09, 0.8), 0.8, 0.0); } static void test_slaves() { TuningTable t = tuning(); SpeciesTechFlags f; // 0.5 x 0.1 + 0.2 x 0.5 + 0.05 = 0.2 - CHECK_NEAR(SlaveDeathRate(0.5, 0.3, 0.5, f, t), 0.2, 1e-12); + CHECK_NEAR(SlaveDeathRate(0.5, 0.3, 0.5, f, t), 0.2, 1e-6); f.translation1 = true; - CHECK_NEAR(SlaveDeathRate(0.5, 0.3, 0.5, f, t), 0.16, 1e-12); + CHECK_NEAR(SlaveDeathRate(0.5, 0.3, 0.5, f, t), 0.16, 1e-6); f.translation2 = f.translation3 = true; - CHECK_NEAR(SlaveDeathRate(0.5, 0.3, 0.5, f, t), 0.08, 1e-12); + CHECK_NEAR(SlaveDeathRate(0.5, 0.3, 0.5, f, t), 0.08, 1e-6); SpeciesTechFlags none; - CHECK_NEAR(SlaveDeathRate(0.0, 0.5, 0.5, none, t), 0.05, 1e-12); // base rate only + CHECK_NEAR(SlaveDeathRate(0.0, 0.5, 0.5, none, t), 0.05, 1e-6); // base rate only + // an unowned system short-circuits to a rate of 1, not 0 + CHECK_NEAR(SlaveDeathRate(0.5, 0.3, 0.5, none, t, false), 1.0, 0.0); SpeciesTechFlags bits = SpeciesTechFlags::FromBits(0x087); // bits 0,1,2,7 CHECK(bits.translation1 && bits.translation2 && bits.translation3 && bits.accommodate); CHECK(!bits.incorporate && !bits.addict && !bits.temperance && !bits.subjugate && !bits.proliferate); - CHECK_NEAR(SlaveDeathRate(0.5, 0.3, 0.5, bits, t), 0.08, 1e-12); + CHECK_NEAR(SlaveDeathRate(0.5, 0.3, 0.5, bits, t), 0.08, 1e-6); CHECK(SpeciesTechFlags::FromBits(0x100).proliferate); CHECK(SpeciesTechFlags::FromBits(0x020).temperance); - CHECK_EQ(SlaveDeaths(1000, 0.2, t), std::int64_t{200}); - CHECK_EQ(SlaveDeaths(0, 0.2, t), std::int64_t{0}); + CHECK_EQ(SlaveDeaths(1000, 0.2, 0.0, t), std::int64_t{200}); + CHECK_EQ(SlaveDeaths(0, 0.2, 0.0, t), std::int64_t{0}); + CHECK_EQ(SlaveDeaths(1000, 0.2, 0.1, t), std::int64_t{300}); // the plague rate ADDS t.SLAVES_MIN_DEATHS = 300; - CHECK_EQ(SlaveDeaths(1000, 0.2, t), std::int64_t{300}); - CHECK_EQ(SlaveDeaths(100, 0.2, t), std::int64_t{100}); // never more than present + CHECK_EQ(SlaveDeaths(1000, 0.2, 0.0, t), std::int64_t{300}); + CHECK_EQ(SlaveDeaths(100, 0.2, 0.0, t), std::int64_t{100}); // never more than present t.SLAVES_MAX_DEATHS = 150; - CHECK_EQ(SlaveDeaths(1000, 0.2, t), std::int64_t{150}); + CHECK_EQ(SlaveDeaths(1000, 0.2, 0.0, t), std::int64_t{150}); + t.SLAVES_MIN_DEATHS = -1; // any negative disables it + t.SLAVES_MAX_DEATHS = -7; + CHECK_EQ(SlaveDeaths(1000, 0.2, 0.0, t), std::int64_t{200}); t.SLAVES_MIN_DEATHS = 0; - CHECK_EQ(SlaveDeaths(7, 0.2, t), std::int64_t{1}); // 1.4 truncates + t.SLAVES_MAX_DEATHS = -1; + CHECK_EQ(SlaveDeaths(7, 0.2, 0.0, t), std::int64_t{1}); // 1.4 truncates } static void test_output() { TuningTable t = tuning(); + // B4: the trade slider is PINNED. Everything else is rescaled to what is left of 1. OutputRates r = NormaliseOutputRates({1, 1, 1, 1}, false, false); - CHECK_NEAR(r.trade, 0.25, 1e-12); - r = NormaliseOutputRates({1, 1, 1, 1}, true, false); + CHECK_NEAR(r.trade, 1.0, 0.0); + CHECK_NEAR(r.construction, 0.0, 0.0); // nothing left over for the other three CHECK_NEAR(r.terraform, 0.0, 0.0); - CHECK_NEAR(r.infra, 1.0 / 3.0, 1e-12); + CHECK_NEAR(r.infra, 0.0, 0.0); + + r = NormaliseOutputRates({0.25, 0.25, 0.25, 0.25}, false, false); + CHECK_NEAR(r.trade, 0.25, 0.0); // untouched + CHECK_NEAR(r.construction, 0.25, 1e-7); + CHECK_NEAR(r.terraform, 0.25, 1e-7); + CHECK_NEAR(r.infra, 0.25, 1e-7); + + r = NormaliseOutputRates({0, 1, 1, 1}, true, false); + CHECK_NEAR(r.terraform, 0.0, 0.0); + CHECK_NEAR(r.construction, 0.5, 1e-7); + CHECK_NEAR(r.infra, 0.5, 1e-7); + + // all-zero: the three unpinned channels split evenly, trade stays at zero r = NormaliseOutputRates({0, 0, 0, 0}, false, false); - CHECK_NEAR(r.construction, 0.25, 0.0); + CHECK_NEAR(r.trade, 0.0, 0.0); + CHECK_NEAR(r.construction, 1.0 / 3.0, 1e-6); + CHECK_NEAR(r.terraform, 1.0 / 3.0, 1e-6); + CHECK_NEAR(r.infra, 1.0 / 3.0, 1e-6); + // ... and the suppressions still apply inside the fallback + r = NormaliseOutputRates({0, 0, 0, 0}, true, true); + CHECK_NEAR(r.construction, 1.0, 1e-7); + CHECK_NEAR(r.terraform, 0.0, 0.0); + CHECK_NEAR(r.infra, 0.0, 0.0); + + // a slider at or below the threshold counts as off + r = NormaliseOutputRates({kOutputRateThreshold, 1, 0, 0}, false, false); + CHECK_NEAR(r.trade, 0.0, 0.0); + CHECK_NEAR(r.construction, 1.0, 1e-7); + r = NormaliseOutputRates({-1, 3, 0, 1}, false, true); CHECK_NEAR(r.trade, 0.0, 0.0); - CHECK_NEAR(r.construction, 1.0, 0.0); + CHECK_NEAR(r.construction, 1.0, 1e-7); CHECK_NEAR(r.infra, 0.0, 0.0); CHECK_NEAR(MoraleOutputMultiplier(80, t), 1.1, 0.0); @@ -198,38 +275,42 @@ static void test_output() { m.baseOutput = 1000; m.morale = 80; m.stations = 1; - CHECK_EQ(TotalSystemOutput(m, t), 1210); // 1000 x 1.1 x 1.1 + CHECK_NEAR(TotalSystemOutput(m, t), 1210.0, 0.0); // 1000 x 1.1 x 1.1 m.addictionPhase3 = true; - CHECK_EQ(TotalSystemOutput(m, t), 605); + CHECK_NEAR(TotalSystemOutput(m, t), 605.0, 0.0); m.addictionPhase3 = false; m.playerOutMod = 0.5; m.systemOutMod = 0.5; - CHECK_EQ(TotalSystemOutput(m, t), 303); // 302.5 rounds up + // B4: the engine's round is ties-to-EVEN, so 302.5 goes DOWN to 302 (it used to be 303) + CHECK_NEAR(TotalSystemOutput(m, t), 302.0, 0.0); m.baseOutput = 0; - CHECK_EQ(TotalSystemOutput(m, t), 0); + CHECK_NEAR(TotalSystemOutput(m, t), 0.0, 0.0); + CHECK_NEAR(RoundHalfEven(0.5), 0.0, 0.0); + CHECK_NEAR(RoundHalfEven(1.5), 2.0, 0.0); + CHECK_NEAR(RoundHalfEven(-2.5), -2.0, 0.0); OutputSplit s = SplitOutput(1000, {0.5, 0.25, 0.125, 0.125}); - CHECK_EQ(s.trade, 500); - CHECK_EQ(s.construction, 250); - CHECK_EQ(s.terraform, 125); - CHECK_EQ(s.infra, 125); + CHECK_NEAR(s.trade, 500.0, 0.0); + CHECK_NEAR(s.construction, 250.0, 0.0); + CHECK_NEAR(s.terraform, 125.0, 0.0); + CHECK_NEAR(s.infra, 125.0, 0.0); CHECK_EQ(ConstructionPoints(250, 2, t), 375); CHECK_EQ(ConstructionPoints(250, 0, t), 250); + CHECK_EQ(ConstructionPoints(3, 1, t), 3); // 3.75 TRUNCATES, it does not round OutputSplit l = SplitLeftover(100, {0.5, 0.25, 0.125, 0.125}, false, false); - CHECK_EQ(l.trade, 67); - CHECK_EQ(l.terraform, 17); - CHECK_EQ(l.infra, 17); - l = SplitLeftover(100, {0, 1, 0, 0}, true, false); // construction-only slider - CHECK_EQ(l.trade, 50); - CHECK_EQ(l.terraform, 0); - CHECK_EQ(l.infra, 50); + CHECK_NEAR(l.trade, 67.0, 0.0); + CHECK_NEAR(l.terraform, 17.0, 0.0); + CHECK_NEAR(l.infra, 17.0, 0.0); // and 67+17+17 != 100 + l = SplitLeftover(100, {0, 1, 0, 0}, true, false); // construction rate exactly 1 + CHECK_NEAR(l.trade, 50.0, 0.0); + CHECK_NEAR(l.terraform, 0.0, 0.0); + CHECK_NEAR(l.infra, 50.0, 0.0); l = SplitLeftover(100, {0, 1, 0, 0}, true, true); - CHECK_EQ(l.trade, 100); + CHECK_NEAR(l.trade, 100.0, 0.0); l = SplitLeftover(0, {0.5, 0.25, 0.125, 0.125}, false, false); - CHECK_EQ(l.trade, 0); - + CHECK_NEAR(l.trade, 0.0, 0.0); } static void test_system_money() { @@ -303,14 +384,29 @@ static void test_bonuses() { CHECK_EQ(bonus, std::int64_t{400}); double infra = 0.95, ibon = 0.1; - ApplyInfrastructureBonus(infra, ibon); - CHECK_NEAR(infra, 1.0, 1e-12); - CHECK_NEAR(ibon, 0.05, 1e-12); + BonusApplyResult br = ApplyInfrastructureBonus(infra, ibon); + CHECK_NEAR(infra, 1.0, 0.0); // the pool covered the remainder: exactly 1, not 0.999... + CHECK_NEAR(ibon, 0.05, 1e-7); + CHECK(br.applied); + CHECK(!br.resetTurnsDeveloping); // a home system is not reset + br = ApplyInfrastructureBonus(infra, ibon, /*homeSystem=*/false); + CHECK(!br.applied); // already at 1: nothing happens, no reset either + infra = 0.5; + ibon = 0.1; + br = ApplyInfrastructureBonus(infra, ibon, /*homeSystem=*/false); + CHECK_NEAR(infra, 0.6, 1e-7); + CHECK_NEAR(ibon, 0.0, 1e-7); + CHECK(br.resetTurnsDeveloping); // a non-home colony absorbing a bonus resets ntdev + + std::int64_t up = 10, ub = 500; + ApplyPopulationBonus(up, 1000, ub, /*owned=*/false); + CHECK_EQ(up, std::int64_t{10}); // an unowned system drops the whole pool + CHECK_EQ(ub, std::int64_t{0}); SystemBonusInputs in; in.stable = true; in.turnsOwned = 11; - in.turnsSinceRebellion = 11; + in.turnsDeveloping = 11; in.capacity = 1000000; std::int64_t pbon = 0; double ibonus = 0; @@ -355,7 +451,7 @@ static void test_bonuses() { AccrueSystemBonus(in, p2, i2, t); CHECK_EQ(p2, std::int64_t{0}); in.stable = true; - in.turnsSinceRebellion = 10; + in.turnsDeveloping = 10; AccrueSystemBonus(in, p2, i2, t); CHECK_EQ(p2, std::int64_t{0}); } diff --git a/tests/game_sim/test_movement.cpp b/tests/game_sim/test_movement.cpp index 9b2fd38..0e33bcf 100644 --- a/tests/game_sim/test_movement.cpp +++ b/tests/game_sim/test_movement.cpp @@ -34,7 +34,6 @@ static void test_steps() { TuningTable zero; CHECK_NEAR(NodeLineSpeed(10, 50, zero), 0.0, 0.0); // no tuning -> no speed } - static void test_stutter_segments() { CHECK_NEAR(DistPointToSegment({5, 3, 0}, {0, 0, 0}, {10, 0, 0}), 3.0, 1e-12); CHECK_NEAR(DistPointToSegment({-5, 3, 0}, {0, 0, 0}, {10, 0, 0}), std::sqrt(34.0), 1e-12); @@ -50,150 +49,226 @@ static void test_stutter_segments() { // One system 30 off the line: chord where (x-50)^2 + 900 <= 2500 -> x in [10, 90] std::vector s = BuildStutterSegments(from, to, {{50, 30, 0}}, t); CHECK_EQ(s.size(), std::size_t{1}); - CHECK_NEAR(s[0].start, 10.0, 1e-9); - CHECK_NEAR(s[0].end, 90.0, 1e-9); + CHECK_NEAR(s[0].start, 10.0, 1e-4); + CHECK_NEAR(s[0].end, 90.0, 1e-4); CHECK_EQ(s[0].systemIndex, 0); - CHECK_NEAR(s[0].speedFactor, 0.8 * 30 / 50 + 0.2, 1e-12); // 0.68 for the whole chord + CHECK_NEAR(s[0].speedFactor, 0.8 * 30 / 50 + 0.2, 1e-6); // 0.68 for the whole chord // Out of reach, and exactly tangent (zero-length chord): no segments CHECK(BuildStutterSegments(from, to, {{50, 200, 0}}, t).empty()); CHECK(BuildStutterSegments(from, to, {{50, 50, 0}}, t).empty()); - // Second system on the line near the end: chord [35, 135] clipped to [35, 100]; - // the overlap with [10, 90] is split at 62.5. + // B4: the overlap rule is NOT a midpoint. Second system near the end gives [35, 100]; + // the earlier chord ends at 90, so both boundaries become 90 + 0.5 x (90 - 35) = 117.5 -- + // pushed forward past both chords, which leaves the second segment inverted. s = BuildStutterSegments(from, to, {{50, 30, 0}, {85, 0, 0}}, t); CHECK_EQ(s.size(), std::size_t{2}); - CHECK_NEAR(s[0].start, 10.0, 1e-9); - CHECK_NEAR(s[0].end, 62.5, 1e-9); + CHECK_NEAR(s[0].start, 10.0, 1e-4); + CHECK_NEAR(s[0].end, 117.5, 1e-3); CHECK_EQ(s[0].systemIndex, 0); - CHECK_NEAR(s[0].speedFactor, 0.68, 1e-12); - CHECK_NEAR(s[1].start, 62.5, 1e-9); - CHECK_NEAR(s[1].end, 100.0, 1e-9); + CHECK_NEAR(s[1].start, 117.5, 1e-3); + CHECK_NEAR(s[1].end, 100.0, 1e-4); + CHECK(s[1].start > s[1].end); // inverted, and left that way CHECK_EQ(s[1].systemIndex, 1); - CHECK_NEAR(s[1].speedFactor, 0.2, 1e-12); // system on the line: min - // Input order does not matter: segments come back sorted + // Input order does not matter: segments come back sorted by start std::vector r = BuildStutterSegments(from, to, {{85, 0, 0}, {50, 30, 0}}, t); CHECK_EQ(r.size(), std::size_t{2}); CHECK_EQ(r[0].systemIndex, 1); CHECK_EQ(r[1].systemIndex, 0); - // A chord contained in an earlier one is split at the midpoint of the overlap - s = BuildStutterSegments(from, to, {{50, 0, 0}, {50, 40, 0}}, t); // [0,100] and [20,80] + // A chord swallowed by an earlier one is not dropped either: [0,100] and [20,80] + // become [0, 140] and [140, 80]. + s = BuildStutterSegments(from, to, {{50, 0, 0}, {50, 40, 0}}, t); CHECK_EQ(s.size(), std::size_t{2}); - CHECK_NEAR(s[0].end, 60.0, 1e-9); - CHECK_NEAR(s[1].start, 60.0, 1e-9); - CHECK_NEAR(s[1].end, 80.0, 1e-9); + CHECK_NEAR(s[0].end, 140.0, 1e-3); + CHECK_NEAR(s[1].start, 140.0, 1e-3); + CHECK_NEAR(s[1].end, 80.0, 1e-4); TuningTable zero; CHECK(BuildStutterSegments(from, to, {{50, 0, 0}}, zero).empty()); // no radius: plain line - // Advance along the [10, 90] x0.68 profile at node speed 20 + // Walk the [10, 90] x0.68 profile at node speed 20. s = BuildStutterSegments(from, to, {{50, 30, 0}}, t); - // half a turn to reach 10 at speed 20, then 0.5 x 13.6 - CHECK_NEAR(AdvanceAlongNodeLine(0, 1.0, 20, 100, s), 16.8, 1e-9); - // from 85: 5 units at 13.6 take 0.36765 turns; the rest at 20 - CHECK_NEAR(AdvanceAlongNodeLine(85, 0.5, 20, 100, s), 90.0 + (0.5 - 5.0 / 13.6) * 20.0, 1e-9); - CHECK_NEAR(AdvanceAlongNodeLine(0, 10.0, 20, 100, s), 100.0, 0.0); // never past the end - CHECK_NEAR(AdvanceAlongNodeLine(0, 1.0, 20, 100, {}), 20.0, 1e-12); // no spheres: plain speed - CHECK_NEAR(AdvanceAlongNodeLine(30, 1.0, 0, 100, s), 30.0, 0.0); // no speed: no movement - CHECK_NEAR(AdvanceAlongNodeLine(30, 0.0, 20, 100, s), 30.0, 0.0); + NodeLineStepResult n = NodeLineStep(20, 1.0, 100, s); + CHECK_NEAR(n.along, 16.8, 1e-4); // half a turn to reach 10, then 0.5 x 13.6 + CHECK(!n.arrived); + n = NodeLineStep(20, 10.0, 100, s); // plenty of time: reaches the far end + CHECK_NEAR(n.along, 100.0, 1e-6); + CHECK(n.arrived); + n = NodeLineStep(20, 1.0, 100, {}); // no spheres: plain speed the whole way + CHECK_NEAR(n.along, 20.0, 1e-9); + CHECK(!n.arrived); + n = NodeLineStep(20, 0.0, 100, s); // no time at all + CHECK_NEAR(n.along, 0.0, 0.0); + CHECK(!n.arrived); + n = NodeLineStep(0, 1.0, 100, s); // no speed: the fleet does not move + CHECK_NEAR(n.along, 0.0, 0.0); } static void test_resolve() { + // range = minShipRange + 0.05 (B4: the grace margin is ADDED, not subtracted) MoveStepResult r = ResolveMoveStep(5, 10, 20); CHECK_NEAR(r.moved, 5.0, 0.0); - CHECK_NEAR(r.fraction, 1.0, 0.0); CHECK(!r.arrived); - CHECK(!r.outOfFuel); + CHECK(!r.stranded); + CHECK_NEAR(PassFraction(0, 20, 5), 1.0, 0.0); // a straight leg reports a full pass - r = ResolveMoveStep(5, 3, 20); // range 2.95 limits the step - CHECK_NEAR(r.moved, 2.95, 1e-12); - CHECK_NEAR(r.fraction, 0.59, 1e-12); + r = ResolveMoveStep(5, 3, 20); // 3.05 of range limits the step + CHECK_NEAR(r.moved, 3.05, 1e-6); + CHECK_NEAR(r.range, 3.05, 1e-6); - r = ResolveMoveStep(5, 0, 20); // no fuel at all + r = ResolveMoveStep(5, 0, 20); // no fuel at all: the RANGE is zeroed CHECK_NEAR(r.moved, 0.0, 0.0); - CHECK(r.outOfFuel); + CHECK_NEAR(r.range, 0.0, 0.0); + CHECK(r.stranded); CHECK(!r.arrived); + // ... and the step survives, so it is still the divisor of the pass fraction + CHECK_NEAR(BlockedPassFraction(r.moved, 5), 0.0, 0.0); - r = ResolveMoveStep(5, 0, 0.01); // even a tiny hop needs range - CHECK(r.outOfFuel); + r = ResolveMoveStep(5, 0, 0.01); // within the grace margin: it moves + CHECK_NEAR(r.moved, 0.01, 1e-9); + CHECK(!r.stranded); + CHECK(r.arrived); - r = ResolveMoveStep(50, 100, 20); // arrives with step to spare + r = ResolveMoveStep(50, 100, 20); // arrives with step to spare CHECK_NEAR(r.moved, 20.0, 0.0); CHECK(r.arrived); - CHECK_NEAR(r.fraction, 0.4, 1e-12); - r = ResolveMoveStep(5, 0.02, 20); // range below the margin: stuck + r = ResolveMoveStep(5, -1, 20); // a negative range moves it BACKWARDS + CHECK(r.moved < 0); // there is no floor at zero + + r = ResolveMoveStep(0, 10, 20); // zero step CHECK_NEAR(r.moved, 0.0, 0.0); - CHECK(!r.outOfFuel); - r = ResolveMoveStep(0, 10, 20); // zero step - CHECK_NEAR(r.moved, 0.0, 0.0); - CHECK_NEAR(r.fraction, 1.0, 0.0); - - r = ResolveMoveStep(5, 10, 0); // already at the destination + r = ResolveMoveStep(5, 10, 0); // already at the destination CHECK(r.arrived); + // an empty fleet is unconstrained rather than stranded + CHECK(FleetMinShipRange({}, 0.0) > 1e30); + CHECK_NEAR(FleetMinShipRange({10, 3, 7}, 0.05), 3.05, 1e-6); + CHECK_NEAR(FleetMinShipRange({10, 3, 7}, 0.0), 3.0, 1e-6); + CHECK_NEAR(ConsumeShipRange(10, 3, false), 7.0, 0.0); CHECK_NEAR(ConsumeShipRange(2, 3, false), 0.0, 0.0); - CHECK_NEAR(ConsumeShipRange(10, 3, true), 10.0, 0.0); + CHECK_NEAR(ConsumeShipRange(10, 3, true), 10.0, 0.0); // range-exempt: pays nothing - CHECK_NEAR(RemainingPassTime(0.4, 1.0), 0.6, 1e-12); - CHECK_NEAR(RemainingPassTime(0.4, 0.5), 0.3, 1e-12); + CHECK_NEAR(RemainingPassTime(0.4, 1.0), 0.6, 1e-7); + CHECK_NEAR(RemainingPassTime(0.4, 0.5), 0.3, 1e-7); CHECK_NEAR(RemainingPassTime(0.99995, 1.0), 0.0, 0.0); CHECK_NEAR(RemainingPassTime(1.0, 1.0), 0.0, 0.0); + // the threshold is the widened float literal and the test is strict + CHECK_NEAR(RemainingPassTime(kPassCompleteFraction, 1.0), 0.0, 0.0); + CHECK(RemainingPassTime(0.99989, 1.0) > 0); + + // only a node waypoint (type 3) reports a partial fraction + CHECK_NEAR(PassFraction(3, 4, 10), 0.4, 1e-7); + CHECK_NEAR(PassFraction(3, 40, 10), 1.0, 0.0); // clamped + CHECK_NEAR(PassFraction(2, 4, 10), 1.0, 0.0); // node LINE is type 2: full pass + CHECK(IsNodeWaypoint(3)); + CHECK(!IsNodeWaypoint(2)); + CHECK(IsGateTransitWaypoint(4) && IsGateTransitWaypoint(5)); + CHECK(!IsGateTransitWaypoint(3) && !IsGateTransitWaypoint(0) && !IsGateTransitWaypoint(9)); } static void test_multi_waypoint_turn() { - // A fleet with speed 10 and plenty of range covers a 4-unit leg, then continues + // A fleet with speed 10 and plenty of range covers a 4-unit node leg, then continues // with the remaining 0.6 of the turn onto the next leg. double dt = kFullStep; MoveStepResult a = ResolveMoveStep(StraightStep(10, dt), 100, 4); CHECK(a.arrived); - dt = RemainingPassTime(a.fraction, dt); - CHECK_NEAR(dt, 0.6, 1e-12); + dt = RemainingPassTime(PassFraction(3, 4, StraightStep(10, dt)), dt); + CHECK_NEAR(dt, 0.6, 1e-6); MoveStepResult b = ResolveMoveStep(StraightStep(10, dt), 96, 20); - CHECK_NEAR(b.moved, 6.0, 1e-12); + CHECK_NEAR(b.moved, 6.0, 1e-6); CHECK(!b.arrived); - CHECK_NEAR(RemainingPassTime(b.fraction, dt), 0.0, 0.0); + CHECK_NEAR(RemainingPassTime(PassFraction(0, 20, StraightStep(10, dt)), dt), 0.0, 0.0); } static void test_jump() { - { - simtest::ScriptedRng rng({0.7f}); + { // 0.7 x 1.0 > 0.5: the jump MISSES and scatters by 0.7 in a random direction, + // which costs a second draw + simtest::ScriptedRng rng({0.7f}, {0x1234u}); JumpResult j = RollProbabilisticJump(1.0, 0.5, rng); CHECK(!j.arrived); - CHECK_NEAR(j.stopFraction, 0.7, 1e-7); + CHECK_NEAR(j.scatter, 0.7, 1e-7); + CHECK_EQ(j.draws, 2); CHECK_EQ(rng.floatDraws(), std::size_t{1}); + CHECK_EQ(rng.intDraws(), std::size_t{1}); } { simtest::ScriptedRng rng({0.3f}); JumpResult j = RollProbabilisticJump(1.0, 0.5, rng); CHECK(j.arrived); - CHECK_NEAR(j.stopFraction, 1.0, 0.0); + CHECK_NEAR(j.scatter, 0.0, 0.0); + CHECK_EQ(j.draws, 1); // an arrival costs exactly one word + CHECK_EQ(rng.intDraws(), std::size_t{0}); } { // efficiency scales the roll: 0.9 x 0.5 = 0.45 <= 0.5 arrives simtest::ScriptedRng rng({0.9f}); JumpResult j = RollProbabilisticJump(0.5, 0.5, rng); CHECK(j.arrived); } - { // roll equal to the threshold is not "greater": arrives + { // a product exactly equal to the threshold is not "greater": it arrives simtest::ScriptedRng rng({0.5f}); JumpResult j = RollProbabilisticJump(1.0, 0.5, rng); CHECK(j.arrived); } { // determinism: the same script gives the same outcome auto run = [] { - simtest::ScriptedRng rng({0.6f, 0.2f, 0.95f}); + simtest::ScriptedRng rng({0.6f, 0.2f, 0.95f}, {1u, 2u, 3u}); std::vector out; - for (int i = 0; i < 3; ++i) out.push_back(RollProbabilisticJump(1.0, 0.5, rng).stopFraction); + for (int i = 0; i < 3; ++i) out.push_back(RollProbabilisticJump(1.0, 0.5, rng).scatter); return out; }; CHECK(run() == run()); } } +static void test_pass_schedule() { + // fleet 1 chases fleet 2 (no relation); fleet 3 follows allied fleet 4; fleet 5 idles. + std::vector f = { + {1, 2, 0, false}, {2, 0, 0, false}, {3, 4, 1, false}, {4, 0, 0, false}, {5, 0, 0, false}, + }; + std::vector p = PlanFleetMovement(f); + // pass 1 prey, pass 2 pursuer, pass 3 prey again, pass 4 the rest, pass 5 followers + CHECK_EQ(p.size(), std::size_t{7}); + CHECK_EQ(p[0].fleetId, 2); CHECK_EQ(p[0].pass, 1); CHECK_NEAR(p[0].dt, 0.5, 0.0); + CHECK_EQ(p[1].fleetId, 1); CHECK_EQ(p[1].pass, 2); CHECK_NEAR(p[1].dt, 0.5, 0.0); + CHECK_EQ(p[2].fleetId, 2); CHECK_EQ(p[2].pass, 3); CHECK_NEAR(p[2].dt, 0.5, 0.0); + CHECK_EQ(p[3].fleetId, 1); CHECK_EQ(p[3].pass, 4); CHECK_NEAR(p[3].dt, 0.5, 0.0); + CHECK_EQ(p[4].fleetId, 4); CHECK_EQ(p[4].pass, 4); CHECK_NEAR(p[4].dt, 1.0, 0.0); + CHECK_EQ(p[5].fleetId, 5); CHECK_EQ(p[5].pass, 4); CHECK_NEAR(p[5].dt, 1.0, 0.0); + CHECK_EQ(p[6].fleetId, 3); CHECK_EQ(p[6].pass, 5); CHECK_NEAR(p[6].dt, 1.0, 0.0); + + // when the pursuer catches its prey, both retire after pass 2 + f[0].caught = true; + p = PlanFleetMovement(f); + CHECK_EQ(p.size(), std::size_t{5}); + CHECK_EQ(p[0].fleetId, 2); CHECK_EQ(p[0].pass, 1); + CHECK_EQ(p[1].fleetId, 1); CHECK_EQ(p[1].pass, 2); + CHECK_EQ(p[2].fleetId, 4); CHECK_EQ(p[2].pass, 4); + CHECK_EQ(p[3].fleetId, 5); CHECK_EQ(p[3].pass, 4); + CHECK_EQ(p[4].fleetId, 3); CHECK_EQ(p[4].pass, 5); +} + +static void test_gate_traffic() { + std::vector f = { + {0, 4, 10}, // gate transit + {0, 5, 5}, // probabilistic jump also counts + {0, 2, 100}, // node line does not + {1, 4, -3}, // the traffic word is SIGNED + {2, -1, 99}, // no waypoints at all + }; + std::vector g = GateTrafficTotals(f, 3); + CHECK_EQ(g.size(), std::size_t{3}); + CHECK_EQ(g[0], 15); + CHECK_EQ(g[1], -3); + CHECK_EQ(g[2], 0); + // an owner index past the player count is dropped rather than corrupting memory + CHECK_EQ(GateTrafficTotals({{9, 4, 10}}, 3)[0], 0); +} + int main() { test_vectors(); test_steps(); @@ -201,5 +276,7 @@ int main() { test_resolve(); test_multi_waypoint_turn(); test_jump(); + test_pass_schedule(); + test_gate_traffic(); return simtest::finish("test_movement"); } diff --git a/tests/shim_colony/CMakeLists.txt b/tests/shim_colony/CMakeLists.txt new file mode 100644 index 0000000..951e11e --- /dev/null +++ b/tests/shim_colony/CMakeLists.txt @@ -0,0 +1,6 @@ +# B4: the colony-turn adapter (ServerSystem snapshot <-> game::sim colony turn). +add_executable(shim_colony_unit_tests unit_tests.cpp) +target_link_libraries(shim_colony_unit_tests PRIVATE shim_colony) +target_include_directories(shim_colony_unit_tests PRIVATE ${CMAKE_SOURCE_DIR}/tests/game_sim) +target_compile_options(shim_colony_unit_tests PRIVATE -Wall -Wextra -Werror) +add_test(NAME shim_colony_unit COMMAND shim_colony_unit_tests) diff --git a/tests/shim_colony/unit_tests.cpp b/tests/shim_colony/unit_tests.cpp new file mode 100644 index 0000000..95491ac --- /dev/null +++ b/tests/shim_colony/unit_tests.cpp @@ -0,0 +1,263 @@ +// B4 adapter tests: a ServerSystem snapshot -> sim::ColonyTurnState/Inputs -> one colony turn +// -> back into the snapshot, which is what the hook lays into its scratch regions. +// +// Hand-written fixtures only. The "reference" one is the turn-2 shape of the human player's +// home system in the reference save (a stable, owned, home colony with no plague, no +// rebellion and no slaves), so the mapping is exercised on the numbers the live compare will +// also see. +#include "shim/hooks/colony_inputs.h" + +#include "check.h" + +using namespace shim::hooks; +using sots::sim::AddictionPhase; +using sots::sim::AddictionPhaseOf; +using sots::sim::ColonyCountdowns; +using sots::sim::ColonyTurnInputs; +using sots::sim::ColonyTurnResult; +using sots::sim::ColonyTurnState; +using sots::sim::ProcessColonyTurn; + +namespace { + +ColonyTuning Tuning() { + ColonyTuning t; + t.SYSTEMBONUS_MINTURNS = 10; + t.SYSTEMBONUS_POPBONUS = 0.1; + t.SYSTEMBONUS_POPBONUS_INC = 0.01; + t.SYSTEMBONUS_INFRABONUS = 0.2; + t.SYSTEMBONUS_INFRABONUS_INC = 0.05; + return t; +} + +// A stable, owned, home colony well past the bonus gates. +ColonySnapshot Reference() { + ColonySnapshot s; + s.systemIndex = 7; + s.ownerIndex = 0; + s.ownerSpecies = 6; // Morrigi + s.owned = true; + s.homeSystem = true; + s.stable = true; + s.playerCount = 8; + s.currentTurn = 2; + s.turnAcquired = 0; + s.infra = 1.0f; + s.infraBonus = 0.0f; + s.pop = 2000000000; + s.popBonus = 0; + s.turnsDeveloping = 2; + s.totalResources = 5000; + s.imperialCapacity = 2000000000; + s.addictionPhase2Start = 10; + s.addictionPhase3Start = 15; + return s; +} + +void test_state_round_trip() { + ColonySnapshot s = Reference(); + s.infra = 0.25f; + s.infraBonus = 0.125f; + s.popBonus = 4242; + s.totalResources = 99; + s.growthHalted[1] = 1; + s.battles = 0x00000000000000f3ull; + s.battlesMask = 0x5u; + s.recon = 0x0000000000000021ull; + s.reconMask = 0x3u; + + const ColonyTurnState st = ToColonyTurnState(s); + CHECK_NEAR(st.infra, 0.25, 0.0); + CHECK_NEAR(st.infraBonus, 0.125, 0.0); + CHECK_EQ(st.popBonus, std::int64_t{4242}); + CHECK_EQ(st.totalResources, 99); + CHECK(!st.growthHalted[0] && st.growthHalted[1] && !st.growthHalted[2]); + CHECK_EQ(st.battles.counters, 0x00000000000000f3ull); + CHECK_EQ(st.battles.active, 0x5u); + + ColonySnapshot back = Reference(); + ApplyColonyTurnState(st, back); + CHECK_NEAR(back.infra, 0.25f, 0.0); + CHECK_EQ(back.popBonus, 4242); + CHECK_EQ(back.battles, 0x00000000000000f3ull); + CHECK_EQ(back.reconMask, 0x3u); +} + +void test_inputs_mapping() { + ColonySnapshot s = Reference(); + s.currentTurn = 30; + s.turnAcquired = 4; + s.temperance[2] = 1; + s.civilianPresent[2] = 1; + s.addictionStart[2] = 11; + const ColonyTurnInputs in = ToColonyTurnInputs(s); + CHECK_EQ(in.turnsOwned, 26); // ModCount - TAcq + CHECK(in.owned && in.homeSystem && in.stable); + CHECK(in.temperance[2] && in.civilianPresent[2]); + CHECK_EQ(in.addictionStart[2], 11); + CHECK_EQ(in.playerCount, 8); + CHECK_EQ(in.imperialCapacity, std::int64_t{2000000000}); +} + +// The reference colony's turn: nothing to do but the housekeeping the dispatcher owns. +void test_reference_turn() { + const sots::sim::TuningTable t = ToTuningTable(Tuning()); + ColonySnapshot s = Reference(); + s.battles = 0x0000000000000231ull; // players 0, 1, 2 counting down from 1, 3, 2 + s.battlesMask = 0x7u; + s.recon = 0; + s.reconMask = 0x2u; // stale bit with no counter: the sweep is skipped + + ColonyTurnState st = ToColonyTurnState(s); + const ColonyTurnResult r = ProcessColonyTurn(st, ToColonyTurnInputs(s), t); + + CHECK_EQ(r.rngDraws, 0); // a colony turn draws only from ProcessRebellion + CHECK(r.moraleEvents.empty()); // no civilians recorded, so no addiction sweep + CHECK_EQ(st.totalResources, 0); // TRes is reset every turn + CHECK(!st.growthHalted[0] && !st.growthHalted[1] && !st.growthHalted[2]); + CHECK_EQ(st.turnsDeveloping, 3); // stable: ntdev++ + CHECK_NEAR(st.infra, 1.0, 0.0); // already built out, no pending bonus + CHECK_EQ(st.battles.counters, 0x0000000000000120ull); // 1->0, 3->2, 2->1 + // The mask bit is cleared on the turn a counter is FOUND at zero, not on the turn it + // reaches zero -- player 0's counter is 0 now, so its bit goes next turn. + CHECK_EQ(st.battles.active, 0x7u); + ColonyTurnState st2 = st; + sots::sim::TickCountdowns(st2.battles, 8); + CHECK_EQ(st2.battles.active, 0x6u); + CHECK_EQ(st.recon.counters, 0ull); + CHECK_EQ(st.recon.active, 0x2u); // the whole rcex sweep is skipped when the word is 0 + + // an unstable colony resets the counter instead + ColonySnapshot u = Reference(); + u.stable = false; + u.turnsDeveloping = 9; + ColonyTurnState us = ToColonyTurnState(u); + ProcessColonyTurn(us, ToColonyTurnInputs(u), t); + CHECK_EQ(us.turnsDeveloping, 0); +} + +void test_bonus_and_accrual() { + const sots::sim::TuningTable t = ToTuningTable(Tuning()); + ColonySnapshot s = Reference(); + s.currentTurn = 40; + s.turnAcquired = 0; + s.turnsDeveloping = 30; + s.infra = 0.5f; + s.infraBonus = 0.1f; + s.pop = 1000; + s.popBonus = 500; + s.imperialCapacity = 1000000; + + ColonyTurnState st = ToColonyTurnState(s); + ProcessColonyTurn(st, ToColonyTurnInputs(s), t); + // the pending pools drain first, then the accrual tops them back up + CHECK_NEAR(st.infra, 0.6, 1e-6); + CHECK_NEAR(st.infraBonus, 0.05, 1e-6); // drained to 0, then + INFRABONUS_INC + CHECK_EQ(st.pop, std::int64_t{1500}); + CHECK_EQ(st.popBonus, std::int64_t{10000}); // drained to 0, then + 1e6 x 0.01 + + // a non-home colony absorbing a bonus has its ntdev reset, which then costs it the + // system-bonus gate on the very same turn + ColonySnapshot n = s; + n.homeSystem = false; + ColonyTurnState ns = ToColonyTurnState(n); + ProcessColonyTurn(ns, ToColonyTurnInputs(n), t); + CHECK_EQ(ns.turnsDeveloping, 1); + CHECK_EQ(ns.popBonus, std::int64_t{0}); // the accrual gate failed + CHECK_NEAR(ns.infraBonus, 0.0, 1e-6); +} + +void test_addiction_sweep() { + const sots::sim::TuningTable t = ToTuningTable(Tuning()); + ColonySnapshot s = Reference(); + s.currentTurn = 40; + s.civilianPresent[0] = 1; // never addicted -> no event + s.civilianPresent[1] = 1; // onset + s.addictionStart[1] = 35; // elapsed 5 <= 10 + s.civilianPresent[2] = 1; // established -> no event at all + s.addictionStart[2] = 28; // elapsed 12, > 10 and <= 15 + s.civilianPresent[3] = 1; // terminal + s.addictionStart[3] = 20; // elapsed 20 > 15 + s.civilianPresent[4] = 1; // temperance wins regardless of the phase + s.addictionStart[4] = 20; + s.temperance[4] = 1; + s.civilianPresent[5] = 0; // no civilians: never looked at + s.addictionStart[5] = 20; + + ColonyTurnState st = ToColonyTurnState(s); + const ColonyTurnResult r = ProcessColonyTurn(st, ToColonyTurnInputs(s), t); + CHECK_EQ(r.moraleEvents.size(), std::size_t{3}); + CHECK_EQ(r.moraleEvents[0].species, 1); + CHECK_EQ(r.moraleEvents[0].eventId, sots::sim::kMoraleEventAddictionOnset); + CHECK_EQ(r.moraleEvents[0].moraleDelta, 1); + CHECK_EQ(r.moraleEvents[1].species, 3); + CHECK_EQ(r.moraleEvents[1].eventId, sots::sim::kMoraleEventAddictionTerminal); + CHECK_EQ(r.moraleEvents[1].moraleDelta, -2); + CHECK_EQ(r.moraleEvents[2].species, 4); + CHECK_EQ(r.moraleEvents[2].eventId, sots::sim::kMoraleEventAddictionSuppressed); + CHECK_EQ(r.moraleEvents[2].moraleDelta, -1); + + // an independent colony does not sweep at all + ColonySnapshot i = s; + i.independent = true; + ColonyTurnState is = ToColonyTurnState(i); + CHECK(ProcessColonyTurn(is, ToColonyTurnInputs(i), t).moraleEvents.empty()); + + // phase boundaries are strict + CHECK(AddictionPhaseOf(0, 40, 10, 15) == AddictionPhase::None); + CHECK(AddictionPhaseOf(30, 40, 10, 15) == AddictionPhase::Onset); // elapsed 10 + CHECK(AddictionPhaseOf(29, 40, 10, 15) == AddictionPhase::Established); // elapsed 11 + CHECK(AddictionPhaseOf(25, 40, 10, 15) == AddictionPhase::Established); // elapsed 15 + CHECK(AddictionPhaseOf(24, 40, 10, 15) == AddictionPhase::Terminal); // elapsed 16 +} + +void test_unowned_colony() { + const sots::sim::TuningTable t = ToTuningTable(Tuning()); + ColonySnapshot s = Reference(); + s.owned = false; + s.homeSystem = false; + s.stable = false; + s.infra = 0.5f; + s.infraBonus = 0.25f; + s.popBonus = 900; + ColonyTurnState st = ToColonyTurnState(s); + ProcessColonyTurn(st, ToColonyTurnInputs(s), t); + // decay first (0.5 - 0.02f), then the pending infrastructure pool drains into it + CHECK_NEAR(st.infra, 0.48 + 0.25, 1e-6); + CHECK_NEAR(st.infraBonus, 0.0, 1e-6); + CHECK_EQ(st.popBonus, std::int64_t{0}); // an unowned system drops the population pool + CHECK_EQ(st.turnsDeveloping, 0); +} + +void test_countdown_edges() { + ColonyCountdowns c; + c.counters = 0; + c.active = 0xffffu; + sots::sim::TickCountdowns(c, 8); + CHECK_EQ(c.active, 0xffffu); // an all-zero word skips the sweep entirely + + c.counters = 0xfULL << (4 * 14); // player 14 is the last one with a nibble + c.active = 0xffffu; + sots::sim::TickCountdowns(c, 16); + CHECK_EQ(sots::sim::CountdownFor(c, 14), 14); + CHECK_EQ(c.active & (1u << 15), 1u << 15); // player 15 has no nibble and is untouched + CHECK_EQ(sots::sim::CountdownFor(c, 15), 0); + + sots::sim::SetCountdown(c, 3, 99); + CHECK_EQ(sots::sim::CountdownFor(c, 3), 15); // clamped + sots::sim::SetCountdown(c, 20, 5); // out of range: ignored + CHECK_EQ(sots::sim::CountdownFor(c, 20), 0); +} + +} // namespace + +int main() { + test_state_round_trip(); + test_inputs_mapping(); + test_reference_turn(); + test_bonus_and_accrual(); + test_addiction_sweep(); + test_unowned_colony(); + test_countdown_edges(); + return simtest::finish("shim_colony_unit"); +} diff --git a/tests/shim_movement/CMakeLists.txt b/tests/shim_movement/CMakeLists.txt new file mode 100644 index 0000000..fb39842 --- /dev/null +++ b/tests/shim_movement/CMakeLists.txt @@ -0,0 +1,6 @@ +# B4: the movement adapter (StarFleet snapshot <-> game::sim movement). +add_executable(shim_movement_unit_tests unit_tests.cpp) +target_link_libraries(shim_movement_unit_tests PRIVATE shim_movement) +target_include_directories(shim_movement_unit_tests PRIVATE ${CMAKE_SOURCE_DIR}/tests/game_sim) +target_compile_options(shim_movement_unit_tests PRIVATE -Wall -Wextra -Werror) +add_test(NAME shim_movement_unit COMMAND shim_movement_unit_tests) diff --git a/tests/shim_movement/unit_tests.cpp b/tests/shim_movement/unit_tests.cpp new file mode 100644 index 0000000..566dcab --- /dev/null +++ b/tests/shim_movement/unit_tests.cpp @@ -0,0 +1,173 @@ +// B4 adapter tests: a StarFleet snapshot -> one movement step, and the fleet list -> the +// turn's pass schedule and per-player gate traffic. +// +// Hand-written fixtures only. +#include "shim/hooks/movement_inputs.h" + +#include "check.h" + +using namespace shim::hooks; +using sots::sim::WaypointKind; + +namespace { + +FleetStepSnapshot Fleet(double distance, int waypointType, int ships, float range) { + FleetStepSnapshot s; + s.fleetId = 42; + s.ownerIndex = 1; + s.waypointType = waypointType; + s.waypointCount = 1; + s.dt = 1.0f; + s.speed = 10.0f; + s.pos[0] = 0; + s.dest[0] = distance; + s.shipCount = ships; + for (int i = 0; i < ships; ++i) s.ships[i].range = range; + return s; +} + +void test_straight_step() { + simtest::ScriptedRng rng; + FleetStepSnapshot s = Fleet(100, 0, 2, 50.f); + FleetStepResult r = StepFleet(s, 10.0, rng); + CHECK_NEAR(r.distance, 100.0, 1e-9); + CHECK_NEAR(r.moved, 10.0, 1e-6); + CHECK_NEAR(r.pos[0], 10.0, 1e-5); + CHECK(!r.arrived); + CHECK(!r.stranded); + CHECK_EQ(r.rngDraws, 0); + CHECK_NEAR(r.shipRanges[0], 40.0, 1e-5); + CHECK_NEAR(r.shipRanges[1], 40.0, 1e-5); + CHECK_NEAR(r.fraction, 1.0, 0.0); // a straight leg reports a full pass + + // an exempt ship still clamps the fleet but pays nothing + s.ships[1].range = 3.f; + s.ships[1].exempt = 1; + r = StepFleet(s, 10.0, rng); + CHECK_NEAR(r.moved, 3.05, 1e-5); // the tanker's 3 + the 0.05 grace margin + CHECK_NEAR(r.shipRanges[1], 3.0, 0.0); + CHECK_NEAR(r.shipRanges[0], 50.0 - 3.05, 1e-4); + + // out of fuel entirely + s.ships[0].range = 0.f; + s.ships[1].range = 0.f; + s.ships[1].exempt = 0; + r = StepFleet(s, 10.0, rng); + CHECK(r.stranded); + CHECK_NEAR(r.moved, 0.0, 0.0); + CHECK_NEAR(r.pos[0], 0.0, 0.0); +} + +void test_arrival_snap() { + simtest::ScriptedRng rng; + FleetStepSnapshot s = Fleet(4, 0, 1, 100.f); + s.dest[1] = 3; // a 3-4-5 leg, distance 5 + const FleetStepResult r = StepFleet(s, 10.0, rng); + CHECK(r.arrived); + CHECK_NEAR(r.pos[0], 4.0, 0.0); // copied verbatim, not extrapolated + CHECK_NEAR(r.pos[1], 3.0, 0.0); + CHECK_NEAR(r.moved, 5.0, 1e-9); +} + +void test_node_waypoint_fraction() { + simtest::ScriptedRng rng; + FleetStepSnapshot s = Fleet(4, static_cast(WaypointKind::NodeRoute), 1, 100.f); + const FleetStepResult r = StepFleet(s, 10.0, rng); + CHECK(r.arrived); + CHECK_NEAR(r.fraction, 0.4, 1e-6); // only a node waypoint reports a partial pass +} + +void test_gate_teleport() { + simtest::ScriptedRng rng; + FleetStepSnapshot s = Fleet(1000, static_cast(WaypointKind::GateTeleport), 1, 1.f); + const FleetStepResult r = StepFleet(s, 10.0, rng); + CHECK(r.arrived); + CHECK_NEAR(r.pos[0], 1000.0, 0.0); // no distance clamp at all + CHECK_NEAR(r.shipRanges[0], 1.0, 0.0); // and no fuel is charged + CHECK_EQ(r.rngDraws, 0); +} + +void test_probabilistic_jump() { + FleetStepSnapshot s = Fleet(1000, static_cast(WaypointKind::ProbabilisticJump), 1, 1.f); + s.castEfficiency = 2.f; + s.castThreshold = 1.f; + { // 0.3 x 2 = 0.6 <= 1: it arrives, on one draw, with no fuel charged + simtest::ScriptedRng rng({0.3f}); + const FleetStepResult r = StepFleet(s, 10.0, rng); + CHECK(r.arrived); + CHECK_NEAR(r.pos[0], 1000.0, 0.0); + CHECK_EQ(r.rngDraws, 1); + CHECK_NEAR(r.shipRanges[0], 1.0, 0.0); + } + { // 0.9 x 2 = 1.8 > 1: a misjump, two draws, scattered by 1.8 + simtest::ScriptedRng rng({0.9f}, {7u}); + const FleetStepResult r = StepFleet(s, 10.0, rng); + CHECK(!r.arrived); + CHECK_EQ(r.rngDraws, 2); + CHECK_NEAR(r.moved, 1.8, 1e-6); + } +} + +void test_held_fleet() { + simtest::ScriptedRng rng; + FleetStepSnapshot s = Fleet(100, 0, 1, 50.f); + s.heldFlag = 1; + const FleetStepResult r = StepFleet(s, 10.0, rng); + CHECK_NEAR(r.moved, 0.0, 0.0); + CHECK(!r.arrived); + CHECK_EQ(r.rngDraws, 0); + + FleetStepSnapshot empty = Fleet(100, -1, 1, 50.f); + const FleetStepResult e = StepFleet(empty, 10.0, rng); + CHECK_NEAR(e.moved, 0.0, 0.0); // no waypoints: nothing happens +} + +void test_gate_traffic_and_schedule() { + std::vector f(4); + f[0] = {1, 0, 2, 0, static_cast(WaypointKind::GateTeleport), 10, 0}; + f[1] = {2, 0, 0, 0, static_cast(WaypointKind::ProbabilisticJump), 5, 0}; + f[2] = {3, 1, 0, 0, static_cast(WaypointKind::NodeLine), 100, 0}; + f[3] = {4, 1, 3, 2, static_cast(WaypointKind::GateTeleport), -4, 0}; + + const std::vector g = GateTraffic(f, 2); + CHECK_EQ(g.size(), std::size_t{2}); + CHECK_EQ(g[0], 15); // both gate-transit kinds count + CHECK_EQ(g[1], -4); // the node-line fleet contributes nothing; the word is signed + + // fleet 1 chases fleet 2 (relation 0), fleet 4 follows fleet 3 (relation 2) + const std::vector p = PassSchedule(f); + CHECK_EQ(p.size(), std::size_t{6}); + CHECK_EQ(p[0].fleetId, 2); CHECK_EQ(p[0].pass, 1); + CHECK_EQ(p[1].fleetId, 1); CHECK_EQ(p[1].pass, 2); + CHECK_EQ(p[2].fleetId, 2); CHECK_EQ(p[2].pass, 3); + CHECK_EQ(p[3].fleetId, 1); CHECK_EQ(p[3].pass, 4); // the chase failed: a second half + // fleet 3 is only a *follower's* target, which is not the prey set, so it takes a full + // turn in pass 4 like any unengaged fleet + CHECK_EQ(p[4].fleetId, 3); CHECK_EQ(p[4].pass, 4); CHECK_NEAR(p[4].dt, 1.0, 0.0); + CHECK_EQ(p[5].fleetId, 4); CHECK_EQ(p[5].pass, 5); +} + +void test_ship_ranges_helper() { + FleetStepSnapshot s = Fleet(10, 0, 3, 1.f); + s.ships[1].range = 2.f; + s.ships[2].range = 3.f; + const std::vector r = ShipRanges(s); + CHECK_EQ(r.size(), std::size_t{3}); + CHECK_NEAR(r[2], 3.0, 0.0); + s.shipCount = kMaxTracedShips + 10; // a garbage count is truncated, not overrun + CHECK_EQ(ShipRanges(s).size(), std::size_t{kMaxTracedShips}); +} + +} // namespace + +int main() { + test_straight_step(); + test_arrival_snap(); + test_node_waypoint_fraction(); + test_gate_teleport(); + test_probabilistic_jump(); + test_held_fleet(); + test_gate_traffic_and_schedule(); + test_ship_ranges_helper(); + return simtest::finish("shim_movement_unit"); +} diff --git a/tools/build-shim.sh b/tools/build-shim.sh index 3a8d269..1fb3bff 100755 --- a/tools/build-shim.sh +++ b/tools/build-shim.sh @@ -34,5 +34,8 @@ fi mkdir -p "$DIST" cp "$dll" src/shim/shim.cfg tools/deploy.ps1 tools/undeploy.ps1 "$DIST/" +# Per-milestone config variants (shim.cfg.) ship alongside the default one; a run copies +# the variant it wants over C:\SOTS\shim.cfg. +for cfg in src/shim/shim.cfg.*; do [ -e "$cfg" ] && cp "$cfg" "$DIST/"; done echo "$BUILD_ID" > "$DIST/BUILD_ID" echo "staged $BUILD_ID in $DIST"