diff --git a/src/mars/stream/shapes.h b/src/mars/stream/shapes.h index a21d116..12cd172 100644 --- a/src/mars/stream/shapes.h +++ b/src/mars/stream/shapes.h @@ -961,13 +961,154 @@ struct PrEntry { ar.i32(A("PRBt"), prbt); } }; +// ---- special projects: Game::SpecialProjectImpl and the four subclasses ----------- +// +// `Sprj` is a POLYMORPHIC frame and `SprjT`, written immediately before it, is the +// discriminator. Both halves of the mapping are measured, not guessed: +// +// * ServerPlayer::Write (0x856fe4) writes `SprjT` from the plain member at +// `project+0x3c` -- not from a virtual call -- and then writes the object as +// the `Sprj` frame; +// * ServerPlayer::Read (0x881aa7) reads `SprjT` and passes it as the first +// argument to the factory at 0x8610a0, whose 4-entry jump table (0x8611e8) is +// +// 0 -> new 0x54, ctor(0, ...), vftable 0xa3185c Game::BackEngProject +// 1 -> new 0x54, ctor(...), vftable 0xa3180c Game::MonitorProject +// 2 -> new 0x4c, ctor(...), vftable 0x9fa438 Game::JewelsProject +// 3 -> new 0x60, ctor(3, ...), vftable 0xa31884 Game::TechOfferProject +// +// and each of those constructors stores that same index at `+0x3c`, which +// closes the loop: the value the writer emits is the index the factory takes. +// Any other value creates nothing, so a save carrying one is malformed. +// +// Game::TechProject (vftable 0xa31834) has NO factory entry and cannot come off a +// save; it shares BackEngProject's serializer (Read 0x853280 / Write 0x853390) +// byte for byte, so the two are indistinguishable on the wire anyway. +// +// ONLY SprjT 0 IS EXERCISED by the corpus (one record, in the 12 `ad-*`/`ar-*`/`bp-*` +// saves: a back-engineering project on "Magonian Prophicies" for `DRV_RecFiss`). +// The other three arms are typed from the recovered schema and the factory, and +// are HYPOTHESES until a save exercises them (rule 6). An unknown SprjT falls to +// `rest()`, which shows up as opaque coverage rather than as a silent mis-read. +struct SpecialProject { // Game::SpecialProject -- the `SPi` sub-frame, common to all four + static constexpr const char* kStreamName = "SPi"; + int32_t spid = 0, sts = 0; + float cst = 0; + int32_t mxc = 0; + std::string nm; + int32_t trns = 0; + template + void io(Ar& ar) { + ar.i32(A("SPid"), spid); + ar.i32(A("Sts"), sts); + ar.f32(A("Cst"), cst); + ar.i32(A("MxC"), mxc); + ar.str(A("Nm"), nm); + ar.i32(A("Trns"), trns); + } +}; +struct BackEngProject { // Game::BackEngProject (SprjT 0); Game::TechProject shares this serializer + static constexpr const char* kStreamName = "Sprj"; + int32_t stp = 0; + SpecialProject spi; + float aOdd = 0, aInc = 0; + std::string tch; + int32_t rCst = 0, rDn = 0; + template + void io(Ar& ar) { + ar.i32(A("Stp"), stp); + ar.obj(A("SPi"), spi); + ar.f32(A("AOdd"), aOdd); + ar.f32(A("AInc"), aInc); + ar.str(A("Tch"), tch); + ar.i32(A("RCst"), rCst); + ar.i32(A("RDn"), rDn); + } +}; +struct MonitorProject { // Game::MonitorProject (SprjT 1) -- no save exercises it + static constexpr const char* kStreamName = "Sprj"; + int32_t stp = 0; + SpecialProject spi; + int32_t sys = 0, rMn = 0, rMx = 0, rDn = 0; + float rMd = 0; + template + void io(Ar& ar) { + ar.i32(A("Stp"), stp); + ar.obj(A("SPi"), spi); + ar.i32(A("Sys"), sys); + ar.i32(A("RMn"), rMn); + ar.i32(A("RMx"), rMx); + ar.i32(A("RDn"), rDn); + ar.f32(A("RMd"), rMd); + } +}; +struct JewelsProject { // Game::JewelsProject (SprjT 2) -- no save exercises it + static constexpr const char* kStreamName = "Sprj"; + int32_t stp = 0; + SpecialProject spi; + int32_t einv = 0, eapp = 0; + template + void io(Ar& ar) { + ar.i32(A("Stp"), stp); + ar.obj(A("SPi"), spi); + ar.i32(A("einv"), einv); + ar.i32(A("eapp"), eapp); + } +}; +struct TechOfferProject { // Game::TechOfferProject (SprjT 3) -- no save exercises it + static constexpr const char* kStreamName = "Sprj"; + int32_t stp = 0; + SpecialProject spi; + float aOdd = 0, aInc = 0; + std::string tch; + int32_t rCst = 0, rDn = 0; + int32_t giv = 0, rcp = 0; + bool rcpd2 = false; + template + void io(Ar& ar) { + ar.i32(A("Stp"), stp); + ar.obj(A("SPi"), spi); + ar.f32(A("AOdd"), aOdd); + ar.f32(A("AInc"), aInc); + ar.str(A("Tch"), tch); + ar.i32(A("RCst"), rCst); + ar.i32(A("RDn"), rDn); + ar.i32(A("giv"), giv); + ar.i32(A("rcp"), rcp); + ar.b(A("rcpd2"), rcpd2); + } +}; +// The `Sprj` body, selected by the `SprjT` beside it. Same idiom as EncounterObject: +// a switch, not a `when`, so SchemaProbe does not concatenate the arms -- each arm is +// bound to its own wire class in tests/mars_stream/test_wire_schema.cpp. +struct SpecialProjectBody { + static constexpr const char* kStreamName = "Sprj"; + int32_t type = 0; + BackEngProject backEng; // 0 + MonitorProject monitor; // 1 + JewelsProject jewels; // 2 + TechOfferProject offer; // 3 + std::vector unknown; // no factory entry: carried, and counted as opaque + void select(int32_t sprjT) { type = sprjT; } + template + void io(Ar& ar) { + switch (type) { + case 0: backEng.io(ar); break; + case 1: monitor.io(ar); break; + case 2: jewels.io(ar); break; + case 3: offer.io(ar); break; + default: ar.rest(unknown); break; + } + } +}; struct SprjEntry { int32_t sprjT = 0; - Node sprj; + SpecialProjectBody sprj; template void io(Ar& ar) { ar.i32(A("SprjT"), sprjT); - ar.any(A("Sprj"), sprj); + sprj.select(sprjT); + ar.obj(A("Sprj"), sprj); } }; struct NexpEntry { @@ -1351,12 +1492,37 @@ struct SpyManager { // Game::ServerSpyManager ar.rest(extra); } }; +// One used-suffix slot of a name-generator record: the `usp`/`usc` PAIR. +// +// MEASURED from the writer (Game::SpecialProjectNameGen::Write, 0x8147e0). Each +// record is 0x58 bytes and opens with a 32-byte table of per-suffix use counts. +// The writer counts the NON-ZERO entries (the unrolled byte loop at 0x814850) and +// emits that as `usnc`; it then walks slots 0..31 and, for every non-zero one, +// writes the SLOT INDEX as `usp` (0x8148b1) and that slot's count byte, widened by +// `movzx`, as `usc` (0x8148c2). Both go through WriteInt, so both are i32 on the +// wire (rule 5). `Nm` and `Ntg` follow from record+0x20 and record+0x3c. +// +// The old shape read `usnc` as a count of ONE item and named only `usc`, on the +// strength of "usnc is 0 in every save available". That was rule 6's hypothesis +// and `ad-turn27-two-raiders.sav` falsified it: 102 of its 103 records have an +// empty table and one has `usnc == 1`, so the writer dropped that record's `usp` +// and the save round-tripped exactly 12 bytes short (one i32 item: 4-byte length + +// 3-byte tag + 4-byte value + 1 pad). 12 of the 43 corpus saves carry that record. +struct UsedName { + int32_t slot = 0; // usp -- index into the record's 32-slot use table + int32_t count = 0; // usc -- that slot's use count (a byte in memory, i32 on the wire) + template + void io(Ar& ar) { + ar.i32(A("usp"), slot); + ar.i32(A("usc"), count); + } +}; struct ProjectName { // one entry of Game::SpecialProjectNameGen - std::vector used; // usnc is 0 in every save available + std::vector used; std::string nm, ntg; template void io(Ar& ar) { - ar.narr(A("usnc"), used, [](Ar& a, Node& e) { a.any(A("usc"), e); }); + ar.narr(A("usnc"), used, [](Ar& a, UsedName& e) { e.io(a); }); ar.str(A("Nm"), nm); ar.str(A("Ntg"), ntg); } @@ -1692,19 +1858,40 @@ struct ShipEntry { ar.obj(A("Ship"), ship); } }; +struct FieldPoint { // Game::FieldTemplate::Point -- one ship's slot in a stored formation + static constexpr const char* kStreamName = ""; + int32_t shipID = 0, desID = 0, posX = 0, posY = 0, sqd = 0; + template + void io(Ar& ar) { + ar.i32(A("FTPShID"), shipID); + ar.i32(A("FTPDesID"), desID); + ar.i32(A("FTPPosX"), posX); + ar.i32(A("FTPPosY"), posY); + ar.i32(A("FTPSqd"), sqd); + } +}; struct FieldTemplate { // Game::FieldTemplate, the NULL-named first frame of a Lay static constexpr const char* kStreamName = ""; - // FTPnts is the ONE item in this round's work that the recovery itself marks - // unresolved: it names the element class from the decorated helper name - // (Game::FieldTemplate::Point, whose own serializer is fully recovered -- - // FTPShID/FTPDesID/FTPPosX/FTPPosY/FTPSqd) but could not type the item, and - // the count is 0 in every Lay in the corpus. That is exactly the pair of - // conditions under which SysMem, mts and nalat were all typed wrong: the - // element FRAMING is a property of the helper, not of the element class, and - // no save has ever shown one. So the elements stay carried. What settles it - // is a save whose fleet has a stored tactical formation -- set a fleet's - // combat layout in the tactical setup screen, then save. - std::vector points; + // FTPnts was the ONE item 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 was 0 in every Lay in the + // 22-save corpus. The elements were therefore carried as Nodes rather than + // typed against a framing no save had ever shown -- the trap that mis-typed + // SysMem, mts and nalat. + // + // The workload that note asked for now EXISTS. Six saves in the 43-save corpus + // carry stored tactical formations (`ap-turn22` 2 points, `az-turn23` 3, and + // `ap-turn25` / `ar-oracle-A-pre` / `ar-r1-turn43-post` / `ar-turn37` 1 each), + // and the framing is the ordinary CArr: a `FTPnts` frame holding a "." count + // and that many NULL-named element frames of five i32 items. Typed, and + // byte-identical on all six. + // + // FTPPosX/FTPPosY/FTPSqd read 0 in every point observed so far, so their DISK + // type (i32, from the WriteInt vftable slot) is the schema's word and not the + // corpus's -- a formation with a ship moved off the origin is what would tell + // an i32 grid coordinate from a float one by value. + std::vector points; std::vector extra; template void io(Ar& ar) { diff --git a/tests/mars_stream/test_wire_schema.cpp b/tests/mars_stream/test_wire_schema.cpp index 5be6ef0..444ef4e 100644 --- a/tests/mars_stream/test_wire_schema.cpp +++ b/tests/mars_stream/test_wire_schema.cpp @@ -305,6 +305,15 @@ int main() { check("SpyReport", "Game::SpyReport"); check("SpyManager", "Game::ServerSpyManager"); check("ProjectNames", "Game::SpecialProjectNameGen"); + // The `Sprj` variant: the SELECTION (SprjT -> class) is not a wire item and so + // is not checkable here; it came from the game's own factory at 0x8610a0 (see + // the note on sh::SpecialProjectBody). What is checked is that each arm agrees + // with its class's serializer. Only SprjT 0 is exercised by any save. + check("SpecialProject", "Game::SpecialProject"); + check("BackEngProject", "Game::BackEngProject"); + check("MonitorProject", "Game::MonitorProject"); + check("JewelsProject", "Game::JewelsProject"); + check("TechOfferProject", "Game::TechOfferProject"); check("TradeManager", "Game::ServerTradeManagerImpl"); check("TradeSector", "Game::ServerTradeSector"); check("ShipSectionID", "Game::ShipSectionID"); @@ -319,11 +328,11 @@ int main() { check("TradeRoute", "Game::TradeRoute"); check("SpyCraft", "Game::SpyCraft"); check("FleetLayout", "Game::FleetLayout"); - // Game::FieldTemplate::Point is deliberately NOT bound: FTPnts is empty in - // every save and the recovery marks the item unresolved, so its element - // framing is a hypothesis and FieldTemplate carries the elements (see the - // note on sh::FieldTemplate). + // Game::FieldTemplate::Point is bound now that the corpus exercises it: six + // saves carry stored tactical formations, so the element framing is measured + // rather than assumed (see the note on sh::FieldTemplate). check("FieldTemplate", "Game::FieldTemplate"); + check("FieldPoint", "Game::FieldTemplate::Point"); check("Crep", "Game::CombatReport"); check("CrepPrep", "Game::CombatPlayerReport"); check("Srep", "Game::CombatShipReport");