Compare commits

..

2 commits

Author SHA1 Message Date
alex
1d3f8116c3 merge lane WS: 14 new wire bindings; corpus coverage 99.50% -> 99.9949%, all 20 saves above the ratchet 2026-09-08 20:16:30 -04:00
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
4 changed files with 352 additions and 42 deletions

View file

@ -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).

View file

@ -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<int32_t> banks;
template <class Ar>
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 <class Ar>
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<WeaponGroup> groups;
std::vector<Node> extra;
template <class Ar>
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<DesignSection> 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<Node> extra;
template <class Ar>
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<Node> extra;
template <class Ar>
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<Node> 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<FreighterWarning> fwarn;
std::vector<int32_t> systems, fleets;
std::vector<Node> extra;
template <class Ar>
@ -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<Node> extra;
template <class Ar>
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<Entry> 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<TradeRoute> routes;
std::vector<Node> extra;
template <class Ar>
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<int32_t> cpl; // ncp: compromised players; 0 in the one record we hold
std::vector<Node> extra;
template <class Ar>
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<Node> 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<SpyCraft> spies;
std::vector<Node> extra;
template <class Ar>
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<Node> points;
std::vector<Node> extra;
template <class Ar>
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<int32_t> shipIDs;
std::vector<Node> extra;
template <class Ar>
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<ShipEntry> ships;
std::vector<Node> extra;
template <class Ar>
@ -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<Node> extra;
template <class Ar>
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<Node> extra;
template <class Ar>
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<Node> extra;
template <class Ar>
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 <class Ar>
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 <class Ar>
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<CrepClass> classes;
std::vector<CrepSector> sectors;
int32_t ndam = 0;
std::vector<Srep> srep;
std::vector<Node> extra;
template <class Ar>
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<CrepPrep> prep;
bool prepFramed = true;
std::vector<Wrep> 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<Node> extra;
template <class Ar>
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 <class Ar>
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<TacClass> classes;
std::vector<Node> extra;
template <class Ar>
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<Node> tacReports;
std::vector<TacReport> tacReports;
std::vector<Node> extra;
template <class Ar>
void io(Ar& ar) {

View file

@ -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);

View file

@ -310,6 +310,27 @@ int main() {
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