sots-engine/tests/game_design/test_census_saves.cpp
alex f177a5fdd0 lane D2: hull size + the defence-platform flag; the ship census reproduces 480/480
The two derived words the per-player turn record's ship census counts by, and
the design serializers that three lanes had been told did not exist.

HOW DESIGNS PERSIST. Game::ShipDesign derives from Game::ShipDesignDef and
reaches IStreamable through adjustor thunks, so a design is written by TWO
serializers: the base emits FAIDes/DHide/DWep/DName and exactly three section
frames (command, mission, engine on the wire), the derived one appends Dtc, the
Dwgv flag and, only when that flag is set, a weapon-group frame. The earlier
"the writer makes no stream call at all" note named an address that is in no
vftable at all. Corrected in shapes.h.

THREE sections, not five. The "two reserved slots" were Dtc and Dwgv swept into
the section list by the reference reader's catch-all tail; the constructor
builds a three-element array. design.h's comment is corrected and the fixture
loader now accepts 3-5 raw_slots so old fixtures still load; the array keeps
five inert entries deliberately, since touching the slot enum reaches rules.cpp
and another lane's tests for no behavioural gain.

DWep and Dwgv are bools, not ints -- both writers call the bool primitive. With
four-character tags a bool item and an int item are the same size on the wire
and 0/1 the same bytes, so no save can tell them apart. Byte-neutral: the typed
round trip is still byte-identical on all 11 saves at 100% named coverage.

HULL SIZE is the section_class of the last resolved section in memory slot
order, mapped Destroyer/Cruiser/Dreadnought -> 0/1/2 case-insensitively, with
absent or unrecognised meaning 0 rather than an error. The DEFENCE-PLATFORM
flag is one bit of a 64-bit role-flag word OR-ed across the design's sections.
Neither is on the wire; both are rebuilt from the section catalog.

MEASURED, not assumed: the new game_design_census test rebuilds the six census
counters per player and compares them against the record the game archived for
each save's own frame. 11 saves, 503 designs, 480 leaves, 0 mismatched, 0 ships
with an unresolvable design, 0 designs where first- and last-resolved section
disagree on hull size. COVERAGE IS THIN AND THE TEST SAYS SO: only 32 of the
480 leaves are nonzero, and three of the six census leaves (both cruiser rows
and dreadnought platforms) are never exercised by any save in the corpus -- the
test prints the per-leaf nonzero counts and names them unexercised rather than
verified.

Nothing is wired into the turn record: src/app is another lane's this cycle, so
this is evaluated and reported, not written.

host ctest 43/43 (was 42/42; +1, skips cleanly without the env). With a data
root set, game_data_realdata and mars_text_realdata fail identically on main --
both are the absent Locale/EN/Strings.csv, not this change. clean-room OK.
Reference readers fixed openly in the RE repo: save_reader 49/49,
design rules 32/32, stock_designs.json regenerated (raw_slots 5->3 and dWep
int->bool are the only field changes across all 127 designs).
2026-09-08 12:44:41 -04:00

225 lines
9.4 KiB
C++

