The coverage ratchet reads `pct >= 99.99`, and with the last carried bodies typed it cannot usefully be raised: the only opaque items left are the MT19937 block's two in every save, so the number is 2/items and it still ranges from 99.99400 on a 532 KB save to 99.99746 on a 1.17 MB one. Moving the bar to 99.995 would fail a save that types perfectly and pass a save twice its size that had picked up a whole new opaque body. A percentage ratchet on a fixed-size debt is a ratchet on save size. So the percentage stays where it is and a stronger line goes beside it: the opaque set must be exactly the two RNG items. That breaks on the save that has the new body, and `opaque_by_tag` names it. test_domains: 43 saves give 755 fields observed, 538 varying, 217 constant, against a baseline of 490 measured at 22 saves. Nineteen of the new fields are the bodies typed in the previous commit and only two of them (FTPShID, FTPDesID) have ever been seen to move -- which is why the constants grew faster than the varying fields, and is worth leaving visible.
206 lines
10 KiB
C++
206 lines
10 KiB
C++
// 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 <name>.cpp.dump / <name>.cpp.summary there
|
|
// for tests/mars_stream/oracle/compare.py.
|
|
#include <algorithm>
|
|
#include <cstdio>
|
|
#include <cstdlib>
|
|
#include <cstring>
|
|
#include <dirent.h>
|
|
#include <fstream>
|
|
#include <string>
|
|
#include <vector>
|
|
|
|
#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<std::string> list_saves(const std::string& dir) {
|
|
std::vector<std::string> 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<std::pair<size_t, std::string>> 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<std::string> 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;
|
|
}
|