From 7f04bcc88b90aaff72670fcdacd6ff0325727cfa Mon Sep 17 00:00:00 2001 From: alex Date: Tue, 8 Sep 2026 20:14:18 -0400 Subject: [PATCH] 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 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. --- docs/mars-stream.md | 20 +- src/mars/stream/shapes.h | 332 ++++++++++++++++++++++--- tests/mars_stream/test_stream.cpp | 21 +- tests/mars_stream/test_wire_schema.cpp | 21 ++ 4 files changed, 352 insertions(+), 42 deletions(-) diff --git a/docs/mars-stream.md b/docs/mars-stream.md index e2e4fa6..2ed3ba9 100644 --- a/docs/mars-stream.md +++ b/docs/mars-stream.md @@ -85,10 +85,14 @@ struct Summary { reference name differs from the disk spelling, e.g. `faiDes` → `FAIDes`, `ontF` → `otnF`, `nextId` → `nextid`); the second argument of `R` is the tag to write. Conditionals are plain `ar.when(vnh, ...)`, optional legacy tags `ar.opt_i32(A("ARes"), aRes)`, uncounted lists -`ar.repeat("stats", ...)`, inline arrays `ar.narr(A("NumSys"), systems, elem)`. Bodies the -format keeps opaque (`TechTree`, `Events`, `ShipRecs`, `spy2`, `civr`, `comms`, `Ojvs`, -`Attrib`, `sprjs`, `SvSctOb`, `trdmgr`, `spymgr`, `CD`, the RNG blob) are `Node` members and -re-emit verbatim. +`ar.repeat("stats", ...)`, inline arrays `ar.narr(A("NumSys"), systems, elem)`. Bodies still +kept opaque are `Node` members and re-emit verbatim. That list has shrunk to two kinds: the +**RNG blob**, which is deliberately opaque, and **containers no save has ever filled** — +`nodePaths`, `Ojvs`, `usnc`, `FTPnts`, the four `SpyReport` sub-lists, the `deflay`/`rtgt` +pairs, and the polymorphic keys with no factory entry (`SvSctOb` variants, `CD` block +suffixes). Those are hypotheses under rule 6, not modelling debt: the element framing is a +property of the writer's helper and no record has ever shown one. On every save in the +corpus the only opaque items left are the RNG blob's two. `ReadArchive` mirrors the reference applier: a confirmed tag that is missing is an error, unexpected items before it a warning, a positional item read under another tag an info; @@ -111,12 +115,14 @@ same `io()` drives `WriteArchive`, so a shape loaded from a real save writes bac `SOTS_SAVE_READER` set, `oracle/compare.py` diffs the C++ dump against the reference `--dump` (exact and canonical agreement, summary values). -Result on the owner's three saves (SotS 1.8, turns 1–3): 100% exact line agreement, both -round trips identical, summaries agree. +Result on the owner's 20-save corpus (SotS 1.8): 100% exact line agreement, both round trips +identical, summaries agree. Named coverage is 99.994–99.996% per save; the residual is the +two items of the RNG blob and nothing else. ## Not done / open * Only joint padding is implemented (the split convention never matched a real file). -* Shapes for the opaque bodies listed above (kept generic on purpose, as in the reference). +* Element shapes for the never-filled containers listed above — each needs a save that puts + something in it before the framing can be typed rather than guessed. * The float-vs-int guess for unhinted words inside opaque bodies is a heuristic; the typed layer never depends on it (it coerces from the raw bytes). diff --git a/src/mars/stream/shapes.h b/src/mars/stream/shapes.h index 6290aa2..a21d116 100644 --- a/src/mars/stream/shapes.h +++ b/src/mars/stream/shapes.h @@ -851,6 +851,41 @@ struct DesignSection { // Game::ShipDesignDef::Section, one DSec of a Des frame ar.rest(extra); } }; +// Game::GunBankSelection -- the `wgb` frame of a weapon group. The recovery +// resolves ONE NULL-named int here, which is a loop body: the only record in the +// corpus (human-turn15-spyprogram, the first save with Dwgv set) carries three. +// Neither a count word nor a fixed arity is on the wire, so this is read as an +// uncounted run of "." ints rather than as a fixed three -- a design with a +// different bank arity cannot desynchronise the frame. What settles the arity is +// a save whose design has a non-three gun-bank layout. +struct GunBankSelection { + static constexpr const char* kStreamName = ""; + std::vector banks; + template + void io(Ar& ar) { + ar.repeat(".", banks, [](Ar& a, int32_t& e) { a.i32(R("bank"), e); }); + } +}; +struct WeaponGroup { // one wgng element: the group id and its bank selection + static constexpr const char* kStreamName = ""; + int32_t wgid = 0; + GunBankSelection wgb; + template + void io(Ar& ar) { + ar.i32(A("wgid"), wgid); + ar.obj(A("wgb"), wgb); + } +}; +struct WeaponGroups { // Game::WeaponGroups, the Dwg frame + static constexpr const char* kStreamName = "Dwg"; + std::vector groups; + std::vector extra; + template + void io(Ar& ar) { + ar.narr(A("wgng"), groups, [](Ar& a, WeaponGroup& e) { e.io(a); }); + ar.rest(extra); + } +}; // A design is written by TWO serializers, a base and a derived one. The base // emits FAIDes / DHide / DWep / DName and then EXACTLY THREE section frames; // the derived one appends Dtc, the Dwgv flag and -- only when that flag is set @@ -875,7 +910,10 @@ struct Design { // on-disk tags are case variants of the reference names (FAIDe std::vector sections; int32_t dtc = 0; bool dwgv = false; - Node weaponGroups; // Dwg; present only when dwgv, which is false in every save available + // Dwg; present only when dwgv, which is set on exactly one design in the + // corpus (human-turn15-spyprogram). Typed from that record plus the + // recovered Game::WeaponGroups serializer. + WeaponGroups weaponGroups; std::vector extra; template void io(Ar& ar) { @@ -886,7 +924,7 @@ struct Design { // on-disk tags are case variants of the reference names (FAIDe ar.repeat("DSec", sections, [](Ar& a, DesignSection& e) { a.obj(A("DSec"), e); }); ar.i32(A("Dtc"), dtc); ar.b(A("Dwgv"), dwgv); - ar.when(dwgv, [&](Ar& a) { a.any(A("Dwg"), weaponGroups); }); + ar.when(dwgv, [&](Ar& a) { a.obj(A("Dwg"), weaponGroups); }); ar.rest(extra); } }; @@ -1186,11 +1224,24 @@ struct SpyReport { // Game::SpyReport: four counted lists, one per report kind ar.rest(extra); } }; +struct FreighterWarning { // Game::ServerTradeSector::FreighterWarning, one fwarn element + static constexpr const char* kStreamName = ""; + int32_t pid = 0, ntrns = 0; + std::vector extra; + template + void io(Ar& ar) { + ar.i32(A("pid"), pid); + ar.i32(A("ntrns"), ntrns); + ar.rest(extra); + } +}; struct TradeSector { // Game::ServerTradeSector static constexpr const char* kStreamName = "Trade"; Vec3 pos, ctr; int32_t gridID = 0, tssec = 0, tsct = 0, tscr = 0, ptssec = 0, ptsct = 0, ptscr = 0; - std::vector fwarn; // ServerTradeSector::FreighterWarning; empty in every save available + // ServerTradeSector::FreighterWarning. The count was 0 in every save until + // human-turn11-spytechs, which raided a route and produced one. + std::vector fwarn; std::vector systems, fleets; std::vector extra; template @@ -1210,6 +1261,23 @@ struct TradeSector { // Game::ServerTradeSector ar.rest(extra); } }; +struct TradeRoute { // Game::TradeRoute, one `rt` frame of the trade manager + static constexpr const char* kStreamName = "rt"; + int32_t tro = 0, trfow = 0, trfr = 0, trfrs = 0, trtow = 0, trto = 0, trtos = 0, trtc = 0; + std::vector extra; + template + void io(Ar& ar) { + ar.i32(A("tro"), tro); + ar.i32(A("trfow"), trfow); + ar.i32(A("trfr"), trfr); + ar.i32(A("trfrs"), trfrs); + ar.i32(A("trtow"), trtow); + ar.i32(A("trto"), trto); + ar.i32(A("trtos"), trtos); + ar.i32(A("trtc"), trtc); + ar.rest(extra); + } +}; struct TradeManager { // Game::ServerTradeManagerImpl // The `trdmgr` member is declared as Game::ServerTradeManager, whose Read and // Write really are the inherited no-op -- the campaign recorded that as an open @@ -1228,23 +1296,58 @@ struct TradeManager { // Game::ServerTradeManagerImpl }; std::vector sectors; float sctSize = 0; + // `rt` is a container write with NO count word: the recovery marks it a loop + // body (member == false) and the wire has the frames back to back after + // SctSize, so it is read as an uncounted run keyed on the tag. Every save + // that has any route at all (human-turn11, human-turn15) carries exactly one + // here, so "uncounted" is the framing the evidence supports, not "one". + std::vector routes; std::vector extra; template void io(Ar& ar) { ar.narr(A("NumTradeSectors"), sectors, [](Ar& a, Entry& e) { e.io(a); }); ar.f32(A("SctSize"), sctSize); + ar.repeat("rt", routes, [](Ar& a, TradeRoute& e) { a.obj(A("rt"), e); }); + ar.rest(extra); + } +}; +struct SpyCraft { // Game::SpyCraft, one `spy` frame + static constexpr const char* kStreamName = "spy"; + int32_t sid = 0, sown = 0, atto = 0, deat = 0, tdep = 0; + float cbh = 0, sdo = 0; + int32_t sdet = 0, spyon = 0, spyat = 0, cm = 0; + float cmo = 0; + std::vector cpl; // ncp: compromised players; 0 in the one record we hold + std::vector extra; + template + void io(Ar& ar) { + ar.i32(A("sid"), sid); + ar.i32(A("sown"), sown); + ar.i32(A("atto"), atto); + ar.i32(A("deat"), deat); + ar.i32(A("tdep"), tdep); + ar.f32(A("cbh"), cbh); + ar.f32(A("sdo"), sdo); + ar.i32(A("sdet"), sdet); + ar.i32(A("spyon"), spyon); + ar.i32(A("spyat"), spyat); + ar.i32(A("cm"), cm); + ar.f32(A("cmo"), cmo); + ar.narr(A("ncp"), cpl, [](Ar& a, int32_t& e) { a.i32(A("cpl"), e); }); ar.rest(extra); } }; struct SpyManager { // Game::ServerSpyManager static constexpr const char* kStreamName = "spymgr"; int32_t xsid = 0; - std::vector spies; // Game::SpyCraft frames; nspy is 0 in every save available + // nspy was 0 in every save until human-turn15-spyprogram, which ran a spy + // program and produced one craft. + std::vector spies; std::vector extra; template void io(Ar& ar) { ar.i32(A("xsid"), xsid); - ar.narr(A("nspy"), spies, [](Ar& a, Node& e) { a.any(A("spy"), e); }); + ar.narr(A("nspy"), spies, [](Ar& a, SpyCraft& e) { a.obj(A("spy"), e); }); ar.rest(extra); } }; @@ -1589,6 +1692,40 @@ struct ShipEntry { ar.obj(A("Ship"), ship); } }; +struct FieldTemplate { // Game::FieldTemplate, the NULL-named first frame of a Lay + static constexpr const char* kStreamName = ""; + // FTPnts is the ONE item in this round's work that the recovery itself marks + // unresolved: it names the element class from the decorated helper name + // (Game::FieldTemplate::Point, whose own serializer is fully recovered -- + // FTPShID/FTPDesID/FTPPosX/FTPPosY/FTPSqd) but could not type the item, and + // the count is 0 in every Lay in the corpus. That is exactly the pair of + // conditions under which SysMem, mts and nalat were all typed wrong: the + // element FRAMING is a property of the helper, not of the element class, and + // no save has ever shown one. So the elements stay carried. What settles it + // is a save whose fleet has a stored tactical formation -- set a fleet's + // combat layout in the tactical setup screen, then save. + std::vector points; + std::vector extra; + template + void io(Ar& ar) { + ar.carr(A("FTPnts"), points); + ar.rest(extra); + } +}; +// Game::FleetLayout, the `Lay` frame gated by HLay. Every item in it is +// NULL-named: a FieldTemplate frame, then a count, then that many ship ids. +struct FleetLayout { + static constexpr const char* kStreamName = "Lay"; + FieldTemplate tmpl; + std::vector shipIDs; + std::vector extra; + template + void io(Ar& ar) { + ar.obj(R("tmpl"), tmpl); + ar.narr(R("nships"), shipIDs, [](Ar& a, int32_t& e) { a.i32(R("shipID"), e); }); + ar.rest(extra); + } +}; struct Fleet { static constexpr const char* kStreamName = "Flt"; Vec3 pos; @@ -1606,7 +1743,7 @@ struct Fleet { bool perm = false; Vec3 prvPos; bool hLay = false; - Node lay; + FleetLayout lay; std::vector ships; std::vector extra; template @@ -1633,44 +1770,94 @@ struct Fleet { ar.b(A("Perm"), perm); ar.vec3(A("PrvPos"), prvPos); ar.b(A("HLay"), hLay); - ar.when(hLay, [&](Ar& a) { a.any(A("Lay"), lay); }); + ar.when(hLay, [&](Ar& a) { a.obj(A("Lay"), lay); }); ar.narr(A("NShips"), ships, [](Ar& a, ShipEntry& e) { e.io(a); }); ar.rest(extra); } }; // ---- combat reports, node grid --------------------------------------------------- -struct Dams { - static constexpr const char* kStreamName = "dams"; - int32_t dams = 0, damp = 0, dami = 0, damt = 0; - std::vector extra; - template - void io(Ar& ar) { - ar.i32(R("dams", "dams"), dams); - ar.i32(R("damp", "damp"), damp); - ar.i32(R("dami", "dami"), dami); - ar.i32(R("damt", "damt"), damt); - ar.rest(extra); - } -}; -struct Wrep { +// +// The damage quartet is written by TWO classes, Game::CombatWeaponReport and +// Game::CombatShipReport, and both recoveries agree: dams and damp are ints, dami +// and damt are FLOATS. An earlier `Dams` sub-struct read all four as ints and was +// reached through obj_flex, i.e. it modelled a nested `dams` frame that the +// binary does not write and that no save in the corpus contains. Both are +// corrected here: the quartet is flat, and the last two are f32. The int reading +// round-tripped because the bytes are the bytes; the values it produced +// (dami = 1033392620 for what is 0.0787) were nonsense. +struct Wrep { // Game::CombatWeaponReport static constexpr const char* kStreamName = ""; std::string wep; - Dams dams; - bool damsFramed = true; + int32_t dams = 0, damp = 0; + float dami = 0, damt = 0; std::vector extra; template void io(Ar& ar) { ar.str(R("wep", "wep"), wep); - ar.obj_flex(R("dams", "dams"), dams, damsFramed); + ar.i32(R("dams", "dams"), dams); + ar.i32(R("damp", "damp"), damp); + ar.f32(R("dami", "dami"), dami); + ar.f32(R("damt", "damt"), damt); ar.rest(extra); } }; -struct CrepPrep { +struct Srep { // Game::CombatShipReport, one srep element + static constexpr const char* kStreamName = ""; + std::string name; + int32_t did = 0, cls = 0; + int64_t caps2 = 0; + int32_t nshp = 0, nfld = 0, nlst = 0, dtak = 0, dams = 0, damp = 0; + float dami = 0, damt = 0; + std::vector extra; + template + void io(Ar& ar) { + ar.str(A("name"), name); + ar.i32(A("did"), did); + ar.i32(A("cls"), cls); + ar.i64(A("caps2"), caps2); + ar.i32(A("nshp"), nshp); + ar.i32(A("nfld"), nfld); + ar.i32(A("nlst"), nlst); + ar.i32(A("dtak"), dtak); + ar.i32(A("dams"), dams); + ar.i32(A("damp"), damp); + ar.f32(A("dami"), dami); + ar.f32(A("damt"), damt); + ar.rest(extra); + } +}; +struct CrepClass { // one ncls loop body of a CombatPlayerReport (per ship class) + static constexpr const char* kStreamName = ""; + int32_t cls = 0, nshp = 0, nsat = 0, nshfld = 0, nshlst = 0, nsatl = 0; + template + void io(Ar& ar) { + ar.i32(A("cls"), cls); + ar.i32(A("nshp"), nshp); + ar.i32(A("nsat"), nsat); + ar.i32(A("nshfld"), nshfld); + ar.i32(A("nshlst"), nshlst); + ar.i32(A("nsatl"), nsatl); + } +}; +struct CrepSector { // one nsec loop body of a CombatPlayerReport (per ship section) + static constexpr const char* kStreamName = ""; + int32_t ssec = 0, nshp = 0; + template + void io(Ar& ar) { + ar.i32(A("ssec"), ssec); + ar.i32(A("nshp"), nshp); + } +}; +struct CrepPrep { // Game::CombatPlayerReport static constexpr const char* kStreamName = ""; int32_t plr = 0; bool ai = false; int32_t ally = 0, status = 0, mxeng = 0, mxcls = 0, mxmsl = 0; + std::vector classes; + std::vector sectors; + int32_t ndam = 0; + std::vector srep; std::vector extra; template void io(Ar& ar) { @@ -1681,14 +1868,28 @@ struct CrepPrep { ar.i32(R("mxeng", "mxeng"), mxeng); ar.i32(R("mxcls", "mxcls"), mxcls); ar.i32(R("mxmsl", "mxmsl"), mxmsl); + ar.narr(A("ncls"), classes, [](Ar& a, CrepClass& e) { e.io(a); }); + ar.narr(A("nsec"), sectors, [](Ar& a, CrepSector& e) { e.io(a); }); + ar.i32(A("ndam"), ndam); + ar.carr(A("srep"), srep); ar.rest(extra); } }; +// Game::CombatReport. auto and cdst are BOOLs and dur, cdt and cdi are FLOATS -- +// all five were read as ints here. Four of them are 0 or 1 in every save, so the +// bytes never moved; cdt is not (0x3FCC... = 1.598 turns of combat), and reading +// it as an int gave 1070805848. struct Crep { static constexpr const char* kStreamName = "crep"; int32_t cid = 0, trn = 0; Vec3 pos; - int32_t sid = 0, autoF = 0, dur = 0, cow = 0, cdst = 0, cpk = 0, cpt = 0, cdt = 0, cdi = 0; + int32_t sid = 0; + bool autoF = false; + float dur = 0; + int32_t cow = 0; + bool cdst = false; + int32_t cpk = 0, cpt = 0; + float cdt = 0, cdi = 0; std::vector prep; bool prepFramed = true; std::vector wrep; @@ -1700,14 +1901,14 @@ struct Crep { ar.i32(R("trn", "trn"), trn); ar.vec3(R("pos", "pos"), pos); ar.i32(R("sid", "sid"), sid); - ar.i32(R("auto", "auto"), autoF); - ar.i32(R("dur", "dur"), dur); + ar.b(R("auto", "auto"), autoF); + ar.f32(R("dur", "dur"), dur); ar.i32(R("cow", "cow"), cow); - ar.i32(R("cdst", "cdst"), cdst); + ar.b(R("cdst", "cdst"), cdst); ar.i32(R("cpk", "cpk"), cpk); ar.i32(R("cpt", "cpt"), cpt); - ar.i32(R("cdt", "cdt"), cdt); - ar.i32(R("cdi", "cdi"), cdi); + ar.f32(R("cdt", "cdt"), cdt); + ar.f32(R("cdi", "cdi"), cdi); ar.carr_flex(R("prep", "prep"), prep, prepFramed); ar.carr_flex(R("wrep", "wrep"), wrep, wrepFramed); ar.rest(extra); @@ -2415,14 +2616,77 @@ struct AIAutoPeaceRun { // Game::AIAutoPeaceRun } }; +struct TacReportEvents { // Game::TacReportEvents, the TRby / TRto frames + static constexpr const char* kStreamName = ""; + int32_t hd = 0, hi = 0; + float d = 0, dp = 0, di = 0, dt = 0; + bool b = false; + std::vector extra; + template + void io(Ar& ar) { + ar.i32(A("TREhd"), hd); + ar.i32(A("TREhi"), hi); + ar.f32(A("TREd"), d); + ar.f32(A("TREdp"), dp); + ar.f32(A("TREdi"), di); + ar.f32(A("TREdt"), dt); + ar.b(A("TREb"), b); + ar.rest(extra); + } +}; +struct TacClass { // one TRnc loop body: the per-ship-class tail of a TacReport + static constexpr const char* kStreamName = ""; + int32_t ships = 0, sats = 0, shipsL = 0; + template + void io(Ar& ar) { + ar.i32(A("TRships"), ships); + ar.i32(A("TRsats"), sats); + ar.i32(A("TRshipsL"), shipsL); + } +}; +// Game::TacReport. Two lanes left this carried: the recovery ends with a computed +// count (TRnc) and three trailing scalar items, and with TRnc == 0 in every save +// held at the time there was no way to tell three trailing scalars from a loop +// body of three. The trade/spy saves settle it -- TRnc == 3 and the wire carries +// TRships, TRsats, TRshipsL three times, INTERLEAVED, so the recovery's three +// items are one loop body, not three runs. +struct TacReport { + static constexpr const char* kStreamName = ""; + TacReportEvents by, to; + // `mine` not `min`: windows.h defines min as a macro and this header is + // compiled by the MinGW cross-build too. + int32_t id = 0, al = 0, bal = 0, las = 0, mis = 0, mine = 0, nrg = 0, bio = 0, brd = 0; + bool sld = false, sldd = false, sldc = false, sldi = false, sldr = false; + std::vector classes; + std::vector extra; + template + void io(Ar& ar) { + ar.obj(A("TRby"), by); + ar.obj(A("TRto"), to); + ar.i32(A("TRid"), id); + ar.i32(A("TRal"), al); + ar.i32(A("TRbal"), bal); + ar.i32(A("TRlas"), las); + ar.i32(A("TRmis"), mis); + ar.i32(A("TRmin"), mine); + ar.i32(A("TRnrg"), nrg); + ar.i32(A("TRbio"), bio); + ar.i32(A("TRbrd"), brd); + ar.b(A("TRsld"), sld); + ar.b(A("TRsldd"), sldd); + ar.b(A("TRsldc"), sldc); + ar.b(A("TRsldi"), sldi); + ar.b(A("TRsldr"), sldr); + ar.narr(A("TRnc"), classes, [](Ar& a, TacClass& e) { e.io(a); }); + ar.rest(extra); + } +}; struct CombatPlayerStats { // Game::CombatPlayerStats static constexpr const char* kStreamName = "CRPlSv2"; float rpBon = 0; int32_t rpBonT = 0, savBonus = 0; bool maintHF = false; - // Game::TacReport has a computed count and three trailing scalar runs that no - // save we hold exercises; its body stays carried rather than typed on a guess. - std::vector tacReports; + std::vector tacReports; std::vector extra; template void io(Ar& ar) { diff --git a/tests/mars_stream/test_stream.cpp b/tests/mars_stream/test_stream.cpp index 5b1c479..e829996 100644 --- a/tests/mars_stream/test_stream.cpp +++ b/tests/mars_stream/test_stream.cpp @@ -635,7 +635,20 @@ static void test_aiagent_block() { rep.crPlSv2.rpBonT = 3; rep.crPlSv2.savBonus = 4; rep.crPlSv2.maintHF = true; - rep.crPlSv2.tacReports.push_back(Node::int32("TRid", 9)); // carried, not typed + // Game::TacReport: two event frames, the scalar block, then the TRnc run of + // (TRships, TRsats, TRshipsL) triples -- interleaved, which is the shape of + // the "three trailing scalars" the linear recovery reports. + shapes::TacReport tac; + tac.by.hi = 4; + tac.by.d = 900.0f; + tac.to.dt = -1.5f; + tac.to.b = true; + tac.id = 9; + tac.bal = 3; + tac.sldc = true; + tac.classes.push_back({1, 2, 3}); + tac.classes.push_back({4, 5, 6}); + rep.crPlSv2.tacReports.push_back(tac); ai.combatReports.push_back(rep); ai.colonyLosses.push_back({2, 272, 16}); ai.provocations.push_back({16, 450.0f}); @@ -675,6 +688,12 @@ static void test_aiagent_block() { CHECK(back.combatReports.size() == 1 && back.combatReports[0].crPce && back.combatReports[0].crSys.value == 272 && back.combatReports[0].crPlSv2.maintHF && back.combatReports[0].crPlSv2.tacReports.size() == 1); + { + const shapes::TacReport& t = back.combatReports[0].crPlSv2.tacReports[0]; + CHECK(t.by.hi == 4 && t.by.d == 900.0f && t.to.dt == -1.5f && t.to.b); + CHECK(t.id == 9 && t.bal == 3 && t.sldc && !t.sld); + CHECK(t.classes.size() == 2 && t.classes[0].sats == 2 && t.classes[1].shipsL == 6); + } CHECK(back.colonyLosses.size() == 1 && back.colonyLosses[0].sysID == 272); CHECK(back.provocations.size() == 1 && back.provocations[0].value == 450.0f); CHECK(back.techScores.size() == 3 && back.techScores[1] == 4); diff --git a/tests/mars_stream/test_wire_schema.cpp b/tests/mars_stream/test_wire_schema.cpp index db3b2e4..5be6ef0 100644 --- a/tests/mars_stream/test_wire_schema.cpp +++ b/tests/mars_stream/test_wire_schema.cpp @@ -310,6 +310,27 @@ int main() { 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