merge lane A: StrategyAIAgent blocks typed; named coverage 98.0 -> 99.9%, opaque items 748 -> 37

This commit is contained in:
alex 2026-09-08 07:34:22 -04:00
commit 1df6977808
5 changed files with 511 additions and 10 deletions

View file

@ -88,6 +88,12 @@ both directions — the reader `select()`s from what it just read, the writer fr
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.
The `CD` custom-data blocks are the same problem one level up, and worse: the key is not even
adjacent. `CDT` lists the block ids and the `CD` frames follow in that order, so a block's body is
selected by the id at the **same ordinal** in a different frame. `SaveGame::io()` walks the two in
step and `select()`s from the id's suffix, in both directions; a suffix with no shape (today,
`.TurnCommands_v5`) falls to a generic `Node`.
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.**
@ -96,3 +102,9 @@ Two more things the table's `prim` column will not save you from:
* **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.
* **A container whose count is 0 in every save hides its element type completely.** The table names
the element class, but the framing is a property of the *helper* that writes it, not of the
element: `VectorHelper<Mars::StreamableEnum<T>>` writes each element as a **frame** holding one
int, not as a bare int, because `StreamableEnum`'s own writer is a nested object. `SysMem`, `mts`
and `nalat` were all typed as int arrays on the strength of the decorated name and all three were
wrong in the same way — invisibly, because none of them has ever had an element.

View file

