Compare commits

..

3 commits

Author SHA1 Message Date
alex
06fe75880c sync generated header (1019 entries) after visibility merge 2026-09-08 13:42:52 -04:00
alex
c51b8d0c39 merge lane E3: nve visibility record; 46 leaves closed 0 regressed; the gate is AFlags 2026-09-08 13:40:06 -04:00
alex
a1910becb0 lane E3: the per-system visibility record, the explored sweep and the system observed-stamp
Three phases, one input. A star system carries four per-player masks and three of
them agree on nearly every system of every save the corpus holds, so a model built
on the wrong one looks right until it does not. The gate is the DERIVED
active-presence mask -- fleet-here OR gate-here OR owner, recomputed on every
arrival and departure -- not the sticky one and not the explored one.

  S29 SystemObservedStamp   the system's own last-observed turn (whole function)
  T17 RebuildPlayerViewTree the per-(system, player) observation record: who saw
                            the system, on what turn, and what encounter was there
  T21 UpdateSurveyAndStats  the explored sweep: seen this turn implies surveyed

game/sim/visibility is pure and knows nothing about save shapes; app/visibility_phase
wires it to them. The mask is READ FROM THE SAVE and never rebuilt: neither reference
pair moves a mask leaf, so the loaded value is the value these phases would see, and
rebuilding it from an unmodelled movement pass would be a change with no evidence.

Measured, closed and regressed reported separately and never netted:

  turn1-state -> turn2-state    209 -> 158   closed 51, regressed 0
  turn2-state -> turn3-state    108 ->  87   closed 21, regressed 0

of which this lane closed 46 and 16 (the rest were already closed at main). The 46
are the brief's 32-leaf target in full -- 8 record counts, 8 player ids, 8 turn
stamps, 8 encounter ids -- plus 8 system stamps and 6 explored masks.

Three further pairs the model was never fitted to, all zero regressions:

  human-turn2 -> human-turn3    353 -> 311   closed 42   (a different game, 21 systems)
  zuul15 -> zuul16              276 -> 264   closed 12
  zuul16 -> zuul17              341 -> 329   closed 12

The corpus's one discriminating row is a host test rather than a comment: a system
whose last visiting fleet has gone carries the sticky and explored bits set, the
active bit clear, and a stamp frozen a turn behind. The test asserts the freeze AND
asserts what the wrong gate would have produced, so a future edit that swaps the
mask fails loudly instead of quietly agreeing with five saves.

Labelled hypothesis, with the workload named in the header: the encounter id is
recovered from the encounter fleet at the system, because the field the original
reads is set once at map generation and is not on the wire. It agrees on all six
encounter fleets in the corpus and no save can separate it -- none kills an
encounter while leaving its system visible.

