merge lane G: generated wire schema channel + SchemaProbe conformance check; named coverage 38% -> 97%

This commit is contained in:
alex 2026-09-08 06:34:43 -04:00
commit 4b19961c07
7 changed files with 4351 additions and 32 deletions

78
docs/G-wire-schema.md Normal file
View file

@ -0,0 +1,78 @@
# G — the wire schema channel
`include/generated/sots_stream_schema.h` is the second generated channel into this repo, alongside
`include/generated/sots_addresses.h`. Both carry **facts about the original binary and nothing
else**, both are produced by a script in the `sots-re` notes repo, and neither is ever hand-edited.
```
sots-re objects/layouts.json (the serializer recovery, memory-layout view)
| tools/streams.py -> objects/streams.json (wire view)
| tools/gen_stream_schema.py
v
sots-engine include/generated/sots_stream_schema.h
```
Regenerate after any change on the notes side:
```sh
cd ~/sots-re
uv run python3 tools/streams.py
uv run python3 tools/gen_stream_schema.py ../sots-engine/include/generated/sots_stream_schema.h
```
## What crosses, and what deliberately does not
What crosses is the **wire schema**: for each serializable class, the ordered sequence of items its
`Write` puts on the stream — on-disk tag, on-disk primitive, and how the item is framed.
What does **not** cross is every memory fact in the recovery: field offsets, `sizeof`, gaps,
container strides. This engine is our own C++, not a byte-for-byte decompilation. It has to read and
write the on-disk *format* faithfully; it must not inherit the original's ABI in its runtime types,
and `shapes.h` is free to lay its structs out however C++ likes.
The shim is the one component that legitimately needs the original's ABI, because it reads the
running game's memory. Those offsets have their own home: `offset` entries in `addresses.json`,
which arrive through `sots_addresses.h`.
Note the primitive in the table is the **disk** type. A member the original holds as `int16` or
`int8` is written by `Stream::WriteInt` and is four bytes on the wire; nothing narrow reaches the
stream.
## Why it is a check, not a generator
The table is a **specification, not a program**. The recovery is a linear pass over the game's
`Write`, so:
* **It cannot see branches.** `StarShip` writes `BQ2` only when `hbq` is set; the table lists it
unconditionally. The sequence is a *superset* of what any single record contains.
* **Container loops are flattened.** A count item is followed by its element items as siblings
(`member == false`), not nested inside the container.
A codec driven straight off the table would desynchronise on the first branch. So the hand-written
`io(Ar&)` shapes stay the codec — they express the conditionals and the nesting the binary facts
cannot supply — and the table is what proves they agree with the binary.
## The two extra archives
`src/mars/stream/probe.h` adds two archives to the three in `archive.h`:
* **`SchemaProbe`** walks a shape's `io()` with **every branch taken** — the same "all branches" view
the recovery has — and records the item sequence. `tests/mars_stream/test_wire_schema.cpp`
LCS-aligns that against the generated table. A tag both sides name with different disk primitives
is a **hard failure**; a wire-only item is a conditional or coverage debt and is reported only.
Run with `SOTS_WIRE_VERBOSE=1` to list every gap.
* **`CoverageArchive`** walks a *populated* shape's `io()` and separates items a field **names**
(`typed`) from items only a generic `Node` **carried** (`opaque`). This matters because a
byte-identical round trip is not a coverage claim: `ar.any` and the `ar.rest` tail round-trip
perfectly by copying bytes they do not understand. `test_save` prints the split and the worst
offenders, and carries a ratchet so typing a body can never silently regress.
## Adding a shape
1. `uv run python3 ~/sots-re/tools/streams.py Game::TheClass` — the item list, in disk order.
2. Write the `io()` in `shapes.h`. Watch for two things the table shows but does not spell out: an
item marked `[element]` is a **loop body**, so the item before it is a **count**, not a field; and
an item the game writes conditionally will be listed anyway.
3. Add a `check<sh::Shape>("Shape", "Game::TheClass")` line to `test_wire_schema.cpp`.
4. `ctest` — the conformance test fails on a type disagreement, and `test_save` fails if the round
trip breaks or coverage regresses.

File diff suppressed because it is too large Load diff

270
src/mars/stream/probe.h Normal file
View file