@ -5,9 +5,9 @@
// 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).
// After the AIAgent round only two regions are still carried that way: the RNG
// state block (correctly opaque) and the one CD block per save whose CDT id ends
// ".TurnCommands_v5", which needs a save with issued orders before it can be typed.
#pragma once
#include <cstdint>
@ -790,6 +790,20 @@ struct ShipSectionID { // Game::ShipSectionID: two "." ints
ar.rest(extra);
}
};
// Mars::StreamableEnum<T>: a framed wrapper whose body is a single "." int. It
// is how an enum-valued key or element reaches the stream, so it is a frame and
// not a bare int -- a distinction our saves cannot show, because every container
// that holds one has count 0.
struct StreamEnum {
static constexpr const char* kStreamName = "";
int32_t value = 0;
std::vector<Node> extra;
template <class Ar>
void io(Ar& ar) {
ar.i32(R("value"), value);
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
@ -1990,7 +2004,9 @@ struct SVSOSwarmQueen { // EncID 10
std::vector<int32_t> designIds;
std::vector<SVSOSwarmQueenHive> hives;
std::vector<SVSOSwarmQueenQueen> queens;
std::vector<int32_t> sysMem; // VectorHelper<StreamableEnum<uint>>
// VectorHelper<StreamableEnum<uint>>: each element is a "." FRAME holding one
// "." int, not a bare int. Count 0 in every save, so nothing exercises it.
std::vector<StreamEnum> sysMem;
std::vector<Node> extra;
template <class Ar>
void io(Ar& ar) {
@ -2069,7 +2085,7 @@ 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>>
std::vector<StreamEnum> mts; // VectorHelper<StreamableEnum<uint>>: framed elements
int32_t ntm = 0, ntb = 0;
bool sken = false;
int32_t skdid = 0, skhid = 0, skfid = 0, sktq = 0, skt = 0, sktc = 0;
@ -2268,6 +2284,281 @@ struct ScriptObjects { // Game::SVSOSots -- the SvSctOb body
}
};
// --- the strategy AI agent's cache, one CD block per AI player ------------------
//
// `CDT` lists the custom-data ids in order and one `CD` frame follows per id. An
// id ending in ".AIAgent" selects Game::StrategyAIAgent::Streamable, whose whole
// Write is unconditional -- every item below is present in every block. The
// shapes here follow that writer item for item; the notes repo carries the
// derivation (findings/objects/aiagent-block.md).
// Game::AttribMap: a string->string map, written as a "." count followed by two
// "." strings per entry. Empty in every save we hold, so the pair layout is a
// hypothesis from the writer, not something the data exercises.
struct AttribMap {
static constexpr const char* kStreamName = "";
struct Entry {
std::string key, value;
};
std::vector<Entry> entries;
std::vector<Node> extra;
template <class Ar>
void io(Ar& ar) {
ar.narr(R("n"), entries, [](Ar& a, Entry& e) {
a.str(R("key"), e.key);
a.str(R("value"), e.value);
});
ar.rest(extra);
}
};
// Game::AIWeightMap<K>: a "." count then, per entry, a "." key frame and a "."
// float weight. Four instantiations exist and all four share this wire shape;
// only the key body differs (StreamEnum, or ShipSectionID's two ints).
template <class Key>
struct AIWeightMap {
static constexpr const char* kStreamName = "";
struct Entry {
Key key;
float weight = 0;
};
std::vector<Entry> entries;
std::vector<Node> extra;
template <class Ar>
void io(Ar& ar) {
ar.narr(R("n"), entries, [](Ar& a, Entry& e) {
a.obj(R("key"), e.key);
a.f32(R("w"), e.weight);
});
ar.rest(extra);
}
};
using AIWeightMapEnum = AIWeightMap<StreamEnum>;
using AIWeightMapSection = AIWeightMap<ShipSectionID>;
struct AISituation { // Game::AISituation
static constexpr const char* kStreamName = "AISit";
AIWeightMapSection sections;
AIWeightMapEnum weaponFamilies;
std::vector<Node> extra;
template <class Ar>
void io(Ar& ar) {
ar.obj(A("AISitSecs"), sections);
ar.obj(A("AISitWeapFams"), weaponFamilies);
ar.rest(extra);
}
};
// Game::AISystem writes one AISituation and nothing else -- and the situation it
// writes is default-constructed at the call, not read from the object, so every
// AISys body on disk is an empty pair of weight maps regardless of game state.
struct AISystem {
static constexpr const char* kStreamName = "AISys";
AISituation situation;
std::vector<Node> extra;
template <class Ar>
void io(Ar& ar) {
ar.obj(A("AISysSit"), situation);
ar.rest(extra);
}
};
struct DesignNameGen { // Game::StrategyAIAgent::DesignNameGen
static constexpr const char* kStreamName = "AIDNG";
struct Name {
ShipSectionID section;
std::string name;
};
std::vector<Name> names; // dnnc counts the (dnid, dnnm) pairs
std::vector<Node> extra;
template <class Ar>
void io(Ar& ar) {
ar.narr(A("dnnc"), names, [](Ar& a, Name& e) {
a.obj(A("dnid"), e.section);
a.str(A("dnnm"), e.name);
});
ar.rest(extra);
}
};
struct AIAutoPeaceRun { // Game::AIAutoPeaceRun
static constexpr const char* kStreamName = "";
int32_t sid = 0, tn0 = 0, tn1 = 0;
std::vector<Node> extra;
template <class Ar>
void io(Ar& ar) {
ar.i32(A("sid"), sid);
ar.i32(A("tn0"), tn0);
ar.i32(A("tn1"), tn1);
ar.rest(extra);
}
};
struct CombatPlayerStats { // Game::CombatPlayerStats
static constexpr const char* kStreamName = "CRPlSv2";
float rpBon = 0;
int32_t rpBonT = 0, savBonus = 0;
bool maintHF = false;
// Game::TacReport has a computed count and three trailing scalar runs that no
// save we hold exercises; its body stays carried rather than typed on a guess.
std::vector<Node> tacReports;
std::vector<Node> extra;
template <class Ar>
void io(Ar& ar) {
ar.f32(A("RPBon"), rpBon);
ar.i32(A("RPBonT"), rpBonT);
ar.i32(A("SavBonus"), savBonus);
ar.b(A("MaintHF"), maintHF);
ar.carr(A("TacReports"), tacReports);
ar.rest(extra);
}
};
struct AICombatReport { // Game::AICombatReport
static constexpr const char* kStreamName = "CmbR";
int32_t crTrn = 0;
bool crPce = false;
StreamEnum crSys;
CombatPlayerStats crPlSv2;
std::vector<Node> extra;
template <class Ar>
void io(Ar& ar) {
ar.i32(A("CRTrn"), crTrn);
ar.b(A("CRPce"), crPce);
ar.obj(A("CRSys"), crSys);
ar.obj(A("CRPlSv2"), crPlSv2);
ar.rest(extra);
}
};
// Game::AIPlayerRequestStamp is a POD reached only through a specialised helper,
// so it has no RTTI class and no entry in the wire table; the two named ints come
// straight from the helper's own writer.
struct AIRequestStamp {
static constexpr const char* kStreamName = "";
int32_t pid = 0, trn = 0;
std::vector<Node> extra;
template <class Ar>
void io(Ar& ar) {
ar.i32(A("pid"), pid);
ar.i32(A("trn"), trn);
ar.rest(extra);
}
};
struct StrategyAIAgent { // Game::StrategyAIAgent::Streamable
static constexpr const char* kStreamName = "CD";
struct DesignShare { // one entry of the map `dsh` counts
int32_t pid = 0;
std::vector<int32_t> turns;
};
struct AISysEntry {
int32_t id = 0;
AISystem sys;
};
struct ColonyLoss {
int32_t turn = 0, sysID = 0, plID = 0;
};
struct Provocation {
int32_t id = 0;
float value = 0;
};
AttribMap attrib;
AIWeightMapEnum turnPriorities;
AISituation situation;
AIWeightMapEnum playerHate;
std::vector<AIRequestStamp> requestStamps;
// `dsh` is the _Mysize of the map the (pid, trns) pairs are walked out of, so
// it is the element count and not a scalar field.
std::vector<DesignShare> designShares;
std::vector<IntPair> borderStability; // NBStab x (BStabPl, BStabTn)
std::vector<IntPair> menaceBlacklist; // NMBlst x (MBlstSy, MBlstTn)
DesignNameGen designNames;
int32_t aiHivJ = 0, sdFlT = 0;
std::vector<StreamEnum> newAlliances; // nalat
int32_t lnat = 0, lat = 0;
std::vector<AISysEntry> systems; // AINumSys x (AISysID, AISys)
std::vector<AICombatReport> combatReports;
std::vector<ColonyLoss> colonyLosses;
std::vector<Provocation> provocations;
std::vector<int32_t> techScores;
int32_t fct = 0;
std::vector<AIAutoPeaceRun> autoPeaceRuns;
std::vector<Node> extra;
template <class Ar>
void io(Ar& ar) {
ar.obj(A("AIAttr"), attrib);
ar.obj(A("AITurnPris"), turnPriorities);
ar.obj(A("AISit"), situation);
ar.obj(A("AIPlyHat"), playerHate);
ar.carr(A("prs2"), requestStamps);
ar.narr(A("dsh"), designShares, [](Ar& a, DesignShare& e) {
a.i32(A("pid"), e.pid);
a.carr(A("trns"), e.turns);
});
ar.narr(A("NBStab"), borderStability, [](Ar& a, IntPair& e) {
a.i32(A("BStabPl"), e.a);
a.i32(A("BStabTn"), e.b);
});
ar.narr(A("NMBlst"), menaceBlacklist, [](Ar& a, IntPair& e) {
a.i32(A("MBlstSy"), e.a);
a.i32(A("MBlstTn"), e.b);
});
ar.obj(A("AIDNG"), designNames);
ar.i32(A("AIHivJ"), aiHivJ);
ar.i32(A("SDFlT"), sdFlT);
ar.carr(A("nalat"), newAlliances);
ar.i32(A("lnat"), lnat);
ar.i32(A("lat"), lat);
ar.narr(A("AINumSys"), systems, [](Ar& a, AISysEntry& e) {
a.i32(A("AISysID"), e.id);
a.obj(A("AISys"), e.sys);
});
ar.narr(A("NCmbR"), combatReports, [](Ar& a, AICombatReport& e) { a.obj(A("CmbR"), e); });
ar.narr(A("NumCL"), colonyLosses, [](Ar& a, ColonyLoss& e) {
a.i32(A("CLTn"), e.turn);
a.i32(A("CLSyID"), e.sysID);
a.i32(A("CLPlID"), e.plID);
});
ar.narr(A("NPrv"), provocations, [](Ar& a, Provocation& e) {
a.i32(A("NPrvId"), e.id);
a.f32(A("NPrvVa"), e.value);
});
ar.narr(A("NTecS"), techScores, [](Ar& a, int32_t& e) { a.i32(A("TecS"), e); });
ar.i32(A("fct"), fct);
ar.carr(A("apr"), autoPeaceRuns);
ar.rest(extra);
}
};
// One `CD` frame, keyed by the id at the same ordinal in `CDT`. The other id
// suffix in our saves, ".TurnCommands_v5", stays carried: its writer's recovered
// sequence cannot be aligned to a no-orders save even as a subsequence, so it
// needs a save with issued orders before anything can be typed.
struct CustomDataBlock {
static constexpr const char* kStreamName = "CD";
enum class Which { Unknown, AIAgent };
Which which = Which::Unknown;
StrategyAIAgent aiAgent;
std::vector<Node> unknown;
void select(const std::string& id) {
static const std::string suffix = ".AIAgent";
which = (id.size() >= suffix.size() && id.compare(id.size() - suffix.size(), suffix.size(), suffix) == 0)
? Which::AIAgent
: Which::Unknown;
}
template <class Ar>
void io(Ar& ar) {
switch (which) {
case Which::AIAgent: aiAgent.io(ar); break;
case Which::Unknown: ar.rest(unknown); break;
}
}
};
struct Sim {
static constexpr const char* kStreamName = "Sim";
std::string keyPath;
@ -2275,7 +2566,7 @@ struct Sim {
std::vector<int32_t> playerIds, designIds, systemIds, fleetIds, shipIds, tradeIds;
int32_t modCount = 0, frame = 0, gameID = 0;
std::optional<int32_t> aiDifficultyID;
Node attrib;
AttribMap attrib; // StreamableHelper<Game::AttribMap>: a "." count, 0 in every save
std::optional<int32_t> rand;
Node rng; // opaque MT19937 blob: frame with one "." raw item (mt[624] + left, 3 pad bytes)
std::string gameName;
@ -2324,7 +2615,7 @@ struct Sim {
ar.i32(A("Frame"), frame);
ar.i32(A("GameID"), gameID);
ar.opt_i32(A("AIDifficultyID"), aiDifficultyID);
ar.any(A("Attrib"), attrib);
ar.obj(A("Attrib"), attrib);
ar.opt_i32(A("Rand"), rand);
ar.raw_frame(A("RNG"), rng);
ar.str(A("GameName"), gameName);
@ -2393,14 +2684,21 @@ struct SaveGame { // root: Summary, CreateParams, Sim, CDT, then one opaque CD
CreateParams createParams;
Sim sim;
CdTable cdTable;
std::vector<Node> customData;
std::vector<CustomDataBlock> customData;
template <class Ar>
void io(Ar& ar) {
ar.obj(A("Summary"), summary);
ar.obj(A("CreateParams"), createParams);
ar.obj(A("Sim"), sim);
ar.obj(A("CDT"), cdTable);
ar.repeat("CD", customData, [](Ar& a, Node& e) { a.any(A("CD"), e); });
// The CD frames carry no key of their own: the id at the same ordinal in
// CDT selects the body, in both directions.
size_t k = 0;
ar.repeat("CD", customData, [&](Ar& a, CustomDataBlock& e) {
e.select(k < cdTable.ids.size() ? cdTable.ids[k] : std::string());
++k;
a.obj(A("CD"), e);
});
}
};

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 >= 99.8);
}
// --- round trips ------------------------------------------------------------------

View file

@ -591,6 +591,173 @@ static void test_svsctob_variants() {
CHECK(wrong.id == 17 && wrong.swarm.asg.empty());
}
// --- 8c. the AIAgent custom-data block -------------------------------------------------
// Every container in the block has count 0 in all four saves we hold, so the real
// data exercises the frames and none of the element layouts. This test populates
// each of them, which is the only check those layouts get.
static void test_aiagent_block() {
shapes::StrategyAIAgent ai;
// Game::AttribMap: a "." count then two "." strings per entry. The second pair
// is empty on both sides -- an empty string is four zero bytes, the same as the
// int 0, which is exactly how a mistyped string field stays invisible.
ai.attrib.entries = {{"aggression", "high"}, {"", ""}};
// AIWeightMap keys are FRAMES (Mars::StreamableEnum, or ShipSectionID's two
// ints), never bare ints, and each key is followed by a "." float.
ai.turnPriorities.entries.push_back({{7, {}}, 0.5f});
ai.situation.sections.entries.push_back({{2, 44, {}}, 1.5f});
ai.situation.weaponFamilies.entries.push_back({{3, {}}, -2.0f});
ai.playerHate.entries.push_back({{16, {}}, 0.25f});
ai.requestStamps.push_back({16, 4, {}});
// `dsh` counts (pid, trns) pairs walked out of a map; trns is a VectorHelper<int>.
ai.designShares.push_back({16, {1, 2, 3}});
ai.designShares.push_back({32, {}});
ai.borderStability.push_back({16, 5});
ai.menaceBlacklist.push_back({272, 9});
ai.designNames.names.push_back({{5, 37, {}}, "Ravenous"});
ai.designNames.names.push_back({{5, 63, {}}, ""}); // an empty design name
ai.aiHivJ = 3;
ai.sdFlT = 7;
ai.newAlliances.push_back({11, {}}); // framed element, not a bare int
ai.newAlliances.push_back({12, {}});
ai.lnat = 4;
ai.lat = 5;
shapes::StrategyAIAgent::AISysEntry sys;
sys.id = 48;
ai.systems.push_back(sys);
shapes::AICombatReport rep;
rep.crTrn = 2;
rep.crPce = true;
rep.crSys.value = 272;
rep.crPlSv2.rpBon = 1.25f;
rep.crPlSv2.rpBonT = 3;
rep.crPlSv2.savBonus = 4;
rep.crPlSv2.maintHF = true;
rep.crPlSv2.tacReports.push_back(Node::int32("TRid", 9)); // carried, not typed
ai.combatReports.push_back(rep);
ai.colonyLosses.push_back({2, 272, 16});
ai.provocations.push_back({16, 450.0f});
ai.techScores = {2, 4, 6};
ai.fct = 1;
ai.autoPeaceRuns.push_back({16, 2, 5, {}});
Writer w;
WriteArchive wa(w);
wa.obj(A("CD"), ai);
Node root = read_tree(w.bytes());
std::vector<Issue> issues;
shapes::StrategyAIAgent back;
ReadArchive ra(root.children, issues, "");
ra.obj(A("CD"), back);
CHECK_EQ(count(issues, Issue::Error), size_t(0));
CHECK_EQ(count(issues, Issue::Warn), size_t(0));
CHECK(back.attrib.entries.size() == 2);
CHECK(back.attrib.entries[0].key == "aggression" && back.attrib.entries[0].value == "high");
CHECK(back.attrib.entries[1].key.empty() && back.attrib.entries[1].value.empty());
CHECK(back.turnPriorities.entries.size() == 1 && back.turnPriorities.entries[0].key.value == 7);
CHECK(back.turnPriorities.entries[0].weight == 0.5f);
CHECK(back.situation.sections.entries.size() == 1 && back.situation.sections.entries[0].key.a == 2 &&
back.situation.sections.entries[0].key.b == 44);
CHECK(back.situation.weaponFamilies.entries[0].weight == -2.0f);
CHECK(back.requestStamps.size() == 1 && back.requestStamps[0].pid == 16 && back.requestStamps[0].trn == 4);
CHECK(back.designShares.size() == 2 && back.designShares[0].turns.size() == 3 &&
back.designShares[0].turns[2] == 3 && back.designShares[1].turns.empty());
CHECK(back.borderStability.size() == 1 && back.borderStability[0].b == 5);
CHECK(back.menaceBlacklist.size() == 1 && back.menaceBlacklist[0].a == 272);
CHECK(back.designNames.names.size() == 2 && back.designNames.names[0].name == "Ravenous" &&
back.designNames.names[0].section.b == 37 && back.designNames.names[1].name.empty());
CHECK(back.newAlliances.size() == 2 && back.newAlliances[1].value == 12);
CHECK(back.lnat == 4 && back.lat == 5);
CHECK(back.systems.size() == 1 && back.systems[0].id == 48);
CHECK(back.combatReports.size() == 1 && back.combatReports[0].crPce &&
back.combatReports[0].crSys.value == 272 && back.combatReports[0].crPlSv2.maintHF &&
back.combatReports[0].crPlSv2.tacReports.size() == 1);
CHECK(back.colonyLosses.size() == 1 && back.colonyLosses[0].sysID == 272);
CHECK(back.provocations.size() == 1 && back.provocations[0].value == 450.0f);
CHECK(back.techScores.size() == 3 && back.techScores[1] == 4);
CHECK(back.autoPeaceRuns.size() == 1 && back.autoPeaceRuns[0].tn1 == 5);
Writer w2;
WriteArchive wa2(w2);
wa2.obj(A("CD"), back);
CHECK(w2.bytes() == w.bytes());
// A StreamableEnum element is a frame, not a scalar: `nalat`'s two elements must
// come back as complex nodes, which is what a bare-int typing would get wrong.
const Node* cd = &root.children[0];
const Node* nalat = nullptr;
for (const Node& n : cd->children)
if (n.name == "nalat") nalat = &n;
CHECK(nalat != nullptr);
if (nalat) {
CHECK(nalat->children.size() == 3); // the "." count plus two elements
CHECK(!nalat->children[0].is_complex());
CHECK(nalat->children[1].is_complex() && nalat->children[2].is_complex());
}
}
// CDT plus the CD frames, exactly as SaveGame drives them.
struct CdOnly {
static constexpr const char* kStreamName = "";
shapes::CdTable cdTable;
std::vector<shapes::CustomDataBlock> customData;
template <class Ar>
void io(Ar& ar) {
ar.obj(A("CDT"), cdTable);
size_t k = 0;
ar.repeat("CD", customData, [&](Ar& a, shapes::CustomDataBlock& e) {
e.select(k < cdTable.ids.size() ? cdTable.ids[k] : std::string());
++k;
a.obj(A("CD"), e);
});
}
};
// The CD frames carry no key: the id at the same ordinal in CDT selects the body.
static void test_customdata_dispatch() {
CdOnly doc;
doc.cdTable.ids = {"Player.00000016.TurnCommands_v5", "Player.00000032.AIAgent"};
shapes::CustomDataBlock tc; // not modelled: carried verbatim
tc.select(doc.cdTable.ids[0]);
tc.unknown.push_back(Node::int32(".", 42));
doc.customData.push_back(tc);
shapes::CustomDataBlock agent;
agent.select(doc.cdTable.ids[1]);
agent.aiAgent.fct = 9;
agent.aiAgent.techScores = {2, 4};
doc.customData.push_back(agent);
Writer w;
WriteArchive wa(w);
doc.io(wa);
Node root = read_tree(w.bytes());
std::vector<Issue> issues;
CdOnly back;
ReadArchive ra(root.children, issues, "");
back.io(ra);
CHECK_EQ(count(issues, Issue::Error), size_t(0));
CHECK(back.customData.size() == 2);
CHECK(back.customData[0].which == shapes::CustomDataBlock::Which::Unknown);
CHECK(back.customData[0].unknown.size() == 1);
CHECK(back.customData[1].which == shapes::CustomDataBlock::Which::AIAgent);
CHECK(back.customData[1].aiAgent.fct == 9 && back.customData[1].aiAgent.techScores.size() == 2);
Writer w2;
WriteArchive wa2(w2);
back.io(wa2);
CHECK(w2.bytes() == w.bytes());
// The same bytes with the ids swapped select the other way round: nothing in the
// CD frame itself says which body it holds.
shapes::CustomDataBlock probe;
probe.select("Player.00000032.AIAgentX");
CHECK(probe.which == shapes::CustomDataBlock::Which::Unknown);
probe.select(".AIAgent");
CHECK(probe.which == shapes::CustomDataBlock::Which::AIAgent);
}
// --- 9. gzip container ---------------------------------------------------------------
static void test_gzip() {
Bytes data;
@ -640,6 +807,8 @@ int main() {
test_typed_conditionals();
test_typed_string_array();
test_svsctob_variants();
test_aiagent_block();
test_customdata_dispatch();
test_gzip();
test_dump_format();
std::printf("test_stream: %s (%d failures)\n", fails ? "FAILED" : "ok", fails);

View file

@ -258,6 +258,28 @@ int main() {
check<sh::SVSOCrowDefenders>("SVSOCrowDefenders", "Game::SVSOCrowDefenders");
check<sh::SVSOGrandMenaceTrigger>("SVSOGrandMenaceTrigger", "Game::SVSOGrandMenaceTrigger");
// --- the AIAgent custom-data block ------------------------------------------
// The block SELECTION (a CDT id ending ".AIAgent") is not a wire item, so it is
// not checkable here; what is checked is that each body agrees with its class's
// serializer. Game::AIPlayerRequestStamp has no entry: it is a POD reached only
// through a specialised helper, so it has no RTTI class for the recovery to find.
check<sh::StrategyAIAgent>("StrategyAIAgent", "Game::StrategyAIAgent::Streamable");
check<sh::AttribMap>("AttribMap", "Game::AttribMap");
check<sh::AISituation>("AISituation", "Game::AISituation");
check<sh::AISystem>("AISystem", "Game::AISystem");
check<sh::DesignNameGen>("DesignNameGen", "Game::StrategyAIAgent::DesignNameGen");
check<sh::AICombatReport>("AICombatReport", "Game::AICombatReport");
check<sh::CombatPlayerStats>("CombatPlayerStats", "Game::CombatPlayerStats");
check<sh::AIAutoPeaceRun>("AIAutoPeaceRun", "Game::AIAutoPeaceRun");
// One shape covers all four Game::AIWeightMap<K> instantiations: they differ
// only in the key body, which is its own frame on the wire.
check<sh::AIWeightMapSection>("AIWeightMap<Sec>", "Game::Game::VShipSectionID::?$AIWeightMap");
check<sh::AIWeightMapEnum>("AIWeightMap<Pur>",
"Game::Mars::Game::W4AIPurposeID::U?$StreamableEnum::?$AIWeightMap");
check<sh::AIWeightMapEnum>("AIWeightMap<Wep>",
"Game::Mars::Game::W4WeaponFamilyID::U?$StreamableEnum::?$AIWeightMap");
check<sh::AIWeightMapEnum>("AIWeightMap<uint>", "Game::Mars::I::U?$StreamableEnum::?$AIWeightMap");
std::printf(
"\ntotals: %d shapes bound, %d items matched, %d MISMATCH, %d wire-only, "
"%d opaque item(s) in bound shapes\n",