Not written, deliberately: the colony-ownership stamp that moves beside these.
Its gate is demonstrably NOT the active mask (one system in the corpus has a zero
mask and moves it anyway), the formula is not held, so it is reported, not written.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ARBgSooAfokKUy6wKUKEyZ
2026-09-08 13:38:12 -04:00
11 changed files with 763 additions and 6 deletions

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 @ a4aba6a, generated 2026-09-08 by tools/gen_addresses.py
// Source: sots-re ghidra/addresses.json @ d617285, 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>
@ -1319,6 +1319,52 @@ constexpr uint32_t StrategyNetworkClient_off_Server = 0x00000054;
constexpr uint32_t StrategyServer_AbandonChaosCheck = 0x003b9df0;
// thiscall void (StrategySim* this /* S+4 */, ...) // the fleet-move command handler; logs "StrategySim: Fleet not found.", "StrategySim: Waypoint %d(id) not found." and "StrategySim: (see above) cannot move fleet %d(id)." Bumps ModCount at 0x008657aa. THE ONLY ModCount WRITER OTHER THAN THE ABANDON CHECK THAT IS DIRECT-CALL REACHABLE FROM EITHER TURN DRIVER (from OnAllCombatDone_Tail's 1369-function closure; not from ProcessTurn's 1382) [verified]
constexpr uint32_t StrategySim_MoveFleetCommand = 0x00465780;
// thiscall void __thiscall Game::StrategyApp::RunAI(int playerNetId, const char* aiCustomDataName, unsigned char aiPersonality, unsigned int rngSeed) -- RET 0x10, four stack args. The ONE-SHOT AI construction path, reached only from StrategyNetworkClient::OnMessage 0x00784640+0x96e, case SNMRunAI (net msg id 0x3d). Resolves the player through the handle registry at server+0x84; refuses on a human (`p->IsAI(+0xf9) == 0` -> "RunAI: Cannot create a StrategyClient/AI for a human player."); logs "RunAI: Creating AI client for %s using %08x for random seed."; operator_new(0x708) + StrategyClient ctor 0x00782ed0 WITH rngSeed; StrategyServer::InitGame; StrategyClient::CreateAI 0x007653c0(aiPersonality) which builds the Game::StrategyAIAgent into StrategyClient+0x12c ("RunAI: Failed to create AI for %s (player %d)."); if a save-game blob was supplied, "Loading AI custom data from save game..." through agent vt[5]/vt[6]; finally RaiseAIPrepareTurn 0x00815f20. NOT a per-turn entry point [verified]
constexpr uint32_t StrategyApp_RunAI = 0x004706f0;
// cdecl void (Game::StrategyServer* srv) with EBX = Game::StrategyAIAgent* (register-passed; both call sites set EBX before the call). Builds a stack Game::SEAIPrepareTurn (vftable 0x00a23c00) and invokes agent->vt[1](9, &ev) -- i.e. StrategyAIAgent::OnEvent with client event type 9. Also fires the SVScriptObject hooks at srv+0x1b4 with ids 9 and 0xa. Exactly two call sites: RunAI 0x008706f0+0x26c and StrategyApp::CreateGame 0x00888e80+0x3f5 -- both are game/AI construction, so SEAIPrepareTurn is NOT raised once per turn despite the name [verified]
constexpr uint32_t StrategyApp_RaiseAIPrepareTurn = 0x00415f20;
// thiscall void __thiscall Game::StrategyAIAgent::OnEvent(int clientEventType, Game::StrategyEvent** ev) -- RET 8. IStrategyAIAgent vtable slot 1 (vftable 0x00a1b244). Pushes a log scope on this->+0x10, then calls StrategyAIContext::OnStrategyEvent 0x006c2b90 with ECX = this->+0x94 (the context) and args (type, ev, &thunk 0x006d0ab0, this). The thunk forwards to StrategyAIAgent::OnAIPacket 0x006cf8a0. Every StrategyClient event handler in 0x00773xxx-0x00777xxx forwards through this slot when StrategyClient+0x12c is non-null [verified]
constexpr uint32_t StrategyAIAgent_OnEvent = 0x002d0ad0;
// thiscall void __thiscall Game::StrategyAIContext::OnStrategyEvent(int clientEventType, Game::StrategyEvent** ev, void (*cb)(void*, void*), Game::StrategyAIAgent* agent) -- RET 0x10. Registers {cb, agent, seq} on the pending-callback deque at this+0x58 (ring deque, buf@+0x5c cap@+0x60 head@+0x64 size@+0x68, 0x0c-byte nodes, push helper 0x0069e470 under a critical section). Then `switch (type - 6)` over 0..0x20 through the byte index table at 0x006c33a4 and jump table at 0x006c3360 (17 distinct cases), updating the AI world model and emitting INTERNAL AI packets {int code; ...} through StrategyAIContext::Broadcast 0x006b3840. Client event 9 (SEAIPrepareTurn) emits codes 1 then 2; client event 0x26 (SEResumePlaying) emits code 3. Tail: if the pending deque size is 1 it drains a second, separate queue at this+0x38 via 0x006a8690 [verified]
constexpr uint32_t StrategyAIContext_OnStrategyEvent = 0x002c2b90;
// thiscall void __thiscall Game::StrategyAIContext::Broadcast(const AIPacket* pkt) -- walks the listener red-black tree at this+0xc (std::set/map nodes; `_Isnil` at node+0x15) and calls listener->vt[3](pkt) on each -- that is the Game::AIObject event slot, implemented by AIPlayer (0x00723ed0), AISystem (0x006b3ae0), AIFleet (0x006b3970), AIBuildOrder and StrategyAIAgent (0x0069de50). Then, if the pending-callback deque at this+0x68 is non-empty, iterates it (0x0069e510 / 0x006a4ee0) and delivers the same packet to the queued {cb, this} pairs -- the hop that reaches StrategyAIAgent::OnAIPacket 0x006cf8a0. 19 call sites, all inside OnStrategyEvent 0x006c2b90 and 0x006c29c0 [mapped]
constexpr uint32_t StrategyAIContext_Broadcast = 0x002b3840;
// thiscall void __thiscall Game::StrategyAIAgent::OnAIPacket(const AIPacket* pkt) -- 2008 bytes. `eax = pkt->code - 2; if (eax > 0xf) return; jmp [eax*4 + 0x006d0078]` -- a 16-entry jump table over internal packet codes 2..17. Code 2 = the PREPARE TURN body (0x006cf958, logs "====== AI Prepare Turn (%s) ======"); code 3 = the PROCESS TURN body (0x006cfabf, logs "====== AI Process Turn (%s) ======", ~30 phases, ends by calling cl_EndTurn 0x00579310). Codes 4/6/7/8/10/11/13 fall through to the no-op at 0x006d0058. this->+0x10 = the owning StrategyClient, this->+0x14 = the ClientPlayer (name std::string at +0x40), this->+0x94 = the StrategyAIContext. Reached only through the thunk at 0x006d0ab0 [verified]
constexpr uint32_t StrategyAIAgent_OnAIPacket = 0x002cf8a0;
// thiscall void __thiscall Game::StrategyClient::OnResumePlaying(Game::StrategyEvent** ev) -- the case-0x26 handler of StrategyClient::RaiseEvent 0x00783ee0 (jump table 0x00784200, 0x2c entries). At +0x9d: `if (this->+0x12c) agent->vt[1](0x26, ev)` -- THE per-turn AI trigger. SEResumePlaying is broadcast by StrategyServer::ResumePlaying 0x007ddc90 at the very start of a new turn, after OnAllCombatDone_Tail has written the pre-turn autosave [verified]
constexpr uint32_t StrategyClient_OnResumePlaying = 0x00377480;
// offset Game::StrategyAIAgent* -- non-null only on an AI client. Every StrategyClient event handler tests it before forwarding the event to the agent; SendEndTurn 0x00783980 reads agent->vt[8]() through it to fetch the AIEncounterFlags it appends to SNMEndTurn; cl_EndTurn 0x00579310 refuses to end the turn unless it is non-null [verified]
constexpr uint32_t StrategyClient_off_AIAgent = 0x0000012c;
// offset Mars::RNG* -- a PER-CLIENT generator, operator_new(0x9cc) + RNG_Seed(ctorArg) in the StrategyClient constructor 0x00782ed0+0x143..+0x181. For an AI client the seed is RunAI's 4th argument. THE ONLY GENERATOR THE STRATEGIC AI DRAWS FROM: all six direct NextInt sites in the AI module reach it, and so do the two façade helpers cl_Chance 0x00578cf0 and cl_RandRange 0x005798e0. It is distinct from the strategic generator at StrategyServer+0x16c and is NOT serialised anywhere in the save [verified]
constexpr uint32_t StrategyClient_off_RNG = 0x00000134;
// offset Game::TurnCommands -- the ACCUMULATING order queue. Every StrategyClient order method in 0x00762ca0..0x00763f60 does `lea ecx,[this+0x160]` and appends to one of its 27 lists (or sets one of its six gates). At End Turn, StrategyClient::BuildTurnCommands 0x00783780 does TurnCommands::operator=(this->+0x4d8, this->+0x160) and then overwrites the player id, research rate and fleet-move list from live state [verified]
constexpr uint32_t StrategyClient_off_PendingTurnCommands = 0x00000160;
// offset Game::TurnCommands -- the SEND buffer, the object that becomes the `Player.<id>.TurnCommands_v5` block on the wire. Written only by StrategyClient::BuildTurnCommands 0x00783780 (from +0x160) and read by SendEndTurn 0x00783980+0x96, which copies it into the SNMEndTurn message (vftable 0x00a229e0) with TurnCommands::operator= 0x007832b0 [verified]
constexpr uint32_t StrategyClient_off_SendTurnCommands = 0x000004d8;
// thiscall void __thiscall Game::StrategyClient::BuildTurnCommands(Game::TurnCommands* dst) -- called once from EndTurn 0x00783be0+0xee with dst = &this->+0x4d8. TurnCommands::Clear 0x00893f00(dst); TurnCommands::operator= 0x007832b0(dst, &this->+0x160); dst->playerId(+4) = this->+0x150->+4; TurnCommands::SetResearchRate 0x0080f2d0(dst, this->+0x150->+0xbc) -- which is why EVERY save's TurnCommands block has the research-rate gate set and the other five clear; then, if this->+0x6d0, walks the pending fleet-move vector at this->+0x6d4/+0x6d8 (stride 8) into the move list [verified]
constexpr uint32_t StrategyClient_BuildTurnCommands = 0x00383780;
// thiscall Game::TurnCommands& __thiscall Game::TurnCommands::operator=(const TurnCommands& src) -- member-by-member copy of the six gates and their payloads, then the 27 lists. 11 call sites, including SendEndTurn, StrategyServer::OnPlayerEndTurn 0x007d9af0+0x68 (the host storing an arriving block), BuildTurnEvents and LoadGame [verified]
constexpr uint32_t TurnCommands_Assign = 0x003832b0;
// cdecl void () -- the AI's turn-submission façade. `c = g_StrategyClients[g_CurrentClientIndex]; if (c && c->AIAgent(+0x12c) && !c->bTurnEnded(+0x15c)) StrategyClient::EndTurn(c, true);` -- note the +0x12c test: this entry point works ONLY for an AI client. Called from the AI Process Turn body at 0x006cfcd9. The other caller of StrategyClient::EndTurn is the human UI at 0x005e4f80+0x5f [verified]
constexpr uint32_t cl_EndTurn = 0x00179310;
// cdecl bool (float p) -- `c = g_StrategyClients[g_CurrentClientIndex]; return c ? RNG_Chance(c->RNG(+0x134), p) : false;`. One of the AI's two randomness façades; used by the AI Process Turn body at 0x006cfc24 for the surrender roll that follows the "Survival Outlook: Dead in %i turns (%5.2f%% chance to surrender this turn)" log line [verified]
constexpr uint32_t cl_Chance = 0x00178cf0;
// cdecl int (int lo, int hi) -- `c = g_StrategyClients[g_CurrentClientIndex]; if (!c) return 0; n = hi - lo; return lo + RNG_NextInt(&c->RNG(+0x134)->mt, &n);`. NOTE RNG_NextInt is INCLUSIVE of its bound (addresses.json), so the range is [lo, hi] inclusive. Eight AI-module call sites [verified]
constexpr uint32_t cl_RandRange = 0x001798e0;
// offset Game::StrategyClient* g_StrategyClients[] -- the client table the whole 0x00578cf0..0x005793xx façade family indexes with g_CurrentClientIndex (0x00ae4808). 40 functions reference it. The AI runs as the current client: everything it does goes through this indirection, which is how one process hosts the human client and N AI clients over the same API [mapped]
constexpr uint32_t g_StrategyClients = 0x006e47e4;
// offset int -- index into g_StrategyClients (0x00ae47e4). Selects which client the cl_* façade acts on. Not instrumented; who sets it, and when relative to the AI's turn, is open [mapped]
constexpr uint32_t g_CurrentClientIndex = 0x006e4808;
// offset Mars::RNG -- a STATIC generator in .data, 0x9cc bytes. Its only static initialiser (0x009dc6e0) writes the Mars::IStreamable vftable 0x009e22bc, NOT the Mars::RNG vftable 0x009e9aec that RNG_Seed installs: none of the six RNG_Seed call sites in the image targets it, so its mt[624] is the zero-initialised BSS array and `left` is 0. An all-zero MT19937 state is a fixed point of the twist, so EVERY draw from it returns 0. Five consumers: SNMRunAI (the AI client seed, OnMessage+0x955), RunCombatRound 0x007cbe80+0x60f, 0x007c2fa0+0xc84, 0x0079ea90+0x73 (an RNG_Chance) and 0x005b9f00+0xc0 [verified]
constexpr uint32_t g_GlobalRNG = 0x006f6e58;
// thiscall void __thiscall -- loads Data/Strategy/AI/aitechmode.csv, aitechpri.csv and aitechgrp.csv through the Mars::ICSVRowParser subclasses Game::AIUserTechModeRowParser (vftable 0x00a1ae8c), AIUserTechPriRowParser (0x00a1ae7c) and AIUserTechGrpRowParser (0x00a1ae6c). Diagnostics: "%s, %i: tech %s does not exist.", "%s, %i: priority %i (%s) is out of range 0..255.", "%s, %i: bad research mode character: %s", "%s, %i: bad group: %s". Tech group ids are the AITG_* strings emitted by 0x006920a0 (ARMOR BALWEAP BEAM BIOWEAP NRGWEAP SHIELDS TORPS WARHEAD) [mapped]
constexpr uint32_t AIRulesDB_LoadTechTables = 0x002c7980;
// thiscall void __thiscall -- loads data/strategy/ai/stock_design_names.csv, stock_diplomacy_messages.csv and stock_player_names.csv through Game::AIPersonaDB::StockDesignNameRowParser (vftable 0x00a1b034), StockDiplomacyMessageRowParser (0x00a1b024) and StockPlayerNameRowParser (0x00a1b014). Diagnostics key on species and on a diplomatic event id; the id vocabulary is the 53 AIDIP_* strings returned by 0x00690960 [mapped]
constexpr uint32_t AIPersonaDB_LoadStockTables = 0x002c6250;
// thiscall void __thiscall -- loads data/strategy/ai/affinity_weapon.csv and affinity_section.csv ("ToAISectionRule: %s ship section not found: %s"). These are the ship-design affinity weights consumed by the design composer 0x006ad700 ("AIComposeShipBlueprint: SectionBlueprint::MAX_OPTIONS", "While AI for %s was designing a ship: Did not find any weapon to match %s, %s, bNoTrackingWeapons=%i."). Section-class rule selection is 0x0069cc30 / 0x0069cdb0 ("AISelectSectionClassRules: maxout", "AISelectSectionClassRulesMergeFallback: maxout"); the generic selector is 0x00695140 ("AIRulesDB::SelectRules_T: maxout") [mapped]
constexpr uint32_t AIRulesDB_LoadAffinityTables = 0x002c63c0;
// thiscall void __thiscall -- loads Data/Strategy/AI/weapon_replacements.csv through Game::StrategyAIContext::WeaponReplacementsRowParser (vftable 0x00a1a62c). Consumed by 0x00694f80 ("StrategyAIContext::GetWeaponReplacement: maxReplacements (%i)") [mapped]
constexpr uint32_t StrategyAIContext_LoadWeaponReplacements = 0x002b4dc0;
// 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]
@ -1423,6 +1469,26 @@ constexpr uint32_t ShipDesign_off_Dtc = 0x00000134;
constexpr uint32_t ShipSectionDef_off_SectionClass = 0x00000260;
// offset unsigned int // low dword of the section's 64-bit role-flag word (high dword at +0x29c), one bit per boolean role key in the .shipsection file [verified]
constexpr uint32_t ShipSectionDef_off_RoleFlagsLow = 0x00000298;
// thiscall void (ServerSystem* this, ServerPlayer* p, int encounterId) // 75 B, ret 8. THE NVE WRITER. if (!p) return; s = (int16)this->owner(+0x10)->Frame(+0x8); rec = NVE_map_at(&this->NVE(+0x284), &p->PlyrIdx(+0x28)); rec[0] = (s<<16)|s; rec[1] = encounterId. The map value is 8 bytes at node+0x10: an UNSERIALISED int16 touch stamp at +0, the saved ETS int16 at +2, the saved Eid int32 at +4 -- so ETS and the touch stamp are both set to the frame here, and only the writer at 0x007536a0 makes them differ. Sole caller is the tail's PlayerView-rebuild phase 0x007cf560, under the gate (AFlags >> PlyrIdx) & 1 [mapped]
constexpr uint32_t ServerSystem_RecordObservation = 0x00356300;
// thiscall void (ServerSystem* this, ServerPlayer* p, NveValue* src) // 70 B, ret 8. Intel sharing. rec = NVE_map_at(&this->NVE, &p->PlyrIdx(+0x28)); rec[0] = (hi16(src[0]) << 16) | (int16)this->owner(+0x10)->Frame(+0x8); rec[1] = src[1]. i.e. the receiver gets the DONOR's sighting turn (ETS) and encounter id unchanged, and only the unserialised touch stamp becomes the current frame. Never executed by any save in the corpus: no save has two players in an alliance [mapped]
constexpr uint32_t ServerSystem_CopyObservationTo = 0x003536a0;
// cdecl bool (ServerSystem* sys, ServerPlayer* from, ServerPlayer* to) // 96 B. Null-guards all three and from != to; a = FindObservation(from); b = FindObservation(to); if (a && (!b || b->ETS(+2) < a->ETS(+2))) { CopyObservationTo(to, a); return true; } return false. NEWER SIGHTING WINS, compared as a signed int16 [mapped]
constexpr uint32_t ServerSystem_ShareObservation = 0x00354d90;
// thiscall NveValue* (ServerSystem* this, ServerPlayer* p) // 58 B, ret 4. Map find on p->PlyrIdx(+0x28); returns node+0x10 (the 8-byte value) or null when the search ended at this->NVE head (+0x284) [mapped]
constexpr uint32_t ServerSystem_FindObservation = 0x0034d360;
// thiscall int (ServerSystem* this, ServerPlayer* p) // 64 B, ret 4. Same map find; returns node+0x14 (Eid) or -1. The extra `!= -0x10` guard is the null-node case reached through node+0x10 [mapped]
constexpr uint32_t ServerSystem_LastSeenEncounterId = 0x0034f830;
// thiscall bool (ServerSystem* this, ServerPlayer* p) // 34 B, ret 4. return ((1 << (p->PlyrIdx(+0x28) & 0x1f)) & this->AFlags(+0xd4)) != 0. THE gate on the observation record, on the explored sweep and on the PlayerView rebuild -- AFlags, the DERIVED non-sticky union, not VFlags. 19 callers [verified]
constexpr uint32_t ServerSystem_IsVisibleTo = 0x00343fb0;
// fastcall void (ServerSystem* this) // 41 B, WHOLE BODY. if (this->AFlags(+0xd4) != 0) this->ltis(+0x2c8) = this->owner(+0x10)->Frame(+0x8). This is `ltis`'s writer, which board.md recorded as unnamed; it is driver phase 29 (0x007dcbd6, per system). TShn's writer is still unnamed and is demonstrably a DIFFERENT rule: Spica in turn1-state has AFlags == 0 and its TShn moves anyway [mapped]
constexpr uint32_t ServerSystem_UpdateLastObservedTurn = 0x00343ec0;
// thiscall void (ServerSystem* this, ServerPlayer* p, bool wasSet, bool on) // 71 B, WHOLE BODY. Game::ServerSystem primary vftable 0x00a2044c SLOT 7 -- this RESOLVES the indirect edge lane B5 flagged in SetExploredBy's tail (vft[0x1c]). if (!wasSet && on && this->owner(+0x10)->Frame(+0x8) > 1) { FUN_00747a20(p->PlyrIdx(+0x28), 1); if (p->PlyrIdx < 0xf) this->+0x2a4 |= 1 << (PlyrIdx & 0x1f); } -- +0x2a4 sits past NVs and is NOT in the serialised table, so this edge writes no save state. Note the Frame > 1 guard: turn 1 is special-cased [mapped]
constexpr uint32_t ServerSystem_OnExploredChanged = 0x003480b0;
// cdecl bool (StarSystem* sys, int encounterId) // 129 B. Refuses unless the system is unowned (FUN_007437e0 == 0), has no planets, sys->+0x184 == -1 and two further tests pass; then sys->+0x184 = encounterId and ORs a mask from the encounter def into sys->+0x19c. sys->+0x184 is the field ServerSystem_RecordObservation copies into Eid. It is constructed to -1 (StarSystem ctor 0x00752ea0, member index 0x61) and IS NOT ON THE WIRE, so a reimplementation has to recover it from the encounter fleet's FtEnc -- which agrees on all six encounter fleets in the corpus and which no save can separate from the real field [mapped]
constexpr uint32_t StarSystem_PlaceEncounter = 0x003887c0;
// fastcall void (StrategyServer* S) // 948 B, tail phase 17. Four passes: (1) walk the per-(system, player) view tree at S+0x228 and drop entries whose player can no longer see the system, then clear the tree and reset S+0x22c = 0; (2) per player x per system, if IsVisibleTo, FUN_0075f550; (3) per player x per system, if IsVisibleTo, ServerSystem_RecordObservation(sys, player, sys->+0x184) -- byte-decoded at 0x007cf7a7..0x007cf7ce, this is the ONLY caller of the NVE writer outside intel sharing; (4) per system x per player, if IsExploredBy and a colony exists, build a PlayerView via 0x00755ab0/0x007561d0 and apply it. Draw-free [mapped]
constexpr uint32_t StrategyServer_RebuildPlayerViews = 0x003cf560;
// cdecl void (const char* gamename) /* GameSpy SDK gsAvailable. sprintf("%s.available.gamespy.com"), inet_addr/gethostbyname, UDP socket, sendto port 27900 (htons 0x6cfc) with '\x09\0\0\0\0' + gamename + NUL, len = strlen(gamename)+6. Overridable hostname buffer at 0x00b085b0. Leaves socket = -1 on DNS failure. */ [verified]
constexpr uint32_t GameSpy_GSIStartAvailableCheck = 0x0000a060;
// cdecl int (void) /* returns 0=waiting 1=available 2=unavailable 3=temporarily-unavailable. Socket==-1 (start failed) => returns 1. Retries once after 2000 ms then returns 1. */ [verified]

