Compare commits

..

No commits in common. "f4f3a7aae7197a65f980c146dfefc9123ebc5f45" and "4a4c7bc2495ad22c1306cd1e010bbe2f89bc6068" have entirely different histories.

8 changed files with 12 additions and 620 deletions

View file

@ -76,23 +76,3 @@ cannot supply — and the table is what proves they agree with the binary.
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.
## What the table cannot tell you: polymorphic members
`Mars::StreamableHelper<Base>` is a **pointer**, and the wire schema only records that a frame goes
here — never which derived class wrote it. `Game::SVSOSots` writes two such lists, each preceded by
the key that selects the body (`xscn`, a scenario name, for `xsc`; `EncID`, an int, for `EncObj`).
Those maps are not on the wire at all; they were read out of the game's own factories and are
recorded in the notes repo (`findings/objects/svsctob-variants.md`). The shapes apply the key in
both directions — the reader `select()`s from what it just read, the writer from what it is about
to write — so a body goes back out as whatever it came in as, and a key with no shape falls to a
generic `Node` and still round-trips.
Two more things the table's `prim` column will not save you from:
* **A `bool` and an `int` item are the same size only when the tag length makes the padding agree.**
A 4-character tag gives 4 + 4 + 1 → 12 and 4 + 4 + 4 = 12; a 3-character tag gives 8 and 12. That
coincidence is what hid `odet` being a bool for a whole campaign round.
* **An empty string is four zero bytes**, byte-identical to the int 0, so a string field that is
empty in all available data round-trips perfectly while typed as an int. That is what hid
`Game::SystemParams`'s name field.

View file

@ -1,5 +1,5 @@
// GENERATED — do not edit. Facts about Sword of the Stars.exe (GOG 1.8.1).
// Source: sots-re ghidra/addresses.json @ 2308b6e, generated 2026-09-08 by tools/gen_addresses.py
// Source: sots-re ghidra/addresses.json @ 5a3f986, generated 2026-09-08 by tools/gen_addresses.py
// Runtime address = (uintptr_t)GetModuleHandle(NULL) + RVA (the exe is ASLR-relocated).
#pragma once
#include <cstdint>
@ -1261,31 +1261,5 @@ constexpr uint32_t TechTree_ProcessResearch_TechsUnlockedCollector = 0x00187cc3;
constexpr uint32_t ServerPlayer_OnTechResearched_RecordObservedTech = 0x00491790;
// site site in ServerPlayer::OnTechResearched, second statement: `if (this->ResT(+0x294) == def) { if (this->ResearchRollPending(+0x3b4)) RollResearchEvent(this); this->ResearchRollPending = 0; this->ResT = 0; }`. RollResearchEvent (0x0088df20) draws EXACTLY ONE NextFloat unconditionally and then enters 0x00889d60 only when roll < ResearchEventOdds -- the odds are 0 for every tech outside the plague and AI-rebellion families, so that branch is normally dead. This is the one extra RNG word a completion consumes, and clearing ResT means a second completion in the same pass consumes none [verified]
constexpr uint32_t ServerPlayer_OnTechResearched_ResearchRollBlock = 0x00491790;
// cdecl Game::SVScriptObject* (int encID) // The EncObj factory. `dec eax; cmp eax,0x16; ja <null>; jmp dword [eax*4 + 0x0052bf60]` -- a 23-entry dword jump table indexed by encID-1. Live ids: 1 VonNeumann, 3 Swarm, 4 Derelict, 5 Monitor, 7 SystemKiller, 8 PuppetMaster, 9 SlaversRefuel, 10 SwarmQueen, 14 Locust, 17 CrowRuins, 20 Refugees, 21 Ortgay. Ids 2, 6, 11-13, 15, 16, 18, 19, 22, 23 and everything outside 1..23 return NULL. Class names read off the vftable store in each ctor [verified]
constexpr uint32_t SVScriptObject_FactoryByEncID = 0x0012bf00;
// data void* [23] // the jump table SVScriptObject_FactoryByEncID indexes with encID-1 [verified]
constexpr uint32_t SVScriptObject_EncIDJumpTable = 0x0012bf60;
// thiscall Game::SVScriptObject* (Game::SVSOSots* this, int encID) // scans the loaded (id, obj) pair vector at this+0x0C..0x10 first and only calls SVScriptObject_FactoryByEncID on a miss [verified]
constexpr uint32_t SVSOSots_GetOrCreateEncObj = 0x001a4450;
// cdecl Game::SVScriptObject* (const char* xscn) // the xsc factory: a flat four-way _stricmp chain, EXHAUSTIVE. "traps" -> Game::SVSOTraps (ctor 0x0052cbb0), "crowdefs" -> Game::SVSOCrowDefenders (0x0052b3c0), "indsys" -> Game::SVSOIndependentSystems (0x0075b530), "gmtrigger" -> Game::SVSOGrandMenaceTrigger (0x004f5630). Falls through with "Error creating extra script %s." [verified]
constexpr uint32_t SVScriptObject_FactoryByScenarioName = 0x001a7050;
// thiscall void (Game::SVSOSots* this) // registers exactly traps, crowdefs, indsys and -- only when ScnObj == NULL -- gmtrigger. Corroborates that the four scenario names are the whole set [verified]
constexpr uint32_t SVSOSots_SeedNewGameScripts = 0x001a7d70;
// thiscall void (Game::SVSOSots* this, Mars::IStream* s) // slot 2 of vftable 0x00A063C4. Emits ScnID, ScnObj (only when non-NULL), numx x (xscn, xsc), NEncObjs x (EncID, EncObj) [verified]
constexpr uint32_t SVSOSots_Write = 0x0019ddf0;
// thiscall void (Game::SVSOSots* this, Mars::IStream* s) // slot 1. Accepts two tags Write never emits -- NPCPlr (int, first) and hastraps (bool) with a following traps object -- read-only backward compatibility; a writer that omits them is correct [verified]
constexpr uint32_t SVSOSots_Read = 0x001a7a40;
// data Game::SVSOSots vftable (slot0 dtor, slot1 Read 0x005a7a40, slot2 Write 0x0059ddf0) [verified]
constexpr uint32_t SVSOSots_vftable = 0x006063c4;
// data Game::SVSOIndependentSystems vftable. slot0 dtor 0x0075afb0; slot1 Read AND slot2 Write are both 0x005f8ac0, the shared `ret 4` no-op stub -- so the "indsys" frame is genuinely empty on disk and the class's 0x1c4-byte body is runtime-only state [verified]
constexpr uint32_t SVSOIndependentSystems_vftable = 0x00620314;
// thiscall void (void*, Mars::IStream*) // `C2 04 00` -- a bare RET 4 shared as the inherited Read/Write for classes that serialize nothing [verified]
constexpr uint32_t Streamable_NoOpStub = 0x001f8ac0;
// thiscall void (Game::SVSOCrowDefenders* this, Mars::IStream* s) // sys; ndsys count then a loop writing dsys; ndes count then a loop writing des; drad. NOTE: `dsys` is INSIDE the ndsys loop -- objects/layouts.json records it as a plain member, which is wrong, and no save can settle it because both counts are 0 everywhere [verified]
constexpr uint32_t SVSOCrowDefenders_Write = 0x000f8c90;
// thiscall void (Game::SVSOMonitor* this, Mars::IStream* s) // calls SVSODerelict::Write (0x004fc2b0) as its first act -- Monitor derives from Derelict, which is why its tag run starts NDsn/DsnID/Dwght + NAsg/Eflt/Esys before nt/scnm/spwt/rsmd/dsgn [verified]
constexpr uint32_t SVSOMonitor_Write = 0x000fd810;
// thiscall void (Game::SVSODerelict* this, Mars::IStream* s) // NDsn count then a loop of (DsnID, Dwght); NAsg count then a loop of (Eflt, Esys). Two fields per iteration in each, confirmed by the 8-byte element strides [verified]
constexpr uint32_t SVSODerelict_Write = 0x000fc2b0;
} // namespace sots::addr

