From c662f73b699d3cfbcc289ddf5a2d3ec7999bee5f Mon Sep 17 00:00:00 2001 From: alex Date: Tue, 8 Sep 2026 09:03:11 -0400 Subject: [PATCH] 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 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. --- src/mars/stream/shapes.h | 316 ++++++++++++++++++++++++- tests/mars_stream/test_save.cpp | 20 +- tests/mars_stream/test_stream.cpp | 31 ++- tests/mars_stream/test_wire_schema.cpp | 81 +++++++ 4 files changed, 433 insertions(+), 15 deletions(-) diff --git a/src/mars/stream/shapes.h b/src/mars/stream/shapes.h index 79f2235..02b90b3 100644 --- a/src/mars/stream/shapes.h +++ b/src/mars/stream/shapes.h @@ -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 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` 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 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 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 ids; +}; +struct TcCmd21 { + int32_t a = 0, b = 0; + std::vector values; // VectorHelper> +}; +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 newDesigns; // 1 + std::vector list02; // 2 + std::vector buildOrders; // 3 observed: ordinal, designID, systemID, 0 + std::vector list04; // 4 + std::vector systemRates; // 5 observed: the planetary budget sliders + std::vector playerNotes; // 6 + std::vector colonizeOrders; // 7 observed: shipID, 1 + std::vector fleetMoves; // 8 observed: fleetID, route + std::vector list09; // 9 + std::vector list10; // 10 + std::vector list11; // 11 + std::vector fleetLayouts; // 12 Game::FleetLayout + std::vector list13; // 13 an id and a string + std::vector list14; // 14 observed once, alongside a fleet move + std::vector list15; // 15 + std::vector list16; // 16 + std::vector list17; // 17 + std::vector list18; // 18 + std::vector list19; // 19 + std::vector list20; // 20 + std::vector list21; // 21 + std::vector weaponGroups; // 22 Game::WeaponGroups + std::vector popCmds; // 23 Game::Population + std::vector list24; // 24 + std::vector defenceLayouts; // 25 Game::DefenceLayout + std::vector raidTargets; // 26 Game::RaidTargets + std::vector list27; // 27 + std::vector 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 + 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 unknown; + static bool ends_with(const std::string& s, const char* suf) { + const size_t n = std::char_traits::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 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; } } diff --git a/tests/mars_stream/test_save.cpp b/tests/mars_stream/test_save.cpp index fc1e985..e257514 100644 --- a/tests/mars_stream/test_save.cpp +++ b/tests/mars_stream/test_save.cpp @@ -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 ------------------------------------------------------------------ diff --git a/tests/mars_stream/test_stream.cpp b/tests/mars_stream/test_stream.cpp index 73f8947..5b1c479 100644 --- a/tests/mars_stream/test_stream.cpp +++ b/tests/mars_stream/test_stream.cpp @@ -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 --------------------------------------------------------------- diff --git a/tests/mars_stream/test_wire_schema.cpp b/tests/mars_stream/test_wire_schema.cpp index 586697a..db3b2e4 100644 --- a/tests/mars_stream/test_wire_schema.cpp +++ b/tests/mars_stream/test_wire_schema.cpp @@ -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(); + + 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("AIWeightMap", "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",