sots-engine/tests/mars_stream/test_wire_schema.cpp
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

371 lines
19 KiB
C++

// 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 <cstdio>
#include <cstring>
#include <string>
#include <vector>
#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<SchemaProbe::Item>& a, const sots::wire::Class& c, bool verbose) {
const size_t n = a.size(), m = c.count;
std::vector<std::vector<int>> dp(n + 1, std::vector<int>(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 <class T>
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<T>();
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<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);
// --- 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<sh::Summary>("Summary", "Game::StrategyGameInfo");
check<sh::Slot>("Slot", "Game::SlotDef");
check<sh::PlayerSettings>("PlayerSettings", "Game::StrategyPlayerGameSettings");
check<sh::PlayerColor>("PlayerColor", "Game::PlayerColorID");
check<sh::Session>("Session", "Game::StrategySessionParams");
check<sh::CreateParams>("CreateParams", "Game::StrategyGameCreateParams");
check<sh::Scrp>("Scrp", "Game::StrategyScriptParams");
check<sh::MapP>("MapP", "Game::StarMapParams");
check<sh::Planet>("Planet", "Game::SystemParams");
check<sh::Sim>("Sim", "Game::StrategyServer");
check<sh::Sys>("Sys", "Game::ServerSystem");
check<sh::Player>("Player", "Game::ServerPlayer");
check<sh::Fleet>("Fleet", "Game::StarFleet");
check<sh::Ship>("Ship", "Game::StarShip");
check<sh::FlightPlan>("FlightPlan", "Game::FlightPlan");
check<sh::Waypoint>("Waypoint", "Game::FlightPlan::Waypoint");
check<sh::PrisonerHold>("PrisonerHold", "Game::PrisonerHold");
check<sh::Otch>("Otch", "Game::ObservedTech");
check<sh::Owep>("Owep", "Game::ObservedWeapon");
check<sh::Odes>("Odes", "Game::ObservedDesign");
check<sh::Rts>("Rts", "Game::StarSystem::OutputRates");
check<sh::PlayerView>("PlayerView", "Game::StarSystem::PlayerView");
check<sh::IndependenceInfo>("IndependenceInfo", "Game::IndependenceInfo");
check<sh::BuildOrder>("BuildOrder", "Game::ShipBuildOrder");
check<sh::BuildQueue>("BuildQueue", "Game::BuildQueue");
check<sh::Prep>("Prep", "Game::PlayerReport");
check<sh::DipStat>("DipStat", "Game::DiplomacyStats");
check<sh::Alliances>("Alliances", "Game::PlayerAlliances");
check<sh::NodeRoute>("NodeRoute", "Game::NodeRoute");
check<sh::SystemEvent>("SystemEvent", "Game::SystemEvent");
check<sh::PlayerTurnStats>("PlayerTurnStats", "Game::PlayerTurnStats");
check<sh::PlayerTurnHistory>("PlayerTurnHistory", "Game::PlayerTurnHistory");
check<sh::TurnStats>("TurnStats", "Game::GameTurnHistory");
check<sh::PopG>("PopG", "Game::PopulationGroup");
check<sh::Population>("Population", "Game::Population");
check<sh::Morale>("Morale", "Game::Morale");
check<sh::MoraleEvent>("MoraleEvent", "Game::MoraleEvent");
// Game::SimpleNodePath is a different, 2-item type — it is what MapP.nodePaths
// holds (VectorHelper<SimpleNodePath>), and it is empty in every real save.
check<sh::NodePath>("NodePath", "Game::NodePath");
// Bodies typed this round from the recovered schema (previously opaque Nodes).
check<sh::EventStorage>("EventStorage", "Game::EventStorage");
check<sh::TurnEvents>("TurnEvents", "Game::EventStorage::TurnEvents");
check<sh::EventRec>("EventRec", "Game::EventStorage::Event");
check<sh::FleetNameGen>("FleetNameGen", "Game::FleetNameGenerator");
check<sh::SpeciesRatios>("SpeciesRatios", "Game::SpeciesRatios");
check<sh::CivilianRatios>("CivilianRatios", "Game::CivilianRatios");
check<sh::TechTree>("TechTree", "Game::TechTree");
check<sh::ShipRecords>("ShipRecords", "Game::ShipRecords");
check<sh::AIEncounterFlags>("AIEncounterFlags", "Game::AIEncounterFlags");
check<sh::PlayerAid>("PlayerAid", "Game::PlayerAid");
check<sh::CommMessages>("CommMessages", "Game::CommMessageContainer");
check<sh::SpyReport>("SpyReport", "Game::SpyReport");
check<sh::SpyManager>("SpyManager", "Game::ServerSpyManager");
check<sh::ProjectNames>("ProjectNames", "Game::SpecialProjectNameGen");
check<sh::TradeManager>("TradeManager", "Game::ServerTradeManagerImpl");
check<sh::TradeSector>("TradeSector", "Game::ServerTradeSector");
check<sh::ShipSectionID>("ShipSectionID", "Game::ShipSectionID");
check<sh::DesignSection>("DesignSection", "Game::ShipDesignDef::Section");
// --- 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<sh::ScriptObjects>("ScriptObjects", "Game::SVSOSots");
check<sh::SVSOVonNeumann>("SVSOVonNeumann", "Game::SVSOVonNeumann");
check<sh::VonNeumannDefeat>("VonNeumannDefeat", "Game::SVSOVonNeumann::DefeatRecord");
check<sh::SVSOSwarm>("SVSOSwarm", "Game::SVSOSwarm");
check<sh::SwarmInfestation>("SwarmInfestation", "Game::SVSOSwarm::Infestation");
check<sh::SVSODerelict>("SVSODerelict", "Game::SVSODerelict");
check<sh::SVSOMonitor>("SVSOMonitor", "Game::SVSOMonitor");
check<sh::SVSOSlaversRefuel>("SVSOSlaversRefuel", "Game::SVSOSlaversRefuel");
check<sh::SVSOSwarmQueen>("SVSOSwarmQueen", "Game::SVSOSwarmQueen");
check<sh::SVSOSwarmQueenHive>("SVSOSwarmQueenHive", "Game::SVSOSwarmQueen::HiveInfo");
check<sh::SVSOSwarmQueenQueen>("SVSOSwarmQueenQueen", "Game::SVSOSwarmQueen::QueenInfo");
check<sh::SVSOCrowRuins>("SVSOCrowRuins", "Game::SVSOCrowRuins");
check<sh::SVSORefugees>("SVSORefugees", "Game::SVSORefugees");
check<sh::RefugeeStatus>("RefugeeStatus", "Game::SVSORefugees::PlayerStatus");
check<sh::SVSOTraps>("SVSOTraps", "Game::SVSOTraps");
check<sh::SVSOTrap>("SVSOTrap", "Game::SVSOTraps::Trap");
check<sh::SVSOCrowDefenders>("SVSOCrowDefenders", "Game::SVSOCrowDefenders");
check<sh::SVSOGrandMenaceTrigger>("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<sh::StrategyAIAgent>("StrategyAIAgent", "Game::StrategyAIAgent::Streamable");
check<sh::AttribMap>("AttribMap", "Game::AttribMap");
check<sh::AISituation>("AISituation", "Game::AISituation");
check<sh::AISystem>("AISystem", "Game::AISystem");
check<sh::DesignNameGen>("DesignNameGen", "Game::StrategyAIAgent::DesignNameGen");
check<sh::AICombatReport>("AICombatReport", "Game::AICombatReport");
check<sh::CombatPlayerStats>("CombatPlayerStats", "Game::CombatPlayerStats");
check<sh::AIAutoPeaceRun>("AIAutoPeaceRun", "Game::AIAutoPeaceRun");
// One shape covers all four Game::AIWeightMap<K> instantiations: they differ
// only in the key body, which is its own frame on the wire.
check<sh::AIWeightMapSection>("AIWeightMap<Sec>", "Game::Game::VShipSectionID::?$AIWeightMap");
check<sh::AIWeightMapEnum>("AIWeightMap<Pur>",
"Game::Mars::Game::W4AIPurposeID::U?$StreamableEnum::?$AIWeightMap");
check<sh::AIWeightMapEnum>("AIWeightMap<Wep>",
"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",
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;
}