// mars::stream unit tests on hand-built byte fixtures (no game data). #include #include #include #include #include #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 v) { Bytes b; for (int x : v) b.push_back(uint8_t(x)); return b; } static Bytes cat(std::initializer_list 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& 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 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 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 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(" {") != 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 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 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 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 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 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 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 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 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 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 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 and the SvSctOb variant dispatch ----------------------------- static void test_typed_string_array() { // DOpts / trev are VectorHelper: 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 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 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()); } // --- 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. 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; // Game::TacReport: two event frames, the scalar block, then the TRnc run of // (TRships, TRsats, TRshipsL) triples -- interleaved, which is the shape of // the "three trailing scalars" the linear recovery reports. shapes::TacReport tac; tac.by.hi = 4; tac.by.d = 900.0f; tac.to.dt = -1.5f; tac.to.b = true; tac.id = 9; tac.bal = 3; tac.sldc = true; tac.classes.push_back({1, 2, 3}); tac.classes.push_back({4, 5, 6}); rep.crPlSv2.tacReports.push_back(tac); 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 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); { const shapes::TacReport& t = back.combatReports[0].crPlSv2.tacReports[0]; CHECK(t.by.hi == 4 && t.by.d == 900.0f && t.to.dt == -1.5f && t.to.b); CHECK(t.id == 9 && t.bal == 3 && t.sldc && !t.sld); CHECK(t.classes.size() == 2 && t.classes[0].sats == 2 && t.classes[1].shipsL == 6); } 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 customData; template 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; tc.select(doc.cdTable.ids[0]); tc.turnCommands.playerID = 16; tc.turnCommands.hasResearchRate = true; tc.turnCommands.researchRate = 0.97f; tc.turnCommands.buildOrders.push_back({1, 608, 384, 0}); // A two-hop route: the fleet-move element ends in a COUNTED vector of system // ids, not a fixed pair, so a multi-hop order is longer than a single-hop one. shapes::TcFleetMove mv; mv.fleetID = 688; mv.route = {432, 512}; tc.turnCommands.fleetMoves.push_back(mv); 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 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::TurnCmds); { const shapes::TurnCommands& t = back.customData[0].turnCommands; CHECK(t.playerID == 16 && t.hasResearchRate && t.researchRate == 0.97f); CHECK(!t.hasResearchTarget && !t.hasResearchBoost && !t.hasCivilianRatios); CHECK(t.buildOrders.size() == 1 && t.buildOrders[0].b == 608 && t.buildOrders[0].c == 384); CHECK(t.fleetMoves.size() == 1 && t.fleetMoves[0].fleetID == 688); CHECK(t.fleetMoves[0].route.size() == 2 && t.fleetMoves[0].route[1] == 512); CHECK(t.extra.empty()); // the 27 lists account for the whole tail } 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); probe.select("Player.00000016.TurnCommands_v5"); CHECK(probe.which == shapes::CustomDataBlock::Which::TurnCmds); // The layout is only known for _v5. A different version must fall back to the // carried Node rather than be decoded with a stale shape. probe.select("Player.00000016.TurnCommands_v6"); CHECK(probe.which == shapes::CustomDataBlock::Which::Unknown); } // --- 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_aiagent_block(); test_customdata_dispatch(); test_gzip(); test_dump_format(); std::printf("test_stream: %s (%d failures)\n", fails ? "FAILED" : "ok", fails); return fails ? 1 : 0; }