// Real-save test: reads every *.sav in $SOTS_SAVES_DIR (owner's data, never // in the repo) and checks that the walker parses clean, the typed shapes // load, both round trips are byte-identical and the RNG blob is a valid // MT19937 state. Skips (exit 0) when the variable is unset. // // With SOTS_DUMP_DIR set, writes .cpp.dump / .cpp.summary there // for tests/mars_stream/oracle/compare.py. #include #include #include #include #include #include #include #include #include "mars/rng/mt19937.h" #include "mars/stream/dump.h" #include "mars/stream/probe.h" #include "mars/stream/save.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) static std::vector list_saves(const std::string& dir) { std::vector out; DIR* d = opendir(dir.c_str()); if (!d) return out; while (dirent* e = readdir(d)) { std::string n = e->d_name; if (n.size() > 4 && n.compare(n.size() - 4, 4, ".sav") == 0) out.push_back(dir + "/" + n); } closedir(d); std::sort(out.begin(), out.end()); return out; } static void check_save(const std::string& path, const char* dump_dir) { std::printf("== %s\n", path.c_str()); SaveDocument doc = read_save_file(path); std::printf(" inflated %zu bytes, items %u, frames %u, resyncs %u, hint-failures %u, raw-bytes %u\n", doc.inflated.size(), doc.stats.items, doc.stats.frames, doc.stats.resyncs, doc.stats.hint_failures, doc.stats.raw_bytes); std::printf(" issues: %zu error, %zu warn, %zu info\n", doc.count(Issue::Error), doc.count(Issue::Warn), doc.count(Issue::Info)); for (const Issue& i : doc.issues) if (i.level != Issue::Info) std::printf(" %s\n", format_issue(i).c_str()); // --- the confirmed format parses clean -------------------------------------- CHECK(doc.stats.resyncs == 0); CHECK(doc.stats.hint_failures == 0); CHECK(doc.count(Issue::Error) == 0); CHECK(doc.count(Issue::Warn) == 0); CHECK(doc.stats.raw_bytes == 2503); // only the opaque RNG blob // --- typed shapes ------------------------------------------------------------- const auto& s = doc.game.summary; const auto& sim = doc.game.sim; std::printf(" summary: game='%s' turn=%d numSys=%d players=%zu\n", s.gameName.c_str(), s.turn, s.numSys, s.players.size()); CHECK(!s.gameName.empty()); CHECK(s.turn >= 1); CHECK(s.numSys > 0); CHECK(!s.players.empty()); CHECK(sim.gameName == s.gameName); CHECK(int(sim.systems.size()) == s.numSys); CHECK(sim.systems.size() == sim.systemIds.size()); CHECK(sim.players.size() == sim.playerIds.size()); CHECK(sim.fleets.size() == sim.fleetIds.size()); CHECK(sim.species.size() == 7); CHECK(!sim.players.empty() && !sim.players[0].player.plryName.empty()); CHECK(doc.game.createParams.name == s.gameName); CHECK(doc.game.createParams.nSys == s.numSys); CHECK(doc.game.createParams.mapP.planets.size() == size_t(s.numSys)); for (const auto& se : sim.systems) CHECK(!se.sys.name.empty()); for (const auto& fe : sim.fleets) CHECK(fe.flt.ships.size() >= 1); CHECK(!doc.game.cdTable.ids.empty()); // Every ".TurnCommands_v5" block must be consumed by the prologue plus the 27 // counted lists with nothing left over. `extra` is the tail `ar.rest()` would // absorb, so an empty `extra` is the item-granular statement that the model // accounts for the whole frame -- on the 35-item empty blocks and on the // 123-item one alike. It is what a wrong list count or a wrong element width // would break first. for (const auto& cd : doc.game.customData) { if (cd.which != shapes::CustomDataBlock::Which::TurnCmds) continue; CHECK(cd.turnCommands.extra.empty()); CHECK(cd.turnCommands.playerID != 0); std::printf(" TurnCommands: player %d, rate %.4g, %zu build, %zu sysRate, %zu colonize, %zu move\n", cd.turnCommands.playerID, double(cd.turnCommands.researchRate), cd.turnCommands.buildOrders.size(), cd.turnCommands.systemRates.size(), cd.turnCommands.colonizeOrders.size(), cd.turnCommands.fleetMoves.size()); } // --- RNG blob: mt[624] + left, produced by our MT19937 from RSeed -------------- CHECK(sim.rng.is_complex() && sim.rng.children.size() == 1); const Node& blob = sim.rng.children[0]; CHECK(blob.kind == Kind::Raw && blob.raw.size() == 2503); mars::rng::MT19937 saved(1u); CHECK(saved.load_state(blob.raw.data(), blob.raw.size())); std::printf(" rng: left=%d (index %d), RSeed=%d\n", saved.left(), saved.index(), doc.game.createParams.rseed); CHECK(saved.left() >= 0 && saved.left() <= mars::rng::MT19937::N); { // the saved block must be reachable from seed(RSeed) by whole twists mars::rng::MT19937 gen(uint32_t(doc.game.createParams.rseed)); int twists = -1; for (int k = 0; k < 16 && twists < 0; ++k) { if (std::memcmp(gen.state(), saved.state(), sizeof(uint32_t) * mars::rng::MT19937::N) == 0) twists = k; else for (int i = 0; i < mars::rng::MT19937::N; ++i) gen.next_u32(); // consume a block -> next twist } std::printf(" rng: state == seed(RSeed) after %d twist(s)\n", twists); CHECK(twists >= 0); } // --- coverage: what the shapes understand vs what a Node merely carries --------- // The round trip below is byte-identical either way, because an opaque Node is // copied verbatim. This is the number that actually moves when a body gets typed. { CoverageArchive cov; doc.game.io(cov); size_t total = cov.typed + cov.opaque; double pct = total ? 100.0 * double(cov.typed) / double(total) : 0.0; std::printf(" coverage: %zu typed, %zu opaque (%.1f%% of %u stream items typed)\n", cov.typed, cov.opaque, pct, doc.stats.items); std::vector> worst; for (const auto& kv : cov.opaque_by_tag) worst.emplace_back(kv.second, kv.first); std::sort(worst.rbegin(), worst.rend()); std::printf(" still opaque:"); for (size_t i = 0; i < worst.size() && i < 8; ++i) 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. // With TurnCommands_v5 typed, the only items left carried as Nodes are the // two of the MT19937 block, which is deliberately opaque. CHECK(pct >= 99.99); // The percentage is the WEAKER half of this ratchet and cannot be raised // usefully: it is 2/items, so tightening it would fail on a *small* save // that types perfectly and pass a big one that carries a new opaque body. // The strong form is the item count itself. 2026-09-09, 43 saves: the only // opaque items in any save are the MT19937 block's two (`RNG`), so the // ratchet is that exact set. A newly-carried body breaks this line first, // and it breaks it on the save that has the body, not on the smallest one. // Raise it -- by typing the body -- the way rule 27 says; never widen it. CHECK(cov.opaque == 2); CHECK(cov.opaque_by_tag.size() == 1 && cov.opaque_by_tag.count("RNG") == 1); } // --- round trips ------------------------------------------------------------------ Bytes tree_bytes = write_tree(doc.tree); CHECK(tree_bytes == doc.inflated); Bytes typed_bytes = write_save(doc.game); if (typed_bytes != doc.inflated) { size_t i = 0, n = std::min(typed_bytes.size(), doc.inflated.size()); while (i < n && typed_bytes[i] == doc.inflated[i]) ++i; std::printf(" typed round trip differs at 0x%zx (sizes %zu vs %zu)\n", i, typed_bytes.size(), doc.inflated.size()); } CHECK(typed_bytes == doc.inflated); std::printf(" round trip: tree %s, typed %s\n", tree_bytes == doc.inflated ? "identical" : "DIFFERS", typed_bytes == doc.inflated ? "identical" : "DIFFERS"); // --- optional dump for the oracle comparison ------------------------------------- if (dump_dir) { std::string base = path.substr(path.find_last_of('/') + 1); std::ofstream d(std::string(dump_dir) + "/" + base + ".cpp.dump"); for (const std::string& l : dump_tree(doc.tree)) d << l << '\n'; std::ofstream sm(std::string(dump_dir) + "/" + base + ".cpp.summary"); sm << "summary: game=" << json_quote_cp1252(s.gameName) << " turn=" << s.turn << " numSys=" << s.numSys << " players=" << s.players.size() << '\n'; sm << "sim: players=" << sim.players.size() << " systems=" << sim.systems.size() << " fleets=" << sim.fleets.size() << '\n'; } } int main() { const char* dir = std::getenv("SOTS_SAVES_DIR"); if (!dir || !*dir) { std::printf("test_save: SKIPPED (SOTS_SAVES_DIR not set)\n"); return 0; } std::vector saves = list_saves(dir); if (saves.empty()) { std::printf("test_save: SKIPPED (no *.sav in %s)\n", dir); return 0; } const char* dump_dir = std::getenv("SOTS_DUMP_DIR"); for (const std::string& p : saves) { try { check_save(p, dump_dir && *dump_dir ? dump_dir : nullptr); } catch (const std::exception& e) { std::printf("FAIL %s: %s\n", p.c_str(), e.what()); ++fails; } } std::printf("test_save: %s (%zu save(s), %d failures)\n", fails ? "FAILED" : "ok", saves.size(), fails); return fails ? 1 : 0; }