merge b3 (RNG + research corrections; VM verification pending)
This commit is contained in:
commit
3ebc8bf5a9
18 changed files with 1275 additions and 82 deletions
|
|
@ -51,8 +51,9 @@ if(WIN32)
|
||||||
target_include_directories(minhook PUBLIC third_party/minhook/include)
|
target_include_directories(minhook PUBLIC third_party/minhook/include)
|
||||||
|
|
||||||
# ---- hooks: one descriptor per hooked game function (src/shim/hooks/*) ----
|
# ---- hooks: one descriptor per hooked game function (src/shim/hooks/*) ----
|
||||||
add_library(shim_hooks STATIC src/shim/hooks/global_consts.cpp src/shim/hooks/dictionaries.cpp)
|
add_library(shim_hooks STATIC src/shim/hooks/global_consts.cpp src/shim/hooks/dictionaries.cpp
|
||||||
target_link_libraries(shim_hooks PUBLIC shim_trace sots_addresses sots_game_config)
|
src/shim/hooks/research.cpp)
|
||||||
|
target_link_libraries(shim_hooks PUBLIC shim_trace sots_addresses sots_game_config sots_game_sim mars_rng)
|
||||||
target_compile_options(shim_hooks PRIVATE -Wall -Wextra -Werror)
|
target_compile_options(shim_hooks PRIVATE -Wall -Wextra -Werror)
|
||||||
|
|
||||||
add_library(binkw32 SHARED src/shim/main.cpp src/shim/binkw32.def)
|
add_library(binkw32 SHARED src/shim/main.cpp src/shim/binkw32.def)
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ loads) builds, deploys, hooks, and logs from a real game launch. Engine code acc
|
||||||
- `game/sim` — strategic formulas (economy, research, colonies, movement) as pure functions; 356 hand-computed checks
|
- `game/sim` — strategic formulas (economy, research, colonies, movement) as pure functions; 356 hand-computed checks
|
||||||
- `mars/vfs` — `.gob` (ZIP) archive reader + loose-file override; entry counts and bytes verified against `unzip`
|
- `mars/vfs` — `.gob` (ZIP) archive reader + loose-file override; entry counts and bytes verified against `unzip`
|
||||||
- `mars/stream` — the game's self-describing save/serialization format (reader, writer, typed shapes) + gzip; three real saves round-trip byte-identical
|
- `mars/stream` — the game's self-describing save/serialization format (reader, writer, typed shapes) + gzip; three real saves round-trip byte-identical
|
||||||
- `mars/rng` — MT19937 with save-state load/store; layout confirmed against real saves
|
- `mars/rng` — MT19937 with save-state load/store; layout confirmed against real saves, draw mappings read off the binary (`docs/mars-rng.md`)
|
||||||
- `game/data` — typed catalogs (weapons, ship sections, turrets, id registries, tech tree, strings) with cross-reference checks; 229k values agree with the reference
|
- `game/data` — typed catalogs (weapons, ship sections, turrets, id registries, tech tree, strings) with cross-reference checks; 229k values agree with the reference
|
||||||
- `game/design` — ship-design assembly/fit/tech-gating rules and derived stats; validates all 127 stock designs from real saves
|
- `game/design` — ship-design assembly/fit/tech-gating rules and derived stats; validates all 127 stock designs from real saves
|
||||||
|
|
||||||
|
|
|
||||||
233
docs/B3.md
Normal file
233
docs/B3.md
Normal file
|
|
@ -0,0 +1,233 @@
|
||||||
|
# B3 — `TechTree::ProcessResearch` old-vs-new, with the RNG state as a declared region
|
||||||
|
|
||||||
|
**Status (2026-09-08): code complete, cross-built and staged; every VM step still owed.**
|
||||||
|
VM140 is 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-b3`) and its own dist
|
||||||
|
(`/srv/re-lab/shim/dist-b3`), not the shared ones.
|
||||||
|
|
||||||
|
The point of the target: one call exercises the MT19937, the completion-odds formula and the
|
||||||
|
Zuul double roll at once, so a match validates all three together — and its RNG consumption is
|
||||||
|
observable, which makes the draw *count* checkable rather than merely plausible.
|
||||||
|
|
||||||
|
## What was hooked
|
||||||
|
|
||||||
|
| hook name (record `hook`) | RVA | prototype |
|
||||||
|
|---|---|---|
|
||||||
|
| `Game::TechTree::ProcessResearch` | 0x001876c0 | `void (TechTree*, Mars::RNG*, vector<{TechDef*,int}>*, int*)` |
|
||||||
|
|
||||||
|
`__thiscall`, `[verified]`, so it goes through `Hook<>` with `CallConv::Thiscall` (M2's
|
||||||
|
addition). Source: `src/shim/hooks/research.{h,cpp}`, installed from `src/shim/main.cpp` after
|
||||||
|
the M1/M2 hooks. There is exactly one caller (inside `ServerPlayer::ProcessTurn`), which fires
|
||||||
|
once per player per turn.
|
||||||
|
|
||||||
|
**The second parameter was a `?` in the address contract; it is the `Mars::RNG` object.** The
|
||||||
|
call site loads it from `StrategyServer+0x16c` and the function re-bases it with `+4` before
|
||||||
|
every draw. That is now in `ghidra/addresses.json` along with the tree/node offsets, the
|
||||||
|
generator layout and `TechTree::Cost`, and regenerated into `sots_addresses.h`.
|
||||||
|
|
||||||
|
### Region model
|
||||||
|
|
||||||
|
Three kinds of region, all snapshotted before the original runs:
|
||||||
|
|
||||||
|
| region | size | describer |
|
||||||
|
|---|---|---|
|
||||||
|
| `rng` | 0x9cc | `{vptr:ptr, mt:bytes(2496 → sha256+head), left:i32, next_index:i64}` |
|
||||||
|
| `overbudget` | 4 | `{v:i32}` |
|
||||||
|
| `node[i]` | 0x34 each, one per non-null slot | `{def, tech_id, kids_*, unk10, state, cost_rp, progress, turn_available, turn_researched, order, flag, unk30}` |
|
||||||
|
|
||||||
|
`next` is a heap address, so it is reported as its index into `mt` — which is what it means,
|
||||||
|
and what survives being written by a reimplementation. `left` alone already pins the stream
|
||||||
|
position (`next == &mt[624 - left]` always), so the index is a cross-check, not the evidence.
|
||||||
|
|
||||||
|
Args carry the evidence a golden log needs to replay offline: `tree`, `owner`, `species`,
|
||||||
|
`node_count`, `rng`, `rng_left_in`, the `alloc` list as `{tech_id, points}`, `overbudget_in`,
|
||||||
|
and **`fpu_cw`** — the x87 control word in force for the call (see "Float mapping" below).
|
||||||
|
|
||||||
|
Declaring *every* node, not just the ones we expect to change, is deliberate: it is what proves
|
||||||
|
`ours` neither misses a write nor makes an extra one. The cost is size — a compare record is
|
||||||
|
roughly 200 nodes × three snapshots, so the b3 configs turn every other hook off.
|
||||||
|
|
||||||
|
### The comparison design
|
||||||
|
|
||||||
|
`ProcessResearch` consumes RNG, so comparing two implementations that draw from different
|
||||||
|
streams would diverge for a reason that has nothing to do with the formulas. Instead:
|
||||||
|
|
||||||
|
1. the generator object is a **declared region**, so its `mt[624]` + `left` are snapshotted
|
||||||
|
before the original runs, alongside the tech-tree state;
|
||||||
|
2. the original runs and advances the real generator;
|
||||||
|
3. `ours` runs on the scratch copies, and seeds a `mars::rng::MT19937` with `load_state()` from
|
||||||
|
the *pre-call* snapshot — so both implementations read the identical stream;
|
||||||
|
4. `ours` writes its final generator state back into the scratch copy, so the diff compares the
|
||||||
|
**post-call RNG state** as well as the outputs.
|
||||||
|
|
||||||
|
If the post-states match, we consumed the same words in the same order. That is the check with
|
||||||
|
teeth: getting the odds right but drawing twice (or not drawing at all) moves `left` and the
|
||||||
|
hash. `tests/mars_stream/test_rng.cpp` pins the property offline, including the negative case
|
||||||
|
(one draw too few leaves a different state).
|
||||||
|
|
||||||
|
`next` is rebuilt by `ours` against the **live** generator address so the describer's index
|
||||||
|
arithmetic reads the same on both sides; `ours` never dereferences it.
|
||||||
|
|
||||||
|
## What `ours` covers, and what it deliberately does not
|
||||||
|
|
||||||
|
`ours` is `sots::sim::ProcessResearchTurn` (new, `src/game/sim/research.{h,cpp}`) plus a thin
|
||||||
|
shim adapter. It reproduces exactly the words the hooked function writes itself:
|
||||||
|
|
||||||
|
* the allocation loop — spend window, spend, `*overbudget`, progress, the roll, the completion
|
||||||
|
decision, the over-budget flag, the "completed early" flag, `state = 4` on completion;
|
||||||
|
* the decay sweep over every available node.
|
||||||
|
|
||||||
|
It does **not** reproduce `TechTree::SetResearched`, which the original calls on completion:
|
||||||
|
the turn/order stamps, the child-unlock cascade and the owner's tech-effect callback. That is
|
||||||
|
its own milestone, and the callback writes live player state that compare mode must never
|
||||||
|
touch. So:
|
||||||
|
|
||||||
|
> **A turn on which a tech completes is expected to diverge**, in the completing node's
|
||||||
|
> `turn_researched` / `order` and in the child nodes `SetResearched` unlocks — and in nothing
|
||||||
|
> else, including the RNG (the cascade makes no draw; verified statically, though the owner
|
||||||
|
> callback itself was not audited). A turn on which nothing completes — the overwhelmingly
|
||||||
|
> common case — must match everywhere.
|
||||||
|
|
||||||
|
The effective cost of a node comes from the game's own `TechTree::Cost` (read-only: it only
|
||||||
|
reads `costRP`, the def and the owner, and calls the read-only cost-multiplier helper). The
|
||||||
|
cost multiplier is a separate, medium-confidence formula and not what this milestone measures;
|
||||||
|
this is the same delegation M2 makes to `LoadWeapon`. `ours` receives the live tree pointer and
|
||||||
|
treats it as read-only — every node it writes is a scratch copy.
|
||||||
|
|
||||||
|
`ours` also works in `replace` mode, where no `regions`/`rebind` ran: it then reads the tree's
|
||||||
|
own node vector and the live generator. The per-call statics carry a flag that is cleared at
|
||||||
|
the end of every `ours`, so a replace call can never inherit a stale compare mapping.
|
||||||
|
|
||||||
|
## Float mapping — the headline finding
|
||||||
|
|
||||||
|
**A draw is `y / (2^32 − 1)`, not `y × 2^-32`.** The multiplier in the image is the double
|
||||||
|
`0x3df0000000001000`, which is exactly `1/4294967295`; the constant next to it is the `+2^32`
|
||||||
|
unsigned fix-up applied after a sign-extending integer load. So:
|
||||||
|
|
||||||
|
* `MT19937::kUnitScale` is now `1.0 / 4294967295.0`;
|
||||||
|
* the range is **closed**: `y == 0xffffffff` maps to exactly `1.0`, not to just below it;
|
||||||
|
* the value is left in `st(0)` and the caller narrows it — every consumer in the strategic sim
|
||||||
|
stores it to a 4-byte float first, which is what `next_float()` models.
|
||||||
|
|
||||||
|
Honest caveat, because it decides how to read a passing compare: the old and new divisors differ
|
||||||
|
by 2^-32 relative, far below a float32 ulp. Measured over 10^6 draws they give a **different
|
||||||
|
float 0.78 % of the time**, and they flip an actual research completion decision (roll vs an
|
||||||
|
odds of 1/3) **0 times in 10^6** — the expected rate is about one in two billion. So the
|
||||||
|
compare cannot prove the divisor; the disassembly and the constant's bit pattern do, and the
|
||||||
|
compare's job is the rest.
|
||||||
|
|
||||||
|
The one thing the binary cannot settle is the x87 **precision-control** field at run time. At
|
||||||
|
the MSVC default (53-bit, `cw = 0x027f`) the multiply rounds to double and the caller's store
|
||||||
|
rounds again — that is what `next_float()` does. A Direct3D 9 device created without
|
||||||
|
`FPU_PRESERVE` leaves 24-bit precision, in which the fix-up and the multiply each round to 24
|
||||||
|
bits; `MT19937::float_from_pc24()` models that, and the two differ for **0.094 %** of words —
|
||||||
|
last-bit only. The hook records `fpu_cw` on every call, so the first trace settles it; if it
|
||||||
|
comes back 24-bit the change is to route `next_float` through `float_from_pc24` and to compute
|
||||||
|
the odds the same way, and nothing else in the milestone moves.
|
||||||
|
|
||||||
|
`float10` in the decompile is just the i386 float return ABI and was not chased.
|
||||||
|
|
||||||
|
## Bugs found and fixed in our implementation
|
||||||
|
|
||||||
|
All five are read off the instruction sequence, not tuned to make anything match.
|
||||||
|
|
||||||
|
1. **The unit divisor** (above): `2^-32` → `1/(2^32 − 1)`.
|
||||||
|
2. **`NextInt` is inclusive.** The rejection mask is built from `n` itself, not `n − 1`, and the
|
||||||
|
loop re-draws while the masked word is **greater than** `n` — so the result is uniform on
|
||||||
|
`[0, n]`, one value wider than we had. The bound is also passed **by pointer**, which is why
|
||||||
|
the prototype had stayed `[unverified]`. `next_int(n)` is now `next_int_inclusive(n)` and
|
||||||
|
`IRandom::NextInt` is `IRandom::NextIntInclusive`, so every call site had to be re-read.
|
||||||
|
3. **`spend` has no floor at zero.** The original is a plain signed `min(points, hi − progress)`;
|
||||||
|
ours clamped it at 0, which would have hidden a negative spend (and understated `*overbudget`)
|
||||||
|
whenever progress was already past the 150 % ceiling.
|
||||||
|
4. **`odds` is a float32.** The original computes `(progress − lo) / hi` on the x87 and stores it
|
||||||
|
to a 4-byte slot before the comparison; ours kept it in double. Likewise the roll, the Zuul
|
||||||
|
minimum, and the `progress / cost` ratio used for the "completed early" flag.
|
||||||
|
5. **Two constants are widened float literals, not decimals.** The decay fraction is
|
||||||
|
`(double)0.05f = 0.05000000074505806` and the early-completion threshold is
|
||||||
|
`(double)0.8f = 0.800000011920929`. Both sit on a truncation/compare boundary.
|
||||||
|
|
||||||
|
Two smaller ones in the same pass: the decay guard is `progress != 0`, not `progress > 0`; and
|
||||||
|
a node whose cost is still `INT_MAX` is **not** special-cased by the original — it feeds that
|
||||||
|
straight into the 5 % multiply, which wipes any progress out. The 50 %/150 % bounds are a
|
||||||
|
32-bit multiply that wraps near `INT_MAX` rather than a widening one.
|
||||||
|
|
||||||
|
## Host tests
|
||||||
|
|
||||||
|
`ctest` 26/26. New coverage:
|
||||||
|
|
||||||
|
* `mars_rng_unit` — the standard MT19937 vectors (seed 5489, and the 10000th output) were
|
||||||
|
already there; added the unit mapping word by word (`0 → 0`, `0xffffffff → 1.0`, the
|
||||||
|
high-bit fix-up path), the measured rarity of the divisor difference, the PC24 variant's
|
||||||
|
bound, `cover_mask`, the inclusive integer bound (including that `n` itself is reachable and
|
||||||
|
that `n == 0` still consumes a word), and **the compare design end to end**: snapshot →
|
||||||
|
original draws → ours seeded from the snapshot reproduces the values and the post-state, with
|
||||||
|
a negative case that one draw too few does not.
|
||||||
|
* `game_sim_research` — 121 checks. Added the spend window (truncation, the floor/ceiling
|
||||||
|
clamps, the `INT_MAX` edge), that the odds are float32, that `spend` goes negative rather
|
||||||
|
than clamping, the early-completion boundary at exactly 80 % versus one point below, and
|
||||||
|
`ProcessResearchTurn` (order of the passes, the funded node decaying too, hidden slots never
|
||||||
|
touched, an out-of-range entry consuming no draw, `overbudget` accumulating).
|
||||||
|
|
||||||
|
Cross-build: `b3-81218c7-dirty-20260908T0311Z`, exports 66 names identical to `binkw32.dll`,
|
||||||
|
staged in `/srv/re-lab/shim/dist-b3`. `tools/clean_room_check.sh` OK.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
1. **Two different `this` pointers for one object.** `Twist`, `NextFloat` and `NextInt` take
|
||||||
|
`&mt` — the object **plus 4** — so *their* `this+0x9c0/+0x9c4` are `next`/`left`, while the
|
||||||
|
object's own layout is `{vftable @+0, mt[624] @+4, next @+0x9c4, left @+0x9c8}` = 0x9cc
|
||||||
|
bytes. The address contract used to state both readings as if they were one; it now says
|
||||||
|
which is which. Getting this wrong shifts every generator field by a word.
|
||||||
|
2. The save blob is 0x9c4 bytes = `mt[624]` + `left`, i.e. it **skips** `next`, which sits
|
||||||
|
between them in the object. `left` is sufficient because `next == &mt[624 - left]` and the
|
||||||
|
original's `Read` recomputes it.
|
||||||
|
3. `Region::name` is a `const char*` held for the whole call, so the per-node name strings are
|
||||||
|
`resize`d once up front and never grown — a reallocation would dangle every name already
|
||||||
|
pushed.
|
||||||
|
4. Per-call state is kept in statics between `regions()` → `rebind()` → `ours()` (M1's
|
||||||
|
concession). Safe here because the turn pass is single-threaded and `ProcessResearch` has one
|
||||||
|
caller and never nests; do not copy the pattern to a re-entrant hook.
|
||||||
|
5. A compare record is large (one region per tech node). The staged configs
|
||||||
|
(`shim.cfg.b3{trace,compare,replace}`, in the dist) switch every other hook off for that
|
||||||
|
reason; `trace.inline_max` stays at 256 so the 2496-byte state block is hashed rather than
|
||||||
|
inlined.
|
||||||
|
6. `TechDef`'s first word is the tech id and it indexes `TechTree+0x10`; the allocation vector's
|
||||||
|
stride is 8 (`{TechDef*, int}`). Both are in the address contract now rather than inferred.
|
||||||
|
|
||||||
|
## What remains (needs the VM)
|
||||||
|
|
||||||
|
The lane holding VM140 must be finished first; then, in this order:
|
||||||
|
|
||||||
|
1. Deploy `/srv/re-lab/shim/dist-b3` (build `b3-81218c7-dirty-20260908T0311Z`): `scp` it to
|
||||||
|
`C:\SOTS\shimdist-b3\` and run `deploy.ps1 -Dist C:\SOTS\shimdist-b3` — **a separate staging
|
||||||
|
directory from the shared `C:\SOTS\shimdist`**, so the other lane's dist is not overwritten.
|
||||||
|
2. Copy `shim.cfg.b3trace` over `C:\SOTS\shim.cfg`, relaunch, load `ref-turn2.sav`, press End
|
||||||
|
Turn once, and pull `C:\SOTS\shim.trace.jsonl` → `b3-trace-golden.jsonl`.
|
||||||
|
`tracecmp.py` must exit 0 with 0 invalid records. Expect one record per player that both
|
||||||
|
has a research target and did not suffer a lab accident that turn — the caller gates the
|
||||||
|
call on `if (ResT && !RollResearchAccident())` — so a turn with no records at all is a
|
||||||
|
setup problem, not a pass.
|
||||||
|
**Read off this trace before going further:** `fpu_cw` (expect `0x027f`; `0x007f`/`0x003f`
|
||||||
|
means 24-bit precision and the `float_from_pc24` route), and, per record, `rng_left_in`
|
||||||
|
minus the after-state's `left` — 1 for a non-Zuul player who rolled, 2 for a Zuul, 0 when
|
||||||
|
the spend was zero or the tech was already at the 150 % ceiling.
|
||||||
|
3. Copy `shim.cfg.b3compare`, relaunch, load `ref-turn2.sav`, End Turn → `b3-compare.jsonl`.
|
||||||
|
Expect **0 divergences on every record where no tech completed**. On a record whose
|
||||||
|
`ours` shows a completion, the only permitted diffs are `turn_researched` / `order` on that
|
||||||
|
node and the child nodes `SetResearched` unlocked; the `rng` region must still match. Any
|
||||||
|
other diff is a real finding — report it, do not tune the formula.
|
||||||
|
To get more turns, keep pressing End Turn; each turn adds one record per player.
|
||||||
|
4. If the compare is clean: copy `shim.cfg.b3replace`, relaunch, load `ref-turn2.sav`, End Turn,
|
||||||
|
and check the determinism oracle — `(Autosave).sav` = `978041ac…`, `(Autosave EndTurn).sav`
|
||||||
|
= `bb4fd9ac…`. This is the strongest single result available: replace mode means our research
|
||||||
|
pass fed the game, and the save hash means the whole turn still landed byte for byte.
|
||||||
|
5. Restore the previous `shim.cfg` (`hooks=trace`) and leave the game at the main menu, as M1/M2
|
||||||
|
left it.
|
||||||
|
|
||||||
|
Not done, and worth saying: the compare above cannot distinguish the two unit divisors (see
|
||||||
|
"Float mapping"), and it will not exercise the Zuul path at all unless a Zuul player is in the
|
||||||
|
reference save — check `species` in the trace args, and if none is 5, run one more compare from
|
||||||
|
a Zuul save before calling the double roll verified by behaviour rather than by disassembly.
|
||||||
|
|
@ -52,10 +52,13 @@ Confidence legend — **high**: formula verified in the RE notes against the cod
|
||||||
| `RollEdgeAvailable` | include iff `mode == Everything` or `(p > 0 && (mode == NoRoll || p >= 1 || rand01() <= p))`; one draw only when 0 < p < 1 in Normal mode | high |
|
| `RollEdgeAvailable` | include iff `mode == Everything` or `(p > 0 && (mode == NoRoll || p >= 1 || rand01() <= p))`; one draw only when 0 < p < 1 in Normal mode | high |
|
||||||
| `TechCostMultiplier` | `max(0.25, 1 − 0.25 x n)` | medium — which three species techs count is unresolved |
|
| `TechCostMultiplier` | `max(0.25, 1 − 0.25 x n)` | medium — which three species techs count is unresolved |
|
||||||
| `TechCost` | `INT_MAX` stays; else `max(1, ftol(base x mult))` | high |
|
| `TechCost` | `INT_MAX` stays; else `max(1, ftol(base x mult))` | high |
|
||||||
| `ApplyResearchPoints` | `lo = cost x 50/100`, `hi = cost x 150/100` (integer); `spend = min(points, hi − progress)`; below `hi`: `odds = (progress − lo)/hi` (0 at 50 %, 1/3 at 100 %, 2/3 at 150 %), `roll = rand01()`, Zuul keep the lower of two rolls, zero spend → odds 0/roll 1; at `hi`: guaranteed; complete iff `odds >= roll`; crossing 100 % without completing → over-budget event (flag 2); completing below 80 % → "completed early" (flag 0) | high |
|
| `ResearchSpendFloor` / `ResearchSpendCeiling` | `lo = cost x 50 / 100`, `hi = cost x 150 / 100` — a **32-bit** multiply (it wraps near `INT_MAX`) and a truncating divide; then `lo = max(lo, 0)`, `hi = max(lo, hi)` | high — read off the instruction sequence |
|
||||||
| `DecayResearchProgress` | `max(0, progress − ftol(cost x 0.05))` | high |
|
| `ResearchCompletionOdds` | `(progress − lo) / hi` evaluated in double and **narrowed to float32**: the original stores it in a 4-byte float slot before comparing | high |
|
||||||
| `DecayAllResearch` | applies to every Available node with progress, after the target was processed; the just-funded target is **not** excluded and only escapes by completing in the same pass (net gain of the current tech = spend − 5 % of cost) | high — confirmed against the loop |
|
| `ApplyResearchPoints` | `spend = min(points, hi − progress)` — a signed min with **no floor at zero**; `progress += spend`; below `hi`: odds as above, `roll = rand01()` (also narrowed to float32), Zuul keep the lower of two rolls, zero spend → odds 0 / roll 1; at `hi`: odds 1 / roll 0, no draw; complete iff `!(odds < roll)` compared as float32; crossing 100 % without completing → over-budget event (flag 2); completing below `(double)0.8f` of cost, where the ratio is itself a float32, → "completed early" (flag 0). Only the words the original function writes itself: the unlock cascade is `SetResearched`'s | high |
|
||||||
| `RollLabAccident` | `randint(100) < odds` | medium — odds-from-boost function unresolved (caller supplies odds) |
|
| `DecayResearchProgress` | `max(0, progress − ftol(cost x (double)0.05f))` — the image holds the widened float literal `0.05000000074505806`, and there is no special case for a node whose cost is still `INT_MAX` | high |
|
||||||
|
| `DecayAllResearch` | applies to every Available node whose progress is **non-zero** (not "positive"), after the target was processed; the just-funded target is **not** excluded and only escapes by completing in the same pass (net gain of the current tech = spend − 5 % of cost) | high — confirmed against the loop |
|
||||||
|
| `ProcessResearchTurn` | the whole per-turn pass: every allocation entry in order, then the decay sweep. This is the shape the shim hooks; see `docs/B3.md` | high |
|
||||||
|
| `RollLabAccident` | `randint(100) < odds`, where `randint` is uniform on `[0, 100]` **inclusive** (see `docs/mars-rng.md`) | medium — odds-from-boost function unresolved (caller supplies odds) |
|
||||||
| `LabAccidentLossPercent` | `ceil(clamp01(rand01() x (max − min) + min) x 100)` | high |
|
| `LabAccidentLossPercent` | `ceil(clamp01(rand01() x (max − min) + min) x 100)` | high |
|
||||||
|
|
||||||
## Colonies (`colony.h`)
|
## Colonies (`colony.h`)
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,9 @@ and consume words in the same order; the save file carries the generator state v
|
||||||
```cpp
|
```cpp
|
||||||
mars::rng::MT19937 r(seed); // seed(): Knuth initializer, then one twist (left == 624)
|
mars::rng::MT19937 r(seed); // seed(): Knuth initializer, then one twist (left == 624)
|
||||||
uint32_t y = r.next_u32(); // tempered output
|
uint32_t y = r.next_u32(); // tempered output
|
||||||
float f = r.next_float(); // (float)(y * 2^-32), see below
|
double u = r.next_unit(); // y / (2^32 - 1) in double -- see "Draw mappings"
|
||||||
uint32_t k = r.next_int(n); // uniform [0, n): power-of-two mask + rejection
|
float f = r.next_float(); // the same draw narrowed to float32; range [0, 1] CLOSED
|
||||||
|
uint32_t k = r.next_int_inclusive(n); // uniform [0, n] INCLUSIVE: mask covering n + rejection
|
||||||
r.load_state(mt, left); // or load_state(blob, 0x9c4) from a save's "RNG" item
|
r.load_state(mt, left); // or load_state(blob, 0x9c4) from a save's "RNG" item
|
||||||
r.save_state(out); // mt[624] + left, 0x9c4 bytes little-endian
|
r.save_state(out); // mt[624] + left, 0x9c4 bytes little-endian
|
||||||
r.left(); r.index(); r.state();
|
r.left(); r.index(); r.state();
|
||||||
|
|
@ -45,16 +46,41 @@ the tempering or the float mapping (those never touch the saved state).
|
||||||
* seed 5489 → 3499211612, 581869302, 3890346734, … ; the 10000th output is 4123659995.
|
* seed 5489 → 3499211612, 581869302, 3890346734, … ; the 10000th output is 4123659995.
|
||||||
* `save_state`/`load_state` round trip, `left` positioning, malformed-blob rejection.
|
* `save_state`/`load_state` round trip, `left` positioning, malformed-blob rejection.
|
||||||
|
|
||||||
|
## Draw mappings (settled from the binary, B3)
|
||||||
|
|
||||||
|
Both public draws were re-read instruction by instruction for B3 (`docs/B3.md`), and both
|
||||||
|
corrections below are behaviour changes, not cosmetics.
|
||||||
|
|
||||||
|
1. **The unit divisor is `2^32 - 1`, not `2^32`.** The multiplier in the image is the double
|
||||||
|
`0x3df0000000001000`, which is `1/4294967295`, and the sequence is: sign-extending integer
|
||||||
|
load of the tempered word, `+ 2^32` when the signed reading is negative (the unsigned
|
||||||
|
fix-up), then the multiply. So `next_unit() == y / (2^32 - 1)` and the range is **closed**:
|
||||||
|
`y == 0xffffffff` maps to exactly `1.0`, not to just below it. `MT19937::kUnitScale` holds
|
||||||
|
the constant.
|
||||||
|
The previous `2^-32` mapping differed by 2^-32 relative, which is far below a float32 ulp,
|
||||||
|
so the two agree for roughly 99 words in 100 once narrowed. That is worth stating plainly:
|
||||||
|
a behavioural compare over a handful of turns is *not* strong evidence for either divisor,
|
||||||
|
and `tests/mars_stream/test_rng.cpp` pins the difference so nobody reads it that way.
|
||||||
|
|
||||||
|
2. **The value stays in the x87 register.** The function leaves the product in `st(0)` and the
|
||||||
|
caller narrows it; every consumer in the strategic sim stores it to a 4-byte float first,
|
||||||
|
which is what `next_float()` models. The one thing the binary cannot tell us is the x87
|
||||||
|
precision-control field in force at run time: at the MSVC default (53-bit) the multiply
|
||||||
|
rounds to double and the caller's store rounds again, while a Direct3D 9 device created
|
||||||
|
without `FPU_PRESERVE` leaves 24-bit precision, in which the fix-up and the multiply each
|
||||||
|
round to 24 bits. `float_from_pc24()` models the second case; the shim records the control
|
||||||
|
word with every `ProcessResearch` call so one run settles it. The two mappings can only
|
||||||
|
differ in the last bit of the float.
|
||||||
|
|
||||||
|
3. **`next_int_inclusive(n)` is inclusive.** The mask is the smallest `2^k - 1` that is `>= n`
|
||||||
|
(computed from `n` itself, not `n - 1`), and the loop re-draws while the masked word is
|
||||||
|
**greater than** `n` — so the result is uniform on `[0, n]`, one value wider than the
|
||||||
|
half-open range the notes assumed. `n == 0` masks to 0 and still consumes a word. The bound
|
||||||
|
reaches the callee **by pointer**, which is why the prototype had stayed unverified.
|
||||||
|
|
||||||
## Choices that still need binary confirmation
|
## Choices that still need binary confirmation
|
||||||
|
|
||||||
1. **Float mapping.** `next_float()` returns `(float)((double)y * 2^-32)`. This is the mapping
|
1. **Twist timing at the block boundary.** We twist lazily when `left` reaches 0 (so a saved
|
||||||
recorded in the RE notes for the engine's float roll (product in double, then narrowed).
|
state may carry `left == 0`). The original's `NextFloat`/`NextInt` both test `left == 0` on
|
||||||
Note the narrowing rounds `y >= 0xFFFFFF80` up to exactly `1.0f`, so the range is `[0, 1]`
|
entry, which is the same lazy rule; what the three saves (`left` = 454/432/413) still do not
|
||||||
in practice. Confirm against a captured sequence before relying on the exact bits.
|
show is a state saved exactly at the boundary.
|
||||||
2. **Twist timing at the block boundary.** We twist lazily when `left` reaches 0 (so a saved
|
|
||||||
state may carry `left == 0`). If the original twists eagerly after the last word of a block
|
|
||||||
(`left` then never saved as 0, block already advanced), the output sequence is identical but
|
|
||||||
the saved blob at that one boundary differs. The three saves (`left` = 454/432/413) do not
|
|
||||||
distinguish the two.
|
|
||||||
3. **`next_int(n)`** — mask = smallest `2^k - 1 >= n - 1`, reject while `r >= n`. The rejection
|
|
||||||
scheme matches the RE description; the exact mask computation is unconfirmed.
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
// GENERATED — do not edit. Facts about Sword of the Stars.exe (GOG 1.8.1).
|
// GENERATED — do not edit. Facts about Sword of the Stars.exe (GOG 1.8.1).
|
||||||
// Source: sots-re ghidra/addresses.json @ 87a39b6, generated 2026-09-07 by tools/gen_addresses.py
|
// Source: sots-re ghidra/addresses.json @ 73f5f1e, generated 2026-09-07 by tools/gen_addresses.py
|
||||||
// Runtime address = (uintptr_t)GetModuleHandle(NULL) + RVA (the exe is ASLR-relocated).
|
// Runtime address = (uintptr_t)GetModuleHandle(NULL) + RVA (the exe is ASLR-relocated).
|
||||||
#pragma once
|
#pragma once
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
|
|
@ -23,9 +23,9 @@ constexpr uint32_t StrategyClient_EndTurn = 0x00383be0;
|
||||||
constexpr uint32_t StrategyServer_BeginProcessTurn = 0x003d98e0;
|
constexpr uint32_t StrategyServer_BeginProcessTurn = 0x003d98e0;
|
||||||
// thiscall void (StrategyServer* this) [unverified]
|
// thiscall void (StrategyServer* this) [unverified]
|
||||||
constexpr uint32_t StrategyServer_ProcessTurn = 0x003dc6c0;
|
constexpr uint32_t StrategyServer_ProcessTurn = 0x003dc6c0;
|
||||||
// thiscall void (ServerPlayer* this, int out[25], bool projected) [verified]
|
// thiscall void (ServerPlayer* this, Budget* out, bool projected) /* Budget = { int slot[22]; std::vector<{Tech* node, int points}> researchAlloc (3 words @+0x58); int overBudget @+0x64 }. The array is 22 ints, not 25: the three words after slot 21 are the allocation vector and grow by one 8-byte element exactly when the player has a research target. Slots: 0 Sav, 1 systemIncome+, 2 trade, 3 shipCarriedPop, 4 secondaryManager, 5 savingsInterest, 6 bonusIncome, 7 systemIncome-, 8 maintenance, 9 researchMoneyKept (written only when ResT is set), 10 debtInterest, 11 construction, 12 expenses, 13 researchMoneyGiven, 14 savingsGiven, 15 available, 16 researchMoney, 17 researchPoints, 18 TRA, 19 researchPointsGiven, 20 TRP, 21 totalResearchPoints */ [verified-by-trace]
|
||||||
constexpr uint32_t ServerPlayer_ComputeBudget = 0x00463030;
|
constexpr uint32_t ServerPlayer_ComputeBudget = 0x00463030;
|
||||||
// thiscall void (TechTree* this, ?, vector<{node,int}>* alloc, int* overbudget) /* decay loop hits current target */ [verified]
|
// thiscall void (TechTree* this, Mars::RNG* rng, std::vector<{TechDef* target, int points}>* alloc, int* overbudget) /* args confirmed at the single call site: rng = StrategyServer+0x16c object, passed as the RNG *object* and re-based to &mt with lea ecx,[rng+4] before each NextFloat. Loop: node = this->nodes[*(int*)entry.target]; lo/hi from Cost x 50/150 /100 (32-bit imul, signed /100); spend = min(points, hi-progress) with NO clamp at 0; *overbudget += points-spend; odds and roll are both narrowed to float32 before the compare; decay loop hits every state-2 node with progress != 0; the trailing unlock-event loop makes no RNG draw and writes no node */ [verified]
|
||||||
constexpr uint32_t TechTree_ProcessResearch = 0x001876c0;
|
constexpr uint32_t TechTree_ProcessResearch = 0x001876c0;
|
||||||
// thiscall void (ServerSystem* this /*+8 IStreamable*/, Stream* s) [verified-by-save]
|
// thiscall void (ServerSystem* this /*+8 IStreamable*/, Stream* s) [verified-by-save]
|
||||||
constexpr uint32_t ServerSystem_Read = 0x0035d4b0;
|
constexpr uint32_t ServerSystem_Read = 0x0035d4b0;
|
||||||
|
|
@ -49,14 +49,26 @@ constexpr uint32_t StrategyServer_Read = 0x003d27a0;
|
||||||
constexpr uint32_t StrategyServer_Write = 0x0039fa70;
|
constexpr uint32_t StrategyServer_Write = 0x0039fa70;
|
||||||
// thiscall void (Stream* this, const char* tag, NetworkObject* obj) [unverified]
|
// thiscall void (Stream* this, const char* tag, NetworkObject* obj) [unverified]
|
||||||
constexpr uint32_t Stream_WriteNetworkObjectId = 0x00416490;
|
constexpr uint32_t Stream_WriteNetworkObjectId = 0x00416490;
|
||||||
// thiscall void (RNG* this) // this in ECX, no stack args, RET 0; 227/396 split [verified]
|
// thiscall void (uint32_t* mtBlock) // ECX = &mt = RNG object + 4 (NOT the object). N=624 M=397 MATRIX_A 0x9908b0df, 227/396 split; ends with left(&mt+0x9c4) = 624 and next(&mt+0x9c0) = &mt[0]. RET 0 [verified]
|
||||||
constexpr uint32_t RNG_Twist = 0x00026e00;
|
constexpr uint32_t RNG_Twist = 0x00026e00;
|
||||||
// thiscall RNG* (RNG* this, uint32_t seed) // RET 4; MT19937 init mt[i]=(mt[i-1]^(mt[i-1]>>30))*0x6c078965+i for i=1..623, then twists. this: vftable@0, mt[624]@+4, left@+0x9c4 [verified]
|
// thiscall RNG* (RNG* this, uint32_t seed) // RET 4; writes vftable 0x009e9aec, seeds from &mt = this+4: mt[0]=seed, mt[i]=0x6c078965*(mt[i-1]^(mt[i-1]>>30))+i for i=1..623, then Twist(&mt). OBJECT layout: {vftable @+0; uint32 mt[624] @+4 .. +0x9c3; uint32* next @+0x9c4; int left @+0x9c8} = 0x9cc bytes. Beware: NextFloat/NextInt/Twist take &mt, so *their* this+0x9c0/+0x9c4 are next/left [verified]
|
||||||
constexpr uint32_t RNG_Seed = 0x0009fdf0;
|
constexpr uint32_t RNG_Seed = 0x0009fdf0;
|
||||||
// thiscall float (RNG* this) // returns via x87 (float10 in decompile) -- FLOAT PARITY RISK; lazy twist: if (left==0) Twist() [verified]
|
// thiscall float (uint32_t* mtBlock) // ECX = &mt = RNG object + 4. Lazy twist (if left==0 Twist()), y = *next++, left--, standard MT tempering, then value = (double)(uint32)y * 1/(2^32-1) -- the multiplier at 0x009e61b0 is 0x3df0000000001000 = 1/4294967295.0, NOT 2^-32; the unsigned fix-up at 0x009e61b8 is +2^32 after a signed fild. Left in st(0) at the current x87 precision and narrowed by the caller; range [0,1] INCLUSIVE [verified]
|
||||||
constexpr uint32_t RNG_NextFloat = 0x0007d830;
|
constexpr uint32_t RNG_NextFloat = 0x0007d830;
|
||||||
// thiscall uint32_t (RNG* this) [unverified]
|
// thiscall uint32_t (uint32_t* mtBlock, const uint32_t* n) // ECX = &mt; the bound is passed BY POINTER, RET 4. mask = smallest 2^k-1 >= *n via the or/shift cascade; draws (lazy twist + tempering) until (y & mask) <= *n -- note the loop condition is `ja`, so the result is uniform on [0, *n] INCLUSIVE, not [0, n) [verified]
|
||||||
constexpr uint32_t RNG_NextInt = 0x000271c0;
|
constexpr uint32_t RNG_NextInt = 0x000271c0;
|
||||||
|
// offset sizeof(Mars::RNG) -- {vftable @+0; uint32 mt[624] @+4; uint32* next @+0x9c4; int left @+0x9c8} [verified]
|
||||||
|
constexpr uint32_t RNG_size = 0x000009cc;
|
||||||
|
// offset uint32 mt[624]; also the pointer Twist/NextFloat/NextInt receive in ECX [verified]
|
||||||
|
constexpr uint32_t RNG_off_State = 0x00000004;
|
||||||
|
// offset uint32* next; always &mt[624 - left], and recomputed from left on load, so `left` alone pins the stream position [verified]
|
||||||
|
constexpr uint32_t RNG_off_Next = 0x000009c4;
|
||||||
|
// offset int left; words still unread in the current block [verified]
|
||||||
|
constexpr uint32_t RNG_off_Left = 0x000009c8;
|
||||||
|
// offset int tech id -- the index into TechTree_off_Nodes [verified]
|
||||||
|
constexpr uint32_t TechDef_off_TechId = 0x00000000;
|
||||||
|
// offset sizeof({TechDef* target, int points}) -- the element of the vector ProcessResearch walks [verified]
|
||||||
|
constexpr uint32_t ResearchAlloc_stride = 0x00000008;
|
||||||
// cdecl void (void) /* sots.ini [Modules] Mount<N> -> gobio::Init */ [verified]
|
// cdecl void (void) /* sots.ini [Modules] Mount<N> -> gobio::Init */ [verified]
|
||||||
constexpr uint32_t Mars_Application_MountModules = 0x004a0a20;
|
constexpr uint32_t Mars_Application_MountModules = 0x004a0a20;
|
||||||
// cdecl void (int* count, const char*** table) /* count 3 @0x00b2d514, table @0x00a35e98 */ [verified]
|
// cdecl void (int* count, const char*** table) /* count 3 @0x00b2d514, table @0x00a35e98 */ [verified]
|
||||||
|
|
@ -227,6 +239,28 @@ constexpr uint32_t MasterTechTree_IsTech = 0x0017d5d0;
|
||||||
constexpr uint32_t MasterTechTree_GetTechDef = 0x0017d610;
|
constexpr uint32_t MasterTechTree_GetTechDef = 0x0017d610;
|
||||||
// thiscall bool (TechTree* this, int techId) [verified]
|
// thiscall bool (TechTree* this, int techId) [verified]
|
||||||
constexpr uint32_t TechTree_HasResearched = 0x0017d810;
|
constexpr uint32_t TechTree_HasResearched = 0x0017d810;
|
||||||
|
// thiscall int (TechTree* this, TechNode* node) // RET 4. node==0 -> INT_MAX; node->costRP(+0x18)==INT_MAX -> INT_MAX; costRP<=0 or this->owner(+0xc)==0 or mult<=0 -> 0; else max(1, _ftol2((float)TechCostMult(owner, node->def) * costRP)). Reads only (no RNG, no writes), so a reimplementation may call it on a scratch tree whose +0xc is the real owner [verified]
|
||||||
|
constexpr uint32_t TechTree_Cost = 0x0017da00;
|
||||||
|
// thiscall float (ServerPlayer* this, TechDef* def) // 1.0 - 0.25 per applicable species research-bonus tech owned; read-only [verified]
|
||||||
|
constexpr uint32_t ServerPlayer_TechCostMult = 0x0040db50;
|
||||||
|
// thiscall void (TechTree* this, TechDef* def, int flags) // state 4 + turn/order stamps + owner callback + child unlock cascade. Makes no direct RNG draw; the owner callback is not audited, so a compare that runs it is out of scope [verified]
|
||||||
|
constexpr uint32_t TechTree_SetResearched = 0x00181e10;
|
||||||
|
// offset ServerPlayer* owner (0 for a tree with no player) [verified]
|
||||||
|
constexpr uint32_t TechTree_off_Owner = 0x0000000c;
|
||||||
|
// offset std::vector<TechNode*> indexed by tech id (MSVC2010: 3 words {first@+0x10, last@+0x14, end@+0x18}); entries may be NULL [verified]
|
||||||
|
constexpr uint32_t TechTree_off_Nodes = 0x00000010;
|
||||||
|
// offset sizeof(TechNode) -- the ctor's operator new argument [verified]
|
||||||
|
constexpr uint32_t TechNode_size = 0x00000034;
|
||||||
|
// offset TechDef* def; *(int*)def is the tech id used to index TechTree_off_Nodes [verified]
|
||||||
|
constexpr uint32_t TechNode_off_Def = 0x00000000;
|
||||||
|
// offset int state (0 hidden, 1 parent researched, 2 available, 4 researched); ctor writes 0 [verified]
|
||||||
|
constexpr uint32_t TechNode_off_State = 0x00000014;
|
||||||
|
// offset int costRP, INT_MAX = no researched parent yet; ctor writes 0x7fffffff [verified]
|
||||||
|
constexpr uint32_t TechNode_off_CostRP = 0x00000018;
|
||||||
|
// offset int progress in RP; the only node word ProcessResearch itself writes besides the flag [verified]
|
||||||
|
constexpr uint32_t TechNode_off_Progress = 0x0000001c;
|
||||||
|
// offset int flag (1 default from the ctor, 0 completed below 80% of cost, 2 over-budget event raised) [verified]
|
||||||
|
constexpr uint32_t TechNode_off_Flag = 0x0000002c;
|
||||||
// thiscall void (MasterTechTree* this) /* fills TechDef*[196] at this+0 from g_TechIdNames */ [verified]
|
// thiscall void (MasterTechTree* this) /* fills TechDef*[196] at this+0 from g_TechIdNames */ [verified]
|
||||||
constexpr uint32_t MasterTechTree_ResolveTechIds = 0x00181c10;
|
constexpr uint32_t MasterTechTree_ResolveTechIds = 0x00181c10;
|
||||||
// cdecl int (std::string* name) /* membership in g_CombatTechNames (116) */ [verified]
|
// cdecl int (std::string* name) /* membership in g_CombatTechNames (116) */ [verified]
|
||||||
|
|
@ -281,5 +315,49 @@ constexpr uint32_t g_AITechValueTable = 0x00617888;
|
||||||
constexpr uint32_t g_PERGATETRAFFIC_DRV_TpGate = 0x00723e2c;
|
constexpr uint32_t g_PERGATETRAFFIC_DRV_TpGate = 0x00723e2c;
|
||||||
// data int (config storage; PTR slot 0x00aedfe4) [verified]
|
// data int (config storage; PTR slot 0x00aedfe4) [verified]
|
||||||
constexpr uint32_t g_PERGATETRAFFIC_DRV_GatAmp = 0x00723e30;
|
constexpr uint32_t g_PERGATETRAFFIC_DRV_GatAmp = 0x00723e30;
|
||||||
|
// offset int PlyrIdx [verified-by-save]
|
||||||
|
constexpr uint32_t ServerPlayer_off_PlyrIdx = 0x00000028;
|
||||||
|
// offset std::vector<ServerSystem*> owned systems (MSVC2010 release layout: 3 words {first@+0, last@+4, end@+8}, handle ids, 4 B stride); save tag NumOwn/OwnId [verified-by-save]
|
||||||
|
constexpr uint32_t ServerPlayer_off_OwnedSystems = 0x00000030;
|
||||||
|
// offset int Species (0 Human .. 6 Morrigi) [verified-by-save]
|
||||||
|
constexpr uint32_t ServerPlayer_off_Species = 0x0000005c;
|
||||||
|
// offset float ResRate (research share of available money, 0..1) [verified-by-save]
|
||||||
|
constexpr uint32_t ServerPlayer_off_ResRate = 0x000000bc;
|
||||||
|
// offset float ResMod [verified-by-save]
|
||||||
|
constexpr uint32_t ServerPlayer_off_ResMod = 0x000000c0;
|
||||||
|
// offset float ResScl [verified-by-save]
|
||||||
|
constexpr uint32_t ServerPlayer_off_ResScl = 0x000000c4;
|
||||||
|
// offset float TRM (timed research multiplier bonuses) [verified-by-save]
|
||||||
|
constexpr uint32_t ServerPlayer_off_TRM = 0x000000d0;
|
||||||
|
// offset int TRA (per-turn research-point contribution) [verified-by-save]
|
||||||
|
constexpr uint32_t ServerPlayer_off_TRA = 0x000000d4;
|
||||||
|
// offset int TRP (per-turn research-point contribution) [verified-by-save]
|
||||||
|
constexpr uint32_t ServerPlayer_off_TRP = 0x000000d8;
|
||||||
|
// offset bool Elim [verified-by-save]
|
||||||
|
constexpr uint32_t ServerPlayer_off_Elim = 0x000000f8;
|
||||||
|
// offset bool isAI /* gate ComputeBudget reads for the construction slot and for picking the AI difficulty-mod row */ [verified]
|
||||||
|
constexpr uint32_t ServerPlayer_off_IsAI = 0x000000f9;
|
||||||
|
// offset bool NPC [verified-by-save]
|
||||||
|
constexpr uint32_t ServerPlayer_off_NPC = 0x000000fb;
|
||||||
|
// offset bool RebAI [verified-by-save]
|
||||||
|
constexpr uint32_t ServerPlayer_off_RebAI = 0x000000fc;
|
||||||
|
// offset int Maint (raw fleet upkeep, before the difficulty divisor) [verified-by-save]
|
||||||
|
constexpr uint32_t ServerPlayer_off_Maint = 0x0000015c;
|
||||||
|
// offset float shrm (shared research modifier) [verified-by-save]
|
||||||
|
constexpr uint32_t ServerPlayer_off_shrm = 0x00000160;
|
||||||
|
// offset std::vector<ExpenseEntry> expense sliders (3 words; entry 16 B {int xid, int xmin, int xmax, float xper}); save tag Nexp [verified-by-save]
|
||||||
|
constexpr uint32_t ServerPlayer_off_Nexp = 0x00000204;
|
||||||
|
// offset float income multiplier from the game-setup handicap block; ComputeBudget bonus slot = ftol((this - 1.0) x net) [verified]
|
||||||
|
constexpr uint32_t ServerPlayer_off_SetupIncomeMult = 0x00000228;
|
||||||
|
// offset float research multiplier from the game-setup handicap block; a factor of the research-points slot [verified]
|
||||||
|
constexpr uint32_t ServerPlayer_off_SetupResearchMult = 0x0000022c;
|
||||||
|
// offset int Sav (treasury) [verified-by-save]
|
||||||
|
constexpr uint32_t ServerPlayer_off_Sav = 0x00000284;
|
||||||
|
// offset Tech* current research target (ResT); NULL = none [verified-by-save]
|
||||||
|
constexpr uint32_t ServerPlayer_off_ResearchTarget = 0x00000294;
|
||||||
|
// offset float IncMod [verified-by-save]
|
||||||
|
constexpr uint32_t ServerPlayer_off_IncMod = 0x0000030c;
|
||||||
|
// offset std::vector<PlayerAid> (3 words; entry 0x18 B, {+0x8 int researchPercent, +0xc int researchActive, +0x10 int savings, +0x14 int savingsActive}) [verified]
|
||||||
|
constexpr uint32_t ServerPlayer_off_Aid = 0x00000310;
|
||||||
|
|
||||||
} // namespace sots::addr
|
} // namespace sots::addr
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <cmath>
|
#include <cmath>
|
||||||
|
#include <cstddef>
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
#include "game/sim/numeric.h"
|
#include "game/sim/numeric.h"
|
||||||
|
|
||||||
|
|
@ -27,33 +29,61 @@ int TechCost(int baseCost, double multiplier) {
|
||||||
return std::max(1, Ftol(static_cast<double>(baseCost) * multiplier));
|
return std::max(1, Ftol(static_cast<double>(baseCost) * multiplier));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
// The image's copies of two float literals, widened to double: the per-turn decay
|
||||||
|
// fraction and the "completed early" threshold. Both are (double)0.05f and (double)0.8f,
|
||||||
|
// not the exact decimals, and both sit on a truncation/compare boundary where the
|
||||||
|
// difference is observable.
|
||||||
|
constexpr double kDecayFraction = 0.05000000074505806; // (double)0.05f
|
||||||
|
constexpr double kEarlyCompletionRatio = 0.800000011920929; // (double)0.8f
|
||||||
|
|
||||||
|
// 32-bit signed multiply then truncating division by 100, exactly as the original does it
|
||||||
|
// (it wraps rather than widening, which only matters for a cost near INT_MAX).
|
||||||
|
int scale_percent(int cost, unsigned percent) {
|
||||||
|
const std::int32_t p = static_cast<std::int32_t>(static_cast<std::uint32_t>(cost) * percent);
|
||||||
|
return static_cast<int>(p / 100);
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
int ResearchSpendFloor(int cost) { return std::max(0, scale_percent(cost, 50u)); }
|
||||||
|
|
||||||
|
int ResearchSpendCeiling(int cost) {
|
||||||
|
return std::max(ResearchSpendFloor(cost), scale_percent(cost, 150u));
|
||||||
|
}
|
||||||
|
|
||||||
|
float ResearchCompletionOdds(int progress, int lo, int hi) {
|
||||||
|
return static_cast<float>(static_cast<double>(progress - lo) / static_cast<double>(hi));
|
||||||
|
}
|
||||||
|
|
||||||
ResearchStepResult ApplyResearchPoints(ResearchNode& node, int points, Species owner, IRandom& rng) {
|
ResearchStepResult ApplyResearchPoints(ResearchNode& node, int points, Species owner, IRandom& rng) {
|
||||||
ResearchStepResult r;
|
ResearchStepResult r;
|
||||||
const int cost = node.cost;
|
const int cost = node.cost;
|
||||||
const int lo = std::max(0, static_cast<int>(static_cast<std::int64_t>(cost) * 50 / 100));
|
const int lo = ResearchSpendFloor(cost);
|
||||||
const int hi = std::max(lo, static_cast<int>(static_cast<std::int64_t>(cost) * 150 / 100));
|
const int hi = ResearchSpendCeiling(cost);
|
||||||
|
|
||||||
r.wasCompleteBefore = cost <= node.progress;
|
r.wasCompleteBefore = cost <= node.progress;
|
||||||
r.spent = std::max(0, std::min(points, hi - node.progress));
|
// Signed min with no floor at zero: the original clamps neither side.
|
||||||
|
r.spent = std::min(points, hi - node.progress);
|
||||||
r.overbudget = points - r.spent;
|
r.overbudget = points - r.spent;
|
||||||
node.progress += r.spent;
|
node.progress += r.spent;
|
||||||
const bool nowComplete = cost <= node.progress;
|
const bool nowComplete = cost <= node.progress;
|
||||||
|
|
||||||
if (node.progress < hi) {
|
if (node.progress < hi) {
|
||||||
if (r.spent == 0) {
|
if (r.spent == 0) {
|
||||||
r.odds = 0.0;
|
r.odds = 0.f;
|
||||||
r.roll = 1.f;
|
r.roll = 1.f;
|
||||||
} else {
|
} else {
|
||||||
r.odds = static_cast<double>(node.progress - lo) / static_cast<double>(hi);
|
r.odds = ResearchCompletionOdds(node.progress, lo, hi);
|
||||||
r.roll = rng.NextFloat();
|
r.roll = rng.NextFloat();
|
||||||
|
// Zuul draw a second time and keep the lower (better) roll.
|
||||||
if (owner == Species::Zuul) r.roll = std::min(r.roll, rng.NextFloat());
|
if (owner == Species::Zuul) r.roll = std::min(r.roll, rng.NextFloat());
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
r.odds = 1.0;
|
r.odds = 1.f;
|
||||||
r.roll = 0.f;
|
r.roll = 0.f;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (r.odds < static_cast<double>(r.roll)) {
|
if (r.odds < r.roll) {
|
||||||
if (!r.wasCompleteBefore && nowComplete) {
|
if (!r.wasCompleteBefore && nowComplete) {
|
||||||
r.overbudgetEvent = true;
|
r.overbudgetEvent = true;
|
||||||
node.flag = TechFlag::OverBudgetNotified;
|
node.flag = TechFlag::OverBudgetNotified;
|
||||||
|
|
@ -62,7 +92,9 @@ ResearchStepResult ApplyResearchPoints(ResearchNode& node, int points, Species o
|
||||||
}
|
}
|
||||||
|
|
||||||
r.completed = true;
|
r.completed = true;
|
||||||
if (cost > 0 && static_cast<double>(node.progress) / static_cast<double>(cost) < 0.8) {
|
// The ratio is itself narrowed to float32 before the comparison.
|
||||||
|
const float ratio = static_cast<float>(static_cast<double>(node.progress) / static_cast<double>(cost));
|
||||||
|
if (static_cast<double>(ratio) < kEarlyCompletionRatio) {
|
||||||
r.completedEarly = true;
|
r.completedEarly = true;
|
||||||
node.flag = TechFlag::CompletedEarly;
|
node.flag = TechFlag::CompletedEarly;
|
||||||
}
|
}
|
||||||
|
|
@ -71,20 +103,35 @@ ResearchStepResult ApplyResearchPoints(ResearchNode& node, int points, Species o
|
||||||
}
|
}
|
||||||
|
|
||||||
int DecayResearchProgress(int progress, int cost) {
|
int DecayResearchProgress(int progress, int cost) {
|
||||||
if (cost == kNoResearchCost) return std::max(0, progress);
|
return std::max(0, progress - Ftol(static_cast<double>(cost) * kDecayFraction));
|
||||||
return std::max(0, progress - Ftol(static_cast<double>(cost) * 0.05));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void DecayAllResearch(std::vector<ResearchNode>& nodes) {
|
void DecayAllResearch(std::vector<ResearchNode>& nodes) {
|
||||||
for (ResearchNode& n : nodes) {
|
for (ResearchNode& n : nodes) {
|
||||||
if (n.state == TechState::Available && n.progress > 0) {
|
// The original's guard is `progress != 0`, so a negative progress decays too.
|
||||||
|
if (n.state == TechState::Available && n.progress != 0) {
|
||||||
n.progress = DecayResearchProgress(n.progress, n.cost);
|
n.progress = DecayResearchProgress(n.progress, n.cost);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ResearchTurnResult ProcessResearchTurn(std::vector<ResearchNode>& nodes,
|
||||||
|
const std::vector<ResearchAllocEntry>& alloc, Species owner,
|
||||||
|
IRandom& rng) {
|
||||||
|
ResearchTurnResult out;
|
||||||
|
out.steps.reserve(alloc.size());
|
||||||
|
for (const ResearchAllocEntry& e : alloc) {
|
||||||
|
if (e.nodeIndex < 0 || static_cast<std::size_t>(e.nodeIndex) >= nodes.size()) continue;
|
||||||
|
ResearchStepResult s = ApplyResearchPoints(nodes[e.nodeIndex], e.points, owner, rng);
|
||||||
|
out.overbudget += s.overbudget;
|
||||||
|
out.steps.push_back(s);
|
||||||
|
}
|
||||||
|
DecayAllResearch(nodes);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
bool RollLabAccident(int oddsPercent, IRandom& rng) {
|
bool RollLabAccident(int oddsPercent, IRandom& rng) {
|
||||||
const int roll = static_cast<int>(rng.NextInt(100));
|
const int roll = static_cast<int>(rng.NextIntInclusive(100));
|
||||||
return roll < oddsPercent;
|
return roll < oddsPercent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -75,36 +75,82 @@ struct ResearchStepResult {
|
||||||
bool completed = false; // research finished this turn
|
bool completed = false; // research finished this turn
|
||||||
bool completedEarly = false; // finished below 80 % of cost
|
bool completedEarly = false; // finished below 80 % of cost
|
||||||
bool overbudgetEvent = false; // crossed 100 % without finishing -> notify the owner
|
bool overbudgetEvent = false; // crossed 100 % without finishing -> notify the owner
|
||||||
double odds = 0; // completion odds used
|
float odds = 0; // completion odds used (narrowed to float32, as the original)
|
||||||
float roll = 0; // roll compared against the odds
|
float roll = 0; // roll compared against the odds (likewise float32)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// The 50 % / 150 % bounds of the spend window, as the original computes them: the
|
||||||
|
// multiply is a plain 32-bit signed multiply (it wraps for a cost near INT_MAX) and the
|
||||||
|
// division by 100 truncates toward zero. `lo` is then floored at 0 and `hi` raised to at
|
||||||
|
// least `lo`. CONFIDENCE: high.
|
||||||
|
int ResearchSpendFloor(int cost);
|
||||||
|
int ResearchSpendCeiling(int cost);
|
||||||
|
|
||||||
|
// Completion odds for a partially funded tech: (progress - lo) / hi, evaluated in double
|
||||||
|
// and narrowed to a 32-bit float because the original stores it in a float slot before
|
||||||
|
// comparing. 0 at 50 % of cost, 1/3 at 100 %, 2/3 at 150 %. CONFIDENCE: high.
|
||||||
|
float ResearchCompletionOdds(int progress, int lo, int hi);
|
||||||
|
|
||||||
// Apply one turn of research points to the current target:
|
// Apply one turn of research points to the current target:
|
||||||
// lo = cost x 50 / 100, hi = cost x 150 / 100 (integer arithmetic, lo >= 0, hi >= lo)
|
// lo = cost x 50 / 100, hi = cost x 150 / 100 (32-bit, lo >= 0, hi >= lo)
|
||||||
// spend = min(points, hi - progress); progress += spend
|
// spend = min(points, hi - progress) -- signed min, NOT floored at 0
|
||||||
|
// progress += spend
|
||||||
// progress < hi: spend == 0 -> odds 0, roll 1 (cannot complete)
|
// progress < hi: spend == 0 -> odds 0, roll 1 (cannot complete)
|
||||||
// else odds = (progress - lo) / hi, roll = rand01();
|
// else odds = ResearchCompletionOdds(...), roll = rand01();
|
||||||
// Zuul roll twice and keep the lower
|
// Zuul roll twice and keep the lower
|
||||||
// progress >= hi: odds 1, roll 0 (guaranteed)
|
// progress >= hi: odds 1, roll 0 (guaranteed)
|
||||||
// completes iff odds >= roll. Crossing 100 % without completing raises the
|
// completes iff !(odds < roll), compared as float32. Crossing 100 % without completing
|
||||||
// over-budget notification; completing below 80 % marks the node "completed early".
|
// raises the over-budget notification; completing below 80 % of cost marks the node
|
||||||
// On completion the node's state becomes Researched. CONFIDENCE: high.
|
// "completed early".
|
||||||
|
// On completion the node's state becomes Researched. This models only what the original
|
||||||
|
// function itself writes to the node -- the unlock cascade that follows (turn/order stamps,
|
||||||
|
// child states, the owner's tech-effect callback) belongs to SetResearched and is not
|
||||||
|
// reproduced here. CONFIDENCE: high.
|
||||||
ResearchStepResult ApplyResearchPoints(ResearchNode& node, int points, Species owner, IRandom& rng);
|
ResearchStepResult ApplyResearchPoints(ResearchNode& node, int points, Species owner, IRandom& rng);
|
||||||
|
|
||||||
// Progress decay on a partially researched tech: lose 5 % of cost per turn, floored at 0.
|
// Progress decay on a partially researched tech: lose 5 % of cost per turn, floored at 0.
|
||||||
|
// The 5 % constant is a float literal widened to double (0.05f), which is what the image
|
||||||
|
// holds; using the exact double 0.05 would round differently at the truncation boundary.
|
||||||
// CONFIDENCE: high.
|
// CONFIDENCE: high.
|
||||||
int DecayResearchProgress(int progress, int cost);
|
int DecayResearchProgress(int progress, int cost);
|
||||||
|
|
||||||
// Apply the decay to every Available node with progress. This runs after the current
|
// Apply the decay to every Available node whose progress is non-zero. This runs after the
|
||||||
// target has been processed, so the current target decays too (net gain = spend - 5 %).
|
// current target has been processed, so the current target decays too (net gain = spend -
|
||||||
// CONFIDENCE: high.
|
// 5 %). CONFIDENCE: high.
|
||||||
void DecayAllResearch(std::vector<ResearchNode>& nodes);
|
void DecayAllResearch(std::vector<ResearchNode>& nodes);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------------------
|
||||||
|
// The whole per-turn research pass
|
||||||
|
// ---------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// One entry of the allocation the budget builds: which node gets how many research points.
|
||||||
|
// The original stores the tech itself and looks the node up by its id; here the caller has
|
||||||
|
// already resolved the index into `nodes`.
|
||||||
|
struct ResearchAllocEntry {
|
||||||
|
int nodeIndex = -1;
|
||||||
|
int points = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct ResearchTurnResult {
|
||||||
|
int overbudget = 0; // total to add to the caller's counter
|
||||||
|
std::vector<ResearchStepResult> steps; // one per allocation entry, in order
|
||||||
|
};
|
||||||
|
|
||||||
|
// The per-turn pass: apply every allocation entry to its node in order, then decay every
|
||||||
|
// available tech that has progress. `nodes[i].cost` must already hold the node's effective
|
||||||
|
// cost; a slot that does not exist in the tree should be left in state Hidden so the decay
|
||||||
|
// pass skips it. An entry whose index is out of range is skipped without consuming a draw.
|
||||||
|
// CONFIDENCE: high.
|
||||||
|
ResearchTurnResult ProcessResearchTurn(std::vector<ResearchNode>& nodes,
|
||||||
|
const std::vector<ResearchAllocEntry>& alloc, Species owner,
|
||||||
|
IRandom& rng);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------------------
|
||||||
// Lab accidents
|
// Lab accidents
|
||||||
// ---------------------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------------------
|
||||||
|
|
||||||
// Whether a lab accident happens: roll = randint(100); accident iff roll < odds.
|
// Whether a lab accident happens: roll = randint(100), uniform on [0, 100] inclusive;
|
||||||
|
// accident iff roll < odds.
|
||||||
// One RNG draw. CONFIDENCE: medium (the odds-from-boost function is unresolved -- the
|
// One RNG draw. CONFIDENCE: medium (the odds-from-boost function is unresolved -- the
|
||||||
// caller supplies the odds).
|
// caller supplies the odds).
|
||||||
bool RollLabAccident(int oddsPercent, IRandom& rng);
|
bool RollLabAccident(int oddsPercent, IRandom& rng);
|
||||||
|
|
|
||||||
|
|
@ -13,10 +13,13 @@ namespace sots::sim {
|
||||||
|
|
||||||
struct IRandom {
|
struct IRandom {
|
||||||
virtual ~IRandom() = default;
|
virtual ~IRandom() = default;
|
||||||
// Uniform float in [0, 1).
|
// Uniform float in [0, 1] INCLUSIVE: the generator's unit mapping divides by
|
||||||
|
// 2^32 - 1, and the value is narrowed to a 32-bit float before any consumer
|
||||||
|
// compares it (mars::rng::MT19937::next_float).
|
||||||
virtual float NextFloat() = 0;
|
virtual float NextFloat() = 0;
|
||||||
// Uniform integer in [0, n). n == 0 must return 0.
|
// Uniform integer in [0, n] INCLUSIVE (mask-and-reject; the mask covers n, not
|
||||||
virtual std::uint32_t NextInt(std::uint32_t n) = 0;
|
// n - 1). n == 0 returns 0 and still consumes one word.
|
||||||
|
virtual std::uint32_t NextIntInclusive(std::uint32_t n) = 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace sots::sim
|
} // namespace sots::sim
|
||||||
|
|
|
||||||
|
|
@ -45,21 +45,31 @@ uint32_t MT19937::next_u32() {
|
||||||
return temper(y);
|
return temper(y);
|
||||||
}
|
}
|
||||||
|
|
||||||
float MT19937::next_float() {
|
float MT19937::float_from_pc24(uint32_t y) {
|
||||||
return static_cast<float>(static_cast<double>(next_u32()) * (1.0 / 4294967296.0));
|
// Mirrors the instruction sequence: the integer load is exact, the unsigned
|
||||||
|
// fix-up (only when the signed reading of y is negative) rounds, and so does the
|
||||||
|
// multiply. A long double holds the intermediate; on a 64-bit-significand host
|
||||||
|
// this is exact enough that the residual double-rounding risk is ~2^-40 per draw.
|
||||||
|
long double v = static_cast<long double>(static_cast<int32_t>(y));
|
||||||
|
if (static_cast<int32_t>(y) < 0) v = static_cast<float>(v + 4294967296.0L);
|
||||||
|
return static_cast<float>(v * static_cast<long double>(kUnitScale));
|
||||||
}
|
}
|
||||||
|
|
||||||
uint32_t MT19937::next_int(uint32_t n) {
|
uint32_t MT19937::cover_mask(uint32_t n) {
|
||||||
if (n <= 1) return 0;
|
uint32_t mask = n;
|
||||||
uint32_t mask = n - 1;
|
|
||||||
mask |= mask >> 1;
|
mask |= mask >> 1;
|
||||||
mask |= mask >> 2;
|
mask |= mask >> 2;
|
||||||
mask |= mask >> 4;
|
mask |= mask >> 4;
|
||||||
mask |= mask >> 8;
|
mask |= mask >> 8;
|
||||||
mask |= mask >> 16;
|
mask |= mask >> 16;
|
||||||
|
return mask;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint32_t MT19937::next_int_inclusive(uint32_t n) {
|
||||||
|
const uint32_t mask = cover_mask(n);
|
||||||
for (;;) {
|
for (;;) {
|
||||||
uint32_t r = next_u32() & mask;
|
uint32_t r = next_u32() & mask;
|
||||||
if (r < n) return r;
|
if (r <= n) return r;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,16 @@
|
||||||
// (0x9c4 = 2500 bytes). A fresh generator seeds the block with the standard
|
// (0x9c4 = 2500 bytes). A fresh generator seeds the block with the standard
|
||||||
// Knuth-style initializer and twists once immediately, so left == 624 right
|
// Knuth-style initializer and twists once immediately, so left == 624 right
|
||||||
// after seeding.
|
// after seeding.
|
||||||
|
//
|
||||||
|
// Draw mappings. The original derives both of its public draws from one
|
||||||
|
// tempered word:
|
||||||
|
// * a unit value y * (1 / (2^32 - 1)) formed in floating point. Note the
|
||||||
|
// divisor: it is 2^32 - 1, not 2^32, so the range is [0, 1] *inclusive*
|
||||||
|
// (y == 0xffffffff maps to exactly 1.0).
|
||||||
|
// * a bounded integer by masking with the smallest 2^k - 1 that covers the
|
||||||
|
// bound and re-drawing until the masked word is <= the bound, which makes
|
||||||
|
// the result uniform on [0, n] *inclusive*.
|
||||||
|
// Both contracts are inclusive; see docs/mars-rng.md for the evidence.
|
||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#include <cstddef>
|
#include <cstddef>
|
||||||
|
|
@ -21,6 +31,11 @@ public:
|
||||||
static constexpr int M = 397;
|
static constexpr int M = 397;
|
||||||
static constexpr size_t kStateBytes = size_t(N) * 4 + 4; // 0x9c4
|
static constexpr size_t kStateBytes = size_t(N) * 4 + 4; // 0x9c4
|
||||||
|
|
||||||
|
// The unit scale is 1/(2^32 - 1) as a double. Written as a division of two
|
||||||
|
// exactly representable values so the compiler folds the same bit pattern the
|
||||||
|
// original stores in its constant pool.
|
||||||
|
static constexpr double kUnitScale = 1.0 / 4294967295.0;
|
||||||
|
|
||||||
explicit MT19937(uint32_t seed = 5489u) { this->seed(seed); }
|
explicit MT19937(uint32_t seed = 5489u) { this->seed(seed); }
|
||||||
|
|
||||||
// mt[0] = seed; mt[i] = 1812433253 * (mt[i-1] ^ (mt[i-1] >> 30)) + i; then twist.
|
// mt[0] = seed; mt[i] = 1812433253 * (mt[i-1] ^ (mt[i-1] >> 30)) + i; then twist.
|
||||||
|
|
@ -29,17 +44,32 @@ public:
|
||||||
// Next tempered 32-bit output.
|
// Next tempered 32-bit output.
|
||||||
uint32_t next_u32();
|
uint32_t next_u32();
|
||||||
|
|
||||||
// Uniform float in [0, 1): (float)(next_u32() * 2^-32), the product formed
|
// One draw as a unit value: (double)y * kUnitScale, one rounding. This is the
|
||||||
// in double precision then narrowed. NOTE: the narrowing can round the
|
// value the original leaves in the x87 top-of-stack with the FPU in the MSVC
|
||||||
// largest outputs (y >= 0xFFFFFF80) up to exactly 1.0f. Mapping recorded
|
// default 53-bit precision mode.
|
||||||
// from the RE notes; still needs binary confirmation against a captured
|
double next_unit() { return unit_from(next_u32()); }
|
||||||
// sequence (see docs/mars-rng.md).
|
|
||||||
float next_float();
|
|
||||||
|
|
||||||
// Uniform integer in [0, n) by rejection sampling with the smallest
|
// The same draw once a caller narrows it to a 32-bit float, which is what every
|
||||||
// power-of-two mask covering n-1 (n == 0 returns 0). Mask/rejection
|
// consumer of the value in the strategic simulation does before comparing it.
|
||||||
// details need binary confirmation.
|
// Range [0, 1] inclusive.
|
||||||
uint32_t next_int(uint32_t n);
|
float next_float() { return float_from(next_u32()); }
|
||||||
|
|
||||||
|
// Uniform integer in [0, n] INCLUSIVE by rejection sampling with the smallest
|
||||||
|
// power-of-two mask covering n. Always consumes at least one word (n == 0 masks
|
||||||
|
// to 0 and is accepted on the first draw).
|
||||||
|
uint32_t next_int_inclusive(uint32_t n);
|
||||||
|
|
||||||
|
// --- pure mappings (no draw), so tests can pin them word by word ----------
|
||||||
|
static double unit_from(uint32_t y) { return static_cast<double>(y) * kUnitScale; }
|
||||||
|
static float float_from(uint32_t y) { return static_cast<float>(unit_from(y)); }
|
||||||
|
// The same mapping when the x87 is left in 24-bit precision mode, which is what
|
||||||
|
// a Direct3D 9 device created without FPU_PRESERVE leaves behind: the integer
|
||||||
|
// load is still exact, but the unsigned fix-up and the multiply each round to a
|
||||||
|
// 24-bit significand before the result is stored as a float. Diagnostic only —
|
||||||
|
// used to bound the parity risk, not on the normal path.
|
||||||
|
static float float_from_pc24(uint32_t y);
|
||||||
|
// Smallest 2^k - 1 that is >= n; the rejection mask.
|
||||||
|
static uint32_t cover_mask(uint32_t n);
|
||||||
|
|
||||||
// --- state access / serialization ---------------------------------------
|
// --- state access / serialization ---------------------------------------
|
||||||
const uint32_t* state() const { return mt_; }
|
const uint32_t* state() const { return mt_; }
|
||||||
|
|
|
||||||
437
src/shim/hooks/research.cpp
Normal file
437
src/shim/hooks/research.cpp
Normal file
|
|
@ -0,0 +1,437 @@
|
||||||
|
#include "shim/hooks/research.h"
|
||||||
|
|
||||||
|
#include <cstdarg>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <cstring>
|
||||||
|
#include <stdexcept>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#if defined(_WIN32)
|
||||||
|
#define WIN32_LEAN_AND_MEAN
|
||||||
|
#include <windows.h>
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#include "game/sim/research.h"
|
||||||
|
#include "game/sim/species.h"
|
||||||
|
#include "generated/sots_addresses.h"
|
||||||
|
#include "mars/rng/mt19937.h"
|
||||||
|
|
||||||
|
namespace shim::hooks {
|
||||||
|
|
||||||
|
using trace::Tv;
|
||||||
|
namespace tv = trace::tv;
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
namespace A = sots::addr;
|
||||||
|
|
||||||
|
constexpr std::size_t kNodeSize = A::TechNode_size; // 0x34
|
||||||
|
constexpr std::size_t kRngSize = A::RNG_size; // 0x9cc
|
||||||
|
constexpr std::size_t kTreeHeadSize = A::TechTree_off_Nodes + 0xc; // owner + the node vector
|
||||||
|
constexpr std::size_t kMaxNodes = 8192; // loop guard for a garbage vector header
|
||||||
|
constexpr std::size_t kMaxAlloc = 1024;
|
||||||
|
constexpr int kMtWords = mars::rng::MT19937::N;
|
||||||
|
|
||||||
|
using CostFn = int(SHIM_THISCALL*)(void* tree, void* node);
|
||||||
|
|
||||||
|
struct Env {
|
||||||
|
std::uintptr_t exe_base = 0;
|
||||||
|
void (*log_line)(const char*) = nullptr;
|
||||||
|
CostFn cost = nullptr;
|
||||||
|
};
|
||||||
|
Env g_env;
|
||||||
|
|
||||||
|
void logf(const char* fmt, ...) {
|
||||||
|
if (!g_env.log_line) return;
|
||||||
|
char line[512];
|
||||||
|
va_list ap;
|
||||||
|
va_start(ap, fmt);
|
||||||
|
std::vsnprintf(line, sizeof line, fmt, ap);
|
||||||
|
va_end(ap);
|
||||||
|
g_env.log_line(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- safe pointer chasing (same guard the M2 describers use) -------------------------------
|
||||||
|
|
||||||
|
bool readable(const void* p, std::size_t n) {
|
||||||
|
if (!p) return false;
|
||||||
|
if (n == 0) return true;
|
||||||
|
#if defined(_WIN32)
|
||||||
|
const char* c = static_cast<const char*>(p);
|
||||||
|
const char* const end = c + n;
|
||||||
|
while (c < end) {
|
||||||
|
MEMORY_BASIC_INFORMATION mbi;
|
||||||
|
if (!VirtualQuery(c, &mbi, sizeof mbi)) return false;
|
||||||
|
if (mbi.State != MEM_COMMIT) return false;
|
||||||
|
if (mbi.Protect & (PAGE_NOACCESS | PAGE_GUARD)) return false;
|
||||||
|
const DWORD ok = PAGE_READONLY | PAGE_READWRITE | PAGE_WRITECOPY | PAGE_EXECUTE_READ |
|
||||||
|
PAGE_EXECUTE_READWRITE | PAGE_EXECUTE_WRITECOPY;
|
||||||
|
if (!(mbi.Protect & ok)) return false;
|
||||||
|
c = static_cast<const char*>(mbi.BaseAddress) + mbi.RegionSize;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
#else
|
||||||
|
return true;
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
std::int32_t word_at(const void* obj, std::size_t off) {
|
||||||
|
std::int32_t v = 0;
|
||||||
|
std::memcpy(&v, static_cast<const char*>(obj) + off, sizeof v);
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
void set_word(void* obj, std::size_t off, std::int32_t v) {
|
||||||
|
std::memcpy(static_cast<char*>(obj) + off, &v, sizeof v);
|
||||||
|
}
|
||||||
|
void* ptr_at(const void* obj, std::size_t off) {
|
||||||
|
void* v = nullptr;
|
||||||
|
std::memcpy(&v, static_cast<const char*>(obj) + off, sizeof v);
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
void set_ptr(void* obj, std::size_t off, void* v) {
|
||||||
|
std::memcpy(static_cast<char*>(obj) + off, &v, sizeof v);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The x87 control word as the hooked call finds it. The precision-control field decides
|
||||||
|
// whether the generator's multiply and the odds division round to 53 or to 24 significand
|
||||||
|
// bits, which is the one remaining float-parity unknown; recording it settles it from a
|
||||||
|
// single run instead of guessing.
|
||||||
|
std::uint32_t fpu_control_word() {
|
||||||
|
#if defined(__i386__) || defined(__x86_64__)
|
||||||
|
unsigned short cw = 0;
|
||||||
|
__asm__ __volatile__("fnstcw %0" : "=m"(cw));
|
||||||
|
return cw;
|
||||||
|
#else
|
||||||
|
return 0;
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- per-call state --------------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// ProcessResearch is called once per player from the single-threaded turn pass and is never
|
||||||
|
// re-entrant, so the mapping regions() builds can be handed to rebind()/ours() in statics --
|
||||||
|
// the same concession M1 makes, and with the same caveat: do not reuse this for a hook that
|
||||||
|
// can nest.
|
||||||
|
|
||||||
|
struct CallState {
|
||||||
|
bool compare = false; // set by rebind, consumed (and cleared) by ours
|
||||||
|
std::uintptr_t rng_base = 0; // the LIVE generator address, for next-pointer math
|
||||||
|
std::size_t rng_region = 0;
|
||||||
|
std::size_t ob_region = 1;
|
||||||
|
std::vector<int> node_region; // node index -> region index, -1 when the slot is null
|
||||||
|
std::vector<std::string> names; // stable storage for Region::name
|
||||||
|
std::vector<void*> scratch_nodes;
|
||||||
|
};
|
||||||
|
CallState g_call;
|
||||||
|
|
||||||
|
// The tree's node vector, or false when the header does not look like one.
|
||||||
|
bool tree_nodes(const void* tree, std::vector<void*>& out) {
|
||||||
|
out.clear();
|
||||||
|
if (!readable(tree, kTreeHeadSize)) return false;
|
||||||
|
void** begin = static_cast<void**>(ptr_at(tree, A::TechTree_off_Nodes));
|
||||||
|
void** end = static_cast<void**>(ptr_at(tree, A::TechTree_off_Nodes + 4));
|
||||||
|
if (!begin && !end) return true;
|
||||||
|
const std::uintptr_t b = reinterpret_cast<std::uintptr_t>(begin);
|
||||||
|
const std::uintptr_t e = reinterpret_cast<std::uintptr_t>(end);
|
||||||
|
if (!begin || e < b || (b & 3) || (e & 3)) return false;
|
||||||
|
const std::size_t n = (e - b) / sizeof(void*);
|
||||||
|
if (n > kMaxNodes) return false;
|
||||||
|
if (n && !readable(begin, n * sizeof(void*))) return false;
|
||||||
|
out.assign(begin, begin + n);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The {TechDef* target, int points} entries the budget built.
|
||||||
|
struct AllocEntry {
|
||||||
|
void* target = nullptr;
|
||||||
|
int points = 0;
|
||||||
|
};
|
||||||
|
bool alloc_entries(const void* alloc, std::vector<AllocEntry>& out) {
|
||||||
|
out.clear();
|
||||||
|
if (!readable(alloc, 0xc)) return false;
|
||||||
|
const char* begin = static_cast<const char*>(ptr_at(alloc, 0));
|
||||||
|
const char* end = static_cast<const char*>(ptr_at(alloc, 4));
|
||||||
|
if (!begin && !end) return true;
|
||||||
|
if (!begin || end < begin) return false;
|
||||||
|
const std::size_t n = static_cast<std::size_t>(end - begin) / A::ResearchAlloc_stride;
|
||||||
|
if (n > kMaxAlloc) return false;
|
||||||
|
if (n && !readable(begin, n * A::ResearchAlloc_stride)) return false;
|
||||||
|
for (std::size_t i = 0; i < n; ++i) {
|
||||||
|
const char* e = begin + i * A::ResearchAlloc_stride;
|
||||||
|
AllocEntry a;
|
||||||
|
a.target = ptr_at(e, 0);
|
||||||
|
a.points = word_at(e, 4);
|
||||||
|
out.push_back(a);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
int tech_id_of(void* def) {
|
||||||
|
if (!readable(def, 4)) return -1;
|
||||||
|
return word_at(def, A::TechDef_off_TechId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- describers -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
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)));
|
||||||
|
// The whole untempered block, hashed (it is far larger than inline_max): equality of the
|
||||||
|
// hash plus equality of `left` is exactly "the two generators are in the same place in
|
||||||
|
// the same stream".
|
||||||
|
s.add("mt", tv::bytes(static_cast<const char*>(p) + A::RNG_off_State,
|
||||||
|
static_cast<std::size_t>(kMtWords) * 4, inline_max));
|
||||||
|
const std::int32_t left = word_at(p, A::RNG_off_Left);
|
||||||
|
s.add("left", tv::i32(left));
|
||||||
|
// `next` is a heap address, so it is reported as its index into mt -- which is what it
|
||||||
|
// means, and what survives being written by a reimplementation.
|
||||||
|
const std::uintptr_t next = reinterpret_cast<std::uintptr_t>(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<std::int64_t>((next - base) / 4);
|
||||||
|
s.add("next_index", tv::i64(index));
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
Tv describe_node(const void* p, std::size_t, unsigned) {
|
||||||
|
Tv s = tv::struct_();
|
||||||
|
void* def = ptr_at(p, A::TechNode_off_Def);
|
||||||
|
s.add("def", tv::ptr(def));
|
||||||
|
s.add("tech_id", tv::i32(tech_id_of(def)));
|
||||||
|
// +4..+0xc is the children vector (three words) and +0x10 / +0x30 are not modelled;
|
||||||
|
// emitted as opaque pointers so a change still shows without creating a false divergence.
|
||||||
|
s.add("kids_begin", tv::ptr(ptr_at(p, 0x4)));
|
||||||
|
s.add("kids_end", tv::ptr(ptr_at(p, 0x8)));
|
||||||
|
s.add("kids_cap", tv::ptr(ptr_at(p, 0xc)));
|
||||||
|
s.add("unk10", tv::ptr(ptr_at(p, 0x10)));
|
||||||
|
s.add("state", tv::i32(word_at(p, A::TechNode_off_State)));
|
||||||
|
s.add("cost_rp", tv::i32(word_at(p, A::TechNode_off_CostRP)));
|
||||||
|
s.add("progress", tv::i32(word_at(p, A::TechNode_off_Progress)));
|
||||||
|
s.add("turn_available", tv::i32(word_at(p, 0x20)));
|
||||||
|
s.add("turn_researched", tv::i32(word_at(p, 0x24)));
|
||||||
|
s.add("order", tv::i32(word_at(p, 0x28)));
|
||||||
|
s.add("flag", tv::i32(word_at(p, A::TechNode_off_Flag)));
|
||||||
|
s.add("unk30", tv::ptr(ptr_at(p, 0x30)));
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
Tv describe_i32(const void* p, std::size_t, unsigned) {
|
||||||
|
Tv s = tv::struct_();
|
||||||
|
s.add("v", tv::i32(word_at(p, 0)));
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- the generator seen by ours ----------------------------------------------------------
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
// ---- descriptor ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
void TechTreeProcessResearchHook::describe_args(std::vector<Tv>& out, void* tree, void* rng,
|
||||||
|
void* alloc, int* overbudget) {
|
||||||
|
out.push_back(tv::ptr(tree).named("tree"));
|
||||||
|
void* owner = readable(tree, kTreeHeadSize) ? ptr_at(tree, A::TechTree_off_Owner) : nullptr;
|
||||||
|
out.push_back(tv::ptr(owner).named("owner"));
|
||||||
|
const int species =
|
||||||
|
readable(owner, A::ServerPlayer_off_Species + 4) ? word_at(owner, A::ServerPlayer_off_Species) : -1;
|
||||||
|
out.push_back(tv::i32(species).named("species"));
|
||||||
|
std::vector<void*> nodes;
|
||||||
|
const bool nodes_ok = tree_nodes(tree, nodes);
|
||||||
|
out.push_back(tv::u32(static_cast<std::uint32_t>(nodes.size())).named("node_count"));
|
||||||
|
if (!nodes_ok) out.push_back(tv::boolean(true).named("nodes_invalid"));
|
||||||
|
|
||||||
|
out.push_back(tv::ptr(rng).named("rng"));
|
||||||
|
out.push_back(tv::i32(readable(rng, kRngSize) ? word_at(rng, A::RNG_off_Left) : -1).named("rng_left_in"));
|
||||||
|
|
||||||
|
std::vector<AllocEntry> entries;
|
||||||
|
const bool alloc_ok = alloc_entries(alloc, entries);
|
||||||
|
std::vector<Tv> items;
|
||||||
|
items.reserve(entries.size());
|
||||||
|
for (const AllocEntry& e : entries) {
|
||||||
|
Tv t = tv::struct_();
|
||||||
|
t.add("tech_id", tv::i32(tech_id_of(e.target)));
|
||||||
|
t.add("points", tv::i32(e.points));
|
||||||
|
items.push_back(std::move(t));
|
||||||
|
}
|
||||||
|
out.push_back(tv::list(std::move(items)).named("alloc"));
|
||||||
|
if (!alloc_ok) out.push_back(tv::boolean(true).named("alloc_invalid"));
|
||||||
|
out.push_back(tv::i32(readable(overbudget, 4) ? word_at(overbudget, 0) : 0).named("overbudget_in"));
|
||||||
|
// The x87 precision mode in force for this call (see fpu_control_word above).
|
||||||
|
out.push_back(tv::u32(fpu_control_word()).named("fpu_cw"));
|
||||||
|
}
|
||||||
|
|
||||||
|
void TechTreeProcessResearchHook::regions(std::vector<trace::Region>& out, void* tree, void* rng,
|
||||||
|
void* alloc, int* overbudget) {
|
||||||
|
(void)alloc;
|
||||||
|
g_call = CallState{};
|
||||||
|
g_call.rng_base = reinterpret_cast<std::uintptr_t>(rng);
|
||||||
|
|
||||||
|
if (!readable(rng, kRngSize)) throw std::runtime_error("rng object not readable");
|
||||||
|
if (!readable(overbudget, 4)) throw std::runtime_error("overbudget not readable");
|
||||||
|
|
||||||
|
trace::Region r;
|
||||||
|
r.name = "rng";
|
||||||
|
r.ptr = rng;
|
||||||
|
r.size = kRngSize;
|
||||||
|
r.describe = &describe_rng;
|
||||||
|
g_call.rng_region = out.size();
|
||||||
|
out.push_back(r);
|
||||||
|
|
||||||
|
r = trace::Region{};
|
||||||
|
r.name = "overbudget";
|
||||||
|
r.ptr = overbudget;
|
||||||
|
r.size = sizeof(int);
|
||||||
|
r.describe = &describe_i32;
|
||||||
|
g_call.ob_region = out.size();
|
||||||
|
out.push_back(r);
|
||||||
|
|
||||||
|
std::vector<void*> nodes;
|
||||||
|
if (!tree_nodes(tree, nodes)) throw std::runtime_error("tech-tree node vector not readable");
|
||||||
|
// Reserve once: Region::name holds a pointer into these strings for the whole call, so the
|
||||||
|
// vector must never reallocate afterwards.
|
||||||
|
g_call.names.resize(nodes.size());
|
||||||
|
g_call.node_region.assign(nodes.size(), -1);
|
||||||
|
for (std::size_t i = 0; i < nodes.size(); ++i) {
|
||||||
|
char buf[32];
|
||||||
|
std::snprintf(buf, sizeof buf, "node[%u]", static_cast<unsigned>(i));
|
||||||
|
g_call.names[i] = buf;
|
||||||
|
void* p = nodes[i];
|
||||||
|
if (!readable(p, kNodeSize)) continue; // a null slot in the tree: nothing to compare
|
||||||
|
g_call.node_region[i] = static_cast<int>(out.size());
|
||||||
|
trace::Region n;
|
||||||
|
n.name = g_call.names[i].c_str();
|
||||||
|
n.ptr = p;
|
||||||
|
n.size = kNodeSize;
|
||||||
|
n.describe = &describe_node;
|
||||||
|
out.push_back(n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TechTreeProcessResearchHook::Args TechTreeProcessResearchHook::rebind(trace::Scratch& s, void* tree,
|
||||||
|
void* rng, void* alloc,
|
||||||
|
int* overbudget) {
|
||||||
|
(void)rng;
|
||||||
|
(void)overbudget;
|
||||||
|
g_call.scratch_nodes.assign(g_call.node_region.size(), nullptr);
|
||||||
|
for (std::size_t i = 0; i < g_call.node_region.size(); ++i) {
|
||||||
|
if (g_call.node_region[i] >= 0)
|
||||||
|
g_call.scratch_nodes[i] = s.ptr(static_cast<std::size_t>(g_call.node_region[i]));
|
||||||
|
}
|
||||||
|
g_call.compare = true;
|
||||||
|
// The tree pointer is passed through unchanged: ours only reads it (owner, node count) and
|
||||||
|
// hands it to the game's own read-only Cost. Every node it writes is a scratch copy.
|
||||||
|
return Args(tree, s.ptr(g_call.rng_region), alloc,
|
||||||
|
static_cast<int*>(s.ptr(g_call.ob_region)));
|
||||||
|
}
|
||||||
|
|
||||||
|
void TechTreeProcessResearchHook::ours(void* tree, void* rng, void* alloc, int* overbudget) {
|
||||||
|
using namespace sots::sim;
|
||||||
|
const bool compare = g_call.compare;
|
||||||
|
g_call.compare = false; // replace-mode calls must not inherit a stale mapping
|
||||||
|
|
||||||
|
std::vector<void*> live;
|
||||||
|
if (!tree_nodes(tree, live)) throw std::runtime_error("tech-tree node vector not readable");
|
||||||
|
const std::vector<void*>& nodes = compare ? g_call.scratch_nodes : live;
|
||||||
|
if (nodes.size() != live.size()) throw std::runtime_error("scratch node mapping is stale");
|
||||||
|
|
||||||
|
void* owner = ptr_at(tree, A::TechTree_off_Owner);
|
||||||
|
const int species_index =
|
||||||
|
readable(owner, A::ServerPlayer_off_Species + 4) ? word_at(owner, A::ServerPlayer_off_Species) : -1;
|
||||||
|
const Species owner_species = (species_index >= 0 && species_index < kSpeciesCount)
|
||||||
|
? static_cast<Species>(species_index)
|
||||||
|
: static_cast<Species>(kSpeciesCount);
|
||||||
|
|
||||||
|
// Model every slot. A slot the tree does not have stays Hidden so the decay pass skips it,
|
||||||
|
// exactly as the original's null check does.
|
||||||
|
std::vector<ResearchNode> model(nodes.size());
|
||||||
|
for (std::size_t i = 0; i < nodes.size(); ++i) {
|
||||||
|
void* p = nodes[i];
|
||||||
|
if (!p) {
|
||||||
|
model[i].state = TechState::Hidden;
|
||||||
|
model[i].cost = 0;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
model[i].state = static_cast<TechState>(word_at(p, A::TechNode_off_State));
|
||||||
|
model[i].progress = word_at(p, A::TechNode_off_Progress);
|
||||||
|
model[i].flag = static_cast<TechFlag>(word_at(p, A::TechNode_off_Flag));
|
||||||
|
model[i].cost = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<AllocEntry> raw;
|
||||||
|
if (!alloc_entries(alloc, raw)) throw std::runtime_error("research allocation not readable");
|
||||||
|
std::vector<ResearchAllocEntry> entries;
|
||||||
|
entries.reserve(raw.size());
|
||||||
|
for (const AllocEntry& e : raw) entries.push_back({tech_id_of(e.target), e.points});
|
||||||
|
|
||||||
|
// Effective cost, from the game's own read-only TechTree::Cost, for every node the
|
||||||
|
// original would ask about: the allocation targets and every available node.
|
||||||
|
if (!g_env.cost) throw std::runtime_error("TechTree::Cost address not initialised");
|
||||||
|
auto fill_cost = [&](int i) {
|
||||||
|
if (i < 0 || static_cast<std::size_t>(i) >= nodes.size() || !nodes[i]) return;
|
||||||
|
model[i].cost = g_env.cost(tree, nodes[i]);
|
||||||
|
};
|
||||||
|
for (const ResearchAllocEntry& e : entries) fill_cost(e.nodeIndex);
|
||||||
|
for (std::size_t i = 0; i < nodes.size(); ++i)
|
||||||
|
if (model[i].state == TechState::Available) fill_cost(static_cast<int>(i));
|
||||||
|
|
||||||
|
// Seed our generator from the pre-call state so both implementations read one stream.
|
||||||
|
ShimRandom rand;
|
||||||
|
{
|
||||||
|
std::uint32_t mt[mars::rng::MT19937::N];
|
||||||
|
std::memcpy(mt, static_cast<const char*>(rng) + A::RNG_off_State, sizeof mt);
|
||||||
|
const std::int32_t left = word_at(rng, A::RNG_off_Left);
|
||||||
|
if (left < 0 || left > kMtWords) throw std::runtime_error("generator `left` out of range");
|
||||||
|
rand.gen.load_state(mt, left);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ResearchTurnResult r = ProcessResearchTurn(model, entries, owner_species, rand);
|
||||||
|
|
||||||
|
// Write back exactly the words the original function itself writes.
|
||||||
|
for (std::size_t i = 0; i < nodes.size(); ++i) {
|
||||||
|
void* p = nodes[i];
|
||||||
|
if (!p) continue;
|
||||||
|
set_word(p, A::TechNode_off_Progress, model[i].progress);
|
||||||
|
set_word(p, A::TechNode_off_Flag, static_cast<std::int32_t>(model[i].flag));
|
||||||
|
set_word(p, A::TechNode_off_State, static_cast<std::int32_t>(model[i].state));
|
||||||
|
}
|
||||||
|
set_word(overbudget, 0, word_at(overbudget, 0) + r.overbudget);
|
||||||
|
|
||||||
|
// ... and the generator, in the object's own layout. `next` is rebuilt against the LIVE
|
||||||
|
// base so the describer's index arithmetic is the same for both sides.
|
||||||
|
{
|
||||||
|
std::uint8_t blob[mars::rng::MT19937::kStateBytes];
|
||||||
|
rand.gen.save_state(blob);
|
||||||
|
std::memcpy(static_cast<char*>(rng) + A::RNG_off_State, blob,
|
||||||
|
static_cast<std::size_t>(kMtWords) * 4);
|
||||||
|
set_word(rng, A::RNG_off_Left, rand.gen.left());
|
||||||
|
const std::uintptr_t base =
|
||||||
|
(compare ? g_call.rng_base : reinterpret_cast<std::uintptr_t>(rng)) + A::RNG_off_State;
|
||||||
|
set_ptr(rng, A::RNG_off_Next,
|
||||||
|
reinterpret_cast<void*>(base + static_cast<std::uintptr_t>(rand.gen.index()) * 4));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void init_research(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.cost = reinterpret_cast<CostFn>(exe_base + A::TechTree_Cost);
|
||||||
|
logf("research: ProcessResearch hook ready (Cost=%p, node=0x%x, rng=0x%x, fpu_cw=0x%04x)",
|
||||||
|
reinterpret_cast<void*>(g_env.cost), static_cast<unsigned>(kNodeSize),
|
||||||
|
static_cast<unsigned>(kRngSize), fpu_control_word());
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace shim::hooks
|
||||||
62
src/shim/hooks/research.h
Normal file
62
src/shim/hooks/research.h
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
// Hook descriptor for the per-turn research pass (B3):
|
||||||
|
//
|
||||||
|
// Game::TechTree::ProcessResearch(this, rng, alloc, overbudget)
|
||||||
|
//
|
||||||
|
// A verified __thiscall in sots_addresses.h, so it goes through Hook<> with
|
||||||
|
// CallConv::Thiscall. It is called once per player per turn, from ServerPlayer::ProcessTurn,
|
||||||
|
// and it is the only caller. Its four parameters are all confirmed at that call site: the
|
||||||
|
// second is the strategy server's Mars::RNG *object* (the function re-bases it to the state
|
||||||
|
// block with +4 before every draw), the third the {tech, points} allocation the budget built,
|
||||||
|
// the fourth an accumulator for the points that would not fit under the 150 % cap.
|
||||||
|
//
|
||||||
|
// Why this call is worth a compare: it exercises the MT19937, the completion-odds formula and
|
||||||
|
// the Zuul double roll in one place, and its RNG consumption is observable. So the declared
|
||||||
|
// regions are
|
||||||
|
//
|
||||||
|
// rng the whole 0x9cc-byte generator object -- mt[624] plus the stream position
|
||||||
|
// overbudget the caller's accumulator
|
||||||
|
// node[i] every non-null TechNode in the tree, 0x34 bytes each
|
||||||
|
//
|
||||||
|
// and the compare is run with our own MT19937 seeded by load_state() from the *pre-call*
|
||||||
|
// snapshot, so both implementations read the same stream. If the post-call generator state
|
||||||
|
// matches as well, we consumed the same words in the same order -- which is the real evidence.
|
||||||
|
//
|
||||||
|
// Scope of `ours`: exactly what ProcessResearch itself writes. On the turn a tech completes,
|
||||||
|
// the original goes on to call TechTree::SetResearched, which stamps the turn/order words,
|
||||||
|
// walks the unlock cascade into the child nodes and invokes the owner's tech-effect callback.
|
||||||
|
// None of that is reproduced (it is its own milestone, and the callback would write live
|
||||||
|
// player state that compare mode must never touch), so a completion record is expected to
|
||||||
|
// diverge in those fields and only in those fields. The effective cost of a node is taken
|
||||||
|
// from the game's own TechTree::Cost, which is read-only -- the cost multiplier is a separate,
|
||||||
|
// lower-confidence formula and not what this milestone is measuring.
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
#include <tuple>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "shim/trace/hook.h"
|
||||||
|
|
||||||
|
namespace shim::hooks {
|
||||||
|
|
||||||
|
struct TechTreeProcessResearchHook {
|
||||||
|
static constexpr const char* name = "Game::TechTree::ProcessResearch";
|
||||||
|
static constexpr trace::CallConv conv = trace::CallConv::Thiscall;
|
||||||
|
using Ret = void;
|
||||||
|
// this (TechTree*), rng (Mars::RNG*), alloc (vector<{TechDef*,int}>*), overbudget (int*)
|
||||||
|
using Args = std::tuple<void*, void*, void*, int*>;
|
||||||
|
|
||||||
|
static void describe_args(std::vector<trace::Tv>& out, void* tree, void* rng, void* alloc,
|
||||||
|
int* overbudget);
|
||||||
|
static void regions(std::vector<trace::Region>& out, void* tree, void* rng, void* alloc,
|
||||||
|
int* overbudget);
|
||||||
|
static Args rebind(trace::Scratch& s, void* tree, void* rng, void* alloc, int* overbudget);
|
||||||
|
static void ours(void* tree, void* rng, void* alloc, int* overbudget);
|
||||||
|
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_research(std::uintptr_t exe_base, void (*log_line)(const char* line));
|
||||||
|
|
||||||
|
} // namespace shim::hooks
|
||||||
|
|
@ -17,6 +17,7 @@
|
||||||
#include "generated/sots_addresses.h"
|
#include "generated/sots_addresses.h"
|
||||||
#include "shim/hooks/dictionaries.h"
|
#include "shim/hooks/dictionaries.h"
|
||||||
#include "shim/hooks/global_consts.h"
|
#include "shim/hooks/global_consts.h"
|
||||||
|
#include "shim/hooks/research.h"
|
||||||
#include "shim/trace/hook.h"
|
#include "shim/trace/hook.h"
|
||||||
#include "shim/trace/selftest.h"
|
#include "shim/trace/selftest.h"
|
||||||
#include "shim/trace/tracer.h"
|
#include "shim/trace/tracer.h"
|
||||||
|
|
@ -149,6 +150,7 @@ void InstallTemplateHook(shim::trace::Tracer& tracer, uintptr_t exeBase, uint32_
|
||||||
using LoadFileHook = shim::trace::Hook<shim::hooks::GlobalConstsLoadFileHook>;
|
using LoadFileHook = shim::trace::Hook<shim::hooks::GlobalConstsLoadFileHook>;
|
||||||
using WeaponInitHook = shim::trace::Hook<shim::hooks::WeaponDictionaryInitHook>;
|
using WeaponInitHook = shim::trace::Hook<shim::hooks::WeaponDictionaryInitHook>;
|
||||||
using SectionCtorHook = shim::trace::Hook<shim::hooks::SectionDictionaryCtorHook>;
|
using SectionCtorHook = shim::trace::Hook<shim::hooks::SectionDictionaryCtorHook>;
|
||||||
|
using ProcessResearchHook = shim::trace::Hook<shim::hooks::TechTreeProcessResearchHook>;
|
||||||
|
|
||||||
void InstallHooks(shim::trace::Tracer& tracer) {
|
void InstallHooks(shim::trace::Tracer& tracer) {
|
||||||
const uintptr_t exeBase = reinterpret_cast<uintptr_t>(GetModuleHandleA(nullptr));
|
const uintptr_t exeBase = reinterpret_cast<uintptr_t>(GetModuleHandleA(nullptr));
|
||||||
|
|
@ -176,6 +178,10 @@ void InstallHooks(shim::trace::Tracer& tracer) {
|
||||||
shim::hooks::init_dictionaries(exeBase, &ShimLogLine);
|
shim::hooks::init_dictionaries(exeBase, &ShimLogLine);
|
||||||
InstallTemplateHook<shim::hooks::WeaponDictionaryInitHook>(tracer, exeBase, sots::addr::WeaponDictionary_Init);
|
InstallTemplateHook<shim::hooks::WeaponDictionaryInitHook>(tracer, exeBase, sots::addr::WeaponDictionary_Init);
|
||||||
InstallTemplateHook<shim::hooks::SectionDictionaryCtorHook>(tracer, exeBase, sots::addr::SectionDictionary_ctor);
|
InstallTemplateHook<shim::hooks::SectionDictionaryCtorHook>(tracer, exeBase, sots::addr::SectionDictionary_ctor);
|
||||||
|
|
||||||
|
// B3: the per-turn research pass (verified thiscall). One call per player per turn.
|
||||||
|
shim::hooks::init_research(exeBase, &ShimLogLine);
|
||||||
|
InstallTemplateHook<shim::hooks::TechTreeProcessResearchHook>(tracer, exeBase, sots::addr::TechTree_ProcessResearch);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- lifecycle -----------------------------------------------------------------------------
|
// ---- lifecycle -----------------------------------------------------------------------------
|
||||||
|
|
@ -216,6 +222,7 @@ void Shim_Init(HMODULE self) {
|
||||||
LoadFileHook::register_policy(tracer);
|
LoadFileHook::register_policy(tracer);
|
||||||
WeaponInitHook::register_policy(tracer);
|
WeaponInitHook::register_policy(tracer);
|
||||||
SectionCtorHook::register_policy(tracer);
|
SectionCtorHook::register_policy(tracer);
|
||||||
|
ProcessResearchHook::register_policy(tracer);
|
||||||
char exeSha[65] = {};
|
char exeSha[65] = {};
|
||||||
if (!shim::trace::sha256_file(exePath, exeSha)) Log("trace: could not hash %s", exePath);
|
if (!shim::trace::sha256_file(exePath, exeSha)) Log("trace: could not hash %s", exePath);
|
||||||
if (tracer.open(SHIM_BUILD_ID, exeSha)) {
|
if (tracer.open(SHIM_BUILD_ID, exeSha)) {
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,9 @@ hooks=trace
|
||||||
#hook.Mars::GlobalConsts::LoadFile=compare # M1: the flat KEY/value constants loader (docs/M1.md)
|
#hook.Mars::GlobalConsts::LoadFile=compare # M1: the flat KEY/value constants loader (docs/M1.md)
|
||||||
#hook.Game::WeaponDictionary::Init=compare # M2: Weapons/_weapons.txt id manifest (docs/M2.md)
|
#hook.Game::WeaponDictionary::Init=compare # M2: Weapons/_weapons.txt id manifest (docs/M2.md)
|
||||||
#hook.Game::SectionDictionary::SectionDictionary=compare # M2: the seven _shipsections.txt manifests
|
#hook.Game::SectionDictionary::SectionDictionary=compare # M2: the seven _shipsections.txt manifests
|
||||||
|
#hook.Game::TechTree::ProcessResearch=compare # B3: the per-turn research pass (docs/B3.md).
|
||||||
|
# Fires once per player per turn and declares one region per tech node, so a trace or
|
||||||
|
# compare run with it on is large; the b3 configs below turn the other hooks off.
|
||||||
|
|
||||||
# trace.path = <file> default: shim.trace.jsonl next to the DLL (overwritten each run)
|
# trace.path = <file> default: shim.trace.jsonl next to the DLL (overwritten each run)
|
||||||
# trace.inline_max = <bytes> regions up to this size are logged as hex (default 256)
|
# trace.inline_max = <bytes> regions up to this size are logged as hex (default 256)
|
||||||
|
|
|
||||||
|
|
@ -46,10 +46,10 @@ struct ScriptedRng final : sots::sim::IRandom {
|
||||||
if (fi >= floats.size()) { std::fprintf(stderr, "ScriptedRng: float script exhausted\n"); ++failures(); return 0.f; }
|
if (fi >= floats.size()) { std::fprintf(stderr, "ScriptedRng: float script exhausted\n"); ++failures(); return 0.f; }
|
||||||
return floats[fi++];
|
return floats[fi++];
|
||||||
}
|
}
|
||||||
std::uint32_t NextInt(std::uint32_t n) override {
|
std::uint32_t NextIntInclusive(std::uint32_t n) override {
|
||||||
if (n == 0) return 0;
|
|
||||||
if (ii >= ints.size()) { std::fprintf(stderr, "ScriptedRng: int script exhausted\n"); ++failures(); return 0; }
|
if (ii >= ints.size()) { std::fprintf(stderr, "ScriptedRng: int script exhausted\n"); ++failures(); return 0; }
|
||||||
return ints[ii++] % n;
|
const std::uint32_t v = ints[ii++];
|
||||||
|
return n == 0xffffffffu ? v : v % (n + 1); // scripted value folded into [0, n]
|
||||||
}
|
}
|
||||||
std::size_t floatDraws() const { return fi; }
|
std::size_t floatDraws() const { return fi; }
|
||||||
std::size_t intDraws() const { return ii; }
|
std::size_t intDraws() const { return ii; }
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,8 @@ static void test_progress_below_half() {
|
||||||
CHECK_EQ(r.overbudget, 0);
|
CHECK_EQ(r.overbudget, 0);
|
||||||
CHECK(!r.completed);
|
CHECK(!r.completed);
|
||||||
CHECK(!r.overbudgetEvent);
|
CHECK(!r.overbudgetEvent);
|
||||||
CHECK_NEAR(r.odds, -100.0 / 1500.0, 1e-12);
|
// odds is a float32 (the original stores it in a float slot before comparing)
|
||||||
|
CHECK(r.odds == static_cast<float>(-100.0 / 1500.0));
|
||||||
CHECK_EQ(n.progress, 400);
|
CHECK_EQ(n.progress, 400);
|
||||||
CHECK(n.state == TechState::Available);
|
CHECK(n.state == TechState::Available);
|
||||||
CHECK_EQ(rng.floatDraws(), std::size_t{1});
|
CHECK_EQ(rng.floatDraws(), std::size_t{1});
|
||||||
|
|
@ -84,7 +85,7 @@ static void test_progress_at_cost() {
|
||||||
ResearchStepResult r = ApplyResearchPoints(n, 1000, Species::Human, rng);
|
ResearchStepResult r = ApplyResearchPoints(n, 1000, Species::Human, rng);
|
||||||
CHECK(r.completed);
|
CHECK(r.completed);
|
||||||
CHECK(!r.completedEarly);
|
CHECK(!r.completedEarly);
|
||||||
CHECK_NEAR(r.odds, 1.0 / 3.0, 1e-12);
|
CHECK(r.odds == static_cast<float>(500.0 / 1500.0));
|
||||||
CHECK(n.state == TechState::Researched);
|
CHECK(n.state == TechState::Researched);
|
||||||
CHECK(n.flag == TechFlag::Default);
|
CHECK(n.flag == TechFlag::Default);
|
||||||
}
|
}
|
||||||
|
|
@ -164,7 +165,9 @@ static void test_decay() {
|
||||||
CHECK_EQ(DecayResearchProgress(20, 1000), 0); // floored
|
CHECK_EQ(DecayResearchProgress(20, 1000), 0); // floored
|
||||||
CHECK_EQ(DecayResearchProgress(10, 30), 9); // ftol(1.5) = 1
|
CHECK_EQ(DecayResearchProgress(10, 30), 9); // ftol(1.5) = 1
|
||||||
CHECK_EQ(DecayResearchProgress(0, 1000), 0);
|
CHECK_EQ(DecayResearchProgress(0, 1000), 0);
|
||||||
CHECK_EQ(DecayResearchProgress(50, kNoResearchCost), 50);
|
// A node with no known cost is not special-cased: the original feeds Cost()'s
|
||||||
|
// INT_MAX straight into the 5 % multiply, which wipes any progress out.
|
||||||
|
CHECK_EQ(DecayResearchProgress(50, kNoResearchCost), 0);
|
||||||
|
|
||||||
std::vector<ResearchNode> nodes = {node(1000, 300), node(1000, 0), node(1000, 500)};
|
std::vector<ResearchNode> nodes = {node(1000, 300), node(1000, 0), node(1000, 500)};
|
||||||
nodes[2].state = TechState::Researched;
|
nodes[2].state = TechState::Researched;
|
||||||
|
|
@ -222,6 +225,103 @@ static void test_determinism() {
|
||||||
CHECK(run() == expected);
|
CHECK(run() == expected);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- the pieces pinned against the original's instruction sequence ------------------
|
||||||
|
|
||||||
|
static void test_spend_window() {
|
||||||
|
// The window is a 32-bit multiply and a truncating divide by 100, then lo floored at
|
||||||
|
// 0 and hi raised to lo.
|
||||||
|
CHECK_EQ(ResearchSpendFloor(1000), 500);
|
||||||
|
CHECK_EQ(ResearchSpendCeiling(1000), 1500);
|
||||||
|
CHECK_EQ(ResearchSpendFloor(1), 0); // 50/100 truncates to 0
|
||||||
|
CHECK_EQ(ResearchSpendCeiling(1), 1); // 150/100 truncates to 1
|
||||||
|
CHECK_EQ(ResearchSpendFloor(3), 1); // 150/100
|
||||||
|
CHECK_EQ(ResearchSpendCeiling(3), 4); // 450/100
|
||||||
|
CHECK_EQ(ResearchSpendFloor(-1000), 0); // negative floor is clamped away
|
||||||
|
CHECK(ResearchSpendCeiling(-1000) >= ResearchSpendFloor(-1000));
|
||||||
|
// A cost near INT_MAX wraps in the 32-bit multiply rather than widening; the point of
|
||||||
|
// the check is only that we wrap the same way, and that hi never drops below lo.
|
||||||
|
CHECK_EQ(ResearchSpendFloor(kNoResearchCost), 0);
|
||||||
|
CHECK(ResearchSpendCeiling(kNoResearchCost) >= 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_odds_is_float32() {
|
||||||
|
// (progress - lo) / hi in double, then narrowed. 1/3 is not representable, so the
|
||||||
|
// narrowing is observable.
|
||||||
|
const float odds = ResearchCompletionOdds(1000, 500, 1500);
|
||||||
|
CHECK(odds == static_cast<float>(500.0 / 1500.0));
|
||||||
|
CHECK(static_cast<double>(odds) != 500.0 / 1500.0);
|
||||||
|
CHECK(ResearchCompletionOdds(500, 500, 1500) == 0.f);
|
||||||
|
CHECK(ResearchCompletionOdds(1500, 500, 1500) == static_cast<float>(2.0 / 3.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_spend_is_not_floored() {
|
||||||
|
// progress already past the 150 % ceiling: the spend goes negative rather than
|
||||||
|
// clamping at zero, and the surplus lands in the over-budget counter.
|
||||||
|
ResearchNode n = node(1000, 1600);
|
||||||
|
simtest::ScriptedRng rng;
|
||||||
|
ResearchStepResult r = ApplyResearchPoints(n, 100, Species::Human, rng);
|
||||||
|
CHECK_EQ(r.spent, -100); // min(100, 1500 - 1600)
|
||||||
|
CHECK_EQ(r.overbudget, 200); // points - spend
|
||||||
|
CHECK_EQ(n.progress, 1500);
|
||||||
|
CHECK(r.completed); // progress >= hi -> odds 1, roll 0, no draw
|
||||||
|
CHECK_EQ(rng.floatDraws(), std::size_t{0});
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_early_completion_threshold() {
|
||||||
|
// The ratio is narrowed to float32 and compared against (double)0.8f, so a progress
|
||||||
|
// exactly at 80 % of cost is NOT early.
|
||||||
|
// exactly 80 %: (float)0.8 is not below (double)0.8f, so the node is not "early"
|
||||||
|
ResearchNode n = node(1000, 799);
|
||||||
|
simtest::ScriptedRng rng({0.0f});
|
||||||
|
ResearchStepResult r = ApplyResearchPoints(n, 1, Species::Human, rng);
|
||||||
|
CHECK(r.completed); // odds (800-500)/1500 = 0.2 >= roll 0
|
||||||
|
CHECK_EQ(n.progress, 800);
|
||||||
|
CHECK(!r.completedEarly);
|
||||||
|
CHECK(n.flag == TechFlag::Default);
|
||||||
|
// one point below the boundary: early
|
||||||
|
ResearchNode m = node(1000, 798);
|
||||||
|
simtest::ScriptedRng rng2({0.0f});
|
||||||
|
ResearchStepResult r2 = ApplyResearchPoints(m, 1, Species::Human, rng2);
|
||||||
|
CHECK(r2.completed);
|
||||||
|
CHECK_EQ(m.progress, 799);
|
||||||
|
CHECK(r2.completedEarly);
|
||||||
|
CHECK(m.flag == TechFlag::CompletedEarly);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_process_turn() {
|
||||||
|
// One allocation entry plus the decay sweep, in the original's order: the funded node
|
||||||
|
// is decayed too, and a node that is merely available but idle loses 5 % of its cost.
|
||||||
|
std::vector<ResearchNode> nodes = {node(1000, 0), node(400, 100), node(1000, 0)};
|
||||||
|
nodes[2].state = TechState::Hidden; // a slot the tree does not have
|
||||||
|
simtest::ScriptedRng rng({0.9f});
|
||||||
|
std::vector<ResearchAllocEntry> alloc = {{0, 300}};
|
||||||
|
ResearchTurnResult r = ProcessResearchTurn(nodes, alloc, Species::Human, rng);
|
||||||
|
CHECK_EQ(r.steps.size(), std::size_t{1});
|
||||||
|
CHECK_EQ(r.overbudget, 0);
|
||||||
|
CHECK(!r.steps[0].completed); // odds (300-500)/1500 < 0 < 0.9
|
||||||
|
CHECK_EQ(nodes[0].progress, 250); // 300 spent, then -ftol(1000 * 0.05)
|
||||||
|
CHECK_EQ(nodes[1].progress, 80); // idle: -ftol(400 * 0.05)
|
||||||
|
CHECK_EQ(nodes[2].progress, 0); // hidden slots are never touched
|
||||||
|
CHECK_EQ(rng.floatDraws(), std::size_t{1});
|
||||||
|
|
||||||
|
// An out-of-range entry is skipped without a draw.
|
||||||
|
std::vector<ResearchNode> n2 = {node(1000, 0)};
|
||||||
|
simtest::ScriptedRng rng2;
|
||||||
|
ResearchTurnResult r2 = ProcessResearchTurn(n2, {{7, 100}, {-1, 100}}, Species::Human, rng2);
|
||||||
|
CHECK_EQ(r2.steps.size(), std::size_t{0});
|
||||||
|
CHECK_EQ(rng2.floatDraws(), std::size_t{0});
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_overbudget_accumulates() {
|
||||||
|
std::vector<ResearchNode> nodes = {node(100, 0)};
|
||||||
|
simtest::ScriptedRng rng({0.99f});
|
||||||
|
ResearchTurnResult r = ProcessResearchTurn(nodes, {{0, 500}}, Species::Human, rng);
|
||||||
|
CHECK_EQ(r.steps[0].spent, 150); // capped at 150 % of cost
|
||||||
|
CHECK_EQ(r.overbudget, 350);
|
||||||
|
CHECK(r.steps[0].completed); // at the ceiling: guaranteed, no draw
|
||||||
|
CHECK_EQ(rng.floatDraws(), std::size_t{0});
|
||||||
|
}
|
||||||
|
|
||||||
int main() {
|
int main() {
|
||||||
test_edge_roll();
|
test_edge_roll();
|
||||||
test_cost();
|
test_cost();
|
||||||
|
|
@ -234,5 +334,11 @@ int main() {
|
||||||
test_decay();
|
test_decay();
|
||||||
test_lab_accident();
|
test_lab_accident();
|
||||||
test_determinism();
|
test_determinism();
|
||||||
|
test_spend_window();
|
||||||
|
test_odds_is_float32();
|
||||||
|
test_spend_is_not_floored();
|
||||||
|
test_early_completion_threshold();
|
||||||
|
test_process_turn();
|
||||||
|
test_overbudget_accumulates();
|
||||||
return simtest::finish("test_research");
|
return simtest::finish("test_research");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -41,21 +41,122 @@ int main() {
|
||||||
r.next_u32();
|
r.next_u32();
|
||||||
CHECK(r.left() == MT19937::N - 1);
|
CHECK(r.left() == MT19937::N - 1);
|
||||||
}
|
}
|
||||||
// --- float mapping: y * 2^-32 narrowed to float -----------------------------
|
// --- float mapping: y / (2^32 - 1), narrowed to float -----------------------
|
||||||
|
// The divisor is 2^32 - 1, so 0xffffffff maps to exactly 1.0 and the range is
|
||||||
|
// closed at both ends. The unit value is formed in double and the consumer
|
||||||
|
// narrows it to float32.
|
||||||
{
|
{
|
||||||
|
CHECK(MT19937::kUnitScale == 1.0 / 4294967295.0);
|
||||||
|
CHECK(MT19937::kUnitScale != 1.0 / 4294967296.0);
|
||||||
|
CHECK(MT19937::unit_from(0u) == 0.0);
|
||||||
|
CHECK(MT19937::unit_from(0xffffffffu) == 1.0);
|
||||||
|
CHECK(MT19937::float_from(0xffffffffu) == 1.0f);
|
||||||
|
CHECK(MT19937::unit_from(1u) == 1.0 / 4294967295.0);
|
||||||
|
// A word with the high bit set exercises the unsigned fix-up path.
|
||||||
|
CHECK(MT19937::unit_from(0x80000000u) == 2147483648.0 / 4294967295.0);
|
||||||
|
|
||||||
MT19937 r(5489u);
|
MT19937 r(5489u);
|
||||||
float f = r.next_float();
|
float f = r.next_float();
|
||||||
float expect = static_cast<float>(3499211612.0 / 4294967296.0);
|
float expect = static_cast<float>(3499211612.0 / 4294967295.0);
|
||||||
CHECK(f == expect);
|
CHECK(f == expect);
|
||||||
CHECK(f >= 0.f && f <= 1.f);
|
CHECK(f >= 0.f && f <= 1.f);
|
||||||
|
|
||||||
|
// The 2^-32 mapping is wrong but only barely: the two differ by 2^-32 relative,
|
||||||
|
// which is far below a float32 ulp, so they disagree for roughly one word in a
|
||||||
|
// hundred. Pin that the difference is real but rare, so nobody reads a passing
|
||||||
|
// behavioural compare as proof that either divisor would do.
|
||||||
|
int differ = 0;
|
||||||
|
MT19937 q(17u);
|
||||||
|
for (int i = 0; i < 20000; ++i) {
|
||||||
|
const uint32_t y = q.next_u32();
|
||||||
|
if (static_cast<float>(static_cast<double>(y) * (1.0 / 4294967296.0)) !=
|
||||||
|
MT19937::float_from(y))
|
||||||
|
++differ;
|
||||||
|
}
|
||||||
|
CHECK(differ > 0);
|
||||||
|
CHECK(differ < 20000 / 10);
|
||||||
|
|
||||||
|
// next_unit / next_float draw from the same stream position.
|
||||||
|
MT19937 a(11u), b(11u);
|
||||||
|
CHECK(static_cast<float>(a.next_unit()) == b.next_float());
|
||||||
}
|
}
|
||||||
// --- next_int: in range, and consumes exactly one word when mask == n-1 ----
|
// --- the 24-bit-precision variant: same for most words, and never far off ---
|
||||||
{
|
{
|
||||||
|
int differ = 0;
|
||||||
|
MT19937 r(31u);
|
||||||
|
for (int i = 0; i < 20000; ++i) {
|
||||||
|
const uint32_t y = r.next_u32();
|
||||||
|
const float a = MT19937::float_from(y);
|
||||||
|
const float b = MT19937::float_from_pc24(y);
|
||||||
|
if (a != b) ++differ;
|
||||||
|
const double d = static_cast<double>(a) - static_cast<double>(b);
|
||||||
|
CHECK(d < 1e-6 && d > -1e-6);
|
||||||
|
}
|
||||||
|
// The two agree on the vast majority of words; the point of the check is that
|
||||||
|
// the choice of x87 precision mode can only move the last bit.
|
||||||
|
CHECK(differ < 20000 / 2);
|
||||||
|
}
|
||||||
|
// --- next_int_inclusive: mask covers n, the bound is INCLUSIVE --------------
|
||||||
|
{
|
||||||
|
CHECK(MT19937::cover_mask(0u) == 0u);
|
||||||
|
CHECK(MT19937::cover_mask(1u) == 1u);
|
||||||
|
CHECK(MT19937::cover_mask(100u) == 127u);
|
||||||
|
CHECK(MT19937::cover_mask(255u) == 255u);
|
||||||
|
CHECK(MT19937::cover_mask(256u) == 511u);
|
||||||
|
|
||||||
MT19937 r(7u);
|
MT19937 r(7u);
|
||||||
for (int i = 0; i < 1000; ++i) CHECK(r.next_int(10) < 10);
|
bool saw_top = false;
|
||||||
|
for (int i = 0; i < 5000; ++i) {
|
||||||
|
uint32_t v = r.next_int_inclusive(10);
|
||||||
|
CHECK(v <= 10);
|
||||||
|
if (v == 10) saw_top = true;
|
||||||
|
}
|
||||||
|
CHECK(saw_top); // 10 itself is reachable: the bound is inclusive
|
||||||
|
|
||||||
|
// mask == n means no rejection ever, so exactly one word is consumed
|
||||||
MT19937 a(9u), b(9u);
|
MT19937 a(9u), b(9u);
|
||||||
uint32_t x = a.next_int(256);
|
CHECK(a.next_int_inclusive(255u) == (b.next_u32() & 255u));
|
||||||
CHECK(x == (b.next_u32() & 255u));
|
CHECK(a.left() == b.left());
|
||||||
|
|
||||||
|
// n == 0 still consumes a word and always yields 0
|
||||||
|
MT19937 c(13u), d(13u);
|
||||||
|
CHECK(c.next_int_inclusive(0u) == 0u);
|
||||||
|
d.next_u32();
|
||||||
|
CHECK(c.left() == d.left());
|
||||||
|
}
|
||||||
|
// --- the compare design: a generator seeded from a pre-call snapshot reads the
|
||||||
|
// same stream, and ends in the same state after the same number of draws ---------
|
||||||
|
{
|
||||||
|
MT19937 live(0xc0ffeeu);
|
||||||
|
for (int i = 0; i < 1500; ++i) live.next_u32(); // wherever the game happens to be
|
||||||
|
|
||||||
|
// snapshot "before"
|
||||||
|
uint8_t before[MT19937::kStateBytes];
|
||||||
|
live.save_state(before);
|
||||||
|
|
||||||
|
// the original consumes some words...
|
||||||
|
float a1 = live.next_float();
|
||||||
|
float a2 = live.next_float();
|
||||||
|
uint8_t after[MT19937::kStateBytes];
|
||||||
|
live.save_state(after);
|
||||||
|
|
||||||
|
// ...and ours, seeded from the snapshot, must produce the same values and land on
|
||||||
|
// the same state. Matching post-states is the evidence that the draw counts agree.
|
||||||
|
MT19937 ours(1u);
|
||||||
|
CHECK(ours.load_state(before, sizeof before));
|
||||||
|
CHECK(ours.next_float() == a1);
|
||||||
|
CHECK(ours.next_float() == a2);
|
||||||
|
uint8_t ours_after[MT19937::kStateBytes];
|
||||||
|
ours.save_state(ours_after);
|
||||||
|
CHECK(std::memcmp(after, ours_after, sizeof after) == 0);
|
||||||
|
|
||||||
|
// one draw too few leaves a different state, so the check has teeth
|
||||||
|
MT19937 short_(1u);
|
||||||
|
CHECK(short_.load_state(before, sizeof before));
|
||||||
|
short_.next_float();
|
||||||
|
uint8_t short_after[MT19937::kStateBytes];
|
||||||
|
short_.save_state(short_after);
|
||||||
|
CHECK(std::memcmp(after, short_after, sizeof after) != 0);
|
||||||
}
|
}
|
||||||
// --- save_state / load_state round trip, blob layout mt[624] + left --------
|
// --- save_state / load_state round trip, blob layout mt[624] + left --------
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue