sots-engine/tests/game_config/replay_trace.cpp

278 lines
11 KiB
C++

// Offline replay of a golden Mars::GlobalConsts::LoadFile trace through game::config.
//
// For every LoadFile record in $SOTS_M1_TRACE the test rebuilds the registered slots from
// the record's `side.<KEY>.before` values (the region model: one struct per key, typed by
// its "t" field), reads the file named in args from $SOTS_DATA_DIR (case-insensitively --
// the game's VFS is), runs apply() with the scale constant carried in args, and emits the
// results as replay input (TRACE_FORMAT.md section 8). tracecmp.py --replay then diffs them
// against the original's `after` values. SKIPs cleanly when either variable is unset.
#include <cmath>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <filesystem>
#include <fstream>
#include <sstream>
#include <string>
#include <vector>
#include "game/config/config_loader.h"
#include "harness.h"
#include "mini_json.h"
#include "shim/trace/emitter.h"
namespace fs = std::filesystem;
using namespace game::config;
using shim::trace::Tv;
static const char* kHook = "Mars::GlobalConsts::LoadFile";
static std::string getenv_str(const char* k) {
const char* v = std::getenv(k);
return v ? v : "";
}
// Resolve `rel` (forward slashes, any case) under `root`, one component at a time.
static bool resolve_ci(const fs::path& root, const std::string& rel, fs::path& out) {
fs::path cur = root;
std::stringstream ss(rel);
std::string part;
while (std::getline(ss, part, '/')) {
if (part.empty()) continue;
bool found = false;
std::error_code ec;
for (const fs::directory_entry& e : fs::directory_iterator(cur, ec)) {
const std::string name = e.path().filename().string();
if (name.size() == part.size() && std::equal(name.begin(), name.end(), part.begin(), [](char a, char b) {
return std::tolower(static_cast<unsigned char>(a)) == std::tolower(static_cast<unsigned char>(b));
})) {
cur = e.path();
found = true;
break;
}
}
if (!found) return false;
}
out = cur;
return true;
}
static bool read_file(const fs::path& p, std::string& out) {
std::ifstream f(p, std::ios::binary);
if (!f) return false;
std::ostringstream ss;
ss << f.rdbuf();
out = ss.str();
return true;
}
// ---- the region model, host side (mirrors src/shim/hooks/global_consts.cpp) ---------------
static Kind kind_of(const std::string& t) {
if (t == "int") return Kind::Int;
if (t == "float") return Kind::Float;
if (t == "fscaled") return Kind::FloatScaled;
if (t == "colour") return Kind::Colour;
if (t == "vec3") return Kind::Vec3;
if (t == "rect") return Kind::Rect;
if (t == "string") return Kind::String;
return Kind::Unknown;
}
static float fld(const minijson::Value* v, const char* name);
static std::int32_t ild(const minijson::Value* v, const char* name);
static float fval(const minijson::Value* v) {
if (!v) return 0;
if (v->kind == minijson::Value::String) {
if (v->str == "nan") return std::nanf("");
if (v->str == "inf") return HUGE_VALF;
if (v->str == "-inf") return -HUGE_VALF;
}
return static_cast<float>(v->number());
}
// A struct tv {t:{..,v:"int"}, v:{..}} -> slot kind + storage bytes.
static bool slot_from_tv(const std::string& key, const minijson::Value& tv, Slot& slot, std::vector<std::uint8_t>& store) {
const minijson::Value* t = tv.get("t");
if (!t || t->string() != "struct") return false;
const minijson::Value* v = tv.get("v");
if (!v || !v->isObject()) return false;
const minijson::Value* kind = v->get("t");
if (!kind) return false;
slot.key = key;
slot.kind = kind_of(kind->get("v") ? kind->get("v")->string() : "");
store.assign(width(slot.kind), 0);
if (slot.kind == Kind::String) {
// host side: the slot's storage is a std::string owned by the test (see main)
return true;
}
if (slot.kind == Kind::Vec3) {
float c[3] = {fld(v, "x"), fld(v, "y"), fld(v, "z")};
std::memcpy(store.data(), c, sizeof c);
} else if (slot.kind == Kind::Rect) {
std::int32_t c[4] = {ild(v, "x"), ild(v, "y"), ild(v, "w"), ild(v, "h")};
std::memcpy(store.data(), c, sizeof c);
} else if (slot.kind == Kind::Colour) {
float c[4] = {fval(v->get("r") ? v->get("r")->get("v") : nullptr), fval(v->get("g") ? v->get("g")->get("v") : nullptr),
fval(v->get("b") ? v->get("b")->get("v") : nullptr), fval(v->get("a") ? v->get("a")->get("v") : nullptr)};
std::memcpy(store.data(), c, sizeof c);
} else if (slot.kind == Kind::Int) {
const std::int32_t i = static_cast<std::int32_t>(v->get("v") && v->get("v")->get("v") ? v->get("v")->get("v")->number() : 0);
std::memcpy(store.data(), &i, sizeof i);
} else {
const float f = fval(v->get("v") ? v->get("v")->get("v") : nullptr);
std::memcpy(store.data(), &f, sizeof f);
}
slot.storage = store.data();
return true;
}
static float fld(const minijson::Value* v, const char* name) {
const minijson::Value* f = v ? v->get(name) : nullptr;
return fval(f ? f->get("v") : nullptr);
}
static std::int32_t ild(const minijson::Value* v, const char* name) {
const minijson::Value* f = v ? v->get(name) : nullptr;
return static_cast<std::int32_t>(f && f->get("v") ? f->get("v")->number() : 0);
}
static Tv describe(const Slot& s) {
Tv out = shim::trace::tv::struct_();
out.add("t", shim::trace::tv::str(kind_name(s.kind)));
if (s.kind == Kind::String) {
const std::string* str = static_cast<const std::string*>(s.storage);
out.add("v", shim::trace::tv::str(str->data(), str->size()));
} else if (s.kind == Kind::Vec3) {
float c[3];
std::memcpy(c, s.storage, sizeof c);
out.add("x", shim::trace::tv::f32(c[0]));
out.add("y", shim::trace::tv::f32(c[1]));
out.add("z", shim::trace::tv::f32(c[2]));
} else if (s.kind == Kind::Rect) {
std::int32_t c[4];
std::memcpy(c, s.storage, sizeof c);
out.add("x", shim::trace::tv::i32(c[0]));
out.add("y", shim::trace::tv::i32(c[1]));
out.add("w", shim::trace::tv::i32(c[2]));
out.add("h", shim::trace::tv::i32(c[3]));
} else if (s.kind == Kind::Colour) {
float c[4];
std::memcpy(c, s.storage, sizeof c);
out.add("r", shim::trace::tv::f32(c[0]));
out.add("g", shim::trace::tv::f32(c[1]));
out.add("b", shim::trace::tv::f32(c[2]));
out.add("a", shim::trace::tv::f32(c[3]));
} else if (s.kind == Kind::Int) {
std::int32_t i;
std::memcpy(&i, s.storage, sizeof i);
out.add("v", shim::trace::tv::i32(i));
} else {
float f;
std::memcpy(&f, s.storage, sizeof f);
out.add("v", shim::trace::tv::f32(f));
}
return out;
}
int main(int argc, char** argv) {
const std::string trace = getenv_str("SOTS_M1_TRACE");
const std::string data = getenv_str("SOTS_DATA_DIR");
if (trace.empty() || data.empty()) {
std::printf("SKIP: set SOTS_M1_TRACE (golden LoadFile log) and SOTS_DATA_DIR (game data root)\n");
return 0;
}
const std::string out_path = argc > 1 ? argv[1] : "m1-replay-impl.jsonl";
std::ifstream in(trace);
if (!in) {
std::printf("FAIL: cannot open %s\n", trace.c_str());
return 1;
}
std::FILE* out = std::fopen(out_path.c_str(), "wb");
if (!out) {
std::printf("FAIL: cannot write %s\n", out_path.c_str());
return 1;
}
unsigned calls = 0, files_missing = 0;
std::string line;
while (std::getline(in, line)) {
minijson::Value rec;
if (!minijson::Parser(line).parse(rec)) continue;
const minijson::Value* hook = rec.get("hook");
if (!hook || hook->string() != kHook) continue;
const minijson::Value* args = rec.get("args");
const minijson::Value* side = rec.get("side");
if (!args || !args->isArray() || args->arr.empty() || !side || !side->isObject()) continue;
std::string file;
double scale = 0;
for (const minijson::Value& a : args->arr) {
const minijson::Value* n = a.get("n");
const std::string name = n ? n->string() : "";
if (name == "file" && a.get("v")) file = a.get("v")->string();
if (name == "scale" && a.get("v")) scale = a.get("v")->number();
}
// Slots from the `before` snapshots, in the record's (map) order.
std::vector<Slot> slots;
std::vector<std::vector<std::uint8_t>> stores;
std::vector<std::string> strings; // String slots' storage on the host
slots.reserve(side->obj.size());
stores.reserve(side->obj.size());
strings.reserve(side->obj.size());
for (const auto& kv : side->obj) {
const minijson::Value* before = kv.second.get("before");
Slot s;
stores.emplace_back();
if (!before || !slot_from_tv(kv.first, *before, s, stores.back())) {
std::printf("FAIL: call %g region %s is not a typed struct\n", rec.get("call_id")->number(), kv.first.c_str());
return 1;
}
if (s.kind == Kind::String) {
const minijson::Value* bv = before->get("v");
const minijson::Value* txt = bv ? bv->get("v") : nullptr;
strings.push_back(txt && txt->get("v") ? txt->get("v")->string() : "");
s.storage = &strings.back();
}
slots.push_back(std::move(s));
}
const ExternWriter ext = [](Slot& sl, std::string_view v) { *static_cast<std::string*>(sl.storage) = std::string(v); };
shim::trace::Record r;
r.hook = kHook;
r.call_id = static_cast<std::uint32_t>(rec.get("call_id")->number());
r.mode = shim::trace::Mode::Trace;
fs::path path;
std::string text;
if (resolve_ci(data, file, path) && read_file(path, text)) {
apply(file, text, slots, scale, nullptr, ext);
} else {
++files_missing;
std::printf(" note: %s not under %s (leaving defaults, as the original does)\n", file.c_str(), data.c_str());
}
for (const Slot& s : slots) {
shim::trace::SideEntry e;
e.name = s.key;
e.before_kind = shim::trace::SideEntry::Absent;
e.after = describe(s);
r.side.push_back(std::move(e));
}
shim::trace::Buf b;
shim::trace::emit_record(b, r);
std::fwrite(b.data(), 1, b.size(), out);
++calls;
}
std::fclose(out);
std::printf("replayed %u LoadFile calls (%u files missing) -> %s\n", calls, files_missing, out_path.c_str());
if (calls == 0) {
std::printf("FAIL: no %s records in %s\n", kHook, trace.c_str());
return 1;
}
const int rc = tracetest::run_tracecmp(trace, "--hook " + std::string(kHook) + " --replay " + out_path);
if (rc == tracetest::kSkipped) return 0;
std::printf("tracecmp --replay exit %d\n", rc);
return rc == 0 ? 0 : 1;
}