View file

@ -8,6 +8,7 @@ add_library(sots_app STATIC
phase_catalog.cpp
trade_raid.cpp
turn_record.cpp
visibility_phase.cpp
turn.cpp
report.cpp)
target_include_directories(sots_app PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/..)

View file

@ -109,7 +109,10 @@ constexpr PhaseDesc kStrategic[] = {
{Driver::Strategic, 28, "S28", "GrantMetSpeciesTechs", PhaseStatus::Stub,
"the 'you have met this race, its racial tech appears in your tree' rule -- small, pure, "
"draw-free, and the cheapest unimplemented phase in this table"},
{Driver::Strategic, 29, "S29", "SystemTailFixup", PhaseStatus::Stub, ""},
{Driver::Strategic, 29, "S29", "SystemObservedStamp", PhaseStatus::Implemented,
"the system's own last-observed turn, written wherever some player can currently see "
"the system and left alone everywhere else. Whole function modelled; the gate is the "
"active-presence mask, which falls when the last fleet leaves"},
{Driver::Strategic, 30, "S30", "BuildTeamPartition", PhaseStatus::Stub, ""},
{Driver::Strategic, 31, "S31", "EncounterDetectionAndStatusRestore", PhaseStatus::Partial,
"trade-raid generation runs first here and IS modelled: two chances per player, one word "
@ -192,11 +195,19 @@ constexpr PhaseDesc kTail[] = {
"the decision function is modelled in game::sim; the per-player state it reads is not "
"assembled here"},
{Driver::Tail, 16, "T16", "ResolveArrivedColonizers", PhaseStatus::Stub, ""},
{Driver::Tail, 17, "T17", "RebuildPlayerViewTree", PhaseStatus::Stub, ""},
{Driver::Tail, 17, "T17", "RebuildPlayerViewTree", PhaseStatus::Partial,
"the per-(system, player) observation record IS modelled and committed -- who saw "
"the system, on what turn, and what encounter was there. The colony-numbers view "
"the same phase rebuilds beside it is NOT: its list is empty on both reference "
"pairs, so nothing here has evidence to build it against"},
{Driver::Tail, 18, "T18", "PostFleetWarnings", PhaseStatus::Stub, ""},
{Driver::Tail, 19, "T19", "DrainInfraTerraformQueue", PhaseStatus::Stub, ""},
{Driver::Tail, 20, "T20", "ScriptHooksTurnEnd", PhaseStatus::Stub, ""},
{Driver::Tail, 21, "T21", "UpdateSurveyAndSystemStats", PhaseStatus::Stub, ""},
{Driver::Tail, 21, "T21", "UpdateSurveyAndSystemStats", PhaseStatus::Partial,
"the explored sweep IS modelled and committed: every player who can currently see a "
"system has now surveyed it. The event this owes per newly-surveyed pair is not "
"posted, and the derived per-system defence figure the same phase computes is not "
"modelled"},
{Driver::Tail, 22, "T22", "TradeSliderFinalisationSecondPass", PhaseStatus::Stub, ""},
{Driver::Tail, 23, "T23", "TradeManagerEndOfTurnHooks", PhaseStatus::Stub,
"eight vtable calls, wholly unidentified"},

View file

@ -10,6 +10,7 @@
#include "app/alliance.h"
#include "app/trade_raid.h"
#include "app/turn_record.h"
#include "app/visibility_phase.h"
#include "game/sim/colony.h"
#include "game/sim/economy.h"
#include "game/sim/numeric.h"
@ -594,6 +595,14 @@ TurnResult RunStrategicTurn(SaveGame& game, const TurnOptions& opt) {
playerDriverRan = true;
break;
}
case 29: { // S29 SystemObservedStamp
const VisibilityPhaseResult v = RunSystemObservedStamp(game);
rec.invocations = v.systemsVisited;
rec.leafWrites = v.leafWrites;
rec.committed = v.leafWrites > 0;
rec.notes = v.notes;
break;
}
case 31: { // S31 EncounterDetectionAndStatusRestore
// Trade-raid generation runs FIRST inside this phase, before detection
// proper, and it is the turn's dominant RNG consumer: two chances per entry
@ -693,6 +702,18 @@ TurnResult RunStrategicTurn(SaveGame& game, const TurnOptions& opt) {
rec.leafWrites = 1;
rec.committed = true;
rec.notes.push_back(fmt("ModCount -> %d", game.sim.modCount));
} else if (tp[i].index == 17) {
const VisibilityPhaseResult v = RunObservationRecords(game);
rec.invocations = v.systemsVisited;
rec.leafWrites = v.leafWrites;
rec.committed = v.leafWrites > 0;
rec.notes = v.notes;
} else if (tp[i].index == 21) {
const VisibilityPhaseResult v = RunExploredSweep(game);
rec.invocations = v.systemsVisited;
rec.leafWrites = v.leafWrites;
rec.committed = v.leafWrites > 0;
rec.notes = v.notes;
} else if (tp[i].index == 36) {
RunFinalizeTurnRecords(game, opt, rec, recordAudit, allianceMasks);
}

View file

@ -0,0 +1,175 @@
#include "app/visibility_phase.h"
#include <algorithm>
#include <cstdarg>
#include <cstdio>
#include <string>
#include "game/sim/visibility.h"
namespace sots::app {
namespace {
using mars::stream::shapes::SaveGame;
using mars::stream::shapes::Sys;
std::string fmt(const char* f, ...) {
char buf[512];
va_list ap;
va_start(ap, f);
std::vsnprintf(buf, sizeof buf, f, ap);
va_end(ap);
return std::string(buf);
}
sim::SystemMasks MasksOf(const Sys& s) {
sim::SystemMasks m;
m.seen = s.vFlags;
m.explored = s.eFlags;
m.active = s.aFlags;
m.presence = s.fFlags;
m.gate = s.gFlags;
return m;
}
// slot -> player handle id, sized to the highest slot the save uses. A slot no player
// occupies maps to 0 and is never observed, because no mask bit can be set for it.
std::vector<std::int32_t> SlotToPlayerId(const SaveGame& game) {
int maxSlot = -1;
for (const auto& e : game.sim.players) maxSlot = std::max(maxSlot, e.player.plyrIdx);
if (maxSlot < 0) return {};
std::vector<std::int32_t> out(static_cast<std::size_t>(maxSlot) + 1, 0);
for (const auto& e : game.sim.players) {
if (!sim::SlotRepresentable(e.player.plyrIdx)) continue;
out[static_cast<std::size_t>(e.player.plyrIdx)] = e.playerID;
}
return out;
}
} // namespace
std::int32_t EncounterAtSystem(const SaveGame& game, std::int32_t systemId) {
for (const auto& f : game.sim.fleets) {
if (f.flt.locID != systemId) continue;
if (f.flt.ftEnc == 0) continue; // "no encounter"; the system defence fleet uses it
return f.flt.ftEnc;
}
return sim::kNoEncounter;
}
VisibilityPhaseResult RunSystemObservedStamp(SaveGame& game) {
VisibilityPhaseResult r;
int watched = 0;
for (auto& e : game.sim.systems) {
++r.systemsVisited;
if (e.sys.aFlags != 0) ++watched;
const sim::SystemStampResult s =
sim::UpdateSystemStamp(e.sys.ltis, e.sys.aFlags, game.sim.frame);
if (!s.changed) continue;
e.sys.ltis = s.stamp;
++r.systemsTouched;
++r.leafWrites;
}
r.notes.push_back(fmt("%d of %d system(s) are watched by someone; %d stamp(s) moved to %d",
watched, r.systemsVisited, r.leafWrites, game.sim.frame));
if (watched != r.systemsVisited)
r.notes.push_back("an unwatched system keeps the turn it was last watched -- the gate "
"is the ACTIVE mask, which falls when the last fleet leaves, not the "
"sticky one");
return r;
}
VisibilityPhaseResult RunExploredSweep(SaveGame& game) {
VisibilityPhaseResult r;
int newlyExplored = 0;
for (auto& e : game.sim.systems) {
++r.systemsVisited;
const std::int32_t next = sim::ApplyExploredSweep(e.sys.eFlags, e.sys.aFlags);
if (next == e.sys.eFlags) continue;
newlyExplored += static_cast<int>(
sim::NewlyExploredSlots(e.sys.eFlags, e.sys.aFlags).size());
e.sys.eFlags = next;
++r.systemsTouched;
++r.leafWrites;
}
r.notes.push_back(fmt("%d system(s) newly surveyed, across %d (system, player) pair(s)",
r.systemsTouched, newlyExplored));
if (newlyExplored)
r.notes.push_back(fmt("the original posts one event per pair, so this phase owes %d "
"event(s) it does not post",
newlyExplored));
return r;
}
VisibilityPhaseResult RunObservationRecords(SaveGame& game) {
VisibilityPhaseResult r;
const std::vector<std::int32_t> slotToId = SlotToPlayerId(game);
int created = 0, refreshed = 0, derivedEncounters = 0;
for (auto& e : game.sim.systems) {
++r.systemsVisited;
std::vector<sim::Observation> before;
before.reserve(e.sys.nve.size());
for (const auto& n : e.sys.nve) {
sim::Observation o;
// The wire carries the handle id; the map key is the slot. Recover the slot so
// the ordering the container imposes is reproducible.
o.playerId = n.ePid;
o.playerSlot = -1;
for (std::size_t slot = 0; slot < slotToId.size(); ++slot)
if (slotToId[slot] == n.ePid) o.playerSlot = static_cast<int>(slot);
o.turnSeen = n.ets;
o.encounterId = n.eid;
before.push_back(o);
}
const std::int32_t enc = EncounterAtSystem(game, e.sysID);
if (enc != sim::kNoEncounter) ++derivedEncounters;
const sim::ObservationUpdate up = sim::UpdateObservations(
before, MasksOf(e.sys), slotToId, enc, game.sim.frame);
created += up.created;
refreshed += up.refreshed;
// Count the leaves that actually move. A created record adds four (the count and its
// three fields); a refreshed one moves only the fields that changed.
int moved = 0;
if (up.records.size() != e.sys.nve.size()) ++moved; // the count leaf
for (std::size_t i = 0; i < up.records.size(); ++i) {
const sim::Observation& o = up.records[i];
if (i >= e.sys.nve.size()) {
moved += 3;
continue;
}
const auto& old = e.sys.nve[i];
if (old.ePid != o.playerId) ++moved;
if (old.ets != o.turnSeen) ++moved;
if (old.eid != o.encounterId) ++moved;
}
if (moved == 0) continue;
e.sys.nve.clear();
e.sys.nve.reserve(up.records.size());
for (const auto& o : up.records) {
mars::stream::shapes::NveEntry n;
n.ePid = o.playerId;
n.ets = o.turnSeen;
n.eid = o.encounterId;
e.sys.nve.push_back(n);
}
++r.systemsTouched;
r.leafWrites += moved;
}
r.notes.push_back(fmt("%d record(s) created, %d refreshed, over %d system(s); %d leaf/leaves",
created, refreshed, r.systemsTouched, r.leafWrites));
r.notes.push_back(fmt("%d system(s) carry an encounter fleet whose id the record copies -- "
"the field the original reads is NOT on the wire, so this derivation "
"is a HYPOTHESIS and no save in the corpus can separate it from the "
"rule it stands in for",
derivedEncounters));
return r;
}
} // namespace sots::app

View file

@ -0,0 +1,55 @@
// The visibility phases, wired to the save shapes.
//
// Three phases of the turn touch the per-system visibility cluster, in two different
// drivers, and they are listed here together because they share one input -- the system's
// active-presence mask -- and nothing else in the turn reads it.
//
// S29 the system's own last-observed stamp
// T17 the per-(system, player) observation record
// T21 the explored sweep
//
// The mask itself is READ FROM THE SAVE and never rebuilt. The original recomputes it on
// every fleet arrival and departure, which the standalone does not model; but neither
// reference pair moves a mask leaf, so the loaded value is the value these phases would
// see, and rebuilding it from an unmodelled movement pass would be a change with no
// evidence behind it. When that stops being true it will show up as a regression here
// first, which is the point of saying it out loud.
#pragma once
#include <string>
#include <vector>
#include "mars/stream/shapes.h"
namespace sots::app {
// What one visibility phase did, in the terms the run log prints.
struct VisibilityPhaseResult {
int systemsVisited = 0; // systems the phase looked at
int systemsTouched = 0; // systems where something moved
int leafWrites = 0; // save leaves this phase changed
std::vector<std::string> notes;
};
// S29: `ltis`, the system's own last-observed turn. Written on every system some player can
// currently see; left alone on the rest.
VisibilityPhaseResult RunSystemObservedStamp(mars::stream::shapes::SaveGame& game);
// T21: the explored sweep -- every player who can see a system has now surveyed it.
VisibilityPhaseResult RunExploredSweep(mars::stream::shapes::SaveGame& game);
// T17: the per-(system, player) observation record.
VisibilityPhaseResult RunObservationRecords(mars::stream::shapes::SaveGame& game);
// The encounter parked at a system, as this model can recover it.
//
// The field the original reads is a star-system member that is NOT serialised: it is set
// once when the map is generated and never moves. The only observable that carries the same
// id is the encounter fleet sitting at the system, so that is what this reads -- and it is a
// HYPOTHESIS, not a reading. It agrees with the original on all six encounter fleets in the
// corpus and no save can separate it from the true rule, because no save kills an encounter
// while leaving its system visible. Returns -1 when there is none.
std::int32_t EncounterAtSystem(const mars::stream::shapes::SaveGame& game,
std::int32_t systemId);
} // namespace sots::app

View file

@ -7,6 +7,7 @@ add_library(sots_game_sim STATIC
research.cpp
colony.cpp
movement.cpp
visibility.cpp
techgraph.cpp)
target_include_directories(sots_game_sim PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/../..)
target_compile_features(sots_game_sim PUBLIC cxx_std_17)
@ -17,7 +18,7 @@ endif()
option(SOTS_GAME_SIM_TESTS "Build the game/sim unit tests" OFF)
if(SOTS_GAME_SIM_TESTS)
enable_testing()
set(_sim_tests economy research colony movement techgraph)
set(_sim_tests economy research colony movement techgraph visibility)
foreach(_t IN LISTS _sim_tests)
add_executable(game_sim_test_${_t} ${CMAKE_CURRENT_SOURCE_DIR}/../../../tests/game_sim/test_${_t}.cpp)
target_link_libraries(game_sim_test_${_t} PRIVATE sots_game_sim)

View file

@ -0,0 +1,78 @@
#include "game/sim/visibility.h"
#include <algorithm>
namespace sots::sim {
std::vector<int> NewlyExploredSlots(std::int32_t explored, std::int32_t active) {
std::vector<int> out;
const std::uint32_t newly = static_cast<std::uint32_t>(active) & ~static_cast<std::uint32_t>(explored);
for (int slot = 0; slot < 32; ++slot)
if ((newly >> slot) & 1u) out.push_back(slot);
return out;
}
SystemStampResult UpdateSystemStamp(std::int32_t currentStamp, std::int32_t activeMask,
std::int32_t turn) {
SystemStampResult r;
r.stamp = currentStamp;
if (activeMask == 0) return r; // nobody is watching; the old stamp stands
r.stamp = turn;
r.changed = (turn != currentStamp);
return r;
}
ObservationUpdate UpdateObservations(const std::vector<Observation>& existing,
const SystemMasks& masks,
const std::vector<std::int32_t>& slotToId,
std::int32_t encounterId, std::int32_t turn) {
ObservationUpdate up;
up.records = existing;
const int slotCount = static_cast<int>(slotToId.size());
for (int slot = 0; slot < slotCount; ++slot) {
if (!IsObservedBy(masks, slot)) continue;
auto it = std::find_if(up.records.begin(), up.records.end(),
[slot](const Observation& o) { return o.playerSlot == slot; });
if (it == up.records.end()) {
Observation o;
o.playerSlot = slot;
o.playerId = slotToId[static_cast<std::size_t>(slot)];
o.turnSeen = turn;
o.encounterId = encounterId;
up.records.push_back(o);
++up.created;
++up.refreshed;
continue;
}
// The encounter id is a property of the system, fixed for the life of the game, so a
// record that already carries one keeps it. Re-deriving it here from what happens to
// be at the system this turn would be a different rule with the same result on every
// save we hold, and it would silently disagree the first time an encounter dies.
const std::int32_t keptEncounter =
it->encounterId != kNoEncounter ? it->encounterId : encounterId;
if (it->turnSeen == turn && it->encounterId == keptEncounter) {
++up.alreadyCurrent;
} else {
it->turnSeen = turn;
it->encounterId = keptEncounter;
++up.refreshed;
}
}
up.untouched = static_cast<int>(up.records.size()) - up.refreshed - up.alreadyCurrent;
std::stable_sort(up.records.begin(), up.records.end(),
[](const Observation& a, const Observation& b) {
return a.playerSlot < b.playerSlot;
});
return up;
}
bool ShouldShareObservation(const Observation* from, const Observation* to) {
if (from == nullptr) return false;
if (to == nullptr) return true;
return to->turnSeen < from->turnSeen;
}
} // namespace sots::sim

146
src/game/sim/visibility.h Normal file
View file

@ -0,0 +1,146 @@
// Per-system visibility: who can see a system this turn, what they remember about it, and
// the two turn stamps that record it.
//
// The game keeps three per-(system, player) memories side by side on the star system --
// what the player last saw of the OWNER, what they last saw of the ENCOUNTER there, and
// what they last saw of the colony's NUMBERS. This module models the second, plus the two
// masks and the system-level stamp that move with it. It is pure: no save shapes, no I/O.
//
// The load-bearing fact, and the one the corpus can check, is the GATE. Four per-player
// masks live on a star system and three of them are equal on every system of every save
// this project holds, so a model built on the wrong one looks correct until it does not.
//
// presence -- the player has a fleet at the system
// gate -- the player has a gate there
// active -- the derived union `presence | gate | isOwner`, recomputed on every fleet
// arrival and departure, so it FALLS when the last fleet leaves
// seen -- the same union but sticky: only ever OR'd, never cleared
// explored -- ever surveyed
//
// The observation record is refreshed under `active`, not under `seen`. One save in the
// corpus separates them: a system whose last fleet left carries `seen` set, `active` clear,
// and a stamp frozen at the previous turn. That save is the test.
#pragma once
#include <cstdint>
#include <vector>
namespace sots::sim {
// ---------------------------------------------------------------------------------------
// The masks
// ---------------------------------------------------------------------------------------
// The per-player masks a star system carries. Bit index is the player's slot index, not its
// handle id, and not its position in the server's player vector.
struct SystemMasks {
std::int32_t seen = 0; // sticky union
std::int32_t explored = 0; // ever surveyed
std::int32_t active = 0; // presence | gate | isOwner, recomputed, non-sticky
std::int32_t presence = 0; // a fleet is here
std::int32_t gate = 0; // a gate is here
};
// Bit for a player slot. Slots at or above 32 cannot be represented; the original packs the
// same masks into a 32-bit int and separately caps a runtime companion at 15 players, so a
// slot outside the range is not a case this can encode and the caller is told so.
constexpr bool SlotRepresentable(int playerSlot) { return playerSlot >= 0 && playerSlot < 32; }
constexpr std::int32_t SlotBit(int playerSlot) {
return SlotRepresentable(playerSlot) ? static_cast<std::int32_t>(1u << playerSlot) : 0;
}
// Is the system currently observed by this player? This is the gate on the observation
// record and on the system stamp: the ACTIVE mask, not the sticky one.
constexpr bool IsObservedBy(const SystemMasks& m, int playerSlot) {
return SlotRepresentable(playerSlot) && (m.active & SlotBit(playerSlot)) != 0;
}
// Is the system explored by this player?
constexpr bool IsExploredBy(const SystemMasks& m, int playerSlot) {
return SlotRepresentable(playerSlot) && (m.explored & SlotBit(playerSlot)) != 0;
}
// The active mask as the original recomputes it on every fleet arrival and departure. Kept
// here so the invariant is stated in one place even though the standalone reads the mask
// from the save rather than rebuilding it -- the two reference pairs move no mask leaf, so
// the loaded value is the value the phase would see, and a rebuild would be a change with
// no evidence behind it.
constexpr std::int32_t RecomputeActive(std::int32_t presence, std::int32_t gate,
std::int32_t ownerBit) {
return presence | gate | ownerBit;
}
// The explored sweep, once per turn: every player who can currently see the system has now
// surveyed it. Returns the new mask; the caller compares to know whether a leaf moved.
constexpr std::int32_t ApplyExploredSweep(std::int32_t explored, std::int32_t active) {
return explored | active;
}
// Which slots this sweep newly sets -- the ones that would each post an "explored" event.
std::vector<int> NewlyExploredSlots(std::int32_t explored, std::int32_t active);
// ---------------------------------------------------------------------------------------
// The system-level stamp
// ---------------------------------------------------------------------------------------
// The system's own "last observed" turn. Written when ANY player can see it -- the gate is
// the whole active mask being non-zero, not a particular player's bit -- and left alone
// otherwise, so a system nobody watches keeps the turn it was last watched.
struct SystemStampResult {
std::int32_t stamp = 0;
bool changed = false;
};
SystemStampResult UpdateSystemStamp(std::int32_t currentStamp, std::int32_t activeMask,
std::int32_t turn);
// ---------------------------------------------------------------------------------------
// The per-(system, player) observation record
// ---------------------------------------------------------------------------------------
// No encounter is present. The system field this mirrors is constructed to this value and
// the map lookup returns it for a player who has no record.
constexpr std::int32_t kNoEncounter = -1;
// One remembered sighting. `playerSlot` is the map key and orders the list; `playerId` is
// what the wire carries in its place.
struct Observation {
int playerSlot = 0;
std::int32_t playerId = 0;
std::int32_t turnSeen = 0;
std::int32_t encounterId = kNoEncounter;
};
// What one system's observation list should be after this turn's pass.
//
// `existing` is the list as loaded, `encounterId` the encounter parked at the system, and
// `slotToId` maps a player slot to the handle id the wire carries. A player who can see the
// system gets a refreshed record; a player who cannot keeps whatever it had. Nothing is ever
// removed: the original's map has exactly three callers of `operator[]` and none of them
// erases, which is what makes this a memory rather than a state.
//
// The result is ordered by player slot ascending, because the original stores it in a tree
// keyed by that slot. Every system in the corpus has at most one entry, so the ordering is
// asserted from the container and not from evidence.
struct ObservationUpdate {
std::vector<Observation> records;
int refreshed = 0; // records whose stamp was rewritten
int created = 0; // of those, ones that did not exist before
int alreadyCurrent = 0; // observed this turn and already carrying this turn's stamp
int untouched = 0; // not observed this turn; kept exactly as loaded
};
ObservationUpdate UpdateObservations(const std::vector<Observation>& existing,
const SystemMasks& masks,
const std::vector<std::int32_t>& slotToId,
std::int32_t encounterId, std::int32_t turn);
// The intel-sharing rule: a player may be handed another player's record when the other's
// sighting is newer, or when it has none of its own. The stamp that is copied is the
// SIGHTING stamp, not the current turn -- the receiver learns what the donor saw, and when.
//
// Nothing in the corpus has ever executed this: no save has two players in an alliance. It
// is here because the rule is short, it is read, and leaving it out would misrepresent the
// record as single-writer.
bool ShouldShareObservation(const Observation* from, const Observation* to);
} // namespace sots::sim

View file

@ -1,5 +1,5 @@
# game/sim tests: four hand-computed suites + a real-save smoke test (skips unless SOTS_SAVES_JSON).
foreach(_t economy research colony movement techgraph)
foreach(_t economy research colony movement techgraph visibility)
add_executable(game_sim_test_${_t} test_${_t}.cpp)
target_link_libraries(game_sim_test_${_t} PRIVATE sots_game_sim)
target_include_directories(game_sim_test_${_t} PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})

View file

@ -0,0 +1,203 @@
#include "game/sim/visibility.h"
#include "check.h"
using namespace sots::sim;
static SystemMasks masks(std::int32_t seen, std::int32_t explored, std::int32_t active,
std::int32_t presence, std::int32_t gate) {
SystemMasks m;
m.seen = seen;
m.explored = explored;
m.active = active;
m.presence = presence;
m.gate = gate;
return m;
}
static void test_bits() {
CHECK(SlotRepresentable(0));
CHECK(SlotRepresentable(31));
CHECK(!SlotRepresentable(-1));
CHECK(!SlotRepresentable(32));
CHECK_EQ(SlotBit(0), std::int32_t{1});
CHECK_EQ(SlotBit(4), std::int32_t{16});
CHECK_EQ(SlotBit(7), std::int32_t{128});
CHECK_EQ(SlotBit(32), std::int32_t{0});
}
// The gate is the ACTIVE mask. Three masks agree on nearly every system of every save the
// project holds, so this pins the one thing the corpus almost cannot show.
static void test_gate_is_the_active_mask() {
// seen and explored set, active clear: this is the shape a system takes after its last
// visiting fleet leaves. It must NOT be treated as observed.
const SystemMasks m = masks(/*seen*/ 2, /*explored*/ 2, /*active*/ 0, 0, 0);
CHECK(!IsObservedBy(m, 1));
CHECK(IsExploredBy(m, 1));
const SystemMasks live = masks(2, 2, 2, 2, 0);
CHECK(IsObservedBy(live, 1));
}
static void test_active_recompute() {
// presence | gate | owner, and the owner term is a bit, not a flag.
CHECK_EQ(RecomputeActive(2, 0, 0), std::int32_t{2});
CHECK_EQ(RecomputeActive(0, 4, 0), std::int32_t{4});
CHECK_EQ(RecomputeActive(0, 0, 16), std::int32_t{16});
CHECK_EQ(RecomputeActive(2, 4, 16), std::int32_t{22});
CHECK_EQ(RecomputeActive(0, 0, 0), std::int32_t{0});
}
static void test_explored_sweep() {
CHECK_EQ(ApplyExploredSweep(0, 16), std::int32_t{16});
CHECK_EQ(ApplyExploredSweep(1, 1), std::int32_t{1}); // idempotent
CHECK_EQ(ApplyExploredSweep(2, 0), std::int32_t{2}); // never cleared
CHECK_EQ(ApplyExploredSweep(1, 16), std::int32_t{17}); // accumulates
const std::vector<int> newly = NewlyExploredSlots(1, 0x11);
CHECK_EQ(newly.size(), std::size_t{1});
CHECK_EQ(newly[0], 4);
CHECK_EQ(NewlyExploredSlots(0x11, 0x11).size(), std::size_t{0});
}
static void test_system_stamp() {
// Watched: the stamp becomes this turn.
SystemStampResult r = UpdateSystemStamp(1, /*active*/ 16, /*turn*/ 2);
CHECK_EQ(r.stamp, std::int32_t{2});
CHECK(r.changed);
// Already current: no leaf moves.
r = UpdateSystemStamp(2, 16, 2);
CHECK_EQ(r.stamp, std::int32_t{2});
CHECK(!r.changed);
// Unwatched: the old stamp stands. This is the Bismol case at the system level.
r = UpdateSystemStamp(22, /*active*/ 0, /*turn*/ 23);
CHECK_EQ(r.stamp, std::int32_t{22});
CHECK(!r.changed);
}
// The corpus's one discriminating row, replayed as a rule.
//
// `zuul-turn23-fleet23.sav`, system "Bismol": the visiting fleet has gone, so the sticky and
// explored masks still carry the player's bit while the active mask does not, and the saved
// record's stamp is 22 while the save is turn 23. A model gated on the sticky mask -- or on
// explored -- refreshes it to 23 and is wrong. A model gated on active leaves it alone.
static void test_bismol_freeze() {
std::vector<Observation> before;
Observation o;
o.playerSlot = 1;
o.playerId = 32;
o.turnSeen = 22;
o.encounterId = kNoEncounter;
before.push_back(o);
const std::vector<std::int32_t> slots = {16, 32, 0, 512, 0, 0, 0};
const ObservationUpdate up =
UpdateObservations(before, masks(/*seen*/ 2, /*explored*/ 2, /*active*/ 0, 0, 0), slots,
kNoEncounter, /*turn*/ 23);
CHECK_EQ(up.records.size(), std::size_t{1});
CHECK_EQ(up.records[0].turnSeen, std::int32_t{22}); // FROZEN
CHECK_EQ(up.refreshed, 0);
CHECK_EQ(up.created, 0);
CHECK_EQ(up.untouched, 1);
// The same row under the wrong gate, stated so the failure mode is visible: had the model
// used the sticky mask the record would have moved to 23.
const ObservationUpdate wrong =
UpdateObservations(before, masks(2, 2, /*active*/ 2, 0, 0), slots, kNoEncounter, 23);
CHECK_EQ(wrong.records[0].turnSeen, std::int32_t{23});
CHECK_EQ(wrong.refreshed, 1);
}
static void test_creation_and_refresh() {
const std::vector<std::int32_t> slots = {16, 32, 0, 512, 528, 0, 0, 576};
// Turn 1 -> 2 at Hyperion: no record, active mask carries slot 4, an encounter is there.
ObservationUpdate up = UpdateObservations({}, masks(16, 0, 16, 16, 0), slots,
/*encounter*/ 5, /*turn*/ 2);
CHECK_EQ(up.records.size(), std::size_t{1});
CHECK_EQ(up.created, 1);
CHECK_EQ(up.records[0].playerSlot, 4);
CHECK_EQ(up.records[0].playerId, std::int32_t{528}); // the HANDLE id, not the slot
CHECK_EQ(up.records[0].turnSeen, std::int32_t{2});
CHECK_EQ(up.records[0].encounterId, std::int32_t{5});
// Turn 2 -> 3 at the same system: the stamp moves, the encounter id does not.
const ObservationUpdate next =
UpdateObservations(up.records, masks(16, 16, 16, 16, 0), slots, 5, 3);
CHECK_EQ(next.records.size(), std::size_t{1});
CHECK_EQ(next.created, 0);
CHECK_EQ(next.refreshed, 1);
CHECK_EQ(next.records[0].turnSeen, std::int32_t{3});
CHECK_EQ(next.records[0].encounterId, std::int32_t{5});
CHECK_EQ(next.records[0].playerId, std::int32_t{528});
// A system nobody can see gains nothing. This is Spica: a colony record exists, the
// active mask is zero, and no observation record is ever created.
const ObservationUpdate none =
UpdateObservations({}, masks(0, 0, 0, 0, 0), slots, kNoEncounter, 2);
CHECK_EQ(none.records.size(), std::size_t{0});
CHECK_EQ(none.created, 0);
}
// An existing record keeps its encounter id even if nothing derivable is at the system now.
// The field the original reads is fixed at map generation; re-deriving it every turn is a
// different rule that no save can separate, so the one that cannot lose information is used.
static void test_encounter_id_is_sticky() {
std::vector<Observation> before;
Observation o;
o.playerSlot = 4;
o.playerId = 528;
o.turnSeen = 2;
o.encounterId = 5;
before.push_back(o);
const std::vector<std::int32_t> slots = {16, 32, 0, 512, 528};
const ObservationUpdate up = UpdateObservations(
before, masks(16, 16, 16, 16, 0), slots, /*nothing derivable now*/ kNoEncounter, 3);
CHECK_EQ(up.records[0].encounterId, std::int32_t{5});
CHECK_EQ(up.records[0].turnSeen, std::int32_t{3});
}
// Two observers of one system. Never exercised by any save in the corpus -- every system in
// it has a single-bit active mask -- so this pins the ordering the container imposes rather
// than a behaviour anyone has seen.
static void test_two_observers_order_by_slot() {
const std::vector<std::int32_t> slots = {16, 32, 0, 512, 528, 0, 0, 576};
// slots 7 and 1 both observe; the list must come out 1 then 7.
const ObservationUpdate up =
UpdateObservations({}, masks(0x82, 0, 0x82, 0x82, 0), slots, kNoEncounter, 4);
CHECK_EQ(up.records.size(), std::size_t{2});
CHECK_EQ(up.records[0].playerSlot, 1);
CHECK_EQ(up.records[0].playerId, std::int32_t{32});
CHECK_EQ(up.records[1].playerSlot, 7);
CHECK_EQ(up.records[1].playerId, std::int32_t{576});
}
static void test_sharing_rule() {
Observation a, b;
a.turnSeen = 10;
b.turnSeen = 8;
CHECK(ShouldShareObservation(&a, &b)); // newer donor wins
CHECK(!ShouldShareObservation(&b, &a)); // older donor does not
CHECK(ShouldShareObservation(&a, nullptr));
CHECK(!ShouldShareObservation(nullptr, &a));
b.turnSeen = 10;
CHECK(!ShouldShareObservation(&a, &b)); // equal is not newer
}
int main() {
test_bits();
test_gate_is_the_active_mask();
test_active_recompute();
test_explored_sweep();
test_system_stamp();
test_bismol_freeze();
test_creation_and_refresh();
test_encounter_id_is_sticky();
test_two_observers_order_by_slot();
test_sharing_rule();
return simtest::finish("visibility");
}