View file

@ -1,5 +1,5 @@
// GENERATED -- do not edit. Wire schema of Sword of the Stars.exe (GOG 1.8.1).
// Source: sots-re objects/streams.json @ 2308b6e, generated 2026-09-08 by tools/gen_stream_schema.py
// Source: sots-re objects/streams.json @ 5a3f986, generated 2026-09-08 by tools/gen_stream_schema.py
//
// For each serializable class, the ordered sequence of items its Mars::IStreamable
// Write() puts on the stream. Recovered mechanically from the serializers; the

View file

@ -264,12 +264,11 @@ public:
sub.finish(&n);
}
// one array element: struct -> "." frame, int32/string -> "." scalar, Node -> any
// one array element: struct -> "." frame, int32 -> "." int, Node -> any
template <class T>
void read_elem(T& e, const std::string& p) {
const Node* n = take();
if constexpr (std::is_same_v<T, int32_t>) e = coerce_int(*n, p);
else if constexpr (std::is_same_v<T, std::string>) e = coerce_string(*n, p);
else if constexpr (std::is_same_v<T, Node>) e = *n;
else read_frame(*n, e, p);
}
@ -494,7 +493,6 @@ public:
template <class T>
void write_elem(T& e) {
if constexpr (std::is_same_v<T, int32_t>) w_.int32(".", e);
else if constexpr (std::is_same_v<T, std::string>) w_.string(".", e);
else if constexpr (std::is_same_v<T, Node>) w_.node(e);
else {
w_.begin(".");
@ -576,7 +574,6 @@ public:
Desc d;
d.type = Desc::CArr;
if constexpr (std::is_same_v<T, int32_t>) d.elem.kind = Prim::Int;
else if constexpr (std::is_same_v<T, std::string>) d.elem.kind = Prim::String;
else if constexpr (std::is_same_v<T, Node>) d.elem = Hint{};
else d.elem.sub = describe<T>();
const Desc* cd = ctx_.reg.add(std::move(d));

View file

@ -4,10 +4,9 @@
// confirmed format). A("x") tags are on-disk names; R("x") fields are written
// by the game with a NULL name ("." on disk) or carry a reference name whose
// disk spelling differs, and are matched by position. Bodies the format keeps
// opaque are held as generic Nodes so a shape can be re-emitted byte-for-byte.
// After the SvSctOb round only three regions are still carried that way: the CD
// custom-data blocks, the RNG state block (correctly opaque) and Attrib (an empty
// frame in every save).
// opaque (TechTree, Events, ShipRecs, spy2, civr, comms, Ojvs, Attrib, sprjs,
// SvSctOb, trdmgr, spymgr, CD, ...) are held as generic Nodes so a shape can
// be re-emitted byte-for-byte.
#pragma once
#include <cstdint>
@ -536,11 +535,7 @@ struct Sys {
Morale cm, pvCM;
std::vector<MoraleEvent> cme2;
bool cme2Framed = true;
// `spies2` is VectorHelper<int> on the original (the helper's own decorated
// type names the element), so it is a framed array of NULL-named ints. The
// count is 0 in every system of every save we hold, so the element TYPE is
// certain but no element VALUE has ever been observed.
std::vector<int32_t> spies2;
Node spies2;
int32_t pid = 0, defF = 0, defSF = 0;
std::optional<BuildQueue> bq;
std::vector<AdctEntry> adct;
@ -626,7 +621,7 @@ struct Sys {
ar.obj(A("cm"), cm);
ar.obj(A("PvCM"), pvCM);
ar.carr_flex(A("cme2"), cme2, cme2Framed);
ar.carr(A("spies2"), spies2);
ar.any(A("spies2"), spies2);
ar.i32(A("PID"), pid);
ar.i32(A("DefF"), defF);
ar.i32(A("DefSF"), defSF);
@ -820,8 +815,7 @@ struct DesignSection { // Game::ShipDesignDef::Section, one DSec of a Des frame
static constexpr const char* kStreamName = "DSec";
ShipSectionID sec;
std::vector<GunBank> gunBanks;
// DOpts is VectorHelper<Mars::String>, so its elements are NULL-named strings.
std::vector<std::string> opts;
std::vector<Node> opts; // DOpts: the recovery says carr<String>
std::vector<Node> extra;
template <class Ar>
void io(Ar& ar) {
@ -1855,419 +1849,6 @@ struct ZoneDefence {
}
};
// ---- SvSctOb: the script-object tree -------------------------------------------------
//
// `SvSctOb` is a StreamableHelper<Game::SVScriptObject> -- a polymorphic pointer.
// In every save it holds a Game::SVSOSots, which writes two variant lists:
//
// numx x { xscn (a scenario NAME) , xsc (SVScriptObject*) }
// NEncObjs x { EncID (an encounter ID), EncObj (SVScriptObject*) }
//
// so both bodies are dispatched by the key item that precedes them. Neither the
// name->class nor the id->class map is anywhere on the wire; both were read out of
// the game: the ids from a 23-entry dword jump table (indexed by EncID-1) and the
// names from a flat _stricmp chain. See findings/objects/svsctob-variants.md in
// the notes repo for the addresses. An id or name we do not model still round
// trips, because the fallback carries the body as a generic Node.
struct SwarmInfestation { // Game::SVSOSwarm::Infestation
static constexpr const char* kStreamName = "";
int32_t sysid = 0, stg = 0, strn = 0, mtrn = 0, trgtrn = 0;
bool canh = false;
template <class Ar>
void io(Ar& ar) {
ar.i32(A("sysid"), sysid);
ar.i32(A("stg"), stg);
ar.i32(A("strn"), strn);
ar.i32(A("mtrn"), mtrn);
ar.i32(A("trgtrn"), trgtrn);
ar.b(A("canh"), canh);
}
};
struct FleetAssignment { // the (Eflt, Esys) pair the NAsg loop writes
int32_t eflt = 0, esys = 0;
};
struct DesignWeight { // the (DsnID, Dwght) pair the NDsn loop writes
int32_t dsnID = 0, dwght = 0;
};
struct SVSOSwarm { // EncID 3
static constexpr const char* kStreamName = "";
std::vector<FleetAssignment> asg;
std::vector<SwarmInfestation> infest;
int32_t deshive = 0, deslarva = 0;
std::vector<Node> extra;
template <class Ar>
void io(Ar& ar) {
ar.narr(A("NAsg"), asg, [](Ar& a, FleetAssignment& e) {
a.i32(A("Eflt"), e.eflt);
a.i32(A("Esys"), e.esys);
});
ar.carr(A("infest"), infest);
ar.i32(A("deshive"), deshive);
ar.i32(A("deslarva"), deslarva);
ar.rest(extra);
}
};
struct SVSODerelict { // EncID 4 -- also the base Game::SVSOMonitor::Write starts with
static constexpr const char* kStreamName = "";
std::vector<DesignWeight> designs;
std::vector<FleetAssignment> asg;
template <class Ar>
void io(Ar& ar) {
ar.narr(A("NDsn"), designs, [](Ar& a, DesignWeight& e) {
a.i32(A("DsnID"), e.dsnID);
a.i32(A("Dwght"), e.dwght);
});
ar.narr(A("NAsg"), asg, [](Ar& a, FleetAssignment& e) {
a.i32(A("Eflt"), e.eflt);
a.i32(A("Esys"), e.esys);
});
}
};
struct MonitorSpawn { // one `nt` element of Game::SVSOMonitor
std::string scnm;
int32_t spwt = 0;
float rsmd = 0;
int32_t dsgn = 0;
};
struct SVSOMonitor { // EncID 5 -- Game::SVSOMonitor derives from Game::SVSODerelict
static constexpr const char* kStreamName = "";
SVSODerelict base;
std::vector<MonitorSpawn> spawns;
std::vector<Node> extra;
template <class Ar>
void io(Ar& ar) {
base.io(ar); // Monitor::Write calls Derelict::Write first
ar.narr(A("nt"), spawns, [](Ar& a, MonitorSpawn& e) {
a.str(A("scnm"), e.scnm);
a.i32(A("spwt"), e.spwt);
a.f32(A("rsmd"), e.rsmd);
a.i32(A("dsgn"), e.dsgn);
});
ar.rest(extra);
}
};
struct SVSOSlaversRefuel { // EncID 9
static constexpr const char* kStreamName = "";
std::vector<FleetAssignment> asg;
int32_t cdiff = 0;
std::vector<int32_t> tdids, adids;
std::vector<Node> extra;
template <class Ar>
void io(Ar& ar) {
ar.narr(A("NAsg"), asg, [](Ar& a, FleetAssignment& e) {
a.i32(A("Eflt"), e.eflt);
a.i32(A("Esys"), e.esys);
});
ar.i32(A("CDiff"), cdiff);
ar.narr(A("NTD"), tdids, [](Ar& a, int32_t& e) { a.i32(A("TDID"), e); });
ar.narr(A("NAD"), adids, [](Ar& a, int32_t& e) { a.i32(A("ADID"), e); });
ar.rest(extra);
}
};
struct SVSOSwarmQueenHive { // Game::SVSOSwarmQueen::HiveInfo
static constexpr const char* kStreamName = "";
int32_t hiveID = 0, queenID = 0, nextQ = 0;
template <class Ar>
void io(Ar& ar) {
ar.i32(A("HiveID"), hiveID);
ar.i32(A("QueenID"), queenID);
ar.i32(A("NextQ"), nextQ);
}
};
struct SVSOSwarmQueenQueen { // Game::SVSOSwarmQueen::QueenInfo
static constexpr const char* kStreamName = "";
int32_t queenID = 0, qDstID = 0;
template <class Ar>
void io(Ar& ar) {
ar.i32(A("QueenID"), queenID);
ar.i32(A("QDstID"), qDstID);
}
};
struct SVSOSwarmQueen { // EncID 10
static constexpr const char* kStreamName = "";
// QDesignID is written from a computed handle id, and the recovery cannot say
// whether the writer emits one or several, so this consumes the whole run.
std::vector<int32_t> designIds;
std::vector<SVSOSwarmQueenHive> hives;
std::vector<SVSOSwarmQueenQueen> queens;
std::vector<int32_t> sysMem; // VectorHelper<StreamableEnum<uint>>
std::vector<Node> extra;
template <class Ar>
void io(Ar& ar) {
ar.repeat("QDesignID", designIds, [](Ar& a, int32_t& e) { a.i32(A("QDesignID"), e); });
ar.carr(A("Hives"), hives);
ar.carr(A("Queens"), queens);
ar.carr(A("SysMem"), sysMem);
ar.rest(extra);
}
};
struct SVSOCrowRuins { // EncID 17
static constexpr const char* kStreamName = "";
std::vector<FleetAssignment> asg;
std::vector<int32_t> wids;
std::vector<Node> extra;
template <class Ar>
void io(Ar& ar) {
ar.narr(A("NAsg"), asg, [](Ar& a, FleetAssignment& e) {
a.i32(A("Eflt"), e.eflt);
a.i32(A("Esys"), e.esys);
});
ar.narr(A("NWD"), wids, [](Ar& a, int32_t& e) { a.i32(A("wid"), e); });
ar.rest(extra);
}
};
struct RefugeeStatus { // Game::SVSORefugees::PlayerStatus -- the `rsu` frame
static constexpr const char* kStreamName = "rsu";
int32_t lr = 0, lrt = 0, llc = 0;
bool atr = false, asy = false;
std::vector<Node> extra;
template <class Ar>
void io(Ar& ar) {
ar.i32(A("lr"), lr);
ar.i32(A("lrt"), lrt);
ar.i32(A("llc"), llc);
ar.b(A("atr"), atr);
ar.b(A("asy"), asy);
ar.rest(extra);
}
};
struct RefugeePlayer { // one `rsuc` element: (pid, rsu)
int32_t pid = 0;
RefugeeStatus status;
};
struct SVSORefugees { // EncID 20
static constexpr const char* kStreamName = "";
std::vector<RefugeePlayer> players;
std::vector<int32_t> dids;
bool ini = false;
std::vector<Node> extra;
template <class Ar>
void io(Ar& ar) {
ar.narr(A("rsuc"), players, [](Ar& a, RefugeePlayer& e) {
a.i32(A("pid"), e.pid);
a.obj(A("rsu"), e.status);
});
ar.narr(A("didc"), dids, [](Ar& a, int32_t& e) { a.i32(A("did"), e); });
ar.b(A("ini"), ini);
ar.rest(extra);
}
};
struct VonNeumannDefeat { // Game::SVSOVonNeumann::DefeatRecord
static constexpr const char* kStreamName = "";
int32_t sys = 0, ndft = 0, nxrv = 0, lstd = 0;
bool bkdf = false;
template <class Ar>
void io(Ar& ar) {
ar.i32(A("sys"), sys);
ar.i32(A("ndft"), ndft);
ar.i32(A("nxrv"), nxrv);
ar.i32(A("lstd"), lstd);
ar.b(A("bkdf"), bkdf);
}
};
struct SVSOVonNeumann { // EncID 1
static constexpr const char* kStreamName = "";
std::vector<VonNeumannDefeat> defeats;
int32_t rusav = 0, nenc = 0, nmb = 0, nml = 0, nbb = 0, nbl = 0, nskb = 0, nskl = 0;
std::vector<int32_t> mts; // VectorHelper<StreamableEnum<uint>>
int32_t ntm = 0, ntb = 0;
bool sken = false;
int32_t skdid = 0, skhid = 0, skfid = 0, sktq = 0, skt = 0, sktc = 0;
int32_t mbid = 0, mbgid = 0, skgid = 0;
int64_t vnhp = 0;
int32_t vnhw = 0;
std::vector<std::string> trev; // VectorHelper<Mars::String>
std::vector<Node> extra;
template <class Ar>
void io(Ar& ar) {
ar.carr(A("dfts"), defeats);
ar.i32(A("rusav"), rusav);
ar.i32(A("nenc"), nenc);
ar.i32(A("nmb"), nmb);
ar.i32(A("nml"), nml);
ar.i32(A("nbb"), nbb);
ar.i32(A("nbl"), nbl);
ar.i32(A("nskb"), nskb);
ar.i32(A("nskl"), nskl);
ar.carr(A("mts"), mts);
ar.i32(A("ntm"), ntm);
ar.i32(A("ntb"), ntb);
ar.b(A("sken"), sken);
ar.i32(A("skdid"), skdid);
ar.i32(A("skhid"), skhid);
ar.i32(A("skfid"), skfid);
ar.i32(A("sktq"), sktq);
ar.i32(A("skt"), skt);
ar.i32(A("sktc"), sktc);
ar.i32(A("mbid"), mbid);
ar.i32(A("mbgid"), mbgid);
ar.i32(A("skgid"), skgid);
ar.i64(A("vnhp"), vnhp);
ar.i32(A("vnhw"), vnhw);
ar.carr(A("trev"), trev);
ar.rest(extra);
}
};
// --- the four named scenario objects (`xsc`, keyed by `xscn`) -------------------
struct SVSOTrap { // Game::SVSOTraps::Trap
static constexpr const char* kStreamName = "";
int32_t sys = 0, pid = 0, trenc = 0, trgenc = 0;
template <class Ar>
void io(Ar& ar) {
ar.i32(A("sys"), sys);
ar.i32(A("pid"), pid);
ar.i32(A("trenc"), trenc);
ar.i32(A("trgenc"), trgenc);
}
};
struct SVSOTraps { // xscn "traps"
static constexpr const char* kStreamName = "";
std::vector<SVSOTrap> traps;
std::vector<Node> extra;
template <class Ar>
void io(Ar& ar) {
ar.carr(A("traps"), traps);
ar.rest(extra);
}
};
struct SVSOCrowDefenders { // xscn "crowdefs"
static constexpr const char* kStreamName = "";
int32_t sys = 0;
// `dsys` is a loop element of `ndsys`, not a plain member: the recovery said
// member, but Game::SVSOCrowDefenders::Write emits it inside the ndsys loop.
// Both counts are 0 in every save, so only the binary settles this.
std::vector<int32_t> dsys, des;
float drad = 0;
std::vector<Node> extra;
template <class Ar>
void io(Ar& ar) {
ar.i32(A("sys"), sys);
ar.narr(A("ndsys"), dsys, [](Ar& a, int32_t& e) { a.i32(A("dsys"), e); });
ar.narr(A("ndes"), des, [](Ar& a, int32_t& e) { a.i32(A("des"), e); });
ar.f32(A("drad"), drad);
ar.rest(extra);
}
};
struct SVSOGrandMenaceTrigger { // xscn "gmtrigger"
static constexpr const char* kStreamName = "";
int32_t gmch = 0;
std::vector<Node> extra;
template <class Ar>
void io(Ar& ar) {
ar.i32(A("gmch"), gmch);
ar.rest(extra);
}
};
struct SVSOIndependentSystems { // xscn "indsys" -- Read and Write are the `ret 4` stub
static constexpr const char* kStreamName = "";
std::vector<Node> extra;
template <class Ar>
void io(Ar& ar) {
ar.rest(extra); // the frame is genuinely empty on disk
}
};
// One `xsc` body. `which` is set from the preceding `xscn` before io() runs, in
// both directions, so a body written back goes out as whatever it came in as.
struct ScenarioObject {
static constexpr const char* kStreamName = "xsc";
enum class Which { Unknown, Traps, CrowDefenders, GrandMenaceTrigger, IndependentSystems };
Which which = Which::Unknown;
SVSOTraps traps;
SVSOCrowDefenders crowDefenders;
SVSOGrandMenaceTrigger grandMenace;
SVSOIndependentSystems indSys;
std::vector<Node> unknown; // a name we do not model: carried verbatim
void select(const std::string& name) {
if (name == "traps") which = Which::Traps;
else if (name == "crowdefs") which = Which::CrowDefenders;
else if (name == "gmtrigger") which = Which::GrandMenaceTrigger;
else if (name == "indsys") which = Which::IndependentSystems;
else which = Which::Unknown;
}
template <class Ar>
void io(Ar& ar) {
switch (which) {
case Which::Traps: traps.io(ar); break;
case Which::CrowDefenders: crowDefenders.io(ar); break;
case Which::GrandMenaceTrigger: grandMenace.io(ar); break;
case Which::IndependentSystems: indSys.io(ar); break;
case Which::Unknown: ar.rest(unknown); break;
}
}
};
// One `EncObj` body, keyed by the preceding `EncID`. The full jump table is
// 1 VonNeumann, 3 Swarm, 4 Derelict, 5 Monitor, 7 SystemKiller, 8 PuppetMaster,
// 9 SlaversRefuel, 10 SwarmQueen, 14 Locust, 17 CrowRuins, 20 Refugees,
// 21 Ortgay; every other id in 0..23 creates nothing. The four ids no save we
// hold exercises (7, 8, 14, 21) are deliberately NOT modelled -- their bodies are
// carried as Nodes rather than typed against a shape nothing can check.
struct EncounterObject {
static constexpr const char* kStreamName = "EncObj";
int32_t id = 0;
SVSOVonNeumann vonNeumann; // 1
SVSOSwarm swarm; // 3
SVSODerelict derelict; // 4
SVSOMonitor monitor; // 5
SVSOSlaversRefuel slavers; // 9
SVSOSwarmQueen swarmQueen; // 10
SVSOCrowRuins crowRuins; // 17
SVSORefugees refugees; // 20
std::vector<Node> unknown; // 7, 8, 14, 21 and any id with no factory entry
void select(int32_t encID) { id = encID; }
template <class Ar>
void io(Ar& ar) {
switch (id) {
case 1: vonNeumann.io(ar); break;
case 3: swarm.io(ar); break;
case 4: derelict.io(ar); break;
case 5: monitor.io(ar); break;
case 9: slavers.io(ar); break;
case 10: swarmQueen.io(ar); break;
case 17: crowRuins.io(ar); break;
case 20: refugees.io(ar); break;
default: ar.rest(unknown); break;
}
}
};
struct ScriptObjects { // Game::SVSOSots -- the SvSctOb body
static constexpr const char* kStreamName = "SvSctOb";
struct Extra {
std::string name;
ScenarioObject obj;
};
struct Enc {
int32_t id = 0;
EncounterObject obj;
};
int32_t scnID = 0;
std::optional<Node> scnObj; // StreamableHelper<SVSOScenarioBase>, NULL in our saves
std::vector<Extra> extras;
std::vector<Enc> encounters;
std::vector<Node> extra;
template <class Ar>
void io(Ar& ar) {
ar.i32(A("ScnID"), scnID);
ar.opt_any(A("ScnObj"), scnObj);
ar.narr(A("numx"), extras, [](Ar& a, Extra& e) {
a.str(A("xscn"), e.name);
e.obj.select(e.name);
a.obj(A("xsc"), e.obj);
});
ar.narr(A("NEncObjs"), encounters, [](Ar& a, Enc& e) {
a.i32(A("EncID"), e.id);
e.obj.select(e.id);
a.obj(A("EncObj"), e.obj);
});
ar.rest(extra);
}
};
struct Sim {
static constexpr const char* kStreamName = "Sim";
std::string keyPath;
@ -2303,7 +1884,7 @@ struct Sim {
SpyManager spymgr;
std::vector<FleetEntry> fleets;
std::vector<int32_t> acts;
std::optional<ScriptObjects> svSctOb;
std::optional<Node> svSctOb;
std::vector<ZoneDefence> zoneDefence;
std::vector<Node> extra;
@ -2369,7 +1950,7 @@ struct Sim {
ar.obj(A("spymgr"), spymgr);
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.opt_obj(A("SvSctOb"), svSctOb); // only if the pointer was non-NULL
ar.opt_any(A("SvSctOb"), svSctOb); // only if the pointer was non-NULL
ar.narr(A("zdsc"), zoneDefence, [](Ar& a, ZoneDefence& e) { e.io(a); });
ar.rest(extra);
}

View file

@ -123,7 +123,7 @@ static void check_save(const std::string& path, const char* dump_dir) {
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 >= 97.5);
CHECK(pct >= 95.0);
}
// --- round trips ------------------------------------------------------------------

View file

@ -476,121 +476,6 @@ static void test_typed_conditionals() {
CHECK_EQ(count(issues, Issue::Error), size_t(0));
}
// --- 8b. carr<std::string> and the SvSctOb variant dispatch -----------------------------
static void test_typed_string_array() {
// DOpts / trev are VectorHelper<Mars::String>: a framed count then NULL-named
// strings. The element branch has to survive the empty string, which is four
// zero bytes and so indistinguishable from the int 0 in isolation.
shapes::DesignSection sec;
sec.opts = {"OPT_Armour", "", "OPT_Shield"};
Writer w;
WriteArchive wa(w);
wa.obj(A("DSec"), sec);
Node root = read_tree(w.bytes());
std::vector<Issue> issues;
shapes::DesignSection back;
ReadArchive ra(root.children, issues, "");
ra.obj(A("DSec"), back);
CHECK_EQ(count(issues, Issue::Error), size_t(0));
CHECK_EQ(count(issues, Issue::Warn), size_t(0));
CHECK(back.opts.size() == 3);
CHECK(back.opts[0] == "OPT_Armour" && back.opts[1].empty() && back.opts[2] == "OPT_Shield");
Writer w2;
WriteArchive wa2(w2);
wa2.obj(A("DSec"), back);
CHECK(w2.bytes() == w.bytes());
}
static void test_svsctob_variants() {
// `xsc` is keyed by the preceding `xscn` and `EncObj` by the preceding
// `EncID`. Nothing on the wire says which body follows, so the round trip is
// only correct if the reader applies the same key the writer did.
shapes::ScriptObjects so;
so.scnID = 0;
shapes::ScriptObjects::Extra traps;
traps.name = "traps";
traps.obj.select(traps.name);
traps.obj.traps.traps.push_back({272, 0, -1, -1});
so.extras.push_back(traps);
shapes::ScriptObjects::Extra gm;
gm.name = "gmtrigger";
gm.obj.select(gm.name);
gm.obj.grandMenace.gmch = 21;
so.extras.push_back(gm);
shapes::ScriptObjects::Extra ind; // "indsys" serializes nothing at all
ind.name = "indsys";
ind.obj.select(ind.name);
so.extras.push_back(ind);
shapes::ScriptObjects::Enc swarm; // EncID 3
swarm.id = 3;
swarm.obj.select(3);
swarm.obj.swarm.asg.push_back({1120, 336});
swarm.obj.swarm.infest.push_back({336, 0, 1, 2147483647, 0, false});
swarm.obj.swarm.deshive = 1072;
swarm.obj.swarm.deslarva = 1088;
so.encounters.push_back(swarm);
shapes::ScriptObjects::Enc mon; // EncID 5: Monitor writes Derelict's body first
mon.id = 5;
mon.obj.select(5);
mon.obj.monitor.base.designs.push_back({1312, 30});
mon.obj.monitor.base.asg.push_back({1360, 64});
mon.obj.monitor.spawns.push_back({"_AsteroidMonitor", 30, 1.0f, 1312});
so.encounters.push_back(mon);
shapes::ScriptObjects::Enc vn; // EncID 1
vn.id = 1;
vn.obj.select(1);
vn.obj.vonNeumann.sken = true;
vn.obj.vonNeumann.ntm = 25;
vn.obj.vonNeumann.vnhp = 0;
so.encounters.push_back(vn);
shapes::ScriptObjects::Enc unmodelled; // EncID 8 (PuppetMaster): carried, not typed
unmodelled.id = 8;
unmodelled.obj.select(8);
unmodelled.obj.unknown.push_back(Node::int32("Flt", 42));
so.encounters.push_back(unmodelled);
Writer w;
WriteArchive wa(w);
wa.obj(A("SvSctOb"), so);
Node root = read_tree(w.bytes());
std::vector<Issue> issues;
shapes::ScriptObjects back;
ReadArchive ra(root.children, issues, "");
ra.obj(A("SvSctOb"), back);
CHECK_EQ(count(issues, Issue::Error), size_t(0));
CHECK_EQ(count(issues, Issue::Warn), size_t(0));
CHECK(back.extras.size() == 3 && back.encounters.size() == 4);
CHECK(back.extras[0].name == "traps" && back.extras[0].obj.traps.traps.size() == 1);
CHECK(back.extras[0].obj.traps.traps[0].sys == 272);
CHECK(back.extras[1].obj.grandMenace.gmch == 21);
CHECK(back.extras[2].obj.indSys.extra.empty()); // the empty frame really is empty
CHECK(back.encounters[0].obj.swarm.deslarva == 1088);
CHECK(back.encounters[0].obj.swarm.infest.size() == 1 &&
back.encounters[0].obj.swarm.infest[0].mtrn == 2147483647);
CHECK(back.encounters[1].obj.monitor.base.designs.size() == 1 &&
back.encounters[1].obj.monitor.spawns.size() == 1);
CHECK(back.encounters[1].obj.monitor.spawns[0].scnm == "_AsteroidMonitor");
CHECK(back.encounters[2].obj.vonNeumann.sken && back.encounters[2].obj.vonNeumann.ntm == 25);
CHECK(back.encounters[3].id == 8 && back.encounters[3].obj.unknown.size() == 1);
Writer w2;
WriteArchive wa2(w2);
wa2.obj(A("SvSctOb"), back);
CHECK(w2.bytes() == w.bytes());
// The key really is what selects the body: read the same bytes with the wrong
// EncID and the Swarm record no longer parses as a Swarm.
shapes::EncounterObject wrong;
wrong.select(17); // CrowRuins over Swarm bytes
CHECK(wrong.id == 17 && wrong.swarm.asg.empty());
}
// --- 9. gzip container ---------------------------------------------------------------
static void test_gzip() {
Bytes data;
@ -638,8 +523,6 @@ int main() {
test_raw_frame();
test_typed_summary();
test_typed_conditionals();
test_typed_string_array();
test_svsctob_variants();
test_gzip();
test_dump_format();
std::printf("test_stream: %s (%d failures)\n", fails ? "FAILED" : "ok", fails);

View file

@ -235,29 +235,6 @@ int main() {
check<sh::ShipSectionID>("ShipSectionID", "Game::ShipSectionID");
check<sh::DesignSection>("DesignSection", "Game::ShipDesignDef::Section");
// --- SvSctOb: Game::SVSOSots and the variant bodies it dispatches to -------
// The variant SELECTION (xscn name / EncID -> class) is not on the wire and so
// is not checkable here; it came from the game's own factories. What this
// does check is that each body we bind agrees with that class's serializer.
check<sh::ScriptObjects>("ScriptObjects", "Game::SVSOSots");
check<sh::SVSOVonNeumann>("SVSOVonNeumann", "Game::SVSOVonNeumann");
check<sh::VonNeumannDefeat>("VonNeumannDefeat", "Game::SVSOVonNeumann::DefeatRecord");
check<sh::SVSOSwarm>("SVSOSwarm", "Game::SVSOSwarm");
check<sh::SwarmInfestation>("SwarmInfestation", "Game::SVSOSwarm::Infestation");
check<sh::SVSODerelict>("SVSODerelict", "Game::SVSODerelict");
check<sh::SVSOMonitor>("SVSOMonitor", "Game::SVSOMonitor");
check<sh::SVSOSlaversRefuel>("SVSOSlaversRefuel", "Game::SVSOSlaversRefuel");
check<sh::SVSOSwarmQueen>("SVSOSwarmQueen", "Game::SVSOSwarmQueen");
check<sh::SVSOSwarmQueenHive>("SVSOSwarmQueenHive", "Game::SVSOSwarmQueen::HiveInfo");
check<sh::SVSOSwarmQueenQueen>("SVSOSwarmQueenQueen", "Game::SVSOSwarmQueen::QueenInfo");
check<sh::SVSOCrowRuins>("SVSOCrowRuins", "Game::SVSOCrowRuins");
check<sh::SVSORefugees>("SVSORefugees", "Game::SVSORefugees");
check<sh::RefugeeStatus>("RefugeeStatus", "Game::SVSORefugees::PlayerStatus");
check<sh::SVSOTraps>("SVSOTraps", "Game::SVSOTraps");
check<sh::SVSOTrap>("SVSOTrap", "Game::SVSOTraps::Trap");
check<sh::SVSOCrowDefenders>("SVSOCrowDefenders", "Game::SVSOCrowDefenders");
check<sh::SVSOGrandMenaceTrigger>("SVSOGrandMenaceTrigger", "Game::SVSOGrandMenaceTrigger");
std::printf(
"\ntotals: %d shapes bound, %d items matched, %d MISMATCH, %d wire-only, "
"%d opaque item(s) in bound shapes\n",