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",