The last opaque block of the save format. Reconciles lane W's provable negative
against lane O's issued-order saves: the two disagreed because three different
things were being compared as one.
Read out of Game::TurnCommands::Write (0x00842540, 764 bytes, no loops of its
own). Two halves:
* a PROLOGUE of six flag-gated groups. Each is a WriteBool on a member,
followed only when set by that command's payload. Write order is NOT offset
order -- each gate sits after its payload in the struct -- so the class is
one of the 89 whose offset-sorted layout view cannot be aligned to the wire.
That, not a branch, is why the sorted view showed an i32 where the save has
a bool at item 4.
* TWENTY-SEVEN std::list<T> members (0x70..0x1a8, stride 0xc, allocator-last),
each written by its own helper as WriteInt(size) then size element records.
All 27 are always written, so an empty list still costs one zero int.
So the recovered 44 items are 17 member writes plus ONE ITEM PER CONTAINER CALL
SITE: the linear recovery keeps the call site, guesses its kind from an element
field it could resolve, and drops the count word. 44 - 17 = 27 = the number of
lists. The "27 trailing ints with only 22 i32 slots" objection is that same 27
seen from both sides -- a kind mismatch in the table, not a structural
impossibility. And 8 prologue items + 27 zero counts = the 35-item block every
no-orders save carries bit-identically.
Item arithmetic closes to the unit on all five distinct workloads (35/38/41/61/
123). One correction to the provisional layout: the fleet-move element ends in a
COUNTED route vector {fleetId, nHops, nHops x systemId}, not a fixed quadruple --
with a four-item element the writer would need 26 lists on one save and 28 on
another, and it has 27.
Conformance: the generic check cannot be used here. Every tag is "." so the LCS
degenerates to a strict positional compare in which any primitive disagreement is
fatal, and the table's tail describes elements where the wire has counts. A
dedicated check states what is checkable instead: the 17-item prologue item for
item (17/17, and SchemaProbe takes every branch, so this is real evidence that
the conditional structure read from the instruction stream is the one the
recovery flattened) and the tail count (27 lists vs 27 table items). The tail is
reported as wire-only, never claimed as matched.
86 shapes / 838 items -> 87 / 856, still 0 MISMATCH.
Twenty-two of the 27 lists are HYPOTHESES: the scalar sequence comes straight off
the helper, but no save exercises them. Nested element bodies not otherwise
modelled here (ShipDesignDef, FleetLayout, WeaponGroups, DefenceLayout,
RaidTargets) are carried as opaque Nodes rather than guessed at, so a wrong body
cannot desynchronise a reader. select() matches the exact ".TurnCommands_v5"
suffix, so a future _v6 falls back to the carried Node.
SECOND DEFECT, present at main and unrelated to this block: zuul-turn23-fleet23
is the first save with a non-empty NVs list, and its element's leading id was
typed positionally as "." where the real tag is PID. The typed round trip on that
save differed at 0x89c14. Rule 6 exactly -- a path no save exercised was a
hypothesis flying as a fact. Fixed, and independently corroborated: the recovered
table for Game::ServerSystem names that item PID, and the Sys row moves from
102 matched / 1 wire-only / 3 shape-only to 103 / 0 / 2.
Coverage (CoverageArchive typed-vs-carried, not round-trip success): all eleven
saves 99.7-99.9% -> 100.0%; opaque items 37/43/63/132 -> 2 everywhere, and those
two are the deliberately-carried MT19937 block. Nothing else in any save we hold
is untyped. Ratchet 99.8 -> 99.99. Round trip byte-identical on all eleven
(newly so on zuul-turn23-fleet23).
test_save now asserts, on every real save, that each TurnCommands_v5 block is
consumed by the prologue plus the 27 lists with nothing left over -- the
item-granular statement a wrong list count or element width breaks first.
clean-room OK; host ctest 36/36; test_save 11 saves 0 failures. src/shim/ not
touched, so no cross-build was needed.
196 lines
9.4 KiB
C++
196 lines
9.4 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);
|
|
}
|
|
|
|
// --- 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;
|
|
}
|