The spine's fourth phase, read byte-for-byte and implemented:
almem[i] = (1 << i) | (ALid != -1 ? AL : 0)
with i the player's POSITION IN THE PLAYER VECTOR, not its index field. Both
inputs are on the wire and so is the output, through the turn-record archive,
so the phase is checkable against bytes the original wrote:
app_turn_record: 11 saves, 80 player-records, 560 fields, 0 mismatches
(was 480 fields over six fields; almem is the seventh)
The eight zero masks of the corpus's earliest archived turn are PREDICTED, not
excluded: the archiving phase also runs on load, and the load path does not run
the spine. BuildTurnRecord takes spineRan and models it, so all 80 records are
compared.
Three parts of the rule the corpus cannot separate -- the bit index, the OR,
and the ALid guard -- are pinned in app_alliance with the separating inputs no
save provides, and app_turn_record prints that it could not separate them.
Divergence, closed and regressed reported separately:
turn1->turn2 default 209 -> 204 closed 5, regressed 0
turn1->turn2 --commit-blocked 209 -> 189 closed 29, regressed 9 (was 17)
turn2->turn3 default 108 -> 103 closed 5, regressed 0
turn2->turn3 --commit-blocked 108 -> 106 closed 13, regressed 11 (was 19)
T36 stays blocked: nine leaves would still be wrong (inc x3, sav x3 behind the
budget; three census leaves behind the design catalogue). It now closes all 24
turnstats leaves on the reference pair, so it becomes a clean +24 once those
two land.
Prediction and falsification committed first in 49ae628.
Gates run separately: clean-room OK; host ctest 43/43. No src/shim touched.
138 lines
6.4 KiB
C++
138 lines
6.4 KiB
C++
// The turn-record model, checked against the record the game itself wrote.
|
|
//
|
|
// The last phase of the post-combat tail archives a per-player summary keyed by turn, and the
|
|
// archive is on the wire: every save carries an element for its own frame. So the model can be
|
|
// checked with no running game and no VM -- build the record from the save's own state and
|
|
// compare it with the element the save already holds.
|
|
//
|
|
// This is the strongest check available to a lane that holds no game: it compares against
|
|
// bytes the original produced. It is NOT the same as `verified` in the phase catalog, which
|
|
// means "compared against the live game", and nothing here is that.
|
|
//
|
|
// Reads $SOTS_SAVES_DIR at run time and skips cleanly when it is unset. No .sav enters this
|
|
// repo.
|
|
#include <cstdint>
|
|
#include <cstdio>
|
|
#include <cstdlib>
|
|
#include <string>
|
|
#include <vector>
|
|
|
|
#include <dirent.h>
|
|
|
|
#include "app/alliance.h"
|
|
#include "app/turn_record.h"
|
|
#include "mars/stream/save.h"
|
|
|
|
static int failures = 0;
|
|
#define CHECK(c) \
|
|
do { \
|
|
if (!(c)) { \
|
|
std::printf("FAIL %s:%d %s\n", __FILE__, __LINE__, #c); \
|
|
++failures; \
|
|
} \
|
|
} while (0)
|
|
|
|
int main() {
|
|
const char* dir = std::getenv("SOTS_SAVES_DIR");
|
|
if (!dir || !*dir) {
|
|
std::printf("app_test_turn_record: SOTS_SAVES_DIR unset, skipped\n");
|
|
return 0;
|
|
}
|
|
DIR* d = opendir(dir);
|
|
if (!d) {
|
|
std::fprintf(stderr, "app_test_turn_record: cannot open %s\n", dir);
|
|
return 1;
|
|
}
|
|
std::vector<std::string> saves;
|
|
while (struct dirent* e = readdir(d)) {
|
|
const std::string n = e->d_name;
|
|
if (n.size() > 4 && n.compare(n.size() - 4, 4, ".sav") == 0)
|
|
saves.push_back(std::string(dir) + "/" + n);
|
|
}
|
|
closedir(d);
|
|
if (saves.empty()) {
|
|
std::printf("app_test_turn_record: no .sav in %s, skipped\n", dir);
|
|
return 0;
|
|
}
|
|
|
|
int files = 0, players = 0, fields = 0, dangling = 0, noArchive = 0;
|
|
// Reported rather than assumed: how much of the alliance rule the corpus actually
|
|
// exercises. A green run over records that are all `alid == -1` would test the self bit
|
|
// and nothing else, and would look identical to a green run that tested everything.
|
|
int alliedRecords = 0, loadWrittenRecords = 0, indexDiffers = 0;
|
|
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;
|
|
int filePlayers = 0, fileFields = 0, fileBad = 0;
|
|
for (std::size_t i = 0; i < sim.players.size(); ++i) {
|
|
if (i >= sim.turnstats.players.size()) break;
|
|
const auto* stored =
|
|
sots::app::FindArchivedRecord(sim.turnstats.players[i].hist, sim.frame);
|
|
if (!stored) {
|
|
++noArchive;
|
|
continue;
|
|
}
|
|
int dang = 0;
|
|
// The archiving phase runs at the end of a turn AND on load, and only the
|
|
// end-of-turn path has a spine behind it to have written the alliance mask. So
|
|
// the earliest turn the archive carries is predicted to have a zero mask -- a
|
|
// positive claim the corpus checks on 8 records, not a field skipped.
|
|
const bool spineRan =
|
|
sim.frame > sots::app::EarliestArchivedTurn(sim.turnstats.players[i].hist);
|
|
const sots::app::TurnRecord built = sots::app::BuildTurnRecord(
|
|
sim.players[i].player, sim.systems, sim.frame, i, spineRan, &dang);
|
|
dangling += dang;
|
|
// An owned-system id the save's table does not carry would drop a term from the
|
|
// population sum without any other symptom, so it is a failure, not a note.
|
|
CHECK(dang == 0);
|
|
if (sim.players[i].player.alliances.alid != sots::app::kNoAlliance) ++alliedRecords;
|
|
if (!spineRan) ++loadWrittenRecords;
|
|
if (sim.players[i].player.plyrIdx != static_cast<std::int32_t>(i)) ++indexDiffers;
|
|
const sots::app::TurnRecordDiff diff = sots::app::CompareTurnRecord(built, *stored);
|
|
++filePlayers;
|
|
fileFields += diff.compared;
|
|
for (const auto& m : diff.mismatches) {
|
|
std::printf("FAIL %s player %zu: %s\n", path.c_str(), i, m.c_str());
|
|
++failures;
|
|
++fileBad;
|
|
}
|
|
}
|
|
players += filePlayers;
|
|
fields += fileFields;
|
|
std::printf(" %s: turn %d, %d player-record(s), %d field(s), %d mismatch(es)\n",
|
|
path.c_str(), sim.frame, filePlayers, fileFields, fileBad);
|
|
}
|
|
|
|
// The check is worthless if it compared nothing -- a green run over zero records is the
|
|
// failure mode this campaign has paid for twice.
|
|
CHECK(files > 0);
|
|
CHECK(players > 0);
|
|
CHECK(fields == players * 7);
|
|
|
|
std::printf("app_test_turn_record: %d save(s), %d player-record(s), %d field(s) compared, "
|
|
"%d player(s) with no archive element, %d dangling owned-system id(s), "
|
|
"%d failure(s)\n",
|
|
files, players, fields, noArchive, dangling, failures);
|
|
// Coverage of the alliance rule, stated as loudly as the verdict (earned rule 15).
|
|
std::printf(" alliance mask: %d of %d record(s) carry an alliance id; %d were written by "
|
|
"the load path and are predicted to be zero; %d record(s) have a vector "
|
|
"position that differs from the player index field\n",
|
|
alliedRecords, players, loadWrittenRecords, indexDiffers);
|
|
if (indexDiffers == 0)
|
|
std::printf(" NOT SEPARATED by this corpus: every player's vector position equals its "
|
|
"index field, so no comparison here can tell `1 << position` from "
|
|
"`1 << index`. The instruction stream is what settles it; app_alliance "
|
|
"holds that reading.\n");
|
|
return failures ? 1 : 0;
|
|
}
|