// The per-hull-class ship census, checked against the census the game itself
// wrote.
//
// Every save carries the archived per-player turn record for its own frame,
// and six of that record's fields are the ship census: `shpt` and `satt` in
// each of the three `cls` groups. Neither the hull size nor the platform flag
// that produce those six numbers is on the wire -- both are recomputed from
// the section catalog -- so this test rebuilds them from $SOTS_DATA_DIR and
// compares against bytes the original produced.
//
// It is NOT a live comparison against the running game: it is a comparison
// against what the game archived. Needs $SOTS_SAVES_DIR and $SOTS_DATA_DIR;
// skips cleanly when either is unset. No save and no game data enters the
// repo.
#include <array>
#include <cstdio>
#include <cstdlib>
#include <map>
#include <string>
#include <vector>
#include <dirent.h>
#include "game/data/catalog.h"
#include "game/design/hull.h"
#include "mars/stream/save.h"
using namespace game::data;
using namespace game::design;
namespace shapes = mars::stream::shapes;
static int failures = 0;
#define CHECK(c) \
do { \
if (!(c)) { \
std::printf("FAIL %s:%d %s\n", __FILE__, __LINE__, #c); \
++failures; \
} \
} while (0)
namespace {
// One save `Des` record, classified. The record names its sections as
// (species index, id in that race's manifest), which is exactly what the
// catalog's id lookup takes.
HullClass classify_saved_design(const Catalog& cat, const shapes::Design& d) {
std::vector<const ShipSectionDef*> found;
int unresolved = 0;
for (const shapes::DesignSection& s : d.sections) {
const int species = s.sec.a, id = s.sec.b;
if (species == 0 && id == 0) continue; // empty slot
if (species < 0 || species >= kSpeciesCount) {
++unresolved;
continue;
}
const ShipSectionDef* def =
cat.section_by_id(species_name(static_cast<Species>(species)), id);
if (!def) {
++unresolved;
continue;
}
found.push_back(def);
}
HullClass h = classify_sections(found);
h.unresolved_sections = unresolved;
return h;
}
const shapes::PlayerTurnStats* archived(const shapes::PlayerTurnHistory& hist, std::int32_t turn) {
for (const shapes::PlayerTurnStats& s : hist.stats)
if (s.trn == turn) return &s;
return nullptr;
}
} // namespace
int main() {
const char* savesDir = std::getenv("SOTS_SAVES_DIR");
const char* dataDir = std::getenv("SOTS_DATA_DIR");
if (!savesDir || !*savesDir || !dataDir || !*dataDir) {
std::printf("game_design_census: SOTS_SAVES_DIR / SOTS_DATA_DIR unset, skipped\n");
return 0;
}
DIR* dir = opendir(savesDir);
if (!dir) {
std::fprintf(stderr, "game_design_census: cannot open %s\n", savesDir);
return 1;
}
std::vector<std::string> saves;
while (struct dirent* e = readdir(dir)) {
const std::string n = e->d_name;
if (n.size() > 4 && n.compare(n.size() - 4, 4, ".sav") == 0)
saves.push_back(std::string(savesDir) + "/" + n);
}
closedir(dir);
if (saves.empty()) {
std::printf("game_design_census: no .sav in %s, skipped\n", savesDir);
return 0;
}
const Catalog cat = load_catalog(dataDir);
if (cat.sections.empty()) {
std::fprintf(stderr, "game_design_census: no sections under %s\n", dataDir);
return 1;
}
int files = 0, compared = 0, mismatches = 0, nonzero = 0;
int designsSeen = 0, designsUnclassified = 0, shipsWithoutDesign = 0;
int hullAssignmentDiffers = 0;
std::array<int, 6> nonzeroByLeaf{}; // cls0.shpt, cls0.satt, cls1.shpt, ...
for (const std::string& path : saves) {
mars::stream::SaveDocument doc;
try {
doc = mars::stream::read_save_file(path);
} catch (const std::exception& ex) {
std::printf(" %s: unreadable (%s), skipped\n", path.c_str(), ex.what());
continue;
}
if (doc.count(mars::stream::Issue::Error)) {
std::printf(" %s: parse errors, skipped\n", path.c_str());
continue;
}
++files;
const auto& sim = doc.game.sim;
// Design ids are unique across the whole game, so one map serves every
// player's fleets. Legacy (rider) designs are in the same id space.
std::map<std::int32_t, HullClass> byId;
for (const shapes::PlayerEntry& pe : sim.players) {
for (const auto* list : {&pe.player.designs, &pe.player.legacyDesigns}) {
for (const shapes::DesignEntry& de : *list) {
HullClass h = classify_saved_design(cat, de.des);
++designsSeen;
if (!h.ok()) ++designsUnclassified;
// First-wins vs last-wins: rule A6 makes every shipped
// design class-homogeneous, so a difference here would be
// news. Counted rather than asserted.
int first = -1;
for (const shapes::DesignSection& s : de.des.sections) {
if (s.sec.a == 0 && s.sec.b == 0) continue;
if (s.sec.a < 0 || s.sec.a >= kSpeciesCount) continue;
if (const ShipSectionDef* d2 =
cat.section_by_id(species_name(static_cast<Species>(s.sec.a)), s.sec.b)) {
first = hull_size(d2->section_class);
break;
}
}
if (h.ok() && first >= 0 && first != h.hull_size) ++hullAssignmentDiffers;
byId[de.desID] = h;
}
}
}
// Player object id -> index into sim.players, which is the index the
// turn-statistics archive is keyed by.
std::map<std::int32_t, std::size_t> playerIndex;
for (std::size_t i = 0; i < sim.players.size(); ++i) playerIndex[sim.players[i].playerID] = i;
std::vector<ShipCensus> built(sim.players.size());
for (const shapes::FleetEntry& fe : sim.fleets) {
auto it = playerIndex.find(fe.flt.pid);
if (it == playerIndex.end()) continue; // unowned / NPC-pool fleet
ShipCensus& c = built[it->second];
for (const shapes::ShipEntry& se : fe.flt.ships) {
auto d = byId.find(se.ship.desID);
if (d == byId.end() || !d->second.ok()) {
++shipsWithoutDesign;
continue;
}
c.add(d->second);
}
}
int fileCompared = 0, fileBad = 0;
for (std::size_t i = 0; i < sim.players.size(); ++i) {
if (i >= sim.turnstats.players.size()) break;
const shapes::PlayerTurnStats* stored =
archived(sim.turnstats.players[i].hist, sim.frame);
if (!stored) continue;
for (std::size_t k = 0; k < stored->classes.size() && k < 3; ++k) {
const shapes::ClassStats& cs = stored->classes[k];
const int wantShips = cs.shpt, wantPlat = cs.satt;
const int gotShips = built[i].ships[k], gotPlat = built[i].platforms[k];
for (int which = 0; which < 2; ++which) {
const int want = which ? wantPlat : wantShips;
const int got = which ? gotPlat : gotShips;
++compared;
++fileCompared;
if (want != 0) {
++nonzero;
++nonzeroByLeaf[k * 2 + static_cast<std::size_t>(which)];
}
if (want != got) {
++mismatches;
++fileBad;
std::printf(" %s p%zu cls%zu %s: built %d != archived %d\n", path.c_str(), i,
k, which ? "satt" : "shpt", got, want);
}
}
}
}
std::printf(" %-34s turn %3d %3d compared %d mismatched\n",
path.c_str(), sim.frame, fileCompared, fileBad);
}
CHECK(files > 0);
CHECK(compared > 0);
CHECK(mismatches == 0);
CHECK(shipsWithoutDesign == 0);
CHECK(designsUnclassified == 0);
std::printf("game_design_census: %d saves, %d designs, %d census leaves, %d mismatched\n",
files, designsSeen, compared, mismatches);
std::printf(" coverage: %d of %d leaves are NONZERO in the archive; per leaf "
"(cls0 shpt/satt, cls1 shpt/satt, cls2 shpt/satt) = %d/%d %d/%d %d/%d\n",
nonzero, compared, nonzeroByLeaf[0], nonzeroByLeaf[1], nonzeroByLeaf[2],
nonzeroByLeaf[3], nonzeroByLeaf[4], nonzeroByLeaf[5]);
std::printf(" a zero leaf agrees for free -- the leaves with no nonzero observation are "
"UNEXERCISED, not verified\n");
std::printf(" designs whose first- and last-resolved section disagree on hull size: %d "
"(rule A6 predicts 0)\n", hullAssignmentDiffers);
if (failures) std::printf("game_design_census: %d FAILURES\n", failures);
return failures ? 1 : 0;
}