SvSctOb is a StreamableHelper<SVScriptObject> -- a polymorphic pointer holding a Game::SVSOSots, which writes two variant lists each dispatched by the key item before it (xscn -> xsc, EncID -> EncObj). Neither map is on the wire; both were read out of the game's factories (see the notes repo). The shapes apply the key in both directions, so a body goes back out as whatever it came in as, and an unmodelled key still round-trips as a Node. 18 new shapes: SVSOSots, the four scenario bodies (traps / crowdefs / indsys / gmtrigger -- indsys really does serialize nothing, its Read and Write are both the shared `ret 4` stub) and the eight encounter bodies the saves exercise. The four factory ids no save carries (7 SystemKiller, 8 PuppetMaster, 14 Locust, 21 Ortgay) are deliberately NOT typed: their serializers are recovered but nothing could check a shape for them. DOpts and SVSOVonNeumann::trev are VectorHelper<Mars::String>, so read_elem / write_elem / SchemaBuilder::carr grew the std::string branch lane G listed as missing. spies2 is VectorHelper<int>: the TYPE is certain from the helper's own decorated name, but the count is 0 in all 28 systems of all four saves, so no element value has ever been observed -- the shape is a hypothesis about behaviour even though it is a fact about type. Same for SysMem and mts. Conformance 56 shapes / 657 items -> 74 / 769, still 0 MISMATCH, and every new binding is 0 wire-only and 0 shape-only. Coverage 97.1/97.2/97.2/97.6 -> 98.0/98.0/98.0/98.4 with the byte-identical round trip preserved; ratchet 95.0 -> 97.5. CD is now the only remaining region of size, and it stays opaque: the recovered 44-item Game::TurnCommands sequence cannot be aligned to the save's 35 items even as a subsequence (item 4 is 8 bytes, so a bool where the recovery says i32; and the 27 trailing ints have only 22 i32 slots to come from), which proves the no-orders diagnosis rather than assuming it. Two unit tests added that need no saves: the string-array element branch (including the empty string, which is four zero bytes and so looks like int 0) and the SvSctOb variant dispatch round trip. ctest 34/34, clean_room_check OK, test_save skips cleanly with SOTS_SAVES_DIR unset. sots_stream_schema.h unchanged: streams.py and gen_stream_schema.py were re-run and the output is byte-identical apart from the provenance line.
647 lines
27 KiB
C++
647 lines
27 KiB
C++
// mars::stream unit tests on hand-built byte fixtures (no game data).
|
|
#include <algorithm>
|
|
#include <cstdio>
|
|
#include <cstring>
|
|
#include <string>
|
|
#include <vector>
|
|
|
|
#include "mars/stream/dump.h"
|
|
#include "mars/stream/gzip.h"
|
|
#include "mars/stream/reader.h"
|
|
#include "mars/stream/save.h"
|
|
#include "mars/stream/writer.h"
|
|
|
|
using namespace mars::stream;
|
|
|
|
static int fails = 0;
|
|
#define CHECK(cond) \
|
|
do { \
|
|
if (!(cond)) { \
|
|
std::printf("FAIL %s:%d: %s\n", __FILE__, __LINE__, #cond); \
|
|
++fails; \
|
|
} \
|
|
} while (0)
|
|
#define CHECK_EQ(a, b) \
|
|
do { \
|
|
auto _a = (a); \
|
|
auto _b = (b); \
|
|
if (!(_a == _b)) { \
|
|
std::printf("FAIL %s:%d: %s == %s\n", __FILE__, __LINE__, #a, #b); \
|
|
++fails; \
|
|
} \
|
|
} while (0)
|
|
|
|
static Bytes B(std::initializer_list<int> v) {
|
|
Bytes b;
|
|
for (int x : v) b.push_back(uint8_t(x));
|
|
return b;
|
|
}
|
|
static Bytes cat(std::initializer_list<Bytes> parts) {
|
|
Bytes b;
|
|
for (const Bytes& p : parts) b.insert(b.end(), p.begin(), p.end());
|
|
return b;
|
|
}
|
|
static size_t count(const std::vector<Issue>& v, Issue::Level l) {
|
|
size_t n = 0;
|
|
for (const Issue& i : v) n += i.level == l;
|
|
return n;
|
|
}
|
|
|
|
// --- 1. primitive encodings and joint padding (bytes written by hand) -----------
|
|
static void test_primitives_bytes() {
|
|
// [len][name][value][pad to 4], padding computed over the whole item
|
|
const Bytes turn = B({4, 0, 0, 0, 'T', 'u', 'r', 'n', 1, 0, 0, 0}); // 12: no pad
|
|
const Bytes haltv = B({5, 0, 0, 0, 'h', 'a', 'l', 't', 'v', 1, 0, 0}); // 10 -> 12
|
|
const Bytes vnh = B({3, 0, 0, 0, 'v', 'n', 'h', 0}); // 8: no pad
|
|
const Bytes name = B({4, 0, 0, 0, 'N', 'a', 'm', 'e', 3, 0, 0, 0, 'S', 'o', 'l', 0}); // 15 -> 16
|
|
const Bytes key = B({3, 0, 0, 0, 'K', 'e', 'y', 0, 0, 0, 0, 0}); // empty string = 4 zero bytes
|
|
const Bytes incmod = B({6, 0, 0, 0, 'I', 'n', 'c', 'M', 'o', 'd', 0, 0, 0x80, 0x3f, 0, 0}); // float 1.0, 14 -> 16
|
|
const Bytes bats2 = B({5, 0, 0, 0, 'B', 'a', 't', 's', '2', 0x2a, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}); // int64 42: 17 -> 20
|
|
const Bytes dot = B({1, 0, 0, 0, '.', 7, 0, 0, 0, 0, 0, 0}); // "." int 7: 9 -> 12
|
|
|
|
Writer w;
|
|
w.int32("Turn", 1);
|
|
w.boolean("haltv", true);
|
|
w.boolean("vnh", false);
|
|
w.string("Name", "Sol");
|
|
w.string("Key", "");
|
|
w.float32("IncMod", 1.0f);
|
|
w.int64("Bats2", 42);
|
|
w.int32(".", 7);
|
|
CHECK(w.bytes() == cat({turn, haltv, vnh, name, key, incmod, bats2, dot}));
|
|
|
|
std::vector<Issue> issues;
|
|
Stats st;
|
|
Node root = read_tree(w.bytes(), &issues, &st); // with the save registry: all tags hinted
|
|
CHECK_EQ(root.children.size(), size_t(8));
|
|
const auto& c = root.children;
|
|
CHECK(c[0].name == "Turn" && c[0].kind == Kind::Int && c[0].as_int() == 1 && c[0].hinted);
|
|
CHECK(c[0].offset == 0 && c[0].size == 12);
|
|
CHECK(c[1].name == "haltv" && c[1].kind == Kind::Bool && c[1].as_bool() && c[1].size == 12);
|
|
CHECK(c[2].name == "vnh" && c[2].kind == Kind::Bool && !c[2].as_bool() && c[2].size == 8);
|
|
CHECK(c[3].name == "Name" && c[3].kind == Kind::String && c[3].as_string() == "Sol" && c[3].size == 16);
|
|
CHECK(c[4].name == "Key" && c[4].kind == Kind::String && c[4].as_string().empty() && c[4].size == 12);
|
|
CHECK(c[5].name == "IncMod" && c[5].kind == Kind::Float && c[5].as_float() == 1.0f && c[5].size == 16);
|
|
CHECK(c[6].name == "Bats2" && c[6].kind == Kind::Int64 && c[6].as_int64() == 42 && c[6].size == 20);
|
|
CHECK(c[7].name == "." && c[7].kind == Kind::Int && c[7].as_int() == 7 && !c[7].hinted);
|
|
CHECK_EQ(st.resyncs, 0u);
|
|
CHECK_EQ(count(issues, Issue::Warn), size_t(0));
|
|
CHECK_EQ(count(issues, Issue::Error), size_t(0));
|
|
|
|
// without any registry everything is guessed by layout + bit pattern
|
|
Node g = read_tree(w.bytes(), nullptr, nullptr, nullptr);
|
|
CHECK(g.children[0].kind == Kind::Int && !g.children[0].hinted);
|
|
CHECK(g.children[1].kind == Kind::Bool);
|
|
CHECK(g.children[3].kind == Kind::String);
|
|
CHECK(g.children[4].kind == Kind::Int && g.children[4].as_int() == 0); // empty string looks like int 0
|
|
CHECK(g.children[5].kind == Kind::Float); // 0x3f800000 is not a small int
|
|
CHECK(g.children[6].kind == Kind::Int64);
|
|
|
|
// round trip: the tree re-emits byte-identically
|
|
CHECK(write_tree(root) == w.bytes());
|
|
CHECK(write_tree(g) == w.bytes());
|
|
}
|
|
|
|
// --- 2. frames: nesting, tagless frame, empty frame, "." arrays ------------------
|
|
static void test_frames() {
|
|
Writer w;
|
|
w.begin("Summary");
|
|
w.string("GameName", "x");
|
|
w.begin("Players"); // VectorHelper: "." count + "." elements
|
|
w.int32(".", 2);
|
|
w.begin(".");
|
|
w.int32("Rank", 1);
|
|
w.end();
|
|
w.begin(".");
|
|
w.int32("Rank", 2);
|
|
w.end();
|
|
w.end();
|
|
w.begin("Empty");
|
|
w.end();
|
|
w.begin_tagless();
|
|
w.float32(".", 2.5f);
|
|
w.end();
|
|
w.end();
|
|
// expected framing bytes for the head: [7]"Summary"[pad] BEEFBEEF
|
|
const Bytes head = B({7, 0, 0, 0, 'S', 'u', 'm', 'm', 'a', 'r', 'y', 0, 0xef, 0xbe, 0xef, 0xbe});
|
|
CHECK(std::equal(head.begin(), head.end(), w.bytes().begin()));
|
|
const Bytes tail = B({0x10, 0x41, 0x10, 0x41});
|
|
CHECK(std::equal(tail.begin(), tail.end(), w.bytes().end() - 4));
|
|
|
|
std::vector<Issue> issues;
|
|
Stats st;
|
|
Node root = read_tree(w.bytes(), &issues, &st);
|
|
CHECK_EQ(root.children.size(), size_t(1));
|
|
const Node& s = root.children[0];
|
|
CHECK(s.is_complex() && s.name == "Summary" && s.offset == 0 && s.size == w.size());
|
|
CHECK_EQ(s.children.size(), size_t(4));
|
|
const Node& players = s.children[1];
|
|
CHECK(players.is_complex() && players.children.size() == 3);
|
|
CHECK(players.children[0].name == "." && players.children[0].as_int() == 2);
|
|
CHECK(players.children[1].is_complex() && players.children[1].name == ".");
|
|
CHECK(players.children[2].children[0].name == "Rank" && players.children[2].children[0].as_int() == 2);
|
|
CHECK(s.children[2].is_complex() && s.children[2].children.empty());
|
|
CHECK(s.children[3].is_complex() && !s.children[3].tagged && s.children[3].children.size() == 1);
|
|
CHECK(s.children[3].children[0].as_float() == 2.5f);
|
|
CHECK_EQ(st.frames, 6u);
|
|
CHECK_EQ(st.resyncs, 0u);
|
|
CHECK(write_tree(root) == w.bytes());
|
|
|
|
// the dump prints frames with item count and size, tagless as <tagless>
|
|
auto lines = dump_tree(root);
|
|
CHECK(lines.size() == 17);
|
|
CHECK(lines[0] == "@00000000 Summary { # 4 items, " + std::to_string(w.size()) + " bytes");
|
|
CHECK(lines[1] == "@00000010 GameName string \"x\"");
|
|
CHECK(lines[13].find("<tagless> {") != std::string::npos);
|
|
}
|
|
|
|
// --- 3. resync: garbage inside a frame becomes raw; the walk continues ----------
|
|
// (both fixtures were checked against the reference reader: same tree, same
|
|
// stats, one warning each)
|
|
static void test_resync() {
|
|
Bytes tail;
|
|
{
|
|
Writer t;
|
|
t.begin("B");
|
|
t.int32("z", 2);
|
|
t.end();
|
|
tail = t.take();
|
|
}
|
|
// case A: no tag at all after a nested frame -> unnamed raw, resync to the next plausible tag
|
|
{
|
|
Writer w;
|
|
w.begin("A");
|
|
w.begin("C");
|
|
w.end();
|
|
Bytes s = w.take();
|
|
s.insert(s.end(), 12, 0xff);
|
|
Writer y;
|
|
y.string("y", std::string(80, 'y'));
|
|
y.end();
|
|
Bytes yb = y.take();
|
|
s = cat({s, yb, tail});
|
|
std::vector<Issue> issues;
|
|
Stats st;
|
|
Node root = read_tree(s, &issues, &st);
|
|
CHECK_EQ(root.children.size(), size_t(2));
|
|
const Node& a = root.children[0];
|
|
CHECK(a.name == "A" && a.children.size() == 3 && a.size == 136);
|
|
CHECK(a.children[0].name == "C" && a.children[0].children.empty() && a.children[0].size == 16);
|
|
const Node& r = a.children[1];
|
|
CHECK(r.kind == Kind::Raw && !r.tagged && r.raw.size() == 12 && r.offset == 0x1c);
|
|
CHECK(a.children[2].name == "y" && a.children[2].kind == Kind::String && a.children[2].as_string().size() == 80);
|
|
CHECK(root.children[1].name == "B" && root.children[1].children[0].as_int() == 2);
|
|
CHECK_EQ(st.resyncs, 1u);
|
|
CHECK_EQ(st.raw_bytes, 12u);
|
|
CHECK_EQ(count(issues, Issue::Warn), size_t(1));
|
|
CHECK(write_tree(root) == s); // raw nodes re-emit exactly
|
|
auto lines = dump_tree(root);
|
|
CHECK(lines[3] == "@0000001c <tagless> raw[12] ffffffffffffffffffffffff");
|
|
}
|
|
// case B: a fine-looking tag whose value fits no layout -> tagged raw up to the next tag
|
|
{
|
|
Bytes s = B({3, 0, 0, 0, 'a', 'b', 'c', 5});
|
|
s.insert(s.end(), 12, 0xff);
|
|
Writer w;
|
|
w.int32("y", 2);
|
|
w.end();
|
|
Bytes head;
|
|
{
|
|
Writer h;
|
|
h.begin("A");
|
|
head = h.take();
|
|
}
|
|
s = cat({head, s, w.take(), tail});
|
|
std::vector<Issue> issues;
|
|
Stats st;
|
|
Node root = read_tree(s, &issues, &st);
|
|
const Node& a = root.children[0];
|
|
CHECK(a.children.size() == 2 && a.size == 48);
|
|
CHECK(a.children[0].name == "abc" && a.children[0].kind == Kind::Raw && a.children[0].tagged);
|
|
CHECK(a.children[0].raw.size() == 13 && a.children[0].raw[0] == 5);
|
|
CHECK(a.children[1].name == "y" && a.children[1].as_int() == 2 && a.children[1].offset == 0x20);
|
|
CHECK_EQ(st.resyncs, 1u);
|
|
CHECK_EQ(st.raw_bytes, 13u);
|
|
CHECK_EQ(count(issues, Issue::Warn), size_t(1));
|
|
CHECK(issues[0].msg.find("no value layout fits; skipped 13 bytes to tag") != std::string::npos);
|
|
CHECK(write_tree(root) == s);
|
|
}
|
|
|
|
// unnamed 12-byte payload right before END is only an info (Vector3 fallback)
|
|
Bytes v3;
|
|
{
|
|
Writer t;
|
|
t.begin("Pos");
|
|
t.end();
|
|
v3 = t.take();
|
|
Bytes body = B({0, 0, 0x80, 0x3f, 0, 0, 0, 0x40, 0, 0, 0x40, 0x40});
|
|
v3.insert(v3.end() - 4, body.begin(), body.end());
|
|
}
|
|
std::vector<Issue> issues;
|
|
Stats st;
|
|
Node r3 = read_tree(v3, &issues, &st);
|
|
CHECK(r3.children[0].children.size() == 1 && r3.children[0].children[0].kind == Kind::Raw);
|
|
CHECK_EQ(count(issues, Issue::Info), size_t(1));
|
|
CHECK_EQ(count(issues, Issue::Warn), size_t(0));
|
|
// and the shape layer reads it as a vec3
|
|
std::vector<Issue> si;
|
|
ReadArchive ar(r3.children, si, "");
|
|
Vec3 v;
|
|
ar.vec3(A("Pos"), v);
|
|
CHECK(v.x == 1.f && v.y == 2.f && v.z == 3.f);
|
|
|
|
// a stray END at top level and trailing bytes are reported, not fatal
|
|
Bytes stray = B({0x10, 0x41, 0x10, 0x41, 1, 2});
|
|
issues.clear();
|
|
Node r4 = read_tree(stray, &issues, &st);
|
|
CHECK(r4.children.size() == 2);
|
|
CHECK(count(issues, Issue::Warn) >= 2);
|
|
CHECK(write_tree(r4) == stray);
|
|
}
|
|
|
|
// --- 4. cp1252 string values never veto layout when the tag is known ------------
|
|
static void test_cp1252() {
|
|
Writer w;
|
|
w.boolean("haltv", true);
|
|
w.string("Name", std::string("Kor\x92Voth")); // 0x92 = right single quote
|
|
w.int32("VFlags", 3);
|
|
std::vector<Issue> issues;
|
|
Stats st;
|
|
Node root = read_tree(w.bytes(), &issues, &st);
|
|
CHECK_EQ(root.children.size(), size_t(3));
|
|
CHECK(root.children[0].kind == Kind::Bool && root.children[0].hinted);
|
|
CHECK(root.children[1].kind == Kind::String && root.children[1].as_string() == "Kor\x92Voth");
|
|
CHECK(root.children[2].kind == Kind::Int && root.children[2].as_int() == 3);
|
|
CHECK_EQ(st.hint_failures, 0u);
|
|
auto lines = dump_tree(root);
|
|
CHECK(lines[1] == "@0000000c Name string \"Kor\\u2019Voth\"");
|
|
// guessing an unknown tag still accepts every cp1252-defined byte
|
|
Writer g;
|
|
g.string("zz", std::string("caf\xe9"));
|
|
g.int32("q", 1);
|
|
Node r2 = read_tree(g.bytes(), nullptr, nullptr, nullptr);
|
|
CHECK(r2.children[0].kind == Kind::String);
|
|
}
|
|
|
|
// --- 5. hints: registry types Summary children positionally and by name ---------
|
|
static void test_hints() {
|
|
Writer w;
|
|
w.begin("Summary");
|
|
w.string("GameName", "g");
|
|
w.int32("Turn", 0); // 0 either way
|
|
w.int32("NumSys", 0);
|
|
w.int32("Checksum", 0);
|
|
w.begin("Players");
|
|
w.int32(".", 0);
|
|
w.end();
|
|
w.begin("Session");
|
|
w.begin("TMRS");
|
|
w.float32("TSTL", 0.0f); // 0.0f: guessed would be int 0
|
|
w.end();
|
|
w.end();
|
|
w.int32("MapShape", 0);
|
|
w.float32("IncMod", 0.0f);
|
|
w.end();
|
|
Node root = read_tree(w.bytes());
|
|
const Node& s = root.children[0];
|
|
CHECK(s.hinted);
|
|
CHECK(s.children[0].kind == Kind::String && s.children[0].hinted);
|
|
CHECK(s.children[4].hinted); // Players CArr
|
|
CHECK(s.children[4].children[0].kind == Kind::Int && s.children[4].children[0].hinted);
|
|
const Node& tstl = s.children[5].children[0].children[0];
|
|
CHECK(tstl.kind == Kind::Float && tstl.hinted && tstl.as_float() == 0.f);
|
|
CHECK(s.children[7].kind == Kind::Float && s.children[7].hinted); // IncMod by name
|
|
// the registry knows the top-level shapes and the RNG raw frame
|
|
const Registry& reg = save_registry();
|
|
CHECK(reg.shape("Summary") && reg.shape("Sim") && reg.shape("Sys") && reg.shape("Player"));
|
|
CHECK(reg.shape("RNG") && reg.shape("RNG")->type == Desc::Raw);
|
|
CHECK(reg.kind("GameName") == Prim::String && reg.kind("Bats2") == Prim::Int64 && reg.kind("haltv") == Prim::Bool);
|
|
CHECK(reg.kind(".") == Prim::None);
|
|
CHECK(reg.kind("Team") == Prim::Int); // int in Slot and Player; the frame is a separate shape entry
|
|
CHECK(reg.shape("Team") != nullptr);
|
|
CHECK(reg.kind("pop") == Prim::Int64 && reg.shape("pop") != nullptr); // int64 in stats, Population frame in Ship
|
|
}
|
|
|
|
// --- 6. RNG raw frame: body read straight to the END marker ---------------------
|
|
static void test_raw_frame() {
|
|
Bytes blob(2500, 0xAB);
|
|
Writer w;
|
|
w.begin("RNG");
|
|
w.raw(".", blob);
|
|
w.end();
|
|
w.int32("Map", 1);
|
|
std::vector<Issue> issues;
|
|
Stats st;
|
|
Node root = read_tree(w.bytes(), &issues, &st);
|
|
CHECK(root.children[0].children.size() == 1);
|
|
const Node& r = root.children[0].children[0];
|
|
CHECK(r.kind == Kind::Raw && r.name == "." && r.raw.size() == 2503); // 2500 + 3 joint-padding bytes
|
|
CHECK_EQ(st.raw_bytes, 2503u);
|
|
CHECK(root.children[1].as_int() == 1);
|
|
CHECK(write_tree(root) == w.bytes());
|
|
}
|
|
|
|
// --- 7. typed shapes: Summary write -> bytes -> read; and hand-checked bytes ------
|
|
static void test_typed_summary() {
|
|
shapes::Summary s;
|
|
s.gameName = "Test";
|
|
s.turn = 7;
|
|
s.numSys = 3;
|
|
s.checksum = 99;
|
|
shapes::PlayerInfo p;
|
|
p.slot.isPlay = true;
|
|
p.slot.fxNm = "re";
|
|
p.slot.fxCrID.idx = -1;
|
|
p.slot.fxCrID.r = 10;
|
|
p.slot.fxCrID.g = 20;
|
|
p.slot.fxCrID.b = 30;
|
|
p.slot.tag = 1466349286;
|
|
p.slot.team = -1;
|
|
p.slot.settings.treasury = 50000;
|
|
p.rank = 2;
|
|
s.players.push_back(p);
|
|
s.session.tmrs.tctl = 240.f;
|
|
s.incMod = 1.f;
|
|
s.resMod = 1.f;
|
|
s.alliances = true;
|
|
s.encounters = true;
|
|
|
|
Writer w;
|
|
WriteArchive wa(w);
|
|
wa.obj(A("Summary"), s);
|
|
Bytes bytes = w.take();
|
|
|
|
// spot-check the head bytes by hand: frame tag, GameName, Turn
|
|
Bytes head = B({7, 0, 0, 0, 'S', 'u', 'm', 'm', 'a', 'r', 'y', 0, 0xef, 0xbe, 0xef, 0xbe,
|
|
8, 0, 0, 0, 'G', 'a', 'm', 'e', 'N', 'a', 'm', 'e', 4, 0, 0, 0, 'T', 'e', 's', 't',
|
|
4, 0, 0, 0, 'T', 'u', 'r', 'n', 7, 0, 0, 0});
|
|
CHECK(bytes.size() > head.size() && std::equal(head.begin(), head.end(), bytes.begin()));
|
|
|
|
std::vector<Issue> issues;
|
|
Stats st;
|
|
Node root = read_tree(bytes, &issues, &st);
|
|
CHECK_EQ(st.resyncs, 0u);
|
|
CHECK_EQ(st.hint_failures, 0u);
|
|
shapes::Summary back;
|
|
ReadArchive ra(root.children, issues, "");
|
|
ra.obj(A("Summary"), back);
|
|
CHECK_EQ(count(issues, Issue::Error), size_t(0));
|
|
CHECK_EQ(count(issues, Issue::Warn), size_t(0));
|
|
CHECK(back.gameName == "Test" && back.turn == 7 && back.numSys == 3 && back.checksum == 99);
|
|
CHECK(back.players.size() == 1 && back.players[0].rank == 2);
|
|
CHECK(back.players[0].slot.fxNm == "re" && back.players[0].slot.tag == 1466349286);
|
|
CHECK(back.players[0].slot.fxCrID.idx == -1 && back.players[0].slot.fxCrID.b == 30);
|
|
CHECK(back.players[0].slot.settings.treasury == 50000);
|
|
CHECK(back.session.tmrs.tctl == 240.f && back.incMod == 1.f && back.alliances && !back.teams);
|
|
CHECK(back.scenario.empty());
|
|
|
|
// and back out: byte-identical
|
|
Writer w2;
|
|
WriteArchive wa2(w2);
|
|
wa2.obj(A("Summary"), back);
|
|
CHECK(w2.bytes() == bytes);
|
|
|
|
// the "." positional items are reported as info, never warn
|
|
CHECK(count(issues, Issue::Info) >= 4); // FxCrID idx/r/g/b, Settings x4, Players count/elements
|
|
|
|
// a missing confirmed field is an error; an unexpected item before it a warning
|
|
Writer w3;
|
|
w3.begin("Summary");
|
|
w3.string("GameName", "g");
|
|
w3.int32("Bogus", 1);
|
|
w3.int32("Turn", 2);
|
|
w3.end();
|
|
Node r3 = read_tree(w3.bytes());
|
|
std::vector<Issue> i3;
|
|
shapes::Summary s3;
|
|
ReadArchive ra3(r3.children, i3, "");
|
|
ra3.obj(A("Summary"), s3);
|
|
CHECK(s3.turn == 2);
|
|
CHECK(count(i3, Issue::Warn) >= 1);
|
|
CHECK(count(i3, Issue::Error) >= 1); // NumSys and the rest are missing
|
|
}
|
|
|
|
// --- 8. conditionals, optionals and inline arrays through the archives ------------
|
|
static void test_typed_conditionals() {
|
|
shapes::Ship ship;
|
|
ship.desID = 5;
|
|
ship.hbq = true;
|
|
shapes::BuildOrder o;
|
|
o.desID = 9;
|
|
ship.bq2.orders.push_back(o);
|
|
ship.hsp = false;
|
|
ship.prisH.prMax = 2;
|
|
ship.prisH.prisoners.push_back({3, 4});
|
|
ship.thrusters.push_back({1.5f, 2.5f});
|
|
Writer w;
|
|
WriteArchive wa(w);
|
|
wa.obj(A("Ship"), ship);
|
|
Node root = read_tree(w.bytes());
|
|
std::vector<Issue> issues;
|
|
shapes::Ship back;
|
|
ReadArchive ra(root.children, issues, "");
|
|
ra.obj(A("Ship"), back);
|
|
CHECK_EQ(count(issues, Issue::Error), size_t(0));
|
|
CHECK_EQ(count(issues, Issue::Warn), size_t(0));
|
|
CHECK(back.hbq && back.bq2.orders.size() == 1 && back.bq2.orders[0].desID == 9);
|
|
CHECK(!back.hsp && back.pop.groups.empty());
|
|
CHECK(back.prisH.prMax == 2 && back.prisH.prisoners.size() == 1 && back.prisH.prisoners[0].prNum == 4);
|
|
CHECK(back.thrusters.size() == 1 && back.thrusters[0].thm == 2.5f);
|
|
Writer w2;
|
|
WriteArchive wa2(w2);
|
|
wa2.obj(A("Ship"), back);
|
|
CHECK(w2.bytes() == w.bytes());
|
|
|
|
// Fleet: optional legacy tags absent, HFPlan false -> no FPlan; Sys: vnh gate
|
|
shapes::Fleet f;
|
|
f.ftName = "Fleet 1";
|
|
f.hfPlan = true;
|
|
shapes::Waypoint wp;
|
|
wp.wpt = 272;
|
|
f.fplan.wpts.push_back(wp);
|
|
Writer w3;
|
|
WriteArchive wa3(w3);
|
|
wa3.obj(A("Flt"), f);
|
|
Node r3 = read_tree(w3.bytes());
|
|
shapes::Fleet fb;
|
|
ReadArchive ra3(r3.children, issues, "");
|
|
ra3.obj(A("Flt"), fb);
|
|
CHECK(fb.hfPlan && fb.fplan.wpts.size() == 1 && fb.fplan.wpts[0].wpt == 272 && fb.fplan.wpts[0].nrtFramed);
|
|
CHECK(!fb.sysID && !fb.caps);
|
|
CHECK(fb.ftName == "Fleet 1");
|
|
Writer w4;
|
|
WriteArchive wa4(w4);
|
|
wa4.obj(A("Flt"), fb);
|
|
CHECK(w4.bytes() == w3.bytes());
|
|
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;
|
|
for (int i = 0; i < 5000; ++i) data.push_back(uint8_t(i * 7));
|
|
Bytes gz = gzip(data.data(), data.size());
|
|
CHECK(is_gzip(gz.data(), gz.size()));
|
|
CHECK(gunzip(gz.data(), gz.size()) == data);
|
|
CHECK(inflate_container(data.data(), data.size()) == data); // not gzip: passthrough
|
|
bool threw = false;
|
|
try {
|
|
Bytes bad = gz;
|
|
bad[bad.size() / 2] ^= 0xff;
|
|
gunzip(bad.data(), bad.size());
|
|
} catch (const GzipError&) {
|
|
threw = true;
|
|
}
|
|
CHECK(threw);
|
|
}
|
|
|
|
// --- 10. dump formatting: Python float repr and JSON quoting ---------------------------
|
|
static void test_dump_format() {
|
|
CHECK_EQ(py_float_repr(240.0), std::string("240.0"));
|
|
CHECK_EQ(py_float_repr(1.0), std::string("1.0"));
|
|
CHECK_EQ(py_float_repr(0.0), std::string("0.0"));
|
|
CHECK_EQ(py_float_repr(double(3.4028235e38f)), std::string("3.4028234663852886e+38"));
|
|
CHECK_EQ(py_float_repr(double(bits_f32(0x0000002a))), std::string("5.885453550164232e-44"));
|
|
CHECK_EQ(py_float_repr(1e-05), std::string("1e-05"));
|
|
CHECK_EQ(py_float_repr(0.0001), std::string("0.0001"));
|
|
CHECK_EQ(py_float_repr(1e16), std::string("1e+16"));
|
|
CHECK_EQ(py_float_repr(1234567890123456.0), std::string("1234567890123456.0"));
|
|
CHECK_EQ(py_float_repr(double(-0.18487215f)), std::string("-0.18487215042114258"));
|
|
CHECK_EQ(py_float_repr(double(2.4307494f)), std::string("2.4307494163513184"));
|
|
CHECK_EQ(json_quote_cp1252("a\"b\\c\n"), std::string("\"a\\\"b\\\\c\\n\""));
|
|
CHECK_EQ(json_quote_cp1252(std::string("Kor\x92Voth")), std::string("\"Kor\\u2019Voth\""));
|
|
CHECK_EQ(json_quote_cp1252(std::string("\x81")), std::string("\"\\ufffd\""));
|
|
CHECK_EQ(json_quote_cp1252(std::string("caf\xe9")), std::string("\"caf\\u00e9\""));
|
|
}
|
|
|
|
int main() {
|
|
test_primitives_bytes();
|
|
test_frames();
|
|
test_resync();
|
|
test_cp1252();
|
|
test_hints();
|
|
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);
|
|
return fails ? 1 : 0;
|
|
}
|