sots-engine/tests/mars_stream/test_wire_schema.cpp
alex 7f04bcc88b lane WS: pay the ratchet debt -- type the five new bodies, do not move the bar
The corpus went 11 -> 19 saves and named coverage fell to 97.6% against a 99.99
bar. Rule 27 says type the content. Typed, all from the recovered wire schema
plus the records the new trade/spy saves finally carry:

  Game::CombatPlayerReport  the <rest:prep> tail -- ncls run of six per ship
                            class, nsec run of two per section, ndam, srep
  Game::CombatShipReport    srep elements (caps2 is an i64; dami/damt f32)
  Game::TacReport           TRnc is a COUNT and TRships/TRsats/TRshipsL are one
                            loop body, INTERLEAVED on the wire -- not three
                            trailing runs. Two lanes could not settle this
                            because TRnc was 0 in every save until tonight.
  Game::TacReportEvents     TRby / TRto
  Game::FleetLayout         Lay: a FieldTemplate frame then a count and its ids
  Game::TradeRoute          rt -- a container write with NO count word, read as
                            an uncounted run keyed on the tag
  Game::ServerTradeSector::FreighterWarning   fwarn elements
  Game::SpyCraft            spy elements
  Game::WeaponGroups        Dwg, and Game::GunBankSelection under it

Corrections found on the way, both silent until a shape was bound to the table:

  * Game::CombatReport: auto and cdst are BOOLs, dur/cdt/cdi are FLOATS. All
    five were ints here. Four are 0/1 everywhere so the bytes never moved; cdt
    is not, and was being read as 1070805848 instead of 1.598.
  * Game::CombatWeaponReport: the damage quartet is flat and dami/damt are
    floats. The old shape reached them through obj_flex, modelling a nested
    `dams` frame the binary does not write. That also made CoverageArchive
    charge one phantom typed item per weapon report -- exactly 8/13/21/32 on the
    four affected saves -- so the pre-fix coverage figures were slightly
    optimistic as well as too low.

FTPnts stays carried: it is the one item the recovery itself marks unresolved,
its count is 0 in every save, and element framing is a property of the helper --
the SysMem / mts / nalat trap. The workload that settles it is a save with a
stored fleet tactical formation.

Named coverage, per save, before -> after:
  human-turn5-traderoutes   99.0003% -> 99.9951%
  human-turn8-traderoutes   98.6349% -> 99.9955%
  human-turn11-spytechs     97.6382% -> 99.9959%
  human-turn15-spyprogram   96.9412% -> 99.9964%
  the other 16 saves        unchanged, 99.9940-99.9953%
  corpus                    99.5021% -> 99.9949%

All 20 saves are above the ratchet; the only opaque items left anywhere are the
two of the RNG blob. 14 new shapes bound to the wire schema, every one a full
match: 100 shapes, 966 items, 0 MISMATCH. ctest 58/58 over all 20 saves, both
round trips byte-identical, 0 errors and 0 warnings.
2026-09-08 20:14:18 -04:00

392 lines
21 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");
// 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<sh::WeaponGroups>("WeaponGroups", "Game::WeaponGroups");
check<sh::GunBankSelection>("GunBankSelection", "Game::GunBankSelection");
check<sh::FreighterWarning>("FreighterWarning", "Game::ServerTradeSector::FreighterWarning");
check<sh::TradeRoute>("TradeRoute", "Game::TradeRoute");
check<sh::SpyCraft>("SpyCraft", "Game::SpyCraft");
check<sh::FleetLayout>("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<sh::FieldTemplate>("FieldTemplate", "Game::FieldTemplate");
check<sh::Crep>("Crep", "Game::CombatReport");
check<sh::CrepPrep>("CrepPrep", "Game::CombatPlayerReport");
check<sh::Srep>("Srep", "Game::CombatShipReport");
check<sh::Wrep>("Wrep", "Game::CombatWeaponReport");
check<sh::TacReport>("TacReport", "Game::TacReport");
check<sh::TacReportEvents>("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<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;
}