merge lane W2: watchpoints + multiplayer Tier 0 (CMake list and main.cpp union-resolved to keep both instruments; header regenerated)

This commit is contained in:
alex 2026-09-08 15:28:28 -04:00
parent eafbc5f03a
commit cc77de3429
17 changed files with 1047 additions and 36 deletions

View file

@ -103,10 +103,12 @@ if(WIN32)
src/shim/hooks/player_turn.cpp
src/shim/hooks/tail_rng.cpp
src/shim/hooks/draw_sites.cpp
src/shim/hooks/probe_entry.cpp)
src/shim/hooks/probe_entry.cpp
src/shim/hooks/watchpoints.cpp)
# `minhook` is here for its include directory: lane H's probe_entry.cpp installs its own
# detours (MH_CreateHook/MH_EnableHook) rather than handing descriptors back to main.cpp,
# because a register-transparent asm stub has no C++ prototype for the template to take.
# Lane W2's watchpoints.cpp uses one detour as an arming point and modifies no other code.
target_link_libraries(shim_hooks PUBLIC shim_trace sots_addresses sots_game_config sots_game_sim
sots_game_effects mars_rng shim_budget shim_techfx
shim_colony shim_movement shim_events

108
docs/W2-predictions.md Normal file
View file

@ -0,0 +1,108 @@
# W2 — predictions, written before any run
Lane W2, 2026-09-08. Committed **before** the VM runs and before the shim build (method rule 2).
## Part 1 — multiplayer Tier 0
Restating lane G2's `multiplayer-gamespy.md` §7 as the things this lane will actually observe, and
where my expectation differs from G2's.
Setup: one Win10 guest (VM140), GOG 1.8.1, `hooks=off` (rule 19 — the instrument is removed for a
test whose whole point is "does the shipped game do this"). Capture is `tcpdump` on **spicy's
`tap140i0`**, i.e. outside the guest, so it cannot perturb the guest and it sees every packet that
leaves the VM. It does **not** see loopback traffic between the two instances — that is a known and
accepted blind spot, and it is the right trade: the predictions that matter (P1's "no GameSpy
traffic", P2's DNS query) are all about traffic that *leaves*.
Pre-run fact established before the test, and it matters for P2: from the guest,
`swordots.available.gamespy.com` returns **NXDOMAIN** (`[System.Net.Dns]::GetHostAddresses` throws
"No such host is known") while `www.google.com` resolves. So there is no stale wildcard record, and
the availability check will take exactly the `gethostbyname == NULL` arm G2 read at `0x0040a0f0`.
That makes P2 a test of the *fail-open* reading specifically.
**W2-P5 (G2's P5) — `/concurrent` reaches the main menu.** A second `Sword of the Stars.exe`
started with `/concurrent` while the first is running exists as its own process with its own window,
rather than foregrounding the first and exiting. *Falsified if* the second process exits within
~20 s and only one PID remains.
Confidence: high. The `CreateMutexA` / `ERROR_ALREADY_EXISTS` / argv-compare chain is read out of
the binary and there is nothing conditional about it.
**W2-P1 (G2's P1) — a direct join succeeds with no GameSpy traffic.** With instance A hosting a LAN
multiplayer game and instance B started as
`"Sword of the Stars.exe" /concurrent /join 127.0.0.1:3369`, B reaches the multiplayer lobby and
appears in a slot on A. Over the whole run the tap capture shows **zero** packets to UDP 27900, UDP
27901, TCP 28910, TCP 6667 and **zero** DNS queries for any `*.gamespy.com` name after `/join` is
parsed. *Falsified if* any of those appear on the join path, or the join fails with a network error
while both instances are up.
Confidence: medium-high on the "no GameSpy traffic" half (static reading is clear), medium on the
"join succeeds" half — see G2's own (a)/(b)/(c) caveats, to which I add a fourth: **the two
instances share `C:\SOTS`**, one `Profiles` directory, one `sots.ini` and one `SavedGames`. Nothing
in G2's reading covers what the host does when a second process opens the same profile. If the join
fails, that is the first thing to rule out before concluding anything about the protocol.
**W2-P2 (G2's P2) — the availability check fails open.** On entering *Join Multi-Player*, at most
one DNS query for `swordots.available.gamespy.com` leaves the guest, it is answered NXDOMAIN, and
the UI does **not** show `MATCHINGSERVICE_UNSUPPORTED` / "Online support … is no longer available".
*Falsified if* that dialog appears.
I differ from G2 here in one respect: G2 predicts "exactly one DNS query". The Windows resolver
negative-caches, and the SDK's own retry is a *socket* retry not a DNS retry, so I expect **one or
two** queries and will not read two as a falsification of anything.
**W2-P3 (G2's P3) — LAN discovery.** B's *LAN* page lists A's game after a refresh. I rate this
**lower** than G2 does, for a reason G2 could not have known: the two instances are on the **same
host**, so the broadcast to `255.255.255.255:3369` and the QR2 socket are contending for one UDP
port on one machine. `SO_REUSEADDR`-less binds mean the second instance may fail to bind 3369 at
all. *Falsified if* the LAN list stays empty while a manual join to the same address succeeds — and
I expect that outcome to be **more likely than not**, and to be an artifact of the single-guest
setup rather than evidence about the mechanism.
**W2-P4 (G2's P4) — self-hosted master server.** Not attempted this lane. Tier 3 is a container
deployment plus a DNS redirect; it is a full lane's work, it is not on the critical path to
*playing*, and spending VM time on it before Tier 0 is proved would be the wrong order. Reported as
not-run, not as unknown.
**New — W2-P6: the dedicated server.** `sots_server.exe` and `Dedicated Server Launchpad.exe` are
both present in `C:\SOTS`. The forum report G2 quotes says the dedicated server does not work. If
Tier 0 via two clients is obstructed by the shared-install problem, this is the fallback host; if
Tier 0 works, running it is a cheap bonus data point on a reproducible community bug. No prediction
is offered — I have not read it, and G2 did not either.
## Part 2 — `ModCount` watchpoint
Lane A2's prediction is adopted unchanged (`alliance-mask-and-modcount.md` §3): **exactly 12 hits**
on a 4-byte write watchpoint at `(char*)S + 8`, two at `0x007dc6f0` and `0x007d92ca`, ten with a
return address in `StrategySim::ApplyTurnCommandBatch` `0x0088f9b0` or its callers, none at
`0x007b9e20`, and **all before `ProcessTurn` is entered**. Falsifiers (a)-(d) as A2 wrote them.
**My own addition, and it is a rule-19 addition.** A hardware watchpoint is an instrument too, and a
more invasive one than a MinHook detour: it single-steps nothing but it does take an exception on
every write, and the handler runs in the game's thread. So the run is only trustworthy if the
autosave it produces is **byte-identical to the `hooks=off` autosave from the same input save**. I
will take that control. If the watchpoint perturbs the turn, the hit list is still useful as an
enumeration of *writers* but the *count* is not a measurement of the un-instrumented game, and I
will say so rather than reporting 12-or-not as if it were.
**Prediction on the control itself:** the watchpoint is byte-neutral. A `#DB` on a data write is
delivered after the store retires, the handler restores state exactly, and there is no code
modification anywhere — unlike a detour, which is what §2 of `tail-probes.md` caught. If this
control *fails*, that is the more interesting result and it says something about the perturbation
mechanism lane H left undetermined.
## Part 3 — `Player.Status` and `TShn`
Same instrument, different addresses; both are write watchpoints with a per-hit EIP + return-address
record.
- **`Player.Status`.** Prediction: **at least one writer after tail phase 31 and before the
autosave**, at an EIP outside the tail-phase code, writing 4. The regression is that the phase
writes 1 and the file carries 4. Falsified if the only write of 4 happens *inside* phase 31, which
would mean the phase catalog's attribution of phase 31 is wrong rather than a later writer
existing.
- **`TShn` (`player+0x274` map).** E3 named Spica vs Bismol as the discriminating pair and proved
the gate is not `AFlags`. Prediction: the writer is a **single** site reached once per qualifying
system, and the discriminant is a property of the *system* rather than of the player — because
a player-level gate would have moved `TShn` for Spica too. Falsified if the writes come from two
or more EIPs, or if the EIP is inside a per-player loop with no system in scope.
Both of these are lower priority than `ModCount` because `ModCount`'s prediction is numeric and
theirs is not.

53
docs/W2-watchpoints.md Normal file
View file

@ -0,0 +1,53 @@
# W2 — the watchpoint module, and what it answered
Companion to `docs/W2-predictions.md` (written first) and to
`sots-re/findings/control-flow/watchpoints-modcount-status.md` (the full report).
## What was added
`src/shim/hooks/watchpoints.{h,cpp}` — hardware data-write watchpoints on the live game.
- **One** MinHook detour in the whole module, on `StrategyServer::ApplyAllTurnCommands`
(`0x0078f6a0`), used only to learn `S` on the turn thread. Debug registers are per-thread, so the
arming point has to run on that thread and precede the writes; A2 established that this function
is the End-Turn command flush and that its `this` is the `S` frame.
- DR0-DR3 as 4-byte data-write breakpoints, set with `GetThreadContext`/`SetThreadContext` on the
calling thread and **read back and logged**, plus a vectored exception handler that records EIP,
the written value, `DR6`, the thread id, the `EBP` chain and the first four code-looking words
above `ESP`.
- A **canary self-test**: DR3 is pointed at a word the shim owns, that word is written once, and the
handler must report exactly one trap before any game address is believed. An arm that silently
failed is indistinguishable from "nothing writes this" (method rule 1).
- A background flusher, because the game is usually killed rather than quit and
`DLL_PROCESS_DETACH` is not guaranteed.
- Config: `watch=on|off`, `watch.players=0..2`, `watch.out=<path>`. `shim.cfg.w2watch` and
`shim.cfg.w2control` differ **only** in `watch=`, so the pair is a real rule-19 control.
The watchpoints modify no code, so the instrument's only patched bytes are the single arming
detour's five.
## Rule 19: the control was taken and it passed
One End Turn from `ref-turn2.sav` with all four watchpoints armed reproduced the determinism oracle
byte for byte — `(Autosave EndTurn).sav` `bb4fd9ac…`, `(Autosave).sav` `978041ac…`. The prediction
in `W2-predictions.md` Part 2 ("the watchpoint is byte-neutral, unlike a detour, because a `#DB` on
a data write is a trap taken after the store retires and no code is modified") held.
## What it answered
1. `ModCount` (`S+0x8`): **exactly 12 writes per End Turn** on this save, twice, values 13→24 and
25→36 with no gap. Lane A2's prediction confirmed, including both predicted addresses
(`0x007dc6f0`, `0x007d92ca`), the zero at `0x007b9e20`, and the whole
`OnMessage → ApplyAllTurnCommands → ApplyTurnCommandBatch` call chain.
2. `Frame` (`S+0xc`): **exactly one** write per End Turn, from `BeginProcessTurn + 0x2a`, value
becoming the turn number. This settles the `ModCount`-vs-`Frame` naming dispute in lane A2's
favour against both lane T and `addresses.json`.
3. `Player.Status`: the writer the S31 regression was missing is
`StrategyNetworkClient::OnMessage + 0xa15`, writing 4 after `ProcessTurn` returns and before the
autosave. Lane T2's "there is no writer between tail phase 31 and the autosave" is falsified.
## Reusing it
Change the four addresses computed in `WatchOnApplyAll`; anything reachable from `S` at the flush is
one line. Keep the canary self-test and keep the `ref-turn2` oracle control — together they cost
about two minutes and they are what makes the numbers evidence rather than output.

View file

@ -1,5 +1,5 @@
// GENERATED — do not edit. Facts about Sword of the Stars.exe (GOG 1.8.1).
// Source: sots-re ghidra/addresses.json @ 5cc66f6, generated 2026-09-08 by tools/gen_addresses.py
// Source: sots-re ghidra/addresses.json @ b77a611, generated 2026-09-08 by tools/gen_addresses.py
// Runtime address = (uintptr_t)GetModuleHandle(NULL) + RVA (the exe is ASLR-relocated).
#pragma once
#include <cstdint>
@ -1415,6 +1415,36 @@ constexpr uint32_t ClientOrder_SetSystemRates = 0x00363270;
constexpr uint32_t ClientOrder_Colonize = 0x00369640;
// offset Game::TurnCommands -- the first of the 27 std::list members lane Q enumerated, stride 0x0c, so list N (1-based, as lane Q numbers them) is at +0x70 + (N-1)*0x0c and the last, list 27, is at +0x1a8. Recorded here because the order-method -> list mapping in ai-task-system.md#4 is expressed entirely in these offsets: the adder for list N is the function whose first `this`-relative access is +0x70 + (N-1)*0x0c [verified]
constexpr uint32_t TurnCommands_off_ListBase = 0x00000070;
// cdecl void __cdecl Game::StrategyApp::OnClientEvent(int netId, int eventId, void* ev) -- THE AI ENQUEUE SITE (lane AI2 §6.1 open item, closed). Operates on the STATIC StrategyApp at 0x00b29f98 (not a pointer -- `mov ecx,0xb29f98` at 0x007843a2 proves the object itself lives there). Body: (1) `if (*(void**)0x00b29f9c == 0) return` -- app+0x4, the StrategyServer; (2) `if (netId == 0) return`; (3) linear search of the client vector at app+0xc/+0x10 (absolutes 0x00b29fa4/0x00b29fa8) for `client->+0x148 == netId`; not found -> return; (4) `p = client->+0x150`; (5) `if (eventId == 0x26 (SEResumePlaying) && p->+0xf9 != 0 && p->+0xfa == 0)` then `if (!0x00438fe0(&pending, &netId)) 0x0059f1a0(&pending, &netId)` -- a DEDUPLICATED push_back onto the pending-AI vector at app+0x1c (absolute 0x00b29fb4), and RETURN; (6) otherwise `StrategyClient::RaiseEvent 0x00783ee0(client, eventId, ev)` inline. So event 0x26 for an AI player is the ONLY deferred event; everything else is delivered synchronously. Registered as StrategyServer+0x170 by CreateGame at 0x00889177; it has ZERO direct callers and no vtable slot (lane B6's third blind spot) [verified]
constexpr uint32_t StrategyApp_OnClientEvent = 0x00438e10;
// thiscall void __thiscall Game::StrategyServer::ResumePlaying() -- THE STEPPING ORDER. (1) `if (0x0080f4d0(&this->+0x4)) return`; (2) `if (++this->+0x168 == 1 && this->+0x1b4) { obs->vt[4](2,0); obs->vt[7](); }`; (3) 0x007dd230(&this->+0x174, 0) then 0x007dd230(&this->+0x174, playerCount) -- clear+resize; (4) FIRST walk of the player vector at this->+0x54..+0x58, IN INDEX ORDER: `if (!p->Elim /*+0xf8*/) p->Status /*+0x164*/ = 0` -- this is a Player.Status WRITER; (5) SECOND walk, again in index order: `if (p->Status == 0) { ev = {vptr 0x00a23bb8}; cb = this->+0x170; cb ? cb(p->+0x4 /*netId*/, 0x26, &ev) : Log(0x00a23c08); }`. The callback is StrategyApp::OnClientEvent 0x00838e10, so the pending-AI vector is filled in SERVER PLAYER INDEX ORDER -- i.e. save player order -- and RunPendingAITurns then walks it in index order. Both Elim and Status are save-visible ServerPlayer fields [verified]
constexpr uint32_t StrategyServer_ResumePlaying = 0x003ddc90;
// thiscall void __thiscall Game::StrategyServer::SetClientEventCallback(void (__cdecl* cb)(int netId, int eventId, void* ev)) -- RET 4. Two instructions: `this->+0x170 = arg`. The only registration in the image is CreateGame 0x00889177 installing StrategyApp::OnClientEvent 0x00838e10. Every server->client event in the game funnels through this one pointer; SynchronizePlayer 0x007c6220 dispatches through it at 0x007c6384, 0x007c65aa, 0x007c6889, 0x007c6a00, 0x007c6ae0 and more [verified]
constexpr uint32_t StrategyServer_SetClientEventCallback = 0x003861f0;
// data Game::StrategyApp -- the STATIC APP OBJECT ITSELF, not a pointer to one. Proved by `mov ecx,0xb29f98; call <method>` at 0x007842f6/0x007843a2 and by the absolute pair 0x00b29fa4/0x00b29fa8 being read where a method reads this->+0xc/this->+0x10. Layout confirmed this lane: +0x0 flag byte (bit 2 = 'a StrategyServer exists'), +0x4 StrategyServer*, +0xc/+0x10/+0x14 vector<StrategyClient*>, +0x1c/+0x20/+0x24 vector<int> pendingAITurns (absolutes 0x00b29fb4/b8/bc), +0x2c AIProcessMinTime (seconds, float). Lane AI2's scan for 'functions that load 0x00b29f98' missed the enqueue because MSVC folds the object base into the absolute address of the member: the enqueue writes 0x00b29fb4 directly and never materialises 0x00b29f98 [verified]
constexpr uint32_t g_StrategyApp = 0x00729f98;
// cdecl void __cdecl Game::StrategyAIAgent::AcquireFleetsForTask(StrategyAIAgent* agent, IAITask* task, double dA, double dB, void* targetA, void* targetB, vector<Candidate32>* candidates, int pass, int flag, vector<StarFleet*>* out) -- THE HUB EVERY FLEET-SHAPED TASK GOES THROUGH, and where `pass` acquires its meaning. Real span 992 bytes to the next function start (Ghidra's size is short). Three blocks, each a `for (i = 0; i <= pass; ++i) gather(..., i, ...)` loop followed by an 0x00698960 sufficiency test: block A (0x006cef5a, entered when targetB->+0x14 == 0, or == 2 with task->GetTypeId() == 0x1e) gathers via 0x006abf80; block B (0x006cf029) gathers via 0x006b7c90; block C (0x006cf0ee, only if A and B both failed and targetA != 0) gathers via 0x006cb310, taking task->vt[11]() at 0x006cf10e as an argument. ALL THREE order-emitting exits are pass==1 only: 0x006bbd50 (0x006cefcf, 0x006cf180) returns immediately unless pass==1, and 0x006c16c0 (0x006cf198) takes the arm at 0x006c1791 only when pass==1. The result vector at [ebp-0x3c] has exactly two possible writers -- 0x006bbd50 and 0x006c16c0 -- enumerated from every `lea` of that slot in the body, so on pass 0 this function RETURNS AN EMPTY FLEET LIST AND WRITES NO TurnCommands. At 0x006cf1aa it special-cases task->GetTypeId() 0x17 (StockFreighters) and 0x1a (NodeBore), substituting 0.0 for dA [verified]
constexpr uint32_t StrategyAIAgent_AcquireFleetsForTask = 0x002ceef0;
// cdecl void __cdecl Game::StrategyAIAgent::GatherFleetsForTier(StrategyAIAgent* agent, float threshold, void* target, vector<Candidate32>* candidates, int tier, vector<Slot36>* out) -- 112 bytes. Walks the 0x20-stride candidate vector in index order, calling 0x006abb00(agent, threshold, target, &cand[k], tier, out, &out[k]) for each. `tier` is the loop index i of AcquireFleetsForTask's `for (i = 0; i <= pass; ++i)`, so it takes the values 0..pass [verified]
constexpr uint32_t StrategyAIAgent_GatherFleetsForTier = 0x002abf80;
// cdecl void __cdecl Game::StrategyAIAgent::FillCandidateToTierQuota(StrategyAIAgent* agent, float threshold, void* target, Candidate32* cand, int tier, vector<Slot36>* out, Slot36* slot) -- 464 bytes. THE INSTRUCTION THAT DEFINES THE TWO PASSES, at 0x006abb1c..0x006abb4d: `have = 0x00695b90(&slot->+0x10) + 0x00698860(slot) + slot->+0x20; want = (tier == 0) ? cand->+0x10 : (tier == 1) ? cand->+0x14 : 0; if (have >= want) return;` -- a compiler-generated switch on tier with case 0 -> 0x006abb41 (cand->+0x10) and case 1 -> 0x006abb39 (cand->+0x14). So each candidate carries TWO quota fields and `pass` selects which one is in force: pass 0 fills the +0x10 quota, pass 1 re-runs tier 0 and then fills the larger +0x14 quota. Then a per-fleet filter loop rejecting on 0x0069c8c0, IsClaimedByAnotherTask 0x006a8d20 and 0x006ab900 [verified]
constexpr uint32_t StrategyAIAgent_FillCandidateToTierQuota = 0x002abb00;
// cdecl bool __cdecl Game::StrategyAIAgent::IssueRouteForFleets(StrategyAIAgent* agent, int pass, vector<Slot36>* fleets, void* target, vector<StarFleet*>* out) -- 192 bytes. `if (pass != 1) return;` at 0x006bbd78 (the MSVC `sub eax,0 / je / dec / jne` switch shape). Otherwise walks the 0x24-stride fleet vector, calling 0x0057aac0 per element to build a route, then 0x006b76a0(agent, &route, target, out), which is one of the three callers of AI_IssueFleetTask 0x006987e0. This is one of the two pass-1 gates that make pass 0 emit nothing [verified]
constexpr uint32_t StrategyAIAgent_IssueRouteForFleets = 0x002bbd50;
// cdecl void __cdecl Game::StrategyAIAgent::RequestBuildForTask(StrategyAIAgent* agent, IAITask* task, int pass, double, double, void* targetA, void* targetB, void* targetA2, vector<Slot36>* gathered) -- 304 bytes, AcquireFleetsForTask's LAST-RESORT arm: no fleet could be found, so build ships. Two gates in the prologue: (1) 0x006cea7b..0x006ceaa6 `if (agent->+0x10->+0x150->+0x2d8 /*plcy*/ == 0 && task->GetTypeId() != 0x1a /*NodeBore*/) return` -- a SECOND, independent consumer of the save-visible `plcy` field, beyond the two defence creators lane AI2 found; (2) 0x006ceaac `if (pass != 1) return`. Reaches list 3 (build orders) via 0x006ce460 -> 0x006ce360 -> 0x006ce190 -> 0x006bd790 -> 0x006b3bc0 -> 0x00762fd0 [verified]
constexpr uint32_t StrategyAIAgent_RequestBuildForTask = 0x002cea50;
// cdecl void __cdecl Game::StrategyAIAgent::AssignFleetsAndIssueOrders(StrategyAIAgent* agent, IAITask* task, int pass, vector<StarFleet*>* fleets, void* targetA, void* targetB, int flag) -- 3536 bytes, the busiest AI->TurnCommands function. `if (pass != 1) goto 0x006c241f` at 0x006c177e (the same `sub eax,0 / je / dec / jne` shape), so its ENTIRE working body -- including both calls to AI_IssueFleetTask 0x006987e0 at 0x006c1c86 and 0x006c1f78, and the two 0x00699fa0 -> list 8 paths -- runs on pass 1 only. Its only indirect call sites are four import thunks (0x009dd12c/0x009dd150), so its direct-call closure is complete: no vtable edge can escape it [verified]
constexpr uint32_t StrategyAIAgent_AssignFleetsAndIssueOrders = 0x002c16c0;
// thiscall bool __thiscall Game::StrategyAIAgent::IsClaimedByAnotherTask(void* obj) -- RET 4. NAMES IAITask VTABLE SLOT 12 (lane AI2 §10.2). Looks `obj->+4` up in the 8-byte-stride claim registry at agent->+0x2e8..+0x2ec (pairs of {IAITask* owner, int objectId}) and in the 4-byte set at agent->+0x2d8..+0x2dc; if the object is in neither, returns false (free). Otherwise `cur = back(agent->+0x12c /*the task call stack*/)`; with an empty stack or a null top it returns TRUE (claimed). Then at 0x006a8db3: `if (!cur->vt[12]()) return true;` -- and when slot 12 IS set, it returns false (i.e. lets the task take the object) only when the owner exists, `cur->GetTypeId() != owner->GetTypeId()`, and `cur->GetPriority() > owner->GetPriority()`. So SLOT 12 IS A PREEMPTION PERMISSION: 'this task may take an object already claimed by a strictly lower-priority task of a different type'. Default false; five classes set it. Caller 0x006abb00 skips the candidate when this returns true [verified]
constexpr uint32_t StrategyAIAgent_IsClaimedByAnotherTask = 0x002a8d20;
// thiscall int __thiscall Game::StrategyAIAgent::RangePenaltyForTask() -- the ONLY consumer of IAITask vtable slot 13 found in the image, dispatched at 0x00696630 on the `this` receiver. `budget = this ? this->vt[13]() : 15; n = max(1, agent->+0x10->+0x8 - 0x0080da80(player) + 1); if (n < budget) return 0;` else a 7-arm species switch on player->+0x5c through the byte index at 0x006966a8 = [0,0,0,0,2,1,0] and the table at 0x0069669c: species 0,1,2,3,4,6 and out-of-range -> 1000000 (0x000f4240), species 5 (Zuul) -> 0. So slot 13 is a RANGE/HOP BUDGET compared against a count, with a prohibitive penalty past it -- and the Zuul are exempt, a FOURTH independent cross-check on lane AI2's species reading (after Hiver gates, Zuul node-bore and NPC building nothing). The two 'Incoming' defence tasks return INT_MAX from slot 13, so they never take the penalty [verified]
constexpr uint32_t StrategyAIAgent_RangePenaltyForTask = 0x00296620;
// thiscall int __thiscall Game::IAITask::GetPriority_Default() -- 17 bytes: `return AITask_PriorityForType(this->vt[1]() /*GetTypeId*/);`, i.e. the vt[1] dispatch followed by a direct call to the 33-arm table at 0x00691f00. This is what the two tuned GetPriority overrides tail-jump to when their flag bit is CLEAR, so lane AI2's priority table stands with an extra hop in front of it [verified]
constexpr uint32_t AITask_GetPriorityDefaultThunk = 0x00294220;
// data int -- image-initialised value 650 (0x0000028a). AITInvade::GetPriority 0x00683670 is `movzx eax,byte [ecx+4]; not al; test al,1; je +5; jmp 0x00694220; mov eax,ds:0xa1795c; ret` -- so the tunable is returned when BIT 0 OF this->+0x4 IS SET, which is the OPPOSITE of lane AI2's stated `if (!(this->+0x4 & 1))`. It has EXACTLY ONE reference in the whole image (this load) and no writer anywhere: no loader, no CSV path. It is a code constant that happens to live in the writable data section. AITInvade's table priority is 500, so the flag raises it to 650 [verified]
constexpr uint32_t g_AITInvadeUncommittedPriority = 0x0061795c;
// data int -- image-initialised value 750 (0x000002ee), the twin of 0x00a1795c. AITEscortGateInvade::GetPriority 0x006835e0 has the identical shape and the identical inverted polarity: the tunable applies when bit 0 of this->+0x4 IS SET. Exactly one reference in the image, no writer. AITEscortGateInvade's table priority is 400, so the flag raises it to 750 [verified]
constexpr uint32_t g_AITEscortGateInvadeUncommittedPriority = 0x00617960;
// thiscall void (CombatResolveContext* this) // THE POST-BATTLE RETREAT PIPELINE. Exactly one caller: CombatResolver_Run 0x007d5af0, unconditionally, at 0x007d5be2. Real body 0x007d5a00..0x007d5abb; the only jcc in it is the operator-new null test whose false arm is a _CxxThrowException. It builds a ~0x2c-byte RetreatContext stack local from the resolver's ctx (rc->+0x00 = ctx->+0x00 = S; rc->+0x04 = ctx->+0x08 = enc; rc->+0x08 = ctx->+0x0c = res; a std::map<int,ServerSystem*> at rc->+0x0c with an operator_new(0x18) head node at rc->+0x10 and _Mysize rc->+0x14; a std::vector<RetreatGroup*> at rc->+0x1c/+0x20/+0x24) and runs SIX unconditional this-calls in a straight line: FUN_0079bb90 (per-player destinations), FUN_0079bcd0 (build groups), FUN_007b0320 (whole vs partial), FUN_00790790 (split partial fleets), FUN_007d5650 (execute; EVENT_FLEET_RETREATED_VIA_TELEPORT), FUN_007a7cd0 (destructor). CORRECTS combat-resolver.md's characterisation of this as 'the per-phase combat pipeline': it is ONE subsystem, retreat, not six combat phases. DRAW-FREE: a 327-function closure (E8 calls plus E9 tail-call thunks) contains zero calls to the four RNG primitives and zero inlined MT tempering immediates [verified]
constexpr uint32_t CombatResolve_Retreat = 0x003d5a00;
// thiscall void (RetreatContext* this) // RETREAT PHASE 1. One loop over enc->members (stride 0x44, magic 0x78787879 / sar 5). Per member: FUN_00787210(&enc->+0x1c, enc->+0x0c, member->+0x00 /*ServerPlayer*/, &r1, &r2, &r3), then this->dest[player->PlyrIdx(+0x28)] = the FIRST NON-NULL of (r1, r2, r3) via std::map<int,T*>::operator[] 0x0076bce0. So the per-player retreat destination is: nearest system you own, else nearest system with no hostile presence, else nearest system at all [verified]
@ -2161,6 +2191,26 @@ constexpr uint32_t SVSOCrowDefenders_Write = 0x000f8c90;
constexpr uint32_t SVSOMonitor_Write = 0x000fd810;
// thiscall void (Game::SVSODerelict* this, Mars::IStream* s) // NDsn count then a loop of (DsnID, Dwght); NAsg count then a loop of (Eflt, Esys). Two fields per iteration in each, confirmed by the 8-byte element strides [verified]
constexpr uint32_t SVSODerelict_Write = 0x000fc2b0;
// site `inc [esi+0x8]` with esi = S -- the FIRST instruction of StrategyServer::ProcessTurn's body bumps ModCount. OBSERVED LIVE by a DR0 4-byte write watchpoint on S+0x8 (lane W2): the trap reports EIP 0x007dc6f3, i.e. the instruction after a 3-byte `inc`, on both measured End Turns. This is the ordering marker for the whole ModCount question -- every command-application bump precedes it and the OnAllCombatDone_Tail bump follows it [verified]
constexpr uint32_t StrategyServer_ProcessTurn_ModCountBump = 0x003dc6f0;
// site ModCount bump at OnAllCombatDone_Tail + 0x2a (trap EIP 0x007d92cd). CORRECTS lane A2's prose, which called it 'OnAllCombatDone_Tail's first instruction': the address A2 predicted is exactly right, the offset is +0x2a and not +0. It is the LAST ModCount write of the turn and it lands AFTER StrategyServer::ProcessTurn has been entered, which refines A2's falsifier (c) -- 'hits after ProcessTurn is entered' is expected for this one site and only this one [verified]
constexpr uint32_t StrategyServer_OnAllCombatDone_Tail_ModCountBump = 0x003d92ca;
// site The turn-number increment: `inc [reg+0xc]` with reg = S, at BeginProcessTurn + 0x2a (trap EIP 0x007d990d). OBSERVED LIVE on DR1 watching S+0xc: EXACTLY ONE write per End Turn, value 3 -> 4 on the second measured turn, against TWELVE writes to S+0x8 in the same window. THIS SETTLES THE NAMING DISPUTE: S+0x8 is a modification counter (lane A2's `StrategySim_off_ModCount`) and S+0xc is the frame/turn number (lane A2's `StrategySim_off_Frame`). addresses.json's `StrategyServer_off_ModCount` (0x8 in the raw frame == S+0xc) carries the name on the wrong word, and lane T's `StrategyServer_off_PhaseCounter` (S+0x8) is ModCount [verified]
constexpr uint32_t StrategyServer_BeginProcessTurn_FrameBump = 0x003d990a;
// site Writes Player.Status(+0x164) = 1 for the LOCAL player only (not for every player), at StrategyServer::ProcessTurn + 0x5ca. Trap EIP 0x007dcc94, so the store is a 10-byte `mov dword ptr [reg+0x164], 1`. Confirms lane T2's static reading of the site AND narrows it: on a two-human-player save only player[0]'s Status moved; player[1]'s did not [verified]
constexpr uint32_t StrategyServer_ProcessTurnTail_PlayerStatusOne = 0x003dcc8a;
// site THE MISSING WRITER OF Player.Status = 4 between tail phase 31 and the post-turn autosave. This address is the instruction AFTER the store (the trap address); the store itself ends here. It lives in StrategyNetworkClient::OnMessage at +0xa15 -- the End-Turn dispatcher -- and it fires AFTER StrategyServer::ProcessTurn has returned and BEFORE the autosave, writing 4 to the local player's Status over the 1 the ProcessTurn tail had just written. CORRECTS lane T2's treaty-turn-stamp.md §3, which read StrategyServer::MarkPlayerTurnEnded 0x00821a40 as 'THE ONLY WRITER OF Player.Status = 4 IN THE IMAGE' and concluded 'there is NO writer between tail phase 31 and the autosave'. There is, and it was watched happening twice on two consecutive End Turns [verified]
constexpr uint32_t StrategyNetworkClient_OnMessage_PlayerStatusFour = 0x00385055;
// site The `p->Status(+0x164) = 4` store inside StrategyServer::MarkPlayerTurnEnded, at +0x35 (trap EIP 0x00821a75, so the store ends here). Called ONCE PER PLAYER at the START of an End Turn, from OnPlayerEndTurn 0x007d9af0 (return address 0x007d9b2a, i.e. the call is at +0x35) -- confirming lane T2's caller list live. It runs BEFORE the pre-turn autosave's successor and before ApplyAllTurnCommands, which is why the `(Autosave EndTurn)` file still carries Status 0 [verified]
constexpr uint32_t StrategyServer_MarkPlayerTurnEnded_StatusStore = 0x00421a75;
// site A ModCount bump observed live but NOT attributable to a named function: the trap EIP is 0x0086c3e9 and the nearest preceding known symbol is ServerTradeManager_ProcessTurn 0x0086b300, +0x10e9 away -- far too far to claim containment. Its return address is 0x0088fce2 (inside StrategySim::ApplyTurnCommandBatch 0x0088f9b0), so it IS one of lane A2's twenty command handlers; only the handler's identity is open. Recorded as an address to disassemble rather than dropped [mapped]
constexpr uint32_t StrategySim_ModCountBump_unresolved_0086c3e6 = 0x0046c3e6;
// site A ModCount bump observed live FOUR times per turn -- the single most frequent command handler on this save. Trap EIP 0x00821a87; nearest known symbol is StrategyServer_MarkPlayerTurnEnded 0x00821a40, but lane T2 measured that function at 60 bytes (ending 0x00821a7c), so this is the NEXT function and MarkPlayerTurnEnded's neighbour, not MarkPlayerTurnEnded. Return address 0x0088ffcb (StrategySim::ApplyTurnCommandBatch) [mapped]
constexpr uint32_t StrategySim_ModCountBump_unresolved_00821a84 = 0x00421a84;
// site A ModCount bump observed live once per turn; trap EIP 0x0084946e, nearest known symbol ServerTradeManagerImpl_vslot11 0x00848570 at +0xefe (not containment). Return address 0x008900a4 (StrategySim::ApplyTurnCommandBatch) [mapped]
constexpr uint32_t StrategySim_ModCountBump_unresolved_0084946b = 0x0044946b;
// site A ModCount bump observed live once per turn; trap EIP 0x0088bf01, nearest known symbol StrategyServer_DestroyFleet 0x0088b980 at +0x581 -- plausibly inside it, but unproven. Return address 0x008902b4 (StrategySim::ApplyTurnCommandBatch), so it is a command handler called from the batch applier rather than an inlined site [mapped]
constexpr uint32_t StrategySim_ModCountBump_unresolved_0088befe = 0x0048befe;
// thiscall void (StrategyServer* this /*base S*/) // phase 11 of OnAllCombatDone_Tail, called at 0x007d9714 as `mov ecx,esi; call`. 1117 B, three loops. LOOP 1 (0x007ae07a..0x007ae1e2) walks the 0x30-stride Game::NodePath records in the vector at (*(S+0x154))+0x8/+0xc, re-reading _Myfirst/_Mylast every iteration, and per record: (1) NodePath::RemainingLife(r, S->Frame) 0x006e2130, `test eax,eax; jg` -> not expired, NEXT RECORD, NO DRAW; (2) THE DRAW, `mov ecx,[esi+0x16c]; fld [0x009e2ea0] /*0.5f*/; call 0x008e6dd0` = Mars::RNG::Chance(0.5f), exactly one MT word; (3) `test al,al; je` -> roll failed, next record; (4) the 0x20000-fleet scan over S->Fleets (S+0x64/+0x68) calling 0x00703500(fleet,0x20000,0) then 0x0078c360(fleet,npid) and dropping the record when that returns 3; (5) push_back npid into a scratch vector<int>. LOOP 2 collapses each collected line via 0x007a92e0(690 B) then 0x007a4700(2244 B); LOOP 3 posts the decay-stage events through NodePath::DecayStage 0x006e21b0. THE ONLY RNG SITE IN THE WHOLE 1117 BYTES: direct-call sweep to depth 5 over 140 functions from 0x007ae010 against {NextFloat 0x0047d830, NextInt 0x004271c0, Chance 0x008e6dd0, Twist 0x00426e00, Seed 0x0049fdf0} yields exactly one hit, 0x007ae010 -> 0x008e6dd0. Neither downstream function draws (138 and 49 functions reached, zero hits) -- caveat: direct calls only, their subtrees contain unresolved indirect sites [verified]
constexpr uint32_t StrategyServer_NodeLineDecay = 0x003ae010;
// site site, and a CORRECTION to findings/control-flow/combat-done-tail.md §3, which says "the roll is skipped for a line if any fleet with flag 0x20000 is targeting it". IT IS NOT: the fleet scan begins HERE, at 0x007ae0b2, which is 0x1d bytes AFTER the Chance(0.5f) call at 0x007ae0a5 and is reached only when the roll SUCCEEDED (`test al,al; je 0x007ae1e2` at 0x007ae0aa). The scan therefore cannot change the draw count -- it suppresses only the collapse (the 0x007a92e0 / 0x007a4700 pair), never the draw. The straight-line order in loop 1 is: expiry test -> DRAW -> roll gate -> fleet gate -> collect. Lane K's headline claim, one NextFloat per expired node line per turn, survives intact and is now pinned to a concrete expiry formula (NodePath_RemainingLife) [verified]

View file

@ -2,7 +2,8 @@
# on first. Pure -- no state, no I/O, no random draws. Deliberately separate from game/sim: the
# sim answers "what happens", this answers "what does an AI player decide to try".
add_library(sots_game_ai STATIC
tasks.cpp)
tasks.cpp
turn_order.cpp)
target_include_directories(sots_game_ai PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/../..)
target_compile_features(sots_game_ai PUBLIC cxx_std_17)
if(NOT MSVC)

View file

@ -100,9 +100,9 @@ int PriorityOf(const RankedTask& t, const TaskPriorityPolicy& policy) {
case TaskType::ReturnArtifact:
return kReturnArtifactPriority;
case TaskType::Invade:
return t.committed ? TablePriority(t.type) : policy.uncommittedInvade;
return t.priorityFlagBit0 ? policy.flaggedInvade : TablePriority(t.type);
case TaskType::EscortGateInvade:
return t.committed ? TablePriority(t.type) : policy.uncommittedEscortGateInvade;
return t.priorityFlagBit0 ? policy.flaggedEscortGateInvade : TablePriority(t.type);
case TaskType::AttackBlockade:
return t.overridePriority ? t.priority : TablePriority(t.type);
default:

View file

@ -18,9 +18,10 @@
// * The NPC species creates no strategic tasks at all.
//
// Pure: no state, no I/O, no random draws.
// CONFIDENCE: high on the enumeration, the priority table and the sort; the two tuned
// priorities (see TaskPriorityPolicy) are inputs this module does not own, and the creation
// order is the call order of the per-family creators, not a claim about what each creates.
// CONFIDENCE: high on the enumeration, the priority table, the sort and the two flagged
// priorities (650 / 750 -- constants with one reader and no writer in the original). The
// creation order is the call order of the per-family creators, not a claim about what each
// creates. What bit 0 of a task's flag byte MEANS is unknown; only its effect is.
#pragma once
#include <cstddef>
@ -86,21 +87,26 @@ constexpr bool IsRetiredTaskType(TaskType t) {
// use -- they override it. Prefer PriorityOf(), which applies the overrides.
int TablePriority(TaskType t);
// The four inputs the priority function needs that are not this module's to know. Two are
// tunables held outside the task code; the other two are per-instance state.
// The two priorities the Invade / EscortGateInvade overrides use in place of the table's. In the
// original these are two standalone integers rather than table entries; they are constants, not
// configuration -- each has exactly one reader in the whole image (its own override) and no
// writer anywhere, so there is nothing that could change them at runtime. Kept as a struct so a
// caller can still substitute them in a test.
struct TaskPriorityPolicy {
// The priority an Invade / EscortGateInvade task takes while its "committed" flag is clear.
// Held as two separate tunables in the original rather than in the shared table.
int uncommittedInvade = 0;
int uncommittedEscortGateInvade = 0;
int flaggedInvade = 650;
int flaggedEscortGateInvade = 750;
};
// One task, as far as ranking is concerned.
struct RankedTask {
TaskType type = TaskType::Steamroll;
// Set for Invade / EscortGateInvade once the task has committed. When clear, those two
// types take the tuned priority from TaskPriorityPolicy instead of the table's.
bool committed = true;
// Bit 0 of the task's flag byte. When SET, Invade and EscortGateInvade take the higher
// priority from TaskPriorityPolicy instead of the table's; when clear they fall through to
// the table. Deliberately named after the bit and not after a meaning: an earlier reading
// called this "committed" AND had the polarity backwards, and what the bit actually means is
// still unknown -- the two bytes on an Invade task that look like commitment state live
// elsewhere on the object.
bool priorityFlagBit0 = false;
// AttackBlockade is the one task whose priority depends on what other tasks exist; the
// caller supplies the result. Ignored for every other type.
bool overridePriority = false;

View file

@ -0,0 +1,40 @@
#include "game/ai/turn_order.h"
#include <algorithm>
namespace sots::ai {
bool PassEmitsCommands(TaskPass pass) { return pass == TaskPass::Fill; }
std::vector<QuotaTier> TiersForPass(TaskPass pass) {
// `for (i = 0; i <= pass; ++i)`. A negative pass would skip the loop entirely in the
// original; the enum has no negative value, so the two cases are the whole space.
if (pass == TaskPass::Reserve) return {QuotaTier::First};
return {QuotaTier::First, QuotaTier::Second};
}
bool ResumeZeroesStatus(const AiTurnPlayer& p) { return !p.eliminated; }
bool ReceivesResumeEvent(const AiTurnPlayer& p) {
// The resume walk zeroes Status for every live player and then, on a SECOND walk over the
// same array, offers the event to every player whose Status is now zero. So a live player
// always qualifies, and an eliminated one qualifies only if its Status was already zero.
return ResumeZeroesStatus(p) || p.status == 0;
}
std::vector<int> AiSteppingOrder(const std::vector<AiTurnPlayer>& players) {
std::vector<int> queue;
for (const AiTurnPlayer& p : players) {
if (!ReceivesResumeEvent(p)) continue;
// The application-side filter, in the original's order: net id first, then the AI test.
if (p.netId == 0) continue;
if (!p.aiControlled) continue;
// Deduplicated: the original searches the pending queue and skips the append on a hit.
// A linear scan, matching the original -- the queue is a handful of entries.
if (std::find(queue.begin(), queue.end(), p.netId) != queue.end()) continue;
queue.push_back(p.netId);
}
return queue;
}
} // namespace sots::ai

101
src/game/ai/turn_order.h Normal file
View file

@ -0,0 +1,101 @@
// Which AI players run, in what order, and what the two Execute passes mean.
//
// The strategic AI does not run on a schedule of its own. Once the server resumes play it walks
// its player array ONCE, in index order, and raises a "resume playing" event at each player. For
// a human that event is delivered inline. For an AI it is instead appended to a pending queue on
// the application object -- deduplicated -- and the whole queue is drained back-to-back inside a
// single frame later on, in the order it was filled.
//
// So the order the AI players are stepped is not a scheduling decision and it is not affected by
// think time: it is the player array's own order, filtered twice. That matters because it is the
// order the players' command blocks are appended in, which is the order the save's modification
// counter advances in. It is computable from a save with no game state at all, which is what
// this header is.
//
// The second half is the two passes. Each selected task's Execute runs twice, with 0 and then 1.
// The argument is NOT a plan/act switch -- it is a tier index. Every fleet request carries two
// force quotas, a smaller one and a larger one; the tier selects which is in force, and the
// gathering loop runs `for (i = 0; i <= pass; ++i)`, so pass 1 redoes tier 0 and then tops up to
// tier 1. Separately, every path that actually writes a command is gated on `pass == 1`.
//
// The result is a two-phase allocation: pass 0 claims each task's minimum force in priority
// order and writes nothing; pass 1 tops each task up to its desired force, again in priority
// order, and issues the orders.
//
// Pure: no state, no I/O, no random draws.
// CONFIDENCE: high on the ordering, the dedup, and the tier semantics -- all read from the
// instruction stream. The one soft edge is IsAiControlled: in the original that decision reads
// two bytes on the player object that the SAVE DOES NOT CARRY, so a caller has to supply it from
// somewhere else. See AiTurnPlayer::aiControlled.
#pragma once
#include <cstddef>
#include <vector>
namespace sots::ai {
// The pass argument each task's Execute receives, in the order it receives them.
enum class TaskPass : int {
// Claim each task's minimum force requirement, in descending priority order. Writes no
// commands: every emitting path in the original returns early unless the pass is Fill.
Reserve = 0,
// Redo Reserve's tier and then top each task up to its larger requirement, again in
// descending priority order -- and issue the orders.
Fill = 1,
};
// The two passes, in order. There are exactly two and the original hard-codes both.
inline constexpr TaskPass kPasses[2] = {TaskPass::Reserve, TaskPass::Fill};
// Whether a pass may append to the player's turn-command block. Only Fill may.
bool PassEmitsCommands(TaskPass pass);
// Which of a fleet request's two quota fields a pass is filling. Reserve fills the first,
// Fill fills the second (having first refilled the first). Any other value selects neither,
// which the original represents as a quota of zero -- i.e. "already satisfied, do nothing".
enum class QuotaTier : int { First = 0, Second = 1, None = 2 };
// The tiers a pass gathers, in the order it gathers them: {First} for Reserve, {First, Second}
// for Fill. This is the `for (i = 0; i <= pass; ++i)` loop, made explicit.
std::vector<QuotaTier> TiersForPass(TaskPass pass);
// One player, as far as the stepping order is concerned. Every field is read straight off the
// player array in index order.
struct AiTurnPlayer {
// The player's network id. This is what actually goes into the pending queue, and what the
// drain matches clients on. Zero is rejected outright by the original.
int netId = 0;
// The save's Elim flag. A live player has its Status reset to zero and therefore always
// qualifies; an eliminated player keeps whatever Status it had.
bool eliminated = false;
// The save's Status field, as it stands BEFORE the resume walk. Only consulted for
// eliminated players, because a live player's is overwritten with zero first.
int status = 0;
// Whether this player's turn is run by the strategic AI. In the original this is two
// in-memory bytes on the player object that sit in a hole in the serialised layout -- they
// are set at load or setup time and are NOT in the save. A caller must supply it; this
// module will not guess.
bool aiControlled = false;
// Opaque to this module. Carried through so callers can recover their own player.
const void* handle = nullptr;
};
// The order the AI players are stepped, as a list of net ids.
//
// Reproduces the original exactly: walk the players in index order; a player is offered the
// resume event when it is not eliminated (its Status having just been zeroed) or when its Status
// was already zero; the event is queued only for AI-controlled players with a non-zero net id;
// and a net id already queued is not queued again.
std::vector<int> AiSteppingOrder(const std::vector<AiTurnPlayer>& players);
// Whether the resume walk offers this player the event at all -- i.e. before the AI filter.
// Exposed because it is also the predicate that decides which players get their Status zeroed,
// and a caller reproducing the save's Status field needs it.
bool ReceivesResumeEvent(const AiTurnPlayer& p);
// Whether the resume walk zeroes this player's Status. Every non-eliminated player, and only
// those. Split out from ReceivesResumeEvent because the two differ for an eliminated player
// whose Status is already zero: it is NOT written, but it IS offered the event.
bool ResumeZeroesStatus(const AiTurnPlayer& p);
} // namespace sots::ai

View file

@ -0,0 +1,364 @@
#include "shim/hooks/watchpoints.h"
#include <windows.h>
#include <cstdarg>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include "MinHook.h"
#include "generated/sots_addresses.h"
namespace shim::hooks {
namespace {
// ---- configuration -------------------------------------------------------------------------
bool g_enabled = false;
int g_players = 2;
char g_outPath[MAX_PATH] = {};
// ---- what is being watched ------------------------------------------------------------------
//
// Slot 0 and 1 are the two words the naming disagreement is about; slots 2 and 3 are the first two
// players' Status. Four slots is the hardware limit and it is exactly enough.
const char* const kSlotName[4] = {
"S+0x8 (A2:ModCount / T:PhaseCounter)",
"S+0xc (A2:Frame / addresses.json:ModCount)",
"player[0]+0x164 Status",
"player[1]+0x164 Status",
};
// ---- hit records -----------------------------------------------------------------------------
//
// Written from a vectored exception handler, so: no allocation, no CRT, no locking. A fixed array
// and one interlocked counter. Reading a stack word is guarded against the thread's own stack
// bounds so a bad walk cannot fault inside the handler.
struct Hit {
std::uint32_t seq;
std::uint32_t slot; // 0..3, or 0xff when DR6 named no slot
std::uint32_t dr6;
std::uint32_t eip;
std::uint32_t value; // the word after the write (data breakpoints are traps)
std::uint32_t tid;
std::uint32_t ebpRet; // [ebp+4]
std::uint32_t ebpRet2; // [[ebp]+4]
std::uint32_t scan[4]; // first four code-looking words above ESP
std::uint32_t mark; // value of the flush marker when the hit was taken
};
constexpr std::size_t kMaxHits = 4096;
Hit g_hits[kMaxHits];
volatile LONG g_hitCount = 0;
std::size_t g_written = 0;
volatile LONG g_mark = 0; // bumped by the arming detour: which End-Turn window a hit belongs to
std::uintptr_t g_textLo = 0, g_textHi = 0;
std::uintptr_t g_watchAddr[4] = {0, 0, 0, 0};
FILE* g_out = nullptr;
void* g_veh = nullptr;
DWORD g_armedTid = 0;
// A word we own, used once to prove the hardware actually traps before any game number is
// believed (method rule 1: a green verdict is not evidence).
volatile std::uint32_t g_canary = 0;
bool g_canaryArmed = false;
volatile LONG g_canaryHits = 0;
// The current thread's stack bounds, straight out of the TIB. Read with one instruction rather
// than through `NtCurrentTeb()`, whose mingw definition trips -Werror=array-bounds when it is
// inlined into a handler. fs:[0x04] = StackBase (high), fs:[0x08] = StackLimit (low).
inline std::uint32_t ReadFs(std::uint32_t off) {
std::uint32_t v;
asm volatile("movl %%fs:(%1), %0" : "=r"(v) : "r"(off));
return v;
}
inline bool StackReadable(std::uintptr_t p) {
const std::uintptr_t hi = ReadFs(0x04);
const std::uintptr_t lo = ReadFs(0x08);
return lo && hi > lo && p >= lo && p + 4 <= hi;
}
inline bool LooksLikeCode(std::uint32_t v) {
return v >= g_textLo && v < g_textHi;
}
LONG CALLBACK WatchVeh(EXCEPTION_POINTERS* ep) {
if (ep->ExceptionRecord->ExceptionCode != EXCEPTION_SINGLE_STEP) return EXCEPTION_CONTINUE_SEARCH;
CONTEXT* c = ep->ContextRecord;
const std::uint32_t dr6 = static_cast<std::uint32_t>(c->Dr6);
std::uint32_t slot = 0xff;
for (int i = 0; i < 4; ++i) {
if (dr6 & (1u << i)) { slot = static_cast<std::uint32_t>(i); break; }
}
// A single-step exception with no DR6 slot bit is not ours; leave it to whoever raised it.
if (slot == 0xff) return EXCEPTION_CONTINUE_SEARCH;
if (g_canaryArmed && slot == 3) {
InterlockedIncrement(&g_canaryHits);
c->Dr6 = 0;
return EXCEPTION_CONTINUE_EXECUTION;
}
const LONG n = InterlockedIncrement(&g_hitCount) - 1;
if (n >= 0 && static_cast<std::size_t>(n) < kMaxHits) {
Hit& h = g_hits[n];
h.seq = static_cast<std::uint32_t>(n);
h.slot = slot;
h.dr6 = dr6;
h.eip = static_cast<std::uint32_t>(c->Eip);
h.tid = GetCurrentThreadId();
h.mark = static_cast<std::uint32_t>(g_mark);
const std::uintptr_t addr = g_watchAddr[slot];
h.value = addr ? *reinterpret_cast<volatile std::uint32_t*>(addr) : 0;
const std::uintptr_t ebp = static_cast<std::uintptr_t>(c->Ebp);
h.ebpRet = StackReadable(ebp + 4) ? *reinterpret_cast<std::uint32_t*>(ebp + 4) : 0;
h.ebpRet2 = 0;
if (StackReadable(ebp)) {
const std::uintptr_t up = *reinterpret_cast<std::uint32_t*>(ebp);
if (StackReadable(up + 4)) h.ebpRet2 = *reinterpret_cast<std::uint32_t*>(up + 4);
}
int found = 0;
h.scan[0] = h.scan[1] = h.scan[2] = h.scan[3] = 0;
for (std::uintptr_t p = static_cast<std::uintptr_t>(c->Esp); found < 4 && p < c->Esp + 0x200;
p += 4) {
if (!StackReadable(p)) break;
const std::uint32_t v = *reinterpret_cast<std::uint32_t*>(p);
if (LooksLikeCode(v)) h.scan[found++] = v;
}
}
c->Dr6 = 0;
return EXCEPTION_CONTINUE_EXECUTION;
}
// Set DR0..DR3 on the CALLING thread. Debug registers are per-thread state, and this is the
// documented-enough way to reach them from inside that thread; the arm is read back and logged
// rather than assumed, because an arm that silently did nothing looks exactly like "no writer".
bool ArmCurrentThread(const std::uintptr_t addr[4], int count, std::uint32_t* readbackDr7) {
CONTEXT c;
std::memset(&c, 0, sizeof c);
c.ContextFlags = CONTEXT_DEBUG_REGISTERS;
const HANDLE th = GetCurrentThread();
if (!GetThreadContext(th, &c)) return false;
c.Dr0 = addr[0];
c.Dr1 = addr[1];
c.Dr2 = addr[2];
c.Dr3 = addr[3];
DWORD dr7 = 0;
for (int i = 0; i < count; ++i) {
if (!addr[i]) continue;
dr7 |= (1u << (i * 2)); // Ln: local enable
dr7 |= (0b01u << (16 + i * 4)); // R/W: 01 = break on data write
dr7 |= (0b11u << (18 + i * 4)); // LEN: 11 = 4 bytes
}
c.Dr7 = dr7;
c.Dr6 = 0;
if (!SetThreadContext(th, &c)) return false;
CONTEXT back;
std::memset(&back, 0, sizeof back);
back.ContextFlags = CONTEXT_DEBUG_REGISTERS;
if (GetThreadContext(th, &back)) *readbackDr7 = static_cast<std::uint32_t>(back.Dr7);
return true;
}
void (*g_log)(const char*) = nullptr;
void LogF(const char* fmt, ...) {
if (!g_log) return;
char buf[1024];
va_list ap;
va_start(ap, fmt);
std::vsnprintf(buf, sizeof buf, fmt, ap);
va_end(ap);
g_log(buf);
}
bool g_armed = false;
DWORD WINAPI FlusherThread(LPVOID) {
for (;;) {
Sleep(2000);
watch_flush(nullptr);
}
}
} // namespace
// The arming detour. Register-transparent asm stub, same shape as the M0 hook in main.cpp: the
// original's own `ret` stays in charge of stack cleanup, so this is correct whatever the real
// convention turns out to be.
extern "C" void* g_watchApplyAllOrig;
void* g_watchApplyAllOrig = nullptr;
extern "C" void WatchApplyAllDetour();
extern "C" void WatchOnApplyAll(void* self) {
InterlockedIncrement(&g_mark);
if (g_armed) {
watch_flush(g_log);
return;
}
const std::uintptr_t S = reinterpret_cast<std::uintptr_t>(self);
g_watchAddr[0] = S + 0x8;
g_watchAddr[1] = S + 0xc;
g_watchAddr[2] = 0;
g_watchAddr[3] = 0;
// players vector lives at S+0x54 (raw +0x50 in the S+4 frame -- see the ctor enumeration in
// StrategyServer_base_delta). Read defensively: a wrong pointer here must not fault.
if (g_players > 0) {
const std::uintptr_t vecBegin = S + 4 + sots::addr::StrategyServer_off_Players;
std::uint32_t* begin = nullptr;
std::uint32_t* end = nullptr;
if (!IsBadReadPtr(reinterpret_cast<void*>(vecBegin), 8)) {
begin = *reinterpret_cast<std::uint32_t**>(vecBegin);
end = *reinterpret_cast<std::uint32_t**>(vecBegin + 4);
}
const int n = (begin && end && end >= begin) ? static_cast<int>(end - begin) : 0;
LogF("watch: players vector @%p begin=%p end=%p count=%d",
reinterpret_cast<void*>(vecBegin), static_cast<void*>(begin), static_cast<void*>(end), n);
for (int i = 0; i < g_players && i < n && i < 2; ++i) {
const std::uintptr_t p = begin[i];
if (p && !IsBadReadPtr(reinterpret_cast<void*>(p), 0x168))
g_watchAddr[2 + i] = p + sots::addr::ServerPlayer_off_Status;
}
}
// Instrument self-test: put slot 3 on a word we own, write it, and require exactly one trap
// before any game number is trusted.
const std::uintptr_t saved3 = g_watchAddr[3];
g_watchAddr[3] = reinterpret_cast<std::uintptr_t>(const_cast<std::uint32_t*>(&g_canary));
std::uint32_t dr7 = 0;
g_canaryArmed = true;
if (!ArmCurrentThread(g_watchAddr, 4, &dr7)) {
LogF("watch: ArmCurrentThread FAILED (err %lu) -- NOTHING IS ARMED", GetLastError());
g_canaryArmed = false;
g_armed = true; // do not retry every turn
return;
}
g_canary = 0x5a5a5a5a;
const LONG canaryHits = g_canaryHits;
g_canaryArmed = false;
LogF("watch: SELFTEST canary writes=1 traps=%ld dr7=0x%08x %s", canaryHits, dr7,
canaryHits == 1 ? "PASS" : "FAIL -- every count below is unmeasured, not zero");
g_watchAddr[3] = saved3;
if (!ArmCurrentThread(g_watchAddr, 4, &dr7)) {
LogF("watch: re-arm FAILED (err %lu)", GetLastError());
g_armed = true;
return;
}
g_armedTid = GetCurrentThreadId();
g_armed = true;
for (int i = 0; i < 4; ++i)
LogF("watch: slot %d -> %s = 0x%08x%s", i, kSlotName[i],
static_cast<unsigned>(g_watchAddr[i]), g_watchAddr[i] ? "" : " (unset)");
LogF("watch: ARMED on tid %lu dr7=0x%08x, S=%p (ApplyAllTurnCommands this)", g_armedTid, dr7,
self);
}
asm(R"(
.text
.globl _WatchApplyAllDetour
_WatchApplyAllDetour:
pushfl
pushal
pushl %ecx
call _WatchOnApplyAll
addl $4, %esp
popal
popfl
jmp *_g_watchApplyAllOrig
)");
bool watch_apply_config(const char* key, const char* value, std::string* err) {
if (std::strcmp(key, "watch") == 0) {
if (std::strcmp(value, "on") == 0) g_enabled = true;
else if (std::strcmp(value, "off") == 0) g_enabled = false;
else if (err) *err = "expected on|off";
return true;
}
if (std::strcmp(key, "watch.out") == 0) {
std::snprintf(g_outPath, sizeof g_outPath, "%s", value);
return true;
}
if (std::strcmp(key, "watch.players") == 0) {
g_players = std::atoi(value);
if (g_players < 0) g_players = 0;
if (g_players > 2) g_players = 2;
return true;
}
return false;
}
bool watch_enabled() { return g_enabled; }
void install_watchpoints(std::uintptr_t exeBase, const char* gameDir, void (*log)(const char*)) {
g_log = log;
if (!g_enabled) {
LogF("watch: disabled (watch=off)");
return;
}
if (!g_outPath[0]) std::snprintf(g_outPath, sizeof g_outPath, "%s\\shim.watch.txt", gameDir);
g_out = std::fopen(g_outPath, "w");
if (!g_out) LogF("watch: cannot open %s -- hits go to shim.log only", g_outPath);
// Text range, for the "does this stack word look like a return address" test. Taken from the
// PE headers rather than guessed: method rule 17's lesson is that assumed extents lose data.
const IMAGE_DOS_HEADER* dos = reinterpret_cast<const IMAGE_DOS_HEADER*>(exeBase);
const IMAGE_NT_HEADERS* nt =
reinterpret_cast<const IMAGE_NT_HEADERS*>(exeBase + dos->e_lfanew);
g_textLo = exeBase + nt->OptionalHeader.BaseOfCode;
g_textHi = g_textLo + nt->OptionalHeader.SizeOfCode;
// A background flusher, because the process may be killed rather than quit and
// DLL_PROCESS_DETACH is not guaranteed. The hit array is append-only and the reader only ever
// trails the writer, so no lock is needed between them.
CreateThread(nullptr, 0, &FlusherThread, nullptr, 0, nullptr);
g_veh = AddVectoredExceptionHandler(1, WatchVeh);
LogF("watch: VEH=%p text=[0x%08x,0x%08x) out=%s", g_veh, static_cast<unsigned>(g_textLo),
static_cast<unsigned>(g_textHi), g_outPath);
void* target = reinterpret_cast<void*>(exeBase + sots::addr::StrategyServer_ApplyAllTurnCommands);
MH_STATUS s1 = MH_CreateHook(target, reinterpret_cast<void*>(&WatchApplyAllDetour),
&g_watchApplyAllOrig);
MH_STATUS s2 = s1 == MH_OK ? MH_EnableHook(target) : s1;
LogF("watch: arm hook StrategyServer::ApplyAllTurnCommands rva=0x%08x va=%p create=%s enable=%s",
sots::addr::StrategyServer_ApplyAllTurnCommands, target, MH_StatusToString(s1),
MH_StatusToString(s2));
}
void watch_flush(void (*log)(const char*)) {
if (log) g_log = log;
const LONG n = g_hitCount;
const std::size_t have = static_cast<std::size_t>(n) > kMaxHits ? kMaxHits
: static_cast<std::size_t>(n);
if (have <= g_written) return;
for (std::size_t i = g_written; i < have; ++i) {
const Hit& h = g_hits[i];
char line[512];
std::snprintf(line, sizeof line,
"watchhit seq=%u mark=%u slot=%u dr6=0x%08x eip=0x%08x value=%d(0x%08x) "
"tid=%lu ebpret=0x%08x ebpret2=0x%08x scan=0x%08x,0x%08x,0x%08x,0x%08x",
h.seq, h.mark, h.slot, h.dr6, h.eip, static_cast<int>(h.value), h.value,
static_cast<unsigned long>(h.tid), h.ebpRet, h.ebpRet2, h.scan[0], h.scan[1],
h.scan[2], h.scan[3]);
if (g_log) g_log(line);
if (g_out) {
std::fputs(line, g_out);
std::fputc('\n', g_out);
}
}
g_written = have;
if (g_out) std::fflush(g_out);
if (static_cast<std::size_t>(n) > kMaxHits)
LogF("watch: OVERFLOW -- %ld hits taken, only %u recorded", n,
static_cast<unsigned>(kMaxHits));
}
} // namespace shim::hooks

View file

@ -0,0 +1,48 @@
// Hardware data-write watchpoints on the live game (lane W2).
//
// WHY A WATCHPOINT AND NOT A HOOK. Method rule 18: "what writes this?" is a watchpoint, not a week
// of reading. Three open questions in this campaign are that exact shape --
// `ModCount`'s writer set (`findings/control-flow/alliance-mask-and-modcount.md` §3), the
// `Player.Status` regression, and the `ModCount`-vs-`Frame` naming disagreement between lane T
// (`StrategyServer_off_PhaseCounter`, S+0x8) and lane A2 (`StrategySim_off_ModCount`, S+0x8).
// All three are answered by one armed run.
//
// HOW IT IS ARMED, AND WHY THERE. Debug registers are per-thread, so they have to be set from a
// point that (a) runs on the turn thread and (b) precedes the writes. `StrategyServer::
// ApplyAllTurnCommands` 0x0078f6a0 is both: lane A2 read it as the End-Turn command flush, called
// by `StrategyNetworkClient::OnMessage` immediately *before* that handler calls
// `StrategyServer::ProcessTurn`, and its `this` is the S frame, so `S+8` and `S+0xc` are both a
// single add away. The watchpoints stay armed after that call returns, so a run of two End Turns
// covers the second turn's window from before the flush to after the autosave.
//
// THE ORDERING MARKER IS FREE. `ProcessTurn`'s first instruction is itself a write to `S+8`
// (`inc [esi+8]` @0x007dc6f0, agreed by lanes T and A2 even though they disagree about the name).
// So the "were the writes before or after ProcessTurn was entered?" question -- lane A2's
// falsifier (c) -- is answered by where that hit falls in the hit list. No second hook is needed,
// and this module therefore adds exactly **one** MinHook detour to the process.
//
// RULE 19. This is an instrument. It is switched by `watch=` in shim.cfg precisely so the same
// End Turn can be run with it removed, and the caller is expected to compare the two autosaves
// before believing any count taken with it installed.
#pragma once
#include <cstdint>
#include <string>
namespace shim::hooks {
// shim.cfg keys owned here. Returns true if `key` was ours (whether or not the value parsed).
// watch=off|on install the ApplyAllTurnCommands arming detour (default off)
// watch.out=<path> hit log (default <gamedir>\shim.watch.txt)
// watch.players=<n> how many player Status words to watch (0..2, default 2)
bool watch_apply_config(const char* key, const char* value, std::string* err);
bool watch_enabled();
// Install the arming detour. `log` receives one line per event, no newline.
void install_watchpoints(std::uintptr_t exeBase, const char* gameDir, void (*log)(const char*));
// Write any recorded hits out and close. Safe to call more than once.
void watch_flush(void (*log)(const char*));
} // namespace shim::hooks

View file

@ -28,6 +28,7 @@
#include "shim/hooks/probe_entry.h"
#include "shim/hooks/tail_rng.h"
#include "shim/hooks/tech_effects.h"
#include "shim/hooks/watchpoints.h"
#include "shim/trace/hook.h"
#include "shim/trace/selftest.h"
#include "shim/trace/tracer.h"
@ -89,6 +90,9 @@ Config ReadConfig() {
if (shim::hooks::probe_config(p, val, &probe_n)) {
Log("config: %s=%s -> %u lane-H entry probes", p, val,
static_cast<unsigned>(probe_n));
if (shim::hooks::watch_apply_config(p, val, &err)) {
if (!err.empty()) Log("config: %s=%s rejected (%s)", p, val, err.c_str());
else Log("config: %s=%s", p, val);
} else if (shim::fpu::apply_config(p, val, &err)) {
if (!err.empty()) Log("config: %s=%s rejected (%s)", p, val, err.c_str());
else Log("config: %s=%s", p, val);
@ -288,6 +292,9 @@ void InstallHooks(shim::trace::Tracer& tracer) {
// asm stubs and not C++ detours.
shim::hooks::init_probe_entries(exeBase, &ShimLogLine);
shim::hooks::install_probe_entries();
// Lane W2: hardware data-write watchpoints. One MinHook detour (the arming point); the
// watchpoints themselves modify no code at all. Off unless `watch=on` (rule 19).
shim::hooks::install_watchpoints(exeBase, g_dir, &ShimLogLine);
// Lane F: x87 control-word forcing at the turn gate + the per-tick change sampler.
// Installed last so it is nowhere near the template hooks it is meant to measure.
@ -378,6 +385,7 @@ void Shim_Init(HMODULE self) {
}
void Shim_Shutdown() {
shim::hooks::watch_flush(&ShimLogLine);
Log("%s", shim::fpu::summary().c_str());
shim::trace::Tracer& tracer = shim::trace::Tracer::instance();
if (tracer.is_open()) {

View file

@ -0,0 +1,22 @@
# Lane W2: hardware data-write watchpoints DISABLED: the rule-19 control for shim.cfg.w2watch.
# Identical to shim.cfg.recaptrace except for the three `watch.*` keys, so the ONLY difference
# between this and the configuration the determinism oracle was last reproduced under is the one
# arming detour plus DR0-DR3. That is what makes the byte-compare against shim.cfg.w2control a
# real rule-19 control rather than a coincidence.
hooks=trace
hook.Shim::SelfTest::Fill=off
hook.Mars::GlobalConsts::LoadFile=off
hook.Game::WeaponDictionary::Init=off
hook.Game::SectionDictionary::SectionDictionary=off
hook.Game::StrategyServer::ProcessFleetMovement=off
hook.Game::TechTree::ProcessResearch=trace
hook.Game::ServerPlayer::ComputeBudget=trace
hook.Game::ServerPlayer::OnTechResearched=trace
hook.Game::ServerSystem::ProcessTurn=trace
hook.Game::StrategyServer::MoveFleet=trace
trace.path=C:\SOTS\shim.trace.jsonl
trace.inline_max=256
trace.flush=always
watch=off
watch.players=2
watch.out=C:\SOTS\shim.watch.txt

22
src/shim/shim.cfg.w2watch Normal file
View file

@ -0,0 +1,22 @@
# Lane W2: hardware data-write watchpoints, armed at StrategyServer::ApplyAllTurnCommands.
# Identical to shim.cfg.recaptrace except for the three `watch.*` keys, so the ONLY difference
# between this and the configuration the determinism oracle was last reproduced under is the one
# arming detour plus DR0-DR3. That is what makes the byte-compare against shim.cfg.w2control a
# real rule-19 control rather than a coincidence.
hooks=trace
hook.Shim::SelfTest::Fill=off
hook.Mars::GlobalConsts::LoadFile=off
hook.Game::WeaponDictionary::Init=off
hook.Game::SectionDictionary::SectionDictionary=off
hook.Game::StrategyServer::ProcessFleetMovement=off
hook.Game::TechTree::ProcessResearch=trace
hook.Game::ServerPlayer::ComputeBudget=trace
hook.Game::ServerPlayer::OnTechResearched=trace
hook.Game::ServerSystem::ProcessTurn=trace
hook.Game::StrategyServer::MoveFleet=trace
trace.path=C:\SOTS\shim.trace.jsonl
trace.inline_max=256
trace.flush=always
watch=on
watch.players=2
watch.out=C:\SOTS\shim.watch.txt

View file

@ -4,3 +4,11 @@ target_link_libraries(game_ai_test_tasks PRIVATE sots_game_ai)
target_include_directories(game_ai_test_tasks PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
target_compile_options(game_ai_test_tasks PRIVATE -Wall -Wextra -pedantic)
add_test(NAME game_ai_tasks COMMAND game_ai_test_tasks)
# The AI stepping order and the two-pass model: which AI players run, in what order, and which
# pass may write commands. Pure data -- the ModCount sequence, computed offline.
add_executable(game_ai_test_turn_order test_turn_order.cpp)
target_link_libraries(game_ai_test_turn_order PRIVATE sots_game_ai)
target_include_directories(game_ai_test_turn_order PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
target_compile_options(game_ai_test_turn_order PRIVATE -Wall -Wextra -pedantic)
add_test(NAME game_ai_turn_order COMMAND game_ai_test_turn_order)

View file

@ -105,8 +105,11 @@ void TestTable() {
// ---- the five overrides --------------------------------------------------------------------
void TestOverrides() {
TaskPriorityPolicy pol;
pol.uncommittedInvade = 4242;
pol.uncommittedEscortGateInvade = 777;
check(pol.flaggedInvade == 650, "the flagged Invade priority defaults to the original's 650");
check(pol.flaggedEscortGateInvade == 750,
"the flagged EscortGateInvade priority defaults to the original's 750");
pol.flaggedInvade = 4242;
pol.flaggedEscortGateInvade = 777;
// The artifact tasks ignore their table entries entirely.
check(PriorityOf(T(TaskType::RetrieveArtifact), pol) == 1260, "RetrieveArtifact overrides to 1260");
@ -119,21 +122,33 @@ void TestOverrides() {
check(PriorityOf(T(TaskType::RetrieveArtifact), pol) > TablePriority(TaskType::Steamroll),
"artifacts rank above Steamroll");
// Invade / EscortGateInvade take the tuned value only while uncommitted.
RankedTask uncommitted = T(TaskType::Invade);
uncommitted.committed = false;
check(PriorityOf(uncommitted, pol) == 4242, "uncommitted Invade takes the tunable");
check(PriorityOf(T(TaskType::Invade), pol) == 500, "committed Invade takes the table");
// Invade / EscortGateInvade take the flagged value when bit 0 is SET. The polarity is the
// whole point of this block: the original tests `not al; test al,1; je <tunable>`, so the
// HIGHER priority is the flag-set state. Getting it backwards silently reorders the list.
RankedTask flagged = T(TaskType::Invade);
flagged.priorityFlagBit0 = true;
check(PriorityOf(flagged, pol) == 4242, "a flagged Invade takes the flagged priority");
check(PriorityOf(T(TaskType::Invade), pol) == 500, "an unflagged Invade takes the table");
RankedTask ug = T(TaskType::EscortGateInvade);
ug.committed = false;
check(PriorityOf(ug, pol) == 777, "uncommitted EscortGateInvade takes the tunable");
check(PriorityOf(T(TaskType::EscortGateInvade), pol) == 400, "committed takes the table");
ug.priorityFlagBit0 = true;
check(PriorityOf(ug, pol) == 777, "a flagged EscortGateInvade takes the flagged priority");
check(PriorityOf(T(TaskType::EscortGateInvade), pol) == 400, "unflagged takes the table");
// The committed flag is meaningless for every other type.
// Both flagged priorities RAISE the task above its table entry -- the reason the polarity
// matters is that the flag makes these tasks more urgent, not less.
TaskPriorityPolicy real;
RankedTask fi = T(TaskType::Invade);
fi.priorityFlagBit0 = true;
check(PriorityOf(fi, real) == 650, "the flag raises Invade 500 -> 650");
RankedTask fe = T(TaskType::EscortGateInvade);
fe.priorityFlagBit0 = true;
check(PriorityOf(fe, real) == 750, "the flag raises EscortGateInvade 400 -> 750");
// The flag is meaningless for every other type.
RankedTask other = T(TaskType::Raid);
other.committed = false;
check(PriorityOf(other, pol) == 399, "the committed flag does not affect Raid");
other.priorityFlagBit0 = true;
check(PriorityOf(other, pol) == 399, "the priority flag does not affect Raid");
// AttackBlockade is the one whose priority the caller supplies.
RankedTask ab = T(TaskType::AttackBlockade);
@ -189,14 +204,23 @@ void TestRank() {
for (std::size_t i = 0; i < ties.size(); ++i)
check(again[i].handle == ties[i].handle, "re-ranking is stable at index " + std::to_string(i));
// The tunables participate in the ordering, which is why they are inputs and not constants.
// The flagged priorities participate in the ordering, so substituting them changes the rank.
TaskPriorityPolicy hot;
hot.uncommittedInvade = 9999;
RankedTask uncommitted = T(TaskType::Invade);
uncommitted.committed = false;
std::vector<RankedTask> mixed = {T(TaskType::DeployGateAt), uncommitted};
hot.flaggedInvade = 9999;
RankedTask flagged = T(TaskType::Invade);
flagged.priorityFlagBit0 = true;
std::vector<RankedTask> mixed = {T(TaskType::DeployGateAt), flagged};
Rank(mixed, hot);
check(mixed[0].type == TaskType::Invade, "a hot uncommitted Invade outranks DeployGateAt");
check(mixed[0].type == TaskType::Invade, "a hot flagged Invade outranks DeployGateAt");
// With the real values a flagged Invade is 650: below EscortGate's 700, above its own 500.
TaskPriorityPolicy real;
std::vector<RankedTask> band = {T(TaskType::Invade), flagged, T(TaskType::EscortGate)};
Rank(band, real);
check(band[0].type == TaskType::EscortGate, "EscortGate 700 leads");
check(band[1].priorityFlagBit0, "the flagged Invade 650 is second");
check(band[2].type == TaskType::Invade && !band[2].priorityFlagBit0,
"and the unflagged Invade 500 is last");
std::vector<RankedTask> empty;
Rank(empty, pol);

View file

@ -0,0 +1,154 @@
// Stepping order and pass-model cases.
//
// Every expectation here was read off the original's instruction stream, not produced by running
// this code. The cases worth keeping are:
// * index order, because the whole point is that the order is the player array's own and not a
// scheduling decision -- a port that sorted or bucketed the queue would reorder the save's
// command blocks and move its modification counter;
// * the dedup, because the original checks the queue before appending and a port that did not
// would double-run a player under any path that raises the event twice;
// * the eliminated/Status interaction, because the original's TWO walks over the same array
// make a live player's Status irrelevant and an eliminated player's decisive;
// * pass 0 emitting nothing, which is the halving of the ModCount arithmetic;
// * pass 1 gathering BOTH tiers, because it redoes tier 0 rather than replacing it.
#include "game/ai/turn_order.h"
#include <cstdio>
#include <string>
#include <vector>
using namespace sots::ai;
namespace {
int g_checks = 0;
int g_fails = 0;
void check(bool ok, const std::string& what) {
++g_checks;
if (!ok) {
++g_fails;
std::fprintf(stderr, "FAIL: %s\n", what.c_str());
}
}
AiTurnPlayer Ai(int netId) {
AiTurnPlayer p;
p.netId = netId;
p.aiControlled = true;
return p;
}
AiTurnPlayer Human(int netId) {
AiTurnPlayer p;
p.netId = netId;
return p;
}
// ---- the pass model ------------------------------------------------------------------------
void TestPasses() {
check(kPasses[0] == TaskPass::Reserve, "the first pass is Reserve");
check(kPasses[1] == TaskPass::Fill, "the second pass is Fill");
check(static_cast<int>(TaskPass::Reserve) == 0, "Reserve is the literal 0 the original passes");
check(static_cast<int>(TaskPass::Fill) == 1, "Fill is the literal 1");
// The load-bearing one: pass 0 writes no commands. Every emitting path in the original --
// the route issuer, the fleet-order issuer and the build request -- returns early unless the
// pass is 1. If this were wrong, every AI order would cost twice the ModCount it does.
check(!PassEmitsCommands(TaskPass::Reserve), "Reserve emits no commands");
check(PassEmitsCommands(TaskPass::Fill), "Fill emits commands");
// The tier loop is `i = 0 .. pass` inclusive, so Fill REDOES tier 0 before adding tier 1.
std::vector<QuotaTier> reserve = TiersForPass(TaskPass::Reserve);
check(reserve.size() == 1, "Reserve gathers one tier");
check(reserve[0] == QuotaTier::First, "and it is the first quota");
std::vector<QuotaTier> fill = TiersForPass(TaskPass::Fill);
check(fill.size() == 2, "Fill gathers two tiers");
check(fill[0] == QuotaTier::First, "Fill redoes the first quota");
check(fill[1] == QuotaTier::Second, "and then fills the second");
// The tiers are the two quota field selectors, in that numeric order.
check(static_cast<int>(QuotaTier::First) == 0, "tier 0 selects the first quota field");
check(static_cast<int>(QuotaTier::Second) == 1, "tier 1 selects the second");
}
// ---- who receives the resume event ---------------------------------------------------------
void TestResumeEligibility() {
AiTurnPlayer live = Ai(7);
check(ResumeZeroesStatus(live), "a live player's Status is zeroed");
check(ReceivesResumeEvent(live), "and it therefore always receives the event");
// A live player's incoming Status is irrelevant: the first walk overwrites it.
AiTurnPlayer liveDirty = Ai(7);
liveDirty.status = 99;
check(ReceivesResumeEvent(liveDirty), "a live player with a non-zero Status still qualifies");
// An eliminated player is skipped by the first walk, so its Status decides.
AiTurnPlayer deadBusy = Ai(8);
deadBusy.eliminated = true;
deadBusy.status = 3;
check(!ResumeZeroesStatus(deadBusy), "an eliminated player's Status is not zeroed");
check(!ReceivesResumeEvent(deadBusy), "and a non-zero Status keeps it out");
AiTurnPlayer deadIdle = Ai(9);
deadIdle.eliminated = true;
deadIdle.status = 0;
check(!ResumeZeroesStatus(deadIdle), "still not zeroed");
check(ReceivesResumeEvent(deadIdle), "but a zero Status lets an eliminated player through");
}
// ---- the stepping order --------------------------------------------------------------------
void TestSteppingOrder() {
// Index order, not net-id order. The ids here are deliberately descending so a port that
// sorted the queue would fail.
std::vector<AiTurnPlayer> players = {Ai(30), Ai(20), Ai(10)};
std::vector<int> order = AiSteppingOrder(players);
check(order.size() == 3, "three AI players give three entries");
check(order[0] == 30 && order[1] == 20 && order[2] == 10,
"the queue is in player-array order, not sorted");
// Humans are delivered inline and never enter the queue.
std::vector<AiTurnPlayer> mixed = {Human(1), Ai(2), Human(3), Ai(4)};
std::vector<int> mixedOrder = AiSteppingOrder(mixed);
check(mixedOrder.size() == 2, "only the AI players queue");
check(mixedOrder[0] == 2 && mixedOrder[1] == 4, "and they keep their array positions");
// A zero net id is rejected before the AI test.
std::vector<AiTurnPlayer> zero = {Ai(0), Ai(5)};
std::vector<int> zeroOrder = AiSteppingOrder(zero);
check(zeroOrder.size() == 1 && zeroOrder[0] == 5, "net id 0 never queues");
// Eliminated AI players drop out on the Status rule, not on a separate check.
AiTurnPlayer dead = Ai(6);
dead.eliminated = true;
dead.status = 1;
std::vector<AiTurnPlayer> withDead = {Ai(5), dead, Ai(7)};
std::vector<int> deadOrder = AiSteppingOrder(withDead);
check(deadOrder.size() == 2, "an eliminated AI with a live Status is skipped");
check(deadOrder[0] == 5 && deadOrder[1] == 7, "and the survivors keep their order");
// The dedup: the same net id twice queues once, and keeps its FIRST position.
std::vector<AiTurnPlayer> dup = {Ai(11), Ai(12), Ai(11)};
std::vector<int> dupOrder = AiSteppingOrder(dup);
check(dupOrder.size() == 2, "a repeated net id queues once");
check(dupOrder[0] == 11 && dupOrder[1] == 12, "and keeps the earlier position");
// Degenerate cases.
check(AiSteppingOrder({}).empty(), "no players, no queue");
check(AiSteppingOrder({Human(1), Human(2)}).empty(), "an all-human game queues nothing");
// An NPC-species AI still queues -- it creates no tasks, but the queue does not know that.
// Recording it so a later lane does not "fix" this module when it finds the NPC arm empty.
check(AiSteppingOrder({Ai(1)}).size() == 1, "the queue does not filter on species");
}
} // namespace
int main() {
TestPasses();
TestResumeEligibility();
TestSteppingOrder();
std::printf("game_ai/turn_order: %d checks, %d failures\n", g_checks, g_fails);
return g_fails == 0 ? 0 : 1;
}