Compare commits
3 commits
54b0353067
...
06fe75880c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
06fe75880c | ||
|
|
c51b8d0c39 | ||
|
|
a1910becb0 |
11 changed files with 763 additions and 6 deletions
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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}/..)
|
||||
|
|
|
|||
|
|
@ -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"},
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
175
src/app/visibility_phase.cpp
Normal file
175
src/app/visibility_phase.cpp
Normal 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
|
||||
55
src/app/visibility_phase.h
Normal file
55
src/app/visibility_phase.h
Normal 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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
78
src/game/sim/visibility.cpp
Normal file
78
src/game/sim/visibility.cpp
Normal 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
146
src/game/sim/visibility.h
Normal 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
|
||||
|
|
@ -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})
|
||||
|
|
|
|||
203
tests/game_sim/test_visibility.cpp
Normal file
203
tests/game_sim/test_visibility.cpp
Normal 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");
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue