// Conformance: every typed shape in shapes.h vs the wire schema recovered from // the game's own Mars::IStreamable serializers (include/generated/sots_stream_schema.h). // // Why a check and not a generator. The recovered table is a *specification*, not // a program: the recovery is a linear pass over the game's Write and cannot see // Write's branches, so a conditional field (StarShip's BQ2, gated by hbq) is // listed unconditionally and the sequence is a superset of any single record. // Container loops are flattened the same way. A codec driven straight off the // table would desynchronise on the first branch. The hand-written io() shapes // stay the codec — they can express the conditionals and the nesting that the // binary facts cannot supply — and this test is what proves they agree with the // binary, item for item, in order. // // SchemaProbe runs each io() with every branch taken, which is the same "all // branches" view the recovery has, so the two sequences are comparable. They // are aligned with an LCS and three numbers come out: // // matched the shape and the binary agree on tag and on-disk primitive // MISMATCH same tag, different primitive — a real bug, and a hard failure // wire-only the binary writes an item the shape does not name: either a // conditional the shape models with opt_*/when, or genuine // coverage debt // shape-only the shape names an item the recovery did not resolve // // Opaque items (ar.any / ar.raw_frame — a body carried as a Node) are counted // separately: they round-trip byte-for-byte but are not understood, and the // count is this codebase's honest coverage number. // // No game data, no saves: the whole test is the generated table plus shapes.h. #include #include #include #include #include "generated/sots_stream_schema.h" #include "mars/stream/probe.h" #include "mars/stream/shapes.h" using namespace mars::stream; namespace sh = mars::stream::shapes; static int fails = 0; #define CHECK(cond) \ do { \ if (!(cond)) { \ std::printf("FAIL %s:%d: %s\n", __FILE__, __LINE__, #cond); \ ++fails; \ } \ } while (0) // --- primitive compatibility ------------------------------------------------- // The generated Prim is the DISK type: a member the original holds as int16 or // int8 is written by Stream::WriteInt and is I32 on the wire, so there is no // narrow integer to reconcile here. static bool prim_ok(SchemaProbe::P got, sots::wire::Prim want) { using P = SchemaProbe::P; using W = sots::wire::Prim; if (want == W::Unknown) return true; // recovery could not type it switch (got) { case P::Unknown: return true; // opaque on our side: any disk type fits case P::I32: return want == W::I32; case P::I64: return want == W::I64; case P::F32: return want == W::F32; case P::Bool: return want == W::Bool; case P::Str: return want == W::Str; case P::Frame: return want == W::Frame; case P::Raw: return want == W::Raw || want == W::I64; } return false; } struct Result { int matched = 0, mismatch = 0, wire_only = 0, shape_only = 0; int opaque = 0; }; // LCS alignment on (tag, compatible primitive). Sequences are short (<= 120). static Result align(const std::vector& a, const sots::wire::Class& c, bool verbose) { const size_t n = a.size(), m = c.count; std::vector> dp(n + 1, std::vector(m + 1, 0)); auto eq = [&](size_t i, size_t j) { return a[i].tag == c.fields[j].tag && prim_ok(a[i].prim, c.fields[j].prim); }; for (size_t i = n; i-- > 0;) for (size_t j = m; j-- > 0;) dp[i][j] = eq(i, j) ? dp[i + 1][j + 1] + 1 : std::max(dp[i + 1][j], dp[i][j + 1]); Result r; for (const SchemaProbe::Item& it : a) r.opaque += it.opaque; size_t i = 0, j = 0; while (i < n && j < m) { if (eq(i, j)) { ++r.matched; ++i; ++j; continue; } // Same tag, incompatible primitive: not a gap, a disagreement. if (a[i].tag == c.fields[j].tag) { ++r.mismatch; std::printf(" MISMATCH %-20s shape %-6s vs wire %-6s\n", a[i].tag.c_str(), probe_prim_name(a[i].prim), sots::wire::prim_name(c.fields[j].prim)); ++i; ++j; continue; } if (dp[i + 1][j] >= dp[i][j + 1]) { ++r.shape_only; if (verbose) std::printf(" shape-only %-20s %s\n", a[i].tag.c_str(), probe_prim_name(a[i].prim)); ++i; } else { ++r.wire_only; if (verbose) std::printf(" wire-only %-20s %s %s\n", c.fields[j].tag, sots::wire::prim_name(c.fields[j].prim), sots::wire::shape_name(c.fields[j].shape)); ++j; } } for (; i < n; ++i) { ++r.shape_only; if (verbose) std::printf(" shape-only %-20s %s\n", a[i].tag.c_str(), probe_prim_name(a[i].prim)); } for (; j < m; ++j) { ++r.wire_only; if (verbose) std::printf(" wire-only %-20s %s %s\n", c.fields[j].tag, sots::wire::prim_name(c.fields[j].prim), sots::wire::shape_name(c.fields[j].shape)); } return r; } static int total_matched = 0, total_mismatch = 0, total_wire_only = 0, total_opaque = 0; static int bound = 0, unresolved_classes = 0; template static void check(const char* shape_name, const char* cls) { const sots::wire::Class* c = sots::wire::find(cls); if (!c) { std::printf(" %-18s -> %-34s NOT IN TABLE\n", shape_name, cls); ++fails; return; } SchemaProbe p = SchemaProbe::of(); bool verbose = std::getenv("SOTS_WIRE_VERBOSE") != nullptr; Result r = align(p.items, *c, verbose); ++bound; total_matched += r.matched; total_mismatch += r.mismatch; total_wire_only += r.wire_only; total_opaque += r.opaque; std::printf(" %-18s -> %-34s [%-8s %2u/%-2u] shape %2zu wire %2u match %2d" " wire-only %2d shape-only %2d opaque %d\n", shape_name, cls, c->grade, c->read_agree, c->read_comparable, p.items.size(), c->count, r.matched, r.wire_only, r.shape_only, r.opaque); // A tag both sides name, typed differently, is a real disagreement between // our codec and the binary. Nothing else here is allowed to fail the build: // wire-only is conditional-field or coverage debt and is reported, not fatal. 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); // --- the table's own integrity ------------------------------------------------- for (const sots::wire::Class& c : sots::wire::kClasses) { if (c.count == 0) continue; for (uint16_t k = 0; k < c.count; ++k) unresolved_classes += c.fields[k].unresolved; } std::printf("shape -> class (recovery tier, Read/Write cross-check)\n"); // Bindings taken from the campaign's own shape->Write table // (sots-re tools/serializers_golden.py DISK_ORDER, resolved through RTTI), // extended with unambiguous name matches. check("Summary", "Game::StrategyGameInfo"); check("Slot", "Game::SlotDef"); check("PlayerSettings", "Game::StrategyPlayerGameSettings"); check("PlayerColor", "Game::PlayerColorID"); check("Session", "Game::StrategySessionParams"); check("CreateParams", "Game::StrategyGameCreateParams"); check("Scrp", "Game::StrategyScriptParams"); check("MapP", "Game::StarMapParams"); check("Planet", "Game::SystemParams"); check("Sim", "Game::StrategyServer"); check("Sys", "Game::ServerSystem"); check("Player", "Game::ServerPlayer"); check("Fleet", "Game::StarFleet"); check("Ship", "Game::StarShip"); check("FlightPlan", "Game::FlightPlan"); check("Waypoint", "Game::FlightPlan::Waypoint"); check("PrisonerHold", "Game::PrisonerHold"); check("Otch", "Game::ObservedTech"); check("Owep", "Game::ObservedWeapon"); check("Odes", "Game::ObservedDesign"); check("Rts", "Game::StarSystem::OutputRates"); check("PlayerView", "Game::StarSystem::PlayerView"); check("IndependenceInfo", "Game::IndependenceInfo"); check("BuildOrder", "Game::ShipBuildOrder"); check("BuildQueue", "Game::BuildQueue"); check("Prep", "Game::PlayerReport"); check("DipStat", "Game::DiplomacyStats"); check("Alliances", "Game::PlayerAlliances"); check("NodeRoute", "Game::NodeRoute"); check("SystemEvent", "Game::SystemEvent"); check("PlayerTurnStats", "Game::PlayerTurnStats"); check("PlayerTurnHistory", "Game::PlayerTurnHistory"); check("TurnStats", "Game::GameTurnHistory"); check("PopG", "Game::PopulationGroup"); check("Population", "Game::Population"); check("Morale", "Game::Morale"); check("MoraleEvent", "Game::MoraleEvent"); // Game::SimpleNodePath is a different, 2-item type — it is what MapP.nodePaths // holds (VectorHelper), and it is empty in every real save. check("NodePath", "Game::NodePath"); // Bodies typed this round from the recovered schema (previously opaque Nodes). check("EventStorage", "Game::EventStorage"); check("TurnEvents", "Game::EventStorage::TurnEvents"); check("EventRec", "Game::EventStorage::Event"); check("FleetNameGen", "Game::FleetNameGenerator"); check("SpeciesRatios", "Game::SpeciesRatios"); check("CivilianRatios", "Game::CivilianRatios"); check("TechTree", "Game::TechTree"); check("ShipRecords", "Game::ShipRecords"); check("AIEncounterFlags", "Game::AIEncounterFlags"); check("PlayerAid", "Game::PlayerAid"); check("CommMessages", "Game::CommMessageContainer"); check("SpyReport", "Game::SpyReport"); check("SpyManager", "Game::ServerSpyManager"); check("ProjectNames", "Game::SpecialProjectNameGen"); check("TradeManager", "Game::ServerTradeManagerImpl"); check("TradeSector", "Game::ServerTradeSector"); check("ShipSectionID", "Game::ShipSectionID"); check("DesignSection", "Game::ShipDesignDef::Section"); // Bodies typed by lane WS from the trade/spy workload saves. Every one of // these was carried as a Node until a save existed that put content in it; // the schema was always there, the data was not (rule 6). check("WeaponGroups", "Game::WeaponGroups"); check("GunBankSelection", "Game::GunBankSelection"); check("FreighterWarning", "Game::ServerTradeSector::FreighterWarning"); check("TradeRoute", "Game::TradeRoute"); check("SpyCraft", "Game::SpyCraft"); check("FleetLayout", "Game::FleetLayout"); // Game::FieldTemplate::Point is deliberately NOT bound: FTPnts is empty in // every save and the recovery marks the item unresolved, so its element // framing is a hypothesis and FieldTemplate carries the elements (see the // note on sh::FieldTemplate). check("FieldTemplate", "Game::FieldTemplate"); check("Crep", "Game::CombatReport"); check("CrepPrep", "Game::CombatPlayerReport"); check("Srep", "Game::CombatShipReport"); check("Wrep", "Game::CombatWeaponReport"); check("TacReport", "Game::TacReport"); check("TacReportEvents", "Game::TacReportEvents"); // --- SvSctOb: Game::SVSOSots and the variant bodies it dispatches to ------- // The variant SELECTION (xscn name / EncID -> class) is not on the wire and so // is not checkable here; it came from the game's own factories. What this // does check is that each body we bind agrees with that class's serializer. check("ScriptObjects", "Game::SVSOSots"); check("SVSOVonNeumann", "Game::SVSOVonNeumann"); check("VonNeumannDefeat", "Game::SVSOVonNeumann::DefeatRecord"); check("SVSOSwarm", "Game::SVSOSwarm"); check("SwarmInfestation", "Game::SVSOSwarm::Infestation"); check("SVSODerelict", "Game::SVSODerelict"); check("SVSOMonitor", "Game::SVSOMonitor"); check("SVSOSlaversRefuel", "Game::SVSOSlaversRefuel"); check("SVSOSwarmQueen", "Game::SVSOSwarmQueen"); check("SVSOSwarmQueenHive", "Game::SVSOSwarmQueen::HiveInfo"); check("SVSOSwarmQueenQueen", "Game::SVSOSwarmQueen::QueenInfo"); check("SVSOCrowRuins", "Game::SVSOCrowRuins"); check("SVSORefugees", "Game::SVSORefugees"); check("RefugeeStatus", "Game::SVSORefugees::PlayerStatus"); check("SVSOTraps", "Game::SVSOTraps"); check("SVSOTrap", "Game::SVSOTraps::Trap"); check("SVSOCrowDefenders", "Game::SVSOCrowDefenders"); check("SVSOGrandMenaceTrigger", "Game::SVSOGrandMenaceTrigger"); // --- the AIAgent custom-data block ------------------------------------------ // The block SELECTION (a CDT id ending ".AIAgent") is not a wire item, so it is // not checkable here; what is checked is that each body agrees with its class's // serializer. Game::AIPlayerRequestStamp has no entry: it is a POD reached only // through a specialised helper, so it has no RTTI class for the recovery to find. check("StrategyAIAgent", "Game::StrategyAIAgent::Streamable"); check("AttribMap", "Game::AttribMap"); check("AISituation", "Game::AISituation"); check("AISystem", "Game::AISystem"); check("DesignNameGen", "Game::StrategyAIAgent::DesignNameGen"); check("AICombatReport", "Game::AICombatReport"); check("CombatPlayerStats", "Game::CombatPlayerStats"); check("AIAutoPeaceRun", "Game::AIAutoPeaceRun"); // One shape covers all four Game::AIWeightMap instantiations: they differ // only in the key body, which is its own frame on the wire. check("AIWeightMap", "Game::Game::VShipSectionID::?$AIWeightMap"); check("AIWeightMap", "Game::Mars::Game::W4AIPurposeID::U?$StreamableEnum::?$AIWeightMap"); check("AIWeightMap", "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", bound, total_matched, total_mismatch, total_wire_only, total_opaque); std::printf("table: %d item(s) the recovery itself could not type\n", unresolved_classes); std::printf("test_wire_schema: %s (%d failures)\n", fails ? "FAILED" : "ok", fails); return fails ? 1 : 0; }