@ -0,0 +1,270 @@
// mars::stream — SchemaProbe: a fourth archive that records what a shape's
// io() puts on the stream, so the shape can be checked against the wire schema
// recovered from the game's own serializers (include/generated/sots_stream_schema.h).
//
// The three working archives read, write and build hints. This one executes
// nothing: it walks io() and appends one Item per stream item, in order. Two
// choices make its output directly comparable to the generated table:
//
// * `when(cond, body)` runs the body **unconditionally**. The recovery is a
// linear pass over the game's Write and cannot see Write's branches either,
// so both sides list every branch. Comparing "all branches taken" against
// "all branches taken" is the only alignment that means anything.
// * `narr` emits the count item and then the element fields as *siblings*
// (member == false), which is exactly how the flattened recovery presents a
// container loop.
//
// `any()` / `raw_frame()` — the escape hatch shapes.h uses for bodies it does
// not model — record Opaque. Opaque items are the coverage debt: the shape
// round-trips them byte-for-byte by carrying the Node, but it does not
// understand them. Counting Opaque against the wire schema is how this
// codebase measures how much of the save it actually reads.
#pragma once
#include <cstdint>
#include <map>
#include <optional>
#include <string>
#include <vector>
#include "archive.h"
namespace mars::stream {
class SchemaProbe {
public:
static constexpr bool reading = false, writing = false, building = false;
enum class P : uint8_t { Unknown, I32, I64, F32, Bool, Str, Frame, Raw };
enum class S : uint8_t { Scalar, Frame, CArr, NArr, Raw };
struct Item {
std::string tag; // on-disk tag ("." when the game writes a NULL name)
P prim = P::Unknown;
S shape = S::Scalar;
bool member = true; // false: a container element
bool optional = false; // the shape consumes it only if the tag matches
bool opaque = false; // carried as a Node: round-tripped, not understood
};
std::vector<Item> items;
bool has_rest = false; // the shape absorbs a trailing tail into `extra`
// --- scalars ------------------------------------------------------------
void i32(Tag t, int32_t&) { add(t, P::I32, S::Scalar); }
void f32(Tag t, float&) { add(t, P::F32, S::Scalar); }
void b(Tag t, bool&) { add(t, P::Bool, S::Scalar); }
void i64(Tag t, int64_t&) { add(t, P::I64, S::Scalar); }
void str(Tag t, std::string&) { add(t, P::Str, S::Scalar); }
void vec3(Tag t, Vec3&) { add(t, P::Frame, S::Frame); } // written as a Vector3 frame
void any(Tag t, Node&) { add(t, P::Unknown, S::Frame).opaque = true; }
// raw_frame is a *framed* item (the RNG block) whose body is one opaque
// payload, so on the wire it is a frame like any other nested object.
void raw_frame(Tag t, Node&) { add(t, P::Frame, S::Frame).opaque = true; }
// --- optional named items ------------------------------------------------
void opt_i32(Tag t, std::optional<int32_t>&) { add(t, P::I32, S::Scalar).optional = true; }
void opt_f32(Tag t, std::optional<float>&) { add(t, P::F32, S::Scalar).optional = true; }
void opt_b(Tag t, std::optional<bool>&) { add(t, P::Bool, S::Scalar).optional = true; }
void opt_any(Tag t, std::optional<Node>&) {
Item& i = add(t, P::Unknown, S::Frame);
i.optional = true;
i.opaque = true;
}
template <class T>
void opt_obj(Tag t, std::optional<T>&) {
add(t, P::Frame, S::Frame).optional = true;
}
// --- framed struct / arrays ------------------------------------------------
// A nested shape is one item here; the nested class is its own wire entry.
template <class T>
void obj(Tag t, T&) {
add(t, P::Frame, S::Frame);
}
template <class T>
void obj_flex(Tag t, T& v, bool&) {
obj(t, v);
}
template <class T>
void carr(Tag t, std::vector<T>&) {
add(t, P::Frame, S::CArr);
}
template <class T>
void carr_flex(Tag t, std::vector<T>& v, bool&) {
carr(t, v);
}
template <class T, class F>
void narr(Tag t, std::vector<T>&, F elem) {
add(t, P::I32, S::NArr);
T tmp{};
size_t first = items.size();
elem(*this, tmp);
for (size_t i = first; i < items.size(); ++i) items[i].member = false;
}
// --- control flow: both sides list every branch ------------------------------
template <class F>
void when(bool, F body) {
body(*this);
}
template <class T, class F>
void repeat(const char*, std::vector<T>&, F elem) {
T tmp{};
size_t first = items.size();
elem(*this, tmp);
for (size_t i = first; i < items.size(); ++i) items[i].member = false;
}
void rest(std::vector<Node>&) { has_rest = true; }
// Run one shape's io() and return its item sequence.
template <class T>
static SchemaProbe of() {
SchemaProbe p;
T tmp{};
tmp.io(p);
return p;
}
private:
Item& add(Tag t, P prim, S shape) {
Item i;
i.tag = t.disk; // what actually goes on the wire, not the schema name
i.prim = prim;
i.shape = shape;
items.push_back(std::move(i));
return items.back();
}
};
// ---------------------------------------------------------------------------
// CoverageArchive — how much of a real save the shapes actually understand.
//
// A byte-identical round trip is not a coverage claim. shapes.h reaches it
// partly by typing fields and partly by carrying whole bodies as generic Nodes
// (`ar.any`, `ar.raw_frame`, and the `ar.rest` tail): a Node round-trips
// trivially because it is copied verbatim. This archive runs a *populated*
// shape's io() and separates the two — every item a field names is `typed`,
// every item that only survives because a Node carried it is `opaque`.
//
// Unlike SchemaProbe this one honours `when()`, because it walks real data.
class CoverageArchive {
public:
static constexpr bool reading = false, writing = false, building = false;
size_t typed = 0, opaque = 0;
std::map<std::string, size_t> opaque_by_tag; // where the untyped items are
static size_t count(const Node& n) {
size_t c = 1;
for (const Node& k : n.children) c += count(k);
return c;
}
void i32(Tag, int32_t&) { ++typed; }
void f32(Tag, float&) { ++typed; }
void b(Tag, bool&) { ++typed; }
void i64(Tag, int64_t&) { ++typed; }
void str(Tag, std::string&) { ++typed; }
void vec3(Tag, Vec3&) { typed += 4; } // the frame plus three floats
void any(Tag t, Node& v) { charge(t.name, count(v)); }
void raw_frame(Tag t, Node& v) { charge(t.name, count(v)); }
void opt_i32(Tag, std::optional<int32_t>& v) { typed += v.has_value(); }
void opt_f32(Tag, std::optional<float>& v) { typed += v.has_value(); }
void opt_b(Tag, std::optional<bool>& v) { typed += v.has_value(); }
void opt_any(Tag t, std::optional<Node>& v) {
if (v) charge(t.name, count(*v));
}
template <class T>
void opt_obj(Tag t, std::optional<T>& v) {
if (v) obj(t, *v);
}
template <class T>
void obj(Tag t, T& v) {
++typed; // the frame item itself
const char* save = cur_;
cur_ = *t.name ? t.name : (*T::kStreamName ? T::kStreamName : cur_);
v.io(*this);
cur_ = save;
}
template <class T>
void obj_flex(Tag t, T& v, bool&) {
obj(t, v);
}
template <class T>
void carr(Tag t, std::vector<T>& v) {
typed += 2; // the frame item and its "." count
for (T& e : v) elem_of(t.name, e);
}
template <class T>
void carr_flex(Tag t, std::vector<T>& v, bool&) {
carr(t, v);
}
template <class T, class F>
void narr(Tag, std::vector<T>& v, F fn) {
++typed; // the count item
for (T& e : v) fn(*this, e);
}
// A tag for the tail is not available here; `rest` is charged to "<rest>".
template <class F>
void when(bool cond, F body) {
if (cond) body(*this);
}
template <class T, class F>
void repeat(const char*, std::vector<T>& v, F fn) {
for (T& e : v) fn(*this, e);
}
void rest(std::vector<Node>& v) {
for (const Node& n : v) charge(std::string("<rest:") + cur_ + ">", count(n));
}
private:
const char* cur_ = "root";
void charge(const std::string& tag, size_t n) {
opaque += n;
if (n) opaque_by_tag[tag] += n;
}
void elem_of(const char* tag, Node& n) { charge(tag, count(n)); }
void elem_of(const char*, int32_t&) { ++typed; }
void elem_of(const char*, float&) { ++typed; }
void elem_of(const char*, std::string&) { ++typed; }
template <class T>
void elem_of(const char* tag, T& v) {
++typed; // the element's own frame
const char* save = cur_;
cur_ = *tag ? tag : cur_;
v.io(*this);
cur_ = save;
}
};
inline const char* probe_prim_name(SchemaProbe::P p) {
switch (p) {
case SchemaProbe::P::Unknown: return "?";
case SchemaProbe::P::I32: return "i32";
case SchemaProbe::P::I64: return "i64";
case SchemaProbe::P::F32: return "f32";
case SchemaProbe::P::Bool: return "bool";
case SchemaProbe::P::Str: return "str";
case SchemaProbe::P::Frame: return "frame";
case SchemaProbe::P::Raw: return "raw";
}
return "?";
}
inline const char* probe_shape_name(SchemaProbe::S s) {
switch (s) {
case SchemaProbe::S::Scalar: return "scalar";
case SchemaProbe::S::Frame: return "frame";
case SchemaProbe::S::CArr: return "carr";
case SchemaProbe::S::NArr: return "narr";
case SchemaProbe::S::Raw: return "raw";
}
return "?";
}
} // namespace mars::stream

