Compare commits

...

3 commits

Author SHA1 Message Date
alex
aae2369a47 sync generated header (756 addresses) after turncommands merge 2026-09-08 09:07:14 -04:00
alex
7f0b2a7c0a merge lane Q: TurnCommands_v5 typed; named coverage 100% on all 11 saves; NVs PID tag fix (round-trip defect present at main) 2026-09-08 09:04:20 -04:00
alex
c662f73b69 lane Q: type TurnCommands_v5 -- named coverage to 100% on all 11 saves
The last opaque block of the save format. Reconciles lane W's provable negative
against lane O's issued-order saves: the two disagreed because three different
things were being compared as one.

Read out of Game::TurnCommands::Write (0x00842540, 764 bytes, no loops of its
own). Two halves:

  * a PROLOGUE of six flag-gated groups. Each is a WriteBool on a member,
    followed only when set by that command's payload. Write order is NOT offset
    order -- each gate sits after its payload in the struct -- so the class is
    one of the 89 whose offset-sorted layout view cannot be aligned to the wire.
    That, not a branch, is why the sorted view showed an i32 where the save has
    a bool at item 4.
  * TWENTY-SEVEN std::list<T> members (0x70..0x1a8, stride 0xc, allocator-last),
    each written by its own helper as WriteInt(size) then size element records.
    All 27 are always written, so an empty list still costs one zero int.

So the recovered 44 items are 17 member writes plus ONE ITEM PER CONTAINER CALL
SITE: the linear recovery keeps the call site, guesses its kind from an element
field it could resolve, and drops the count word. 44 - 17 = 27 = the number of
lists. The "27 trailing ints with only 22 i32 slots" objection is that same 27
seen from both sides -- a kind mismatch in the table, not a structural
impossibility. And 8 prologue items + 27 zero counts = the 35-item block every
no-orders save carries bit-identically.

Item arithmetic closes to the unit on all five distinct workloads (35/38/41/61/
123). One correction to the provisional layout: the fleet-move element ends in a
COUNTED route vector {fleetId, nHops, nHops x systemId}, not a fixed quadruple --
with a four-item element the writer would need 26 lists on one save and 28 on
another, and it has 27.

Conformance: the generic check cannot be used here. Every tag is "." so the LCS
degenerates to a strict positional compare in which any primitive disagreement is
fatal, and the table's tail describes elements where the wire has counts. A
dedicated check states what is checkable instead: the 17-item prologue item for
item (17/17, and SchemaProbe takes every branch, so this is real evidence that
the conditional structure read from the instruction stream is the one the
recovery flattened) and the tail count (27 lists vs 27 table items). The tail is
reported as wire-only, never claimed as matched.
  86 shapes / 838 items -> 87 / 856, still 0 MISMATCH.

Twenty-two of the 27 lists are HYPOTHESES: the scalar sequence comes straight off
the helper, but no save exercises them. Nested element bodies not otherwise
modelled here (ShipDesignDef, FleetLayout, WeaponGroups, DefenceLayout,
RaidTargets) are carried as opaque Nodes rather than guessed at, so a wrong body
cannot desynchronise a reader. select() matches the exact ".TurnCommands_v5"
suffix, so a future _v6 falls back to the carried Node.

SECOND DEFECT, present at main and unrelated to this block: zuul-turn23-fleet23
is the first save with a non-empty NVs list, and its element's leading id was
typed positionally as "." where the real tag is PID. The typed round trip on that
save differed at 0x89c14. Rule 6 exactly -- a path no save exercised was a
hypothesis flying as a fact. Fixed, and independently corroborated: the recovered
table for Game::ServerSystem names that item PID, and the Sys row moves from
102 matched / 1 wire-only / 3 shape-only to 103 / 0 / 2.

Coverage (CoverageArchive typed-vs-carried, not round-trip success): all eleven
saves 99.7-99.9% -> 100.0%; opaque items 37/43/63/132 -> 2 everywhere, and those
two are the deliberately-carried MT19937 block. Nothing else in any save we hold
is untyped. Ratchet 99.8 -> 99.99. Round trip byte-identical on all eleven
(newly so on zuul-turn23-fleet23).

test_save now asserts, on every real save, that each TurnCommands_v5 block is
consumed by the prologue plus the 27 lists with nothing left over -- the
item-granular statement a wrong list count or element width breaks first.

clean-room OK; host ctest 36/36; test_save 11 saves 0 failures. src/shim/ not
touched, so no cross-build was needed.
2026-09-08 09:03:11 -04:00
5 changed files with 446 additions and 16 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 @ 612bfe6, generated 2026-09-08 by tools/gen_addresses.py
// Source: sots-re ghidra/addresses.json @ f9b744e, 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>
@ -1351,6 +1351,18 @@ constexpr uint32_t ServerSystem_ComputeMaxIncome = 0x003521c0;
constexpr uint32_t SNMAllCombatDone_layout = 0x00624758;
// data const float 0.5f -- the progress-ratio threshold in ServerPlayer::ProcessTurn's `ResT != NULL && ResErrRoll != 0 && CONST < progressRatio` gate (events.md §3, window 0x008915ec-0x00891624). Read out of dumps/sots.exe: .rdata bytes at 0x00a2c788 are 00 00 00 3f (float 0.5); the following word 0x00a2c78c is float 100.0, so this is a float32, NOT the double an 8-byte read would suggest (that reads as 5.28e13). CONSEQUENCE, measured on the live game: ResErrRoll survives into ProcessResearch only while progress/cost <= 0.5 at the START of the turn, so the OnTechResearched draw (0x0088df20) can fire only when a single turn supplies more than half the target tech's remaining cost. See the board's `research_roll_pending save` row [verified]
constexpr uint32_t ResearchRollProgressThreshold = 0x0062c788;
// thiscall void __thiscall Game::TurnCommands::Write(Mars::IStream* s) -- the writer for the `Player.<id>.TurnCommands_v5` custom-data block (CDT id -> one CD frame). 764 bytes, no loops of its own. Two halves. (1) A PROLOGUE of six flag-gated groups, each a WriteBool on a member followed, only when that bool is set, by the group's payload; write order is NOT offset order, which is why the offset-sorted layout view cannot be aligned to the wire: bool@0x0c gates f32@0x08 (research rate); bool@0x14 gates i32@0x10 (research target tech); bool@0x20 gates i32@0x18 + f32@0x1c (research boost spend + fraction); bool@0x2c gates bool@0x24 + i32@0x28; bool@0x3c gates f32@0x30,0x34,0x38; bool@0x6c gates a StreamableHelper<Game::CivilianRatios> frame on the member at 0x40. i32@0x04 (player id) is written first and unconditionally. (2) TWENTY-SEVEN std::list<T> members at 0x70..0x1a8, stride 0x0c ({_Myhead, _Mysize, _Alval}), each passed to its own free-function writer as helper(stream, &list). Every one is written unconditionally, so an empty list still costs one zero int: 8 prologue items + 27 zero counts = the 35-item block every no-orders save carries [verified]
constexpr uint32_t TurnCommands_Write = 0x00442540;
// thiscall void __thiscall Game::TurnCommands::Read(Mars::IStream* s) -- the reader paired with TurnCommands_Write (0x00842540). 1543 bytes; not decompiled by lane Q, listed so the pair is on the record [mapped]
constexpr uint32_t TurnCommands_Read = 0x00492ae0;
// cdecl void __cdecl (Mars::IStream* s, std::list<T>* moves) -- writer for TurnCommands member +0xc4, the FLEET MOVE list. Per element: WriteInt(fleetId) then a NESTED counted int vector -- n = (v._Mylast - v._Myfirst) >> 2 written with WriteInt, then n ints. So a fleet move on the wire is {fleetId, hopCount, hopCount x systemId}, NOT a fixed {fleetId, 1, destSystemId, 0} quadruple: a multi-hop route is longer. Observed single-hop in human-turn2-orders.sav {1456, 1, [128]} and zuul-turn15-orders.sav {688, 1, [432]} [verified]
constexpr uint32_t TurnCommands_WriteFleetMoveList = 0x0043e550;
// cdecl void __cdecl (Mars::IStream* s, std::list<T>* orders) -- writer for TurnCommands member +0x88, the BUILD ORDER list. Per element four WriteInts taken in DESCENDING member order (node+0x14, +0x10, +0xc, +0x8), so the wire order is {ordinal, designId, systemId, w}. Observed in zuul-turn15-orders.sav (5 orders, designs 608/576, system 384) and zuul-turn17-orders2.sav (20 orders, ordinals 6..25) [verified]
constexpr uint32_t TurnCommands_WriteBuildOrderList = 0x00422870;
// cdecl void __cdecl (Mars::IStream* s, std::list<T>* cmds) -- writer for TurnCommands member +0xa0, the PLANETARY-BUDGET list. Per element WriteInt(systemId) then a StreamableHelper<Game::StarSystem::OutputRates> frame (helper vftable 0x00a1f884), which is the only NAMED sub-frame anywhere in the block (SRs SRt SRsc SRtf SRi SRoh SRnr). Observed once, in zuul-turn17-orders2.sav: system 384 with SRsc = 1.0 [verified]
constexpr uint32_t TurnCommands_WriteSystemRatesList = 0x0042e3d0;
// cdecl void __cdecl (Mars::IStream* s, std::list<T>* orders) -- writer for TurnCommands member +0xb8, the COLONIZE list. Per element two WriteInts (node+0x8 then node+0xc): {shipId, w}. Observed once, in zuul-turn17-orders2.sav: three ships 2512/2544/2624, each with w = 1 [verified]
constexpr uint32_t TurnCommands_WriteColonizeList = 0x00422960;
// thiscall void (ServerPlayer* this, float dt) // RET 4. The per-player turn driver, called once per player from StrategyServer::ProcessTurn's player loop. THE dt ARGUMENT IS NEVER READ: the whole 1086-byte body contains zero [ebp+N] references (mechanical check over the full instruction decode), so a reimplementation may ignore it. Order: ComputeBudget -> Sav = SatAdd(Sav, net) -> record aid given -> ProcessSpecialProjects -> (ResT ? RollResearchAccident/ProcessResearch) -> research refund -> zero TRM/TRA/TRP -> RebAI decay -> timed-bonus sweep -> ResearchRollPending site -> EVENT_NO_RESEARCH -> PruneRaidTargets [verified]
constexpr uint32_t ServerPlayer_ProcessTurn = 0x00491340;
// site site in ServerPlayer::ProcessTurn: `if (this->ResT(+0x294) != 0) { if (!RollResearchAccident(&budget)) TechTree::ProcessResearch(this->TechTree(+0xf4), rng, &budget.researchAlloc, &overBudget); }`. Argument order read off the push order at 0x00891496-0x008914a5: pushes are (edx=&overBudget), (ecx=&allocVector), (eax=rng), so left-to-right the args are (RNG*, vector*, int*). The RNG is `*(ServerPlayer+8 - 4 + 0x16c)`. `overBudget` is a FRESH STACK LOCAL at [ebp-0x14], NOT Budget+0x64 [verified]

View file

@ -485,12 +485,18 @@ struct NveEntry {
ar.i32(A("Eid"), eid);
}
};
// One `NVs` element. `NVs` had count 0 in every save the campaign held until
// zuul-turn23-fleet23, so the element's leading id was typed positionally on the
// assumption that it, like most anonymous elements, carries a "." tag. The first
// save that actually populates the list shows the tag is `PID`, and writing "."
// broke the typed round trip at the first element. Exactly the case rule 6
// describes: a path no save exercised was a hypothesis, not a fact.
struct ViewEntry {
int32_t pid = 0;
PlayerView pview;
template <class Ar>
void io(Ar& ar) {
ar.i32(R("pid"), pid);
ar.i32(A("PID"), pid);
ar.obj(R("pview", "pview"), pview);
}
};
@ -2533,27 +2539,317 @@ struct StrategyAIAgent { // Game::StrategyAIAgent::Streamable
}
};
// One `CD` frame, keyed by the id at the same ordinal in `CDT`. The other id
// suffix in our saves, ".TurnCommands_v5", stays carried: its writer's recovered
// sequence cannot be aligned to a no-orders save even as a subsequence, so it
// needs a save with issued orders before anything can be typed.
// ---- Game::TurnCommands: the ".TurnCommands_v5" custom-data block ----------------
//
// The pending client->server order queue, drained by ApplyTurnCommands; it is NOT
// applied state (a save taken after issuing orders still shows the old research
// rate on the ServerPlayer while this block already carries the new one).
//
// Every item is written with a NULL name, so the whole block is positional and
// nothing here can be matched by tag. The writer (0x00842540) has two halves:
//
// * a prologue of six flag-gated groups. Each group is a `bool` followed, only
// when the bool is set, by that command's payload. A turn with no orders
// writes the six bools and nothing else, which is why every save the campaign
// held before lane O's was bit-identical here at 35 items.
// * twenty-seven `std::list<T>` members, each written by its own helper as
// WriteInt(size) followed by `size` element records. All twenty-seven are
// always written, so an empty list still costs one zero int. 8 prologue items
// + 27 zero counts = the 35-item empty block exactly.
//
// The recovered wire table lists 44 items for this class: the 17 prologue writes
// (all branches, as the recovery always presents them) plus one item per list
// helper, with the element kind guessed and the count word dropped. That tail is
// a flattening artifact, not a description of the wire -- see the note in
// tests/mars_stream/test_wire_schema.cpp, which checks the prologue against the
// table item for item and checks the two counts (17 + 27) instead of pretending
// the tail aligns.
//
// Only five of the twenty-seven lists have ever been observed non-empty (3, 5, 7,
// 8 and 14 below). The rest are typed from the writer's instruction stream alone
// and are HYPOTHESES: the scalar sequence is read directly off the helper, but no
// save exercises it. Where an element holds a nested object whose own body is not
// otherwise modelled here, it is carried as a Node rather than guessed at.
struct TcDesignCmd { // list 1: a StreamableHelper<ShipDesignDef> frame plus an int
Node design;
int32_t value = 0;
};
struct TcInt1 {
int32_t a = 0;
};
struct TcInt2 {
int32_t a = 0, b = 0;
};
struct TcInt3 {
int32_t a = 0, b = 0, c = 0;
};
struct TcInt4 {
int32_t a = 0, b = 0, c = 0, d = 0;
};
struct TcIntBool {
int32_t a = 0;
bool b = false;
};
struct TcInt2Bool {
int32_t a = 0, b = 0;
bool c = false;
};
struct TcIntFloat {
int32_t a = 0;
float b = 0;
};
struct TcIntStr {
int32_t a = 0;
std::string b;
};
struct TcIntNode { // an id plus a nested object this codec does not model
int32_t a = 0;
Node body;
};
struct TcSysRates { // list 5: system id + Game::StarSystem::OutputRates
int32_t sysID = 0;
Rts rates;
};
struct TcNotes { // list 6: Game::PlayerNotes
Note note;
};
struct TcFleetMove { // list 8: fleet id + the route it was given, as system ids
int32_t fleetID = 0;
std::vector<int32_t> route;
};
struct TcCmd09 {
int32_t a = 0;
bool b = false;
int32_t c = 0, d = 0;
float e = 0;
};
struct TcCmd10 {
int32_t a = 0, b = 0;
std::vector<int32_t> ids;
};
struct TcCmd21 {
int32_t a = 0, b = 0;
std::vector<StreamEnum> values; // VectorHelper<StreamableEnum<uint>>
};
struct TcPopulationCmd { // list 23: id + Game::Population
int32_t a = 0;
Population pop;
};
struct TcRaidCmd { // list 26: Game::RaidTargets
Node targets;
};
struct TurnCommands {
static constexpr const char* kStreamName = "CD";
// --- prologue: six flag-gated groups ------------------------------------------
int32_t playerID = 0;
bool hasResearchRate = false;
float researchRate = 0; // the empire savings/research slider, 0.25 by default
bool hasResearchTarget = false;
int32_t researchTarget = 0; // tech id picked on the research screen
bool hasResearchBoost = false;
int32_t researchBoostSpend = 0; // savings spent by the Boost Research panel
float researchBoostFraction = 0;
bool hasGroup4 = false;
bool group4Flag = false;
int32_t group4Value = 0;
bool hasGroup5 = false;
float group5a = 0, group5b = 0, group5c = 0;
bool hasCivilianRatios = false;
CivilianRatios civilianRatios;
// --- twenty-seven counted command lists ----------------------------------------
std::vector<TcDesignCmd> newDesigns; // 1
std::vector<TcInt1> list02; // 2
std::vector<TcInt4> buildOrders; // 3 observed: ordinal, designID, systemID, 0
std::vector<TcInt3> list04; // 4
std::vector<TcSysRates> systemRates; // 5 observed: the planetary budget sliders
std::vector<TcNotes> playerNotes; // 6
std::vector<TcInt2> colonizeOrders; // 7 observed: shipID, 1
std::vector<TcFleetMove> fleetMoves; // 8 observed: fleetID, route
std::vector<TcCmd09> list09; // 9
std::vector<TcCmd10> list10; // 10
std::vector<TcIntBool> list11; // 11
std::vector<TcIntNode> fleetLayouts; // 12 Game::FleetLayout
std::vector<TcIntStr> list13; // 13 an id and a string
std::vector<TcInt2Bool> list14; // 14 observed once, alongside a fleet move
std::vector<TcIntBool> list15; // 15
std::vector<TcIntBool> list16; // 16
std::vector<TcInt3> list17; // 17
std::vector<TcInt3> list18; // 18
std::vector<TcInt1> list19; // 19
std::vector<TcInt1> list20; // 20
std::vector<TcCmd21> list21; // 21
std::vector<TcIntNode> weaponGroups; // 22 Game::WeaponGroups
std::vector<TcPopulationCmd> popCmds; // 23 Game::Population
std::vector<TcIntFloat> list24; // 24
std::vector<TcIntNode> defenceLayouts; // 25 Game::DefenceLayout
std::vector<TcRaidCmd> raidTargets; // 26 Game::RaidTargets
std::vector<TcInt1> list27; // 27
std::vector<Node> extra;
// The number of list members is a fact about the writer, not a guess, and the
// conformance test checks it against the wire table's own tail length.
static constexpr int kListCount = 27;
template <class Ar>
void io(Ar& ar) {
ar.i32(R("playerID"), playerID);
ar.b(R("hasResearchRate"), hasResearchRate);
ar.when(hasResearchRate, [&](Ar& a) { a.f32(R("researchRate"), researchRate); });
ar.b(R("hasResearchTarget"), hasResearchTarget);
ar.when(hasResearchTarget, [&](Ar& a) { a.i32(R("researchTarget"), researchTarget); });
ar.b(R("hasResearchBoost"), hasResearchBoost);
ar.when(hasResearchBoost, [&](Ar& a) {
a.i32(R("researchBoostSpend"), researchBoostSpend);
a.f32(R("researchBoostFraction"), researchBoostFraction);
});
ar.b(R("hasGroup4"), hasGroup4);
ar.when(hasGroup4, [&](Ar& a) {
a.b(R("group4Flag"), group4Flag);
a.i32(R("group4Value"), group4Value);
});
ar.b(R("hasGroup5"), hasGroup5);
ar.when(hasGroup5, [&](Ar& a) {
a.f32(R("group5a"), group5a);
a.f32(R("group5b"), group5b);
a.f32(R("group5c"), group5c);
});
ar.b(R("hasCivilianRatios"), hasCivilianRatios);
ar.when(hasCivilianRatios, [&](Ar& a) { a.obj(R("civilianRatios"), civilianRatios); });
ar.narr(R("nDesigns"), newDesigns, [](Ar& a, TcDesignCmd& e) {
a.any(R("design"), e.design);
a.i32(R("value"), e.value);
});
ar.narr(R("n02"), list02, [](Ar& a, TcInt1& e) { a.i32(R("a"), e.a); });
ar.narr(R("nBuild"), buildOrders, [](Ar& a, TcInt4& e) {
a.i32(R("ordinal"), e.a);
a.i32(R("designID"), e.b);
a.i32(R("systemID"), e.c);
a.i32(R("d"), e.d);
});
ar.narr(R("n04"), list04, [](Ar& a, TcInt3& e) {
a.i32(R("a"), e.a);
a.i32(R("b"), e.b);
a.i32(R("c"), e.c);
});
ar.narr(R("nSysRates"), systemRates, [](Ar& a, TcSysRates& e) {
a.i32(R("systemID"), e.sysID);
a.obj(R("rates"), e.rates);
});
ar.narr(R("nNotes"), playerNotes, [](Ar& a, TcNotes& e) { a.obj(R("note"), e.note); });
ar.narr(R("nColonize"), colonizeOrders, [](Ar& a, TcInt2& e) {
a.i32(R("shipID"), e.a);
a.i32(R("b"), e.b);
});
ar.narr(R("nFleetMoves"), fleetMoves, [](Ar& a, TcFleetMove& e) {
a.i32(R("fleetID"), e.fleetID);
a.narr(R("nRoute"), e.route, [](Ar& b, int32_t& s) { b.i32(R("systemID"), s); });
});
ar.narr(R("n09"), list09, [](Ar& a, TcCmd09& e) {
a.i32(R("a"), e.a);
a.b(R("b"), e.b);
a.i32(R("c"), e.c);
a.i32(R("d"), e.d);
a.f32(R("e"), e.e);
});
ar.narr(R("n10"), list10, [](Ar& a, TcCmd10& e) {
a.i32(R("a"), e.a);
a.i32(R("b"), e.b);
a.narr(R("nIDs"), e.ids, [](Ar& b, int32_t& s) { b.i32(R("id"), s); });
});
ar.narr(R("n11"), list11, [](Ar& a, TcIntBool& e) {
a.i32(R("a"), e.a);
a.b(R("b"), e.b);
});
ar.narr(R("nFleetLayouts"), fleetLayouts, [](Ar& a, TcIntNode& e) {
a.i32(R("a"), e.a);
a.any(R("layout"), e.body);
});
ar.narr(R("n13"), list13, [](Ar& a, TcIntStr& e) {
a.i32(R("a"), e.a);
a.str(R("b"), e.b);
});
ar.narr(R("n14"), list14, [](Ar& a, TcInt2Bool& e) {
a.i32(R("a"), e.a);
a.i32(R("b"), e.b);
a.b(R("c"), e.c);
});
ar.narr(R("n15"), list15, [](Ar& a, TcIntBool& e) {
a.i32(R("a"), e.a);
a.b(R("b"), e.b);
});
ar.narr(R("n16"), list16, [](Ar& a, TcIntBool& e) {
a.i32(R("a"), e.a);
a.b(R("b"), e.b);
});
ar.narr(R("n17"), list17, [](Ar& a, TcInt3& e) {
a.i32(R("a"), e.a);
a.i32(R("b"), e.b);
a.i32(R("c"), e.c);
});
ar.narr(R("n18"), list18, [](Ar& a, TcInt3& e) {
a.i32(R("a"), e.a);
a.i32(R("b"), e.b);
a.i32(R("c"), e.c);
});
ar.narr(R("n19"), list19, [](Ar& a, TcInt1& e) { a.i32(R("a"), e.a); });
ar.narr(R("n20"), list20, [](Ar& a, TcInt1& e) { a.i32(R("a"), e.a); });
ar.narr(R("n21"), list21, [](Ar& a, TcCmd21& e) {
a.i32(R("a"), e.a);
a.i32(R("b"), e.b);
a.carr(R("values"), e.values);
});
ar.narr(R("nWeaponGroups"), weaponGroups, [](Ar& a, TcIntNode& e) {
a.i32(R("a"), e.a);
a.any(R("groups"), e.body);
});
ar.narr(R("nPop"), popCmds, [](Ar& a, TcPopulationCmd& e) {
a.i32(R("a"), e.a);
a.obj(R("pop"), e.pop);
});
ar.narr(R("n24"), list24, [](Ar& a, TcIntFloat& e) {
a.i32(R("a"), e.a);
a.f32(R("b"), e.b);
});
ar.narr(R("nDefenceLayouts"), defenceLayouts, [](Ar& a, TcIntNode& e) {
a.i32(R("a"), e.a);
a.any(R("layout"), e.body);
});
ar.narr(R("nRaidTargets"), raidTargets, [](Ar& a, TcRaidCmd& e) { a.any(R("targets"), e.targets); });
ar.narr(R("n27"), list27, [](Ar& a, TcInt1& e) { a.i32(R("a"), e.a); });
ar.rest(extra);
}
};
// One `CD` frame, keyed by the id at the same ordinal in `CDT`. Both id suffixes
// the campaign has seen are now typed; anything else is still carried verbatim.
struct CustomDataBlock {
static constexpr const char* kStreamName = "CD";
enum class Which { Unknown, AIAgent };
enum class Which { Unknown, AIAgent, TurnCmds };
Which which = Which::Unknown;
StrategyAIAgent aiAgent;
TurnCommands turnCommands;
std::vector<Node> unknown;
static bool ends_with(const std::string& s, const char* suf) {
const size_t n = std::char_traits<char>::length(suf);
return s.size() >= n && s.compare(s.size() - n, n, suf) == 0;
}
void select(const std::string& id) {
static const std::string suffix = ".AIAgent";
which = (id.size() >= suffix.size() && id.compare(id.size() - suffix.size(), suffix.size(), suffix) == 0)
? Which::AIAgent
: Which::Unknown;
// The version is part of the id, so a future ".TurnCommands_v6" falls back
// to the carried Node instead of being decoded with a stale layout.
if (ends_with(id, ".AIAgent")) which = Which::AIAgent;
else if (ends_with(id, ".TurnCommands_v5")) which = Which::TurnCmds;
else which = Which::Unknown;
}
template <class Ar>
void io(Ar& ar) {
switch (which) {
case Which::AIAgent: aiAgent.io(ar); break;
case Which::TurnCmds: turnCommands.io(ar); break;
case Which::Unknown: ar.rest(unknown); break;
}
}

View file

@ -84,6 +84,22 @@ static void check_save(const std::string& path, const char* dump_dir) {
for (const auto& fe : sim.fleets) CHECK(fe.flt.ships.size() >= 1);
CHECK(!doc.game.cdTable.ids.empty());
// Every ".TurnCommands_v5" block must be consumed by the prologue plus the 27
// counted lists with nothing left over. `extra` is the tail `ar.rest()` would
// absorb, so an empty `extra` is the item-granular statement that the model
// accounts for the whole frame -- on the 35-item empty blocks and on the
// 123-item one alike. It is what a wrong list count or a wrong element width
// would break first.
for (const auto& cd : doc.game.customData) {
if (cd.which != shapes::CustomDataBlock::Which::TurnCmds) continue;
CHECK(cd.turnCommands.extra.empty());
CHECK(cd.turnCommands.playerID != 0);
std::printf(" TurnCommands: player %d, rate %.4g, %zu build, %zu sysRate, %zu colonize, %zu move\n",
cd.turnCommands.playerID, double(cd.turnCommands.researchRate),
cd.turnCommands.buildOrders.size(), cd.turnCommands.systemRates.size(),
cd.turnCommands.colonizeOrders.size(), cd.turnCommands.fleetMoves.size());
}
// --- RNG blob: mt[624] + left, produced by our MT19937 from RSeed --------------
CHECK(sim.rng.is_complex() && sim.rng.children.size() == 1);
const Node& blob = sim.rng.children[0];
@ -123,7 +139,9 @@ static void check_save(const std::string& path, const char* dump_dir) {
std::printf(" %s=%zu", worst[i].second.c_str(), worst[i].first);
std::printf("\n");
// Ratchet, not a target: typing a body must never silently regress.
CHECK(pct >= 99.8);
// With TurnCommands_v5 typed, the only items left carried as Nodes are the
// two of the MT19937 block, which is deliberately opaque.
CHECK(pct >= 99.99);
}
// --- round trips ------------------------------------------------------------------

View file

@ -720,9 +720,18 @@ struct CdOnly {
static void test_customdata_dispatch() {
CdOnly doc;
doc.cdTable.ids = {"Player.00000016.TurnCommands_v5", "Player.00000032.AIAgent"};
shapes::CustomDataBlock tc; // not modelled: carried verbatim
shapes::CustomDataBlock tc;
tc.select(doc.cdTable.ids[0]);
tc.unknown.push_back(Node::int32(".", 42));
tc.turnCommands.playerID = 16;
tc.turnCommands.hasResearchRate = true;
tc.turnCommands.researchRate = 0.97f;
tc.turnCommands.buildOrders.push_back({1, 608, 384, 0});
// A two-hop route: the fleet-move element ends in a COUNTED vector of system
// ids, not a fixed pair, so a multi-hop order is longer than a single-hop one.
shapes::TcFleetMove mv;
mv.fleetID = 688;
mv.route = {432, 512};
tc.turnCommands.fleetMoves.push_back(mv);
doc.customData.push_back(tc);
shapes::CustomDataBlock agent;
agent.select(doc.cdTable.ids[1]);
@ -740,8 +749,16 @@ static void test_customdata_dispatch() {
back.io(ra);
CHECK_EQ(count(issues, Issue::Error), size_t(0));
CHECK(back.customData.size() == 2);
CHECK(back.customData[0].which == shapes::CustomDataBlock::Which::Unknown);
CHECK(back.customData[0].unknown.size() == 1);
CHECK(back.customData[0].which == shapes::CustomDataBlock::Which::TurnCmds);
{
const shapes::TurnCommands& t = back.customData[0].turnCommands;
CHECK(t.playerID == 16 && t.hasResearchRate && t.researchRate == 0.97f);
CHECK(!t.hasResearchTarget && !t.hasResearchBoost && !t.hasCivilianRatios);
CHECK(t.buildOrders.size() == 1 && t.buildOrders[0].b == 608 && t.buildOrders[0].c == 384);
CHECK(t.fleetMoves.size() == 1 && t.fleetMoves[0].fleetID == 688);
CHECK(t.fleetMoves[0].route.size() == 2 && t.fleetMoves[0].route[1] == 512);
CHECK(t.extra.empty()); // the 27 lists account for the whole tail
}
CHECK(back.customData[1].which == shapes::CustomDataBlock::Which::AIAgent);
CHECK(back.customData[1].aiAgent.fct == 9 && back.customData[1].aiAgent.techScores.size() == 2);
Writer w2;
@ -756,6 +773,12 @@ static void test_customdata_dispatch() {
CHECK(probe.which == shapes::CustomDataBlock::Which::Unknown);
probe.select(".AIAgent");
CHECK(probe.which == shapes::CustomDataBlock::Which::AIAgent);
probe.select("Player.00000016.TurnCommands_v5");
CHECK(probe.which == shapes::CustomDataBlock::Which::TurnCmds);
// The layout is only known for _v5. A different version must fall back to the
// carried Node rather than be decoded with a stale shape.
probe.select("Player.00000016.TurnCommands_v6");
CHECK(probe.which == shapes::CustomDataBlock::Which::Unknown);
}
// --- 9. gzip container ---------------------------------------------------------------

View file

@ -160,6 +160,81 @@ static void check(const char* shape_name, const char* cls) {
CHECK(r.mismatch == 0);
}
// --- Game::TurnCommands -------------------------------------------------------
//
// The generic check above cannot be used here, and the reason is the finding.
//
// Every item in this class is written with a NULL name, so the LCS degenerates:
// with all tags equal to "." the gap branches are unreachable and the walk becomes
// a strict positional comparison in which every primitive disagreement is a
// MISMATCH. That is fine when the two sequences describe the same thing. They do
// not. The writer's 44 recovered items are 17 member writes plus ONE ITEM PER
// CONTAINER CALL SITE -- there are exactly 27 std::list members, each written by
// its own helper as WriteInt(size) followed by `size` element records. The
// recovery, being a linear pass, keeps the call site, guesses its kind from an
// element field, and drops the count word. So the table's tail names 27 things
// that are on the wire as 27 counts (plus elements), and comparing an element kind
// against a count is comparing two different items.
//
// What IS checkable, and is checked here:
// * the 17-item prologue, item for item and primitive for primitive. Our shape
// models the six conditional groups the writer really has; SchemaProbe takes
// every branch, which is the same view the recovery has, so the two must agree
// exactly. They do -- 17/17 -- which is the evidence that the branch structure
// read out of the instruction stream is the one the recovery flattened.
// * the tail COUNT: the shape has exactly 27 top-level counted lists and the
// table has exactly 27 tail items. 27 == 27 is the whole content of that half
// of the table, and on a no-orders save it is also why the block is 8 + 27 = 35
// items: every list is empty and still writes its zero count.
static void check_turn_commands() {
using S = SchemaProbe::S;
const char* cls = "Game::TurnCommands";
const sots::wire::Class* c = sots::wire::find(cls);
if (!c) {
std::printf(" %-18s -> %-34s NOT IN TABLE\n", "TurnCommands", cls);
++fails;
return;
}
SchemaProbe p = SchemaProbe::of<sh::TurnCommands>();
size_t prologue = 0;
while (prologue < p.items.size() && !(p.items[prologue].member && p.items[prologue].shape == S::NArr)) ++prologue;
int lists = 0, opaque = 0;
for (const SchemaProbe::Item& i : p.items) {
if (i.member && i.shape == S::NArr) ++lists;
opaque += i.opaque;
}
int matched = 0, mismatch = 0;
const size_t n = std::min(prologue, size_t(c->count));
for (size_t i = 0; i < n; ++i) {
if (p.items[i].tag == c->fields[i].tag && prim_ok(p.items[i].prim, c->fields[i].prim)) {
++matched;
} else {
++mismatch;
std::printf(" MISMATCH prologue[%zu] shape %-6s vs wire %-6s\n", i,
probe_prim_name(p.items[i].prim), sots::wire::prim_name(c->fields[i].prim));
}
}
const int tail = int(c->count) - int(prologue);
++bound;
total_matched += matched;
total_mismatch += mismatch;
total_wire_only += tail; // the flattened container tail: reported, never claimed as matched
total_opaque += opaque;
std::printf(" %-18s -> %-34s [%-8s %2u/%-2u] shape %2zu wire %2u match %2d"
" wire-only %2d shape-only %2d opaque %d\n",
"TurnCommands", cls, c->grade, c->read_agree, c->read_comparable, p.items.size(), c->count,
matched, tail, 0, opaque);
std::printf(" prologue %d/%zu items agree; tail: %d wire item(s) vs %d container writer(s)"
" (one per call site, counts dropped by the linear recovery)\n",
matched, prologue, tail, lists);
CHECK(mismatch == 0);
CHECK(prologue == 17); // six flag-gated groups, all branches
CHECK(lists == sh::TurnCommands::kListCount); // 27 std::list members
CHECK(tail == lists); // and the table has one item per list
}
int main() {
std::printf("wire schema: %zu classes, generated from the game's serializers\n\n",
sots::wire::kClassCount);
@ -280,6 +355,12 @@ int main() {
"Game::Mars::Game::W4WeaponFamilyID::U?$StreamableEnum::?$AIWeightMap");
check<sh::AIWeightMapEnum>("AIWeightMap<uint>", "Game::Mars::I::U?$StreamableEnum::?$AIWeightMap");
// --- the TurnCommands custom-data block ---------------------------------------
// Bound by a dedicated check; see the note above check_turn_commands(). The
// element bodies it reaches are already bound above (OutputRates as Rts,
// Population, CivilianRatios, PlayerNotes as Note).
check_turn_commands();
std::printf(
"\ntotals: %d shapes bound, %d items matched, %d MISMATCH, %d wire-only, "
"%d opaque item(s) in bound shapes\n",