View file

@ -165,13 +165,20 @@ struct Summary {
struct Planet { // SystemParams element, all tags "." struct Planet { // SystemParams element, all tags "."
static constexpr const char* kStreamName = ""; static constexpr const char* kStreamName = "";
Vec3 pos; Vec3 pos;
int32_t p1 = 0, p2 = 0, p3 = 0; // p1 is a std::string, not an int: SystemParams::Write calls the string
// writer for it (sots-re objects/streams.json, Game::SystemParams item 1).
// It is the empty string in every real save, and an empty string is four
// zero bytes -- byte-identical to the int 0 the community reader assumed,
// which is why reading it as an int has never broken a round trip. A save
// carrying a non-empty name here would have desynchronised the parse.
std::string p1;
int32_t p2 = 0, p3 = 0;
float p4 = 0; float p4 = 0;
std::vector<Node> extra; std::vector<Node> extra;
template <class Ar> template <class Ar>
void io(Ar& ar) { void io(Ar& ar) {
ar.vec3(R("pos"), pos); ar.vec3(R("pos"), pos);
ar.i32(R("p1"), p1); ar.str(R("p1"), p1);
ar.i32(R("p2"), p2); ar.i32(R("p2"), p2);
ar.i32(R("p3"), p3); ar.i32(R("p3"), p3);
ar.f32(R("p4"), p4); ar.f32(R("p4"), p4);
@ -711,9 +718,16 @@ struct Odes {
ar.rest(extra); ar.rest(extra);
} }
}; };
// `odet` is a bool. ObservedWeapon::Write / ObservedTech::Write call the bool
// writer, and the campaign's hand-recovered struct table agrees (odet @+0x08,
// bool). The community reader models it as an int; that is byte-safe here only
// by coincidence -- with a 4-char tag a bool item and an int item both occupy 12
// bytes, and the bool's three pad bytes are zeroed, so the two encodings are
// identical. A shorter tag would not have been so forgiving.
struct Owep { struct Owep {
static constexpr const char* kStreamName = ""; static constexpr const char* kStreamName = "";
int32_t ontF = 0, otnL = 0, odet = 0; int32_t ontF = 0, otnL = 0;
bool odet = false;
std::string owep; std::string owep;
int32_t owith = 0; int32_t owith = 0;
std::vector<Node> extra; std::vector<Node> extra;
@ -721,15 +735,16 @@ struct Owep {
void io(Ar& ar) { void io(Ar& ar) {
ar.i32(R("ontF", "otnF"), ontF); ar.i32(R("ontF", "otnF"), ontF);
ar.i32(R("otnL", "otnL"), otnL); ar.i32(R("otnL", "otnL"), otnL);
ar.i32(R("odet", "odet"), odet); ar.b(R("odet", "odet"), odet);
ar.str(R("owep", "owep"), owep); ar.str(R("owep", "owep"), owep);
ar.i32(R("owith", "owith"), owith); ar.i32(R("owith", "owith"), owith);
ar.rest(extra); ar.rest(extra);
} }
}; };
struct Otch { struct Otch { // see the note on Owep::odet
static constexpr const char* kStreamName = ""; static constexpr const char* kStreamName = "";
int32_t ontF = 0, otnL = 0, odet = 0; int32_t ontF = 0, otnL = 0;
bool odet = false;
std::string otch; std::string otch;
int32_t owith = 0; int32_t owith = 0;
std::vector<Node> extra; std::vector<Node> extra;
@ -737,7 +752,7 @@ struct Otch {
void io(Ar& ar) { void io(Ar& ar) {
ar.i32(R("ontF", "otnF"), ontF); ar.i32(R("ontF", "otnF"), ontF);
ar.i32(R("otnL", "otnL"), otnL); ar.i32(R("otnL", "otnL"), otnL);
ar.i32(R("odet", "odet"), odet); ar.b(R("odet", "odet"), odet);
ar.str(R("otch", "otch"), otch); ar.str(R("otch", "otch"), otch);
ar.i32(R("owith", "owith"), owith); ar.i32(R("owith", "owith"), owith);
ar.rest(extra); ar.rest(extra);
@ -759,19 +774,79 @@ struct Note {
} }
}; };
struct ShipSectionID { // Game::ShipSectionID: two "." ints
static constexpr const char* kStreamName = "";
int32_t a = 0, b = 0;
std::vector<Node> extra;
template <class Ar>
void io(Ar& ar) {
ar.i32(R("a"), a);
ar.i32(R("b"), b);
ar.rest(extra);
}
};
struct GunWeapon { // DW2: the weapon in a gun bank, named either by id or by name
static constexpr const char* kStreamName = "DW2";
bool bID = false; // true: the weapon is identified by id, false: by family name
int32_t wid = 0;
std::string wfn;
int32_t did = 0;
std::vector<Node> extra;
template <class Ar>
void io(Ar& ar) {
ar.b(A("bID"), bID);
ar.when(bID, [&](Ar& a) { a.i32(A("wid"), wid); });
ar.when(!bID, [&](Ar& a) { a.str(A("wfn"), wfn); });
ar.i32(A("did"), did);
ar.rest(extra);
}
};
struct GunBank { // one DGbnk2 element: an anonymous frame holding one DW2
static constexpr const char* kStreamName = "";
GunWeapon w;
std::vector<Node> extra;
template <class Ar>
void io(Ar& ar) {
ar.obj(A("DW2"), w);
ar.rest(extra);
}
};
struct DesignSection { // Game::ShipDesignDef::Section, one DSec of a Des frame
static constexpr const char* kStreamName = "DSec";
ShipSectionID sec;
std::vector<GunBank> gunBanks;
std::vector<Node> opts; // DOpts: the recovery says carr<String>
std::vector<Node> extra;
template <class Ar>
void io(Ar& ar) {
ar.obj(A("DSec"), sec);
ar.carr(A("DGbnk2"), gunBanks);
ar.carr(A("DOpts"), opts);
ar.rest(extra);
}
};
struct Design { // on-disk tags are case variants of the reference names (FAIDes, DHide, DWep, DName) struct Design { // on-disk tags are case variants of the reference names (FAIDes, DHide, DWep, DName)
static constexpr const char* kStreamName = "Des"; static constexpr const char* kStreamName = "Des";
bool faiDes = false, dHide = false; bool faiDes = false, dHide = false;
int32_t dWep = 0; int32_t dWep = 0;
std::string dName; std::string dName;
std::vector<Node> sections; // The DSec run is uncounted -- it ends when the next tag stops being DSec.
// Game::ShipDesign::Write makes no stream call at all, so there is no recovered
// serializer for the Des frame itself; the section body is
// Game::ShipDesignDef::Section, which there is, and Dtc/Dwgv are from the saves.
std::vector<DesignSection> sections;
int32_t dtc = 0, dwgv = 0;
std::vector<Node> extra;
template <class Ar> template <class Ar>
void io(Ar& ar) { void io(Ar& ar) {
ar.b(R("faiDes", "FAIDes"), faiDes); ar.b(R("faiDes", "FAIDes"), faiDes);
ar.b(R("dHide", "DHide"), dHide); ar.b(R("dHide", "DHide"), dHide);
ar.i32(R("dWep", "DWep"), dWep); ar.i32(R("dWep", "DWep"), dWep);
ar.str(R("dName", "DName"), dName); ar.str(R("dName", "DName"), dName);
ar.rest(sections); ar.repeat("DSec", sections, [](Ar& a, DesignSection& e) { a.obj(A("DSec"), e); });
ar.i32(A("Dtc"), dtc);
ar.i32(A("Dwgv"), dwgv);
ar.rest(extra);
} }
}; };
@ -828,9 +903,334 @@ struct NexpEntry {
} }
}; };
// ---- bodies typed from the recovered wire schema ---------------------------------
// Each of these was an `ar.any` blob until the serializer recovery named its items.
// The class each shape corresponds to is in the comment; tests/mars_stream/test_wire_schema.cpp
// checks the field list against include/generated/sots_stream_schema.h.
struct EventRec { // Game::EventStorage::Event
static constexpr const char* kStreamName = "";
int32_t evEID = 0;
std::string evDsc, evMsg, evImg;
int32_t evLoc = 0;
Vec3 evPos;
int32_t evAct = 0, evCID = 0;
std::vector<Node> extra;
template <class Ar>
void io(Ar& ar) {
ar.i32(A("EvEID"), evEID);
ar.str(A("EvDsc"), evDsc);
ar.str(A("EvMsg"), evMsg);
ar.str(A("EvImg"), evImg);
ar.i32(A("EvLoc"), evLoc);
ar.vec3(A("EvPos"), evPos);
ar.i32(A("EvAct"), evAct);
ar.i32(A("EvCID"), evCID);
ar.rest(extra);
}
};
struct TurnEvents { // Game::EventStorage::TurnEvents
static constexpr const char* kStreamName = "";
int32_t evTurn = 0;
std::vector<EventRec> events;
std::vector<Node> extra;
template <class Ar>
void io(Ar& ar) {
ar.i32(A("EvTurn"), evTurn);
ar.carr(A("Events"), events);
ar.rest(extra);
}
};
struct EventStorage { // Game::EventStorage
static constexpr const char* kStreamName = "Events";
int32_t evNxID = 0;
std::vector<TurnEvents> turns;
std::vector<Node> extra;
template <class Ar>
void io(Ar& ar) {
ar.i32(A("EvNxID"), evNxID);
ar.carr(A("Events"), turns);
ar.rest(extra);
}
};
struct FleetNameGen { // Game::FleetNameGenerator
static constexpr const char* kStreamName = "FNG";
int32_t fngNum = 0;
std::vector<Node> extra;
template <class Ar>
void io(Ar& ar) {
ar.i32(A("FNGNum"), fngNum);
ar.rest(extra);
}
};
struct SpeciesRatios { // Game::SpeciesRatios
static constexpr const char* kStreamName = "spe";
// `nv` is the count, not a field: the recovery marks sp and va2 as loop-body
// writes, and the saves agree -- nv==1 frames carry one (sp, va2) pair and
// nv==0 frames carry nothing at all.
struct Entry {
int32_t sp = 0, va2 = 0;
template <class Ar>
void io(Ar& ar) {
ar.i32(A("sp"), sp);
ar.i32(A("va2"), va2);
}
};
std::vector<Entry> ratios;
std::vector<Node> extra;
template <class Ar>
void io(Ar& ar) {
ar.narr(A("nv"), ratios, [](Ar& a, Entry& e) { e.io(a); });
ar.rest(extra);
}
};
struct CivilianRatios { // Game::CivilianRatios
static constexpr const char* kStreamName = "civr";
float smx = 0;
SpeciesRatios spe;
std::vector<Node> extra;
template <class Ar>
void io(Ar& ar) {
ar.f32(A("smx"), smx);
ar.obj(A("spe"), spe);
ar.rest(extra);
}
};
struct TechNode { // one entry of Game::TechTree: a name and its branch names
int32_t numBrs = 0;
std::string tNm;
std::vector<std::string> branches;
template <class Ar>
void io(Ar& ar) {
ar.str(A("TNm"), tNm);
ar.narr(A("NumBrs"), branches, [](Ar& a, std::string& e) { a.str(A("TNm"), e); });
}
};
struct TechState { // per-tech state, second NumTechs section of Game::TechTree
std::string tNm;
int32_t st = 0, tResCost = 0, tResDone = 0, tAcq = 0, tiAcq = 0, tbd = 0;
bool tfc = false;
int32_t tUnlck = 0;
template <class Ar>
void io(Ar& ar) {
ar.str(A("TNm"), tNm);
ar.i32(A("St"), st);
ar.i32(A("TResCost"), tResCost);
ar.i32(A("TResDone"), tResDone);
ar.i32(A("TAcq"), tAcq);
ar.i32(A("TiAcq"), tiAcq);
ar.i32(A("Tbd"), tbd);
ar.b(A("Tfc"), tfc);
ar.i32(A("TUnlck"), tUnlck);
}
};
struct TechTree { // Game::TechTree
static constexpr const char* kStreamName = "TechTree";
// TechTree::Write emits *two* NumTechs-counted sections: the tree topology
// (each tech's name and its branch names) and then the per-tech state. The
// recovery sees only the first under Game::TechTree; the second section's
// field list and types are the ones Game::SpyReportTechTree names -- the spy
// report streams the same per-tech record -- which is where Tfc being a bool
// rather than an int comes from.
std::vector<TechNode> techs;
std::vector<TechState> state;
std::vector<Node> extra;
template <class Ar>
void io(Ar& ar) {
ar.narr(A("NumTechs"), techs, [](Ar& a, TechNode& e) { e.io(a); });
ar.narr(A("NumTechs"), state, [](Ar& a, TechState& e) { e.io(a); });
ar.rest(extra);
}
};
struct ShipRecord { // one entry of Game::ShipRecords
int32_t srb = 0, srl = 0, srk = 0, sri = 0;
template <class Ar>
void io(Ar& ar) {
ar.i32(A("srb"), srb);
ar.i32(A("srl"), srl);
ar.i32(A("srk"), srk);
ar.i32(A("sri"), sri);
}
};
struct DesignRecord { // second section of Game::ShipRecords -- note: no srk
int32_t srd = 0, src = 0, srb = 0, srl = 0, sri = 0;
template <class Ar>
void io(Ar& ar) {
ar.i32(A("srd"), srd);
ar.i32(A("src"), src);
ar.i32(A("srb"), srb);
ar.i32(A("srl"), srl);
ar.i32(A("sri"), sri);
}
};
struct ShipRecords { // Game::ShipRecords
static constexpr const char* kStreamName = "ShipRecs";
// Two counted sections. `srbd` is the second COUNT, not a field: the recovery
// lists srd/src/srb/srl/sri right after it, and the saves confirm srbd records
// follow (srbd is 0, 1, 3 or 4 across the players available).
std::vector<ShipRecord> recs;
std::vector<DesignRecord> designs;
std::vector<Node> extra;
template <class Ar>
void io(Ar& ar) {
ar.narr(A("srnc"), recs, [](Ar& a, ShipRecord& e) { e.io(a); });
ar.narr(A("srbd"), designs, [](Ar& a, DesignRecord& e) { e.io(a); });
ar.rest(extra);
}
};
struct AIEncounterFlags { // Game::AIEncounterFlags
static constexpr const char* kStreamName = "AIEnf";
struct Entry {
int32_t fid = 0, ast = 0;
template <class Ar>
void io(Ar& ar) {
ar.i32(A("Fid"), fid);
ar.i32(A("Ast"), ast);
}
};
std::vector<Entry> entries;
std::vector<Node> extra;
template <class Ar>
void io(Ar& ar) {
ar.narr(A("Nas"), entries, [](Ar& a, Entry& e) { e.io(a); });
ar.rest(extra);
}
};
struct PlayerAid { // Game::PlayerAid, one element of the "aid" array
static constexpr const char* kStreamName = "";
int32_t aidto = 0, aidsa = 0, aidst = 0, aidrai = 0, aidrt = 0;
std::vector<Node> extra;
template <class Ar>
void io(Ar& ar) {
ar.i32(A("aidto"), aidto);
ar.i32(A("aidsa"), aidsa);
ar.i32(A("aidst"), aidst);
ar.i32(A("aidrai"), aidrai);
ar.i32(A("aidrt"), aidrt);
ar.rest(extra);
}
};
struct CommMessages { // Game::CommMessageContainer
static constexpr const char* kStreamName = "comms";
struct Entry {
int32_t msgt = 0;
Node msg; // Game::ICommMessage: polymorphic, the subtype is msgt
template <class Ar>
void io(Ar& ar) {
ar.i32(A("msgt"), msgt);
ar.any(A("msg"), msg);
}
};
std::vector<Entry> msgs;
std::vector<Node> extra;
template <class Ar>
void io(Ar& ar) {
ar.narr(A("nmsg"), msgs, [](Ar& a, Entry& e) { e.io(a); });
ar.rest(extra);
}
};
struct SpyReport { // Game::SpyReport: four counted lists, one per report kind
static constexpr const char* kStreamName = "spy2";
std::vector<Node> defences, trade, events, techTree;
std::vector<Node> extra;
template <class Ar>
void io(Ar& ar) {
// Every count is 0 in every save available, so the element shapes named by
// the recovery (SpyReportDefences / …Trade / …Events / …TechTree) are
// unverified; the counts and the tags of the element frames are not.
ar.narr(A("defc2"), defences, [](Ar& a, Node& e) { a.any(A("def"), e); });
ar.narr(A("rtc"), trade, [](Ar& a, Node& e) { a.any(A("strd"), e); });
ar.narr(A("evc"), events, [](Ar& a, Node& e) { a.any(A("evs"), e); });
ar.narr(A("ttc"), techTree, [](Ar& a, Node& e) { a.any(A("tt"), e); });
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
std::vector<int32_t> systems, fleets;
std::vector<Node> extra;
template <class Ar>
void io(Ar& ar) {
ar.vec3(A("Pos"), pos);
ar.i32(A("tsgridID"), gridID);
ar.vec3(A("tsctr"), ctr);
ar.i32(A("tssec"), tssec);
ar.i32(A("tsct"), tsct);
ar.i32(A("tscr"), tscr);
ar.i32(A("ptssec"), ptssec);
ar.i32(A("ptsct"), ptsct);
ar.i32(A("ptscr"), ptscr);
ar.carr(A("fwarn"), fwarn);
ar.narr(A("tsnumsys"), systems, [](Ar& a, int32_t& e) { a.i32(A("tssys"), e); });
ar.narr(A("tsnumflt"), fleets, [](Ar& a, int32_t& e) { a.i32(A("tsflt"), e); });
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
// trap. The call is virtual: the object is a ServerTradeManagerImpl, and *that*
// class has a real serializer, which is where this field list comes from. The
// same shape resolves Game::IServerSpyManager -> Game::ServerSpyManager below.
static constexpr const char* kStreamName = "trdmgr";
struct Entry {
int32_t tradeID = 0;
TradeSector trade;
template <class Ar>
void io(Ar& ar) {
ar.i32(A("TradeID"), tradeID);
ar.obj(A("Trade"), trade);
}
};
std::vector<Entry> sectors;
float sctSize = 0;
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.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
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.rest(extra);
}
};
struct ProjectName { // one entry of Game::SpecialProjectNameGen
std::vector<Node> used; // usnc is 0 in every save available
std::string nm, ntg;
template <class Ar>
void io(Ar& ar) {
ar.narr(A("usnc"), used, [](Ar& a, Node& e) { a.any(A("usc"), e); });
ar.str(A("Nm"), nm);
ar.str(A("Ntg"), ntg);
}
};
struct ProjectNames { // Game::SpecialProjectNameGen
static constexpr const char* kStreamName = "sprjs";
std::vector<ProjectName> names;
std::vector<Node> extra;
template <class Ar>
void io(Ar& ar) {
ar.narr(A("NNms2"), names, [](Ar& a, ProjectName& e) { e.io(a); });
ar.rest(extra);
}
};
struct Player { struct Player {
static constexpr const char* kStreamName = "Player"; static constexpr const char* kStreamName = "Player";
Node techTree; TechTree techTree;
int32_t homeSys = 0, plyrIdx = 0; int32_t homeSys = 0, plyrIdx = 0;
std::string plryName; std::string plryName;
int32_t species = 0; int32_t species = 0;
@ -853,18 +1253,20 @@ struct Player {
bool elim = false, npc = false, rebAI = false, reqCL = false; bool elim = false, npc = false, rebAI = false, reqCL = false;
Alliances alliances; Alliances alliances;
int32_t hasVac = 0, hasImm = 0, npTrk = 0, hasDisc = 0, hasDiscSp = 0, hasDiscCl = 0, hasEnc = 0, hasEng = 0; int32_t hasVac = 0, hasImm = 0, npTrk = 0, hasDisc = 0, hasDiscSp = 0, hasDiscCl = 0, hasEnc = 0, hasEng = 0;
Node events, fng; EventStorage events;
FleetNameGen fng;
int32_t pvSav = 0; int32_t pvSav = 0;
bool pvMA = false, aiBn = false, cnTrd = false, cnRad = false, hgs = false, hadvs = false, harcc = false, bool pvMA = false, aiBn = false, cnTrd = false, cnRad = false, hgs = false, hadvs = false, harcc = false,
cnVItl = false; cnVItl = false;
float pddm = 0; float pddm = 0;
int32_t bnkWrn = 0, bnkTrn = 0, bnkPr = 0, bnkEl = 0; int32_t bnkWrn = 0, bnkTrn = 0, bnkPr = 0, bnkEl = 0;
Node shipRecs; ShipRecords shipRecs;
int32_t nextPrjID = 0, plcy = 0; int32_t nextPrjID = 0, plcy = 0;
std::string pswd; std::string pswd;
int32_t lret = 0, nmeid = 0; int32_t lret = 0, nmeid = 0;
bool cdp = false; bool cdp = false;
Node spy2, civr; SpyReport spy2;
CivilianRatios civr;
int32_t aidf = 0; int32_t aidf = 0;
bool srn = false; bool srn = false;
int32_t srnTo = 0, lboid = 0; int32_t srnTo = 0, lboid = 0;
@ -881,25 +1283,25 @@ struct Player {
bool hasAIR = false; bool hasAIR = false;
Node air; Node air;
bool cta = false; bool cta = false;
Node aiEnf; AIEncounterFlags aiEnf;
std::vector<SprjEntry> specialProjects; std::vector<SprjEntry> specialProjects;
std::vector<NexpEntry> nexp; std::vector<NexpEntry> nexp;
std::vector<int32_t> weapXcl; std::vector<int32_t> weapXcl;
Node ojvs; std::vector<Node> ojvs; // Game::Objective elements; the count is 0 in every save available
std::vector<DipStat> dipstats; std::vector<DipStat> dipstats;
Node comms; CommMessages comms;
std::vector<Prep> preps; std::vector<Prep> preps;
std::vector<Odes> odes; std::vector<Odes> odes;
std::vector<Owep> owep; std::vector<Owep> owep;
std::vector<Otch> otch; std::vector<Otch> otch;
Node aid; std::vector<PlayerAid> aid;
std::vector<Node> defLayouts, raidTargets; std::vector<Node> defLayouts, raidTargets;
int32_t tnc = 0; int32_t tnc = 0;
std::vector<Node> extra; std::vector<Node> extra;
template <class Ar> template <class Ar>
void io(Ar& ar) { void io(Ar& ar) {
ar.any(A("TechTree"), techTree); ar.obj(A("TechTree"), techTree);
ar.i32(A("HomeSys"), homeSys); ar.i32(A("HomeSys"), homeSys);
ar.i32(A("PlyrIdx"), plyrIdx); ar.i32(A("PlyrIdx"), plyrIdx);
ar.str(A("PlryName"), plryName); ar.str(A("PlryName"), plryName);
@ -951,8 +1353,8 @@ struct Player {
ar.i32(A("HasDiscCl"), hasDiscCl); ar.i32(A("HasDiscCl"), hasDiscCl);
ar.i32(A("HasEnc"), hasEnc); ar.i32(A("HasEnc"), hasEnc);
ar.i32(A("HasEng"), hasEng); ar.i32(A("HasEng"), hasEng);
ar.any(A("Events"), events); ar.obj(A("Events"), events);
ar.any(A("FNG"), fng); ar.obj(A("FNG"), fng);
ar.i32(A("PvSav"), pvSav); ar.i32(A("PvSav"), pvSav);
ar.b(A("PvMA"), pvMA); ar.b(A("PvMA"), pvMA);
ar.b(A("AIBn"), aiBn); ar.b(A("AIBn"), aiBn);
@ -967,15 +1369,15 @@ struct Player {
ar.i32(A("BnkTrn"), bnkTrn); ar.i32(A("BnkTrn"), bnkTrn);
ar.i32(A("BnkPr"), bnkPr); ar.i32(A("BnkPr"), bnkPr);
ar.i32(A("BnkEl"), bnkEl); ar.i32(A("BnkEl"), bnkEl);
ar.any(A("ShipRecs"), shipRecs); ar.obj(A("ShipRecs"), shipRecs);
ar.i32(A("NextPrjID"), nextPrjID); ar.i32(A("NextPrjID"), nextPrjID);
ar.i32(A("plcy"), plcy); ar.i32(A("plcy"), plcy);
ar.str(A("pswd"), pswd); ar.str(A("pswd"), pswd);
ar.i32(A("lret"), lret); ar.i32(A("lret"), lret);
ar.i32(A("nmeid"), nmeid); ar.i32(A("nmeid"), nmeid);
ar.b(A("cdp"), cdp); ar.b(A("cdp"), cdp);
ar.any(A("spy2"), spy2); ar.obj(A("spy2"), spy2);
ar.any(A("civr"), civr); ar.obj(A("civr"), civr);
ar.i32(A("aidf"), aidf); ar.i32(A("aidf"), aidf);
ar.b(A("Srn"), srn); ar.b(A("Srn"), srn);
ar.i32(A("SrnTo"), srnTo); ar.i32(A("SrnTo"), srnTo);
@ -993,18 +1395,18 @@ struct Player {
ar.b(A("HasAIR"), hasAIR); ar.b(A("HasAIR"), hasAIR);
ar.when(hasAIR, [&](Ar& a) { a.any(A("AIR"), air); }); ar.when(hasAIR, [&](Ar& a) { a.any(A("AIR"), air); });
ar.b(A("cta"), cta); ar.b(A("cta"), cta);
ar.any(A("AIEnf"), aiEnf); ar.obj(A("AIEnf"), aiEnf);
ar.narr(A("NSprj"), specialProjects, [](Ar& a, SprjEntry& e) { e.io(a); }); ar.narr(A("NSprj"), specialProjects, [](Ar& a, SprjEntry& e) { e.io(a); });
ar.narr(A("Nexp"), nexp, [](Ar& a, NexpEntry& e) { e.io(a); }); ar.narr(A("Nexp"), nexp, [](Ar& a, NexpEntry& e) { e.io(a); });
ar.narr(A("NWeapXcl"), weapXcl, [](Ar& a, int32_t& e) { a.i32(A("WeapXcl"), e); }); ar.narr(A("NWeapXcl"), weapXcl, [](Ar& a, int32_t& e) { a.i32(A("WeapXcl"), e); });
ar.any(A("Ojvs"), ojvs); ar.carr(A("Ojvs"), ojvs);
ar.carr(A("dipstats"), dipstats); ar.carr(A("dipstats"), dipstats);
ar.any(A("comms"), comms); ar.obj(A("comms"), comms);
ar.carr(A("preps"), preps); ar.carr(A("preps"), preps);
ar.carr(A("odes"), odes); ar.carr(A("odes"), odes);
ar.carr(A("owep"), owep); ar.carr(A("owep"), owep);
ar.carr(A("otch"), otch); ar.carr(A("otch"), otch);
ar.any(A("aid"), aid); ar.carr(A("aid"), aid);
ar.narr(A("ndeflay"), defLayouts, [](Ar& a, Node& e) { a.any(A("deflay"), e); }); ar.narr(A("ndeflay"), defLayouts, [](Ar& a, Node& e) { a.any(A("deflay"), e); });
ar.narr(A("rdtc"), raidTargets, [](Ar& a, Node& e) { a.any(A("rdt"), e); }); ar.narr(A("rdtc"), raidTargets, [](Ar& a, Node& e) { a.any(A("rdt"), e); });
ar.i32(A("tnc"), tnc); ar.i32(A("tnc"), tnc);
@ -1467,7 +1869,7 @@ struct Sim {
int32_t npcm = 0, npco = 0, npci = 0, npcv = 0, npca = 0; int32_t npcm = 0, npco = 0, npci = 0, npcv = 0, npca = 0;
std::optional<int32_t> npc; std::optional<int32_t> npc;
float szadj = 0, rsadj = 0, suadj = 0; float szadj = 0, rsadj = 0, suadj = 0;
Node sprjs; ProjectNames sprjs;
float randEncAdj = 0; float randEncAdj = 0;
int32_t cmbtid = 0; int32_t cmbtid = 0;
TurnStats turnstats; TurnStats turnstats;
@ -1478,7 +1880,8 @@ struct Sim {
std::vector<Species> species; std::vector<Species> species;
std::vector<SysEntry> systems; std::vector<SysEntry> systems;
NodeGrid ndGr2; NodeGrid ndGr2;
Node trdmgr, spymgr; TradeManager trdmgr;
SpyManager spymgr;
std::vector<FleetEntry> fleets; std::vector<FleetEntry> fleets;
std::vector<int32_t> acts; std::vector<int32_t> acts;
std::optional<Node> svSctOb; std::optional<Node> svSctOb;
@ -1523,7 +1926,7 @@ struct Sim {
ar.f32(A("szadj"), szadj); ar.f32(A("szadj"), szadj);
ar.f32(A("rsadj"), rsadj); ar.f32(A("rsadj"), rsadj);
ar.f32(A("suadj"), suadj); ar.f32(A("suadj"), suadj);
ar.any(A("sprjs"), sprjs); ar.obj(A("sprjs"), sprjs);
ar.f32(A("RandEncAdj"), randEncAdj); ar.f32(A("RandEncAdj"), randEncAdj);
ar.i32(A("cmbtid"), cmbtid); ar.i32(A("cmbtid"), cmbtid);
ar.obj(A("turnstats"), turnstats); ar.obj(A("turnstats"), turnstats);
@ -1543,8 +1946,8 @@ struct Sim {
ar.repeat("ISsp", species, [](Ar& a, Species& e) { e.io(a); }); // 7 pairs, no count ar.repeat("ISsp", species, [](Ar& a, Species& e) { e.io(a); }); // 7 pairs, no count
ar.narr(A("NumSys"), systems, [](Ar& a, SysEntry& e) { e.io(a); }); ar.narr(A("NumSys"), systems, [](Ar& a, SysEntry& e) { e.io(a); });
ar.obj(A("NdGr2"), ndGr2); ar.obj(A("NdGr2"), ndGr2);
ar.any(A("trdmgr"), trdmgr); ar.obj(A("trdmgr"), trdmgr);
ar.any(A("spymgr"), spymgr); ar.obj(A("spymgr"), spymgr);
ar.narr(A("NumFlts"), fleets, [](Ar& a, FleetEntry& e) { e.io(a); }); ar.narr(A("NumFlts"), fleets, [](Ar& a, FleetEntry& e) { e.io(a); });
ar.narr(A("NumActs"), acts, [](Ar& a, int32_t& e) { a.i32(A("Act"), e); }); ar.narr(A("NumActs"), acts, [](Ar& a, int32_t& e) { a.i32(A("Act"), e); });
ar.opt_any(A("SvSctOb"), svSctOb); // only if the pointer was non-NULL ar.opt_any(A("SvSctOb"), svSctOb); // only if the pointer was non-NULL

View file

@ -16,3 +16,12 @@ foreach(_t mars_stream_test_rng mars_stream_test_stream mars_stream_test_save)
target_include_directories(${_t} PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) target_include_directories(${_t} PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
target_compile_options(${_t} PRIVATE -Wall -Wextra -Wpedantic) target_compile_options(${_t} PRIVATE -Wall -Wextra -Wpedantic)
endforeach() endforeach()
# Conformance of the typed shapes against the wire schema recovered from the game's
# serializers (include/generated/sots_stream_schema.h). No game data: the generated
# table plus shapes.h, so this one always runs.
add_executable(mars_stream_test_wire_schema test_wire_schema.cpp)
target_link_libraries(mars_stream_test_wire_schema PRIVATE mars_stream sots_addresses)
add_test(NAME mars_stream_wire_schema COMMAND mars_stream_test_wire_schema)
target_include_directories(mars_stream_test_wire_schema PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
target_compile_options(mars_stream_test_wire_schema PRIVATE -Wall -Wextra -Wpedantic)

View file

@ -16,6 +16,7 @@
#include "mars/rng/mt19937.h" #include "mars/rng/mt19937.h"
#include "mars/stream/dump.h" #include "mars/stream/dump.h"
#include "mars/stream/probe.h"
#include "mars/stream/save.h" #include "mars/stream/save.h"
using namespace mars::stream; using namespace mars::stream;
@ -104,6 +105,27 @@ static void check_save(const std::string& path, const char* dump_dir) {
CHECK(twists >= 0); CHECK(twists >= 0);
} }
// --- coverage: what the shapes understand vs what a Node merely carries ---------
// The round trip below is byte-identical either way, because an opaque Node is
// copied verbatim. This is the number that actually moves when a body gets typed.
{
CoverageArchive cov;
doc.game.io(cov);
size_t total = cov.typed + cov.opaque;
double pct = total ? 100.0 * double(cov.typed) / double(total) : 0.0;
std::printf(" coverage: %zu typed, %zu opaque (%.1f%% of %u stream items typed)\n", cov.typed,
cov.opaque, pct, doc.stats.items);
std::vector<std::pair<size_t, std::string>> worst;
for (const auto& kv : cov.opaque_by_tag) worst.emplace_back(kv.second, kv.first);
std::sort(worst.rbegin(), worst.rend());
std::printf(" still opaque:");
for (size_t i = 0; i < worst.size() && i < 8; ++i)
std::printf(" %s=%zu", worst[i].second.c_str(), worst[i].first);
std::printf("\n");
// Ratchet, not a target: typing a body must never silently regress.
CHECK(pct >= 95.0);
}
// --- round trips ------------------------------------------------------------------ // --- round trips ------------------------------------------------------------------
Bytes tree_bytes = write_tree(doc.tree); Bytes tree_bytes = write_tree(doc.tree);
CHECK(tree_bytes == doc.inflated); CHECK(tree_bytes == doc.inflated);

View file

@ -0,0 +1,245 @@
// 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);
}
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");
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;
}