sots-engine/tests/shim_events/unit_tests.cpp
lane P f3c6d551de lane P: post the research events from ours (count-only), declare ObservedTech
TechTree::ProcessResearch's events region now compares a modelled value instead of
reporting a known defect. ours posts the pass's events into its own
sots::events::EventStorage, seeded from a scan of the owner's list taken BEFORE the
original runs, and writes only the counts into the region's scratch copy. The game's
PostEvent is never called and no live byte moves; replace mode still posts nothing,
because a bumped EvNxID with no record behind it would corrupt the oracle's save.

- game/events: PostResearchPassEvents (the decision half, pure) + KeylessEventText
- shim/hooks/event_inputs (new lib shim_events, host-tested): the live<->model adapter,
  carrying game pointers as explicit uint32 so a 64-bit host build cannot alias them
- research hook: the wiring, a new observed_techs region for ServerPlayer+0x274, and
  turn / events_next_id_in / events_dedup_risk in the args so the count model's own
  assumption is measured rather than assumed
- EVENT_TECHS_UNLOCKED is NOT posted: its trigger is pinned but needs SetResearched's
  unlock cascade, which ours does not run. The driver takes the unlock list as an input
  and is handed 'no list', so a missing input cannot look like a modelled negative.
  Predicted residual: next_id short by exactly 1 on a completion call.

ctest 33/33 (shim_events_unit is new), clean_room_check OK. The shim TU is
syntax-checked only: no MinGW cross toolchain on this box.

See docs/P-events-wiring.md for the exact prediction for the next VM run.
2026-09-08 04:16:22 -04:00

366 lines
16 KiB
C++

// P adapter tests: read a live-shaped Game::EventStorage out of raw bytes, seed the model from
// it, and write the counts back the way compare mode does.
//
// The fixture is a byte buffer laid out with the *real* offsets from the generated header
// (EventStorage 0x1c / TurnEvents 0x18 / PlayerEvent 0x74, MSVC-2010 std::string 0x1c), so these
// cases pin the nested turn-bucketed shape and the stride, not just the arithmetic. The shape is
// the one verify/results/saves/turn3-state.sav holds for player 1 and the one
// verify/state-checksum/state_checksum.py walks: EvNxID, then n x {EvTurn, m x PlayerEvent}.
#include "shim/hooks/event_inputs.h"
#include <cstdint>
#include <cstring>
#include <string>
#include <vector>
#include "check.h"
#include "game/events/research_events.h"
#include "generated/sots_addresses.h"
using namespace shim::hooks;
namespace A = sots::addr;
namespace {
// ---- a fake address space -----------------------------------------------------------------
//
// One arena; `probe` answers "is this range inside it?", which is the host stand-in for the
// shim's VirtualQuery. Everything the adapter reads must go through it.
std::vector<char>& arena() {
static std::vector<char> a;
return a;
}
bool probe(const void* p, std::size_t n) {
if (!p) return false;
if (n == 0) return true;
const char* c = static_cast<const char*>(p);
const char* lo = arena().data();
const char* hi = lo + arena().size();
return c >= lo && c + n <= hi && c + n >= c;
}
void put_word(std::size_t off, std::int32_t v) { std::memcpy(&arena()[off], &v, sizeof v); }
// A pointer *inside a game object* is four bytes, and in this fake address space a game address
// is simply an offset into the arena, biased so that 0 stays "null".
constexpr std::uint32_t kArenaBias = 0x40000000;
void put_addr(std::size_t off, std::size_t targetOff) {
const std::uint32_t a = kArenaBias + static_cast<std::uint32_t>(targetOff);
std::memcpy(&arena()[off], &a, sizeof a);
}
void put_addr_raw(std::size_t off, std::uint32_t a) { std::memcpy(&arena()[off], &a, sizeof a); }
void put_null(std::size_t off) { put_addr_raw(off, 0); }
const void* to_host(std::uint32_t a) {
if (a < kArenaBias) return nullptr; // not one of ours: the probe will reject it
const std::size_t off = a - kArenaBias;
if (off > arena().size()) return nullptr;
return arena().data() + off;
}
// An MSVC 2010 std::string at `off`, short enough to live in the inline buffer (_Myres 15).
void put_short_string(std::size_t off, const std::string& s) {
CHECK(s.size() < kStdStringInlineCap);
std::memcpy(&arena()[off], s.data(), s.size());
arena()[off + s.size()] = '\0';
put_word(off + kStdStringOffSize, static_cast<std::int32_t>(s.size()));
put_word(off + kStdStringOffRes, static_cast<std::int32_t>(kStdStringInlineCap - 1));
}
// The same string, but heap-allocated: _Myres >= 16 and the first word is the pointer. Every
// EvImg in a real save takes this form -- "EVENT_RESEARCH_OVERBUDGET" is 25 characters -- so it
// is the form that actually matters, and reading the inline buffer instead yields garbage.
void put_long_string(std::size_t off, std::size_t bufOff, const std::string& s) {
std::memcpy(&arena()[bufOff], s.data(), s.size());
arena()[bufOff + s.size()] = '\0';
put_addr(off, bufOff);
put_word(off + kStdStringOffSize, static_cast<std::int32_t>(s.size()));
put_word(off + kStdStringOffRes, static_cast<std::int32_t>(s.size() + 8));
}
// Pick the form the real string would take.
void put_string(std::size_t off, std::size_t bufOff, const std::string& s) {
if (s.size() < kStdStringInlineCap)
put_short_string(off, s);
else
put_long_string(off, bufOff, s);
}
// ---- the fixture ----------------------------------------------------------------------------
//
// Layout inside the arena:
// 0x0000 EventStorage (0x1c)
// 0x0100 TurnEvents[] (0x18 each)
// 0x0400 PlayerEvent[] for bucket 1 (0x74 each)
// 0x1000 string heap
constexpr std::size_t kStorage = 0x0000;
constexpr std::size_t kBuckets = 0x0100;
constexpr std::size_t kEvents = 0x0400;
constexpr std::size_t kHeap = 0x1000;
// `images` is one EvImg per event in the *second* bucket; the first bucket is left empty.
void build(int nextId, const std::vector<int>& bucketTurns, const std::vector<std::string>& images,
std::size_t whichBucketHasEvents) {
arena().assign(0x2000, 0);
put_word(kStorage + A::EventStorage_off_EvNxID, nextId);
if (bucketTurns.empty()) {
put_null(kStorage + A::EventStorage_off_Events);
put_null(kStorage + A::EventStorage_off_Events + kGamePtrSize);
put_null(kStorage + A::EventStorage_off_Events + 2 * kGamePtrSize);
return;
}
const std::size_t bytes = bucketTurns.size() * A::TurnEvents_sizeof;
put_addr(kStorage + A::EventStorage_off_Events, kBuckets);
put_addr(kStorage + A::EventStorage_off_Events + kGamePtrSize, kBuckets + bytes);
put_addr(kStorage + A::EventStorage_off_Events + 2 * kGamePtrSize, kBuckets + bytes);
for (std::size_t i = 0; i < bucketTurns.size(); ++i) {
const std::size_t b = kBuckets + i * A::TurnEvents_sizeof;
put_word(b + A::TurnEvents_off_EvTurn, bucketTurns[i]);
if (i != whichBucketHasEvents || images.empty()) {
put_null(b + A::TurnEvents_off_Events);
put_null(b + A::TurnEvents_off_Events + kGamePtrSize);
put_null(b + A::TurnEvents_off_Events + 2 * kGamePtrSize);
continue;
}
const std::size_t evBytes = images.size() * A::PlayerEvent_sizeof;
put_addr(b + A::TurnEvents_off_Events, kEvents);
put_addr(b + A::TurnEvents_off_Events + kGamePtrSize, kEvents + evBytes);
put_addr(b + A::TurnEvents_off_Events + 2 * kGamePtrSize, kEvents + evBytes);
for (std::size_t k = 0; k < images.size(); ++k) {
const std::size_t img = kEvents + k * A::PlayerEvent_sizeof + A::PlayerEvent_off_EvImg;
put_string(img, kHeap + k * 0x40, images[k]);
}
}
}
const void* storage() { return arena().data() + kStorage; }
// Read the turns vector's {first, last} back out of a scratch header, as 32-bit game addresses.
std::size_t scratch_turns_bytes(const std::vector<char>& scratch) {
std::uint32_t first = 0, last = 0;
std::memcpy(&first, scratch.data() + A::EventStorage_off_Events, sizeof first);
std::memcpy(&last, scratch.data() + A::EventStorage_off_Events + kGamePtrSize, sizeof last);
CHECK(last >= first);
return last - first;
}
std::uint32_t scratch_turns_first(const std::vector<char>& scratch) {
std::uint32_t first = 0;
std::memcpy(&first, scratch.data() + A::EventStorage_off_Events, sizeof first);
return first;
}
// The write-back runs on a scratch buffer, not on the arena, so it gets its own probe.
bool scratch_probe(const void* p, std::size_t n) { return p != nullptr && n <= 0x100; }
// ---- cases ------------------------------------------------------------------------------------
// turn3-state.sav, player 1: EvNxID 4, buckets for turns 2 and 3, the turn-3 bucket holding
// EVENT_SHIPS_BUILT and EVENT_RESEARCH_OVERBUDGET.
void test_scan_reads_the_real_save_shape() {
build(4, {2, 3}, {"EVENT_SHIPS_BUILT", "EVENT_RESEARCH_OVERBUDGET"}, 1);
const EventStorageScan s = ScanEventStorage(storage(), 3, &probe, &to_host);
CHECK(s.ok);
CHECK(!s.scanTruncated);
CHECK_EQ(s.nextId, 4);
CHECK_EQ(s.bucketTurns.size(), 2u);
CHECK_EQ(s.bucketTurns[0], 2);
CHECK_EQ(s.bucketTurns[1], 3);
CHECK_EQ(s.turnsBytes, 2u * A::TurnEvents_sizeof);
CHECK(s.turnBucketExists);
CHECK_EQ(s.eventsInTurnBucket, 2u);
// Only the research event counts towards the dedup risk; EVENT_SHIPS_BUILT can never be a
// duplicate of anything the research path posts.
CHECK_EQ(s.researchEventsInTurnBucket, 1u);
}
// The pre-call state of recap-b3-compare call 0: EvNxID 3 and no research event in the bucket
// yet -- so a count-only model is exact on that call.
void test_scan_reports_no_dedup_risk_before_the_post() {
build(3, {2, 3}, {"EVENT_SHIPS_BUILT"}, 1);
const EventStorageScan s = ScanEventStorage(storage(), 3, &probe, &to_host);
CHECK_EQ(s.nextId, 3);
CHECK_EQ(s.researchEventsInTurnBucket, 0u);
CHECK_EQ(s.eventsInTurnBucket, 1u);
}
// A player who has never seen an event: EvNxID 0 and {null,null,null}. Two of the four players
// in the reference save look exactly like this.
void test_scan_of_an_empty_storage() {
build(0, {}, {}, 0);
const EventStorageScan s = ScanEventStorage(storage(), 3, &probe, &to_host);
CHECK(s.ok);
CHECK_EQ(s.nextId, 0);
CHECK_EQ(s.bucketTurns.size(), 0u);
CHECK_EQ(s.turnsBytes, 0u);
CHECK(!s.turnBucketExists);
}
// The heap form of std::string must read the same as the inline form.
void test_scan_reads_heap_strings() {
build(4, {3}, {"EVENT_RESEARCH_UNDERBUDGET"}, 0);
const EventStorageScan s = ScanEventStorage(storage(), 3, &probe, &to_host);
CHECK(s.ok);
CHECK_EQ(s.researchEventsInTurnBucket, 1u);
}
// A header that is not a vector must not be believed, and must not walk off the arena.
void test_scan_rejects_a_bad_header() {
build(4, {3}, {}, 0);
// last < first
put_addr(kStorage + A::EventStorage_off_Events, kBuckets + A::TurnEvents_sizeof);
put_addr(kStorage + A::EventStorage_off_Events + kGamePtrSize, kBuckets);
CHECK(!ScanEventStorage(storage(), 3, &probe, &to_host).ok);
// a span that is not a whole number of TurnEvents
build(4, {3}, {}, 0);
put_addr(kStorage + A::EventStorage_off_Events + kGamePtrSize, kBuckets + 7);
CHECK(!ScanEventStorage(storage(), 3, &probe, &to_host).ok);
// a span far larger than the arena
build(4, {3}, {}, 0);
put_addr(kStorage + A::EventStorage_off_Events + kGamePtrSize,
kBuckets + (kMaxTurnBuckets + 1) * A::TurnEvents_sizeof);
const EventStorageScan s = ScanEventStorage(storage(), 3, &probe, &to_host);
CHECK(!s.ok);
}
// ---- seed + post + write back: the whole compare-mode path -----------------------------------
// recap-b3-compare call 0, end to end. THE case: EvNxID 3 -> 4 with one over-budget post, and
// `turns` unchanged because the turn-3 bucket already existed.
void test_overbudget_call_reaches_next_id_4() {
build(3, {2, 3}, {"EVENT_SHIPS_BUILT"}, 1);
const EventStorageScan s = ScanEventStorage(storage(), 3, &probe, &to_host);
CHECK_EQ(s.researchEventsInTurnBucket, 0u); // count-only is exact here
sots::events::EventStorage log = SeedFromScan(s);
CHECK_EQ(log.nextId(), 3);
CHECK_EQ(log.turns().size(), 2u);
std::vector<sots::events::ResearchPassOutcome> out(1);
out[0].techName = "144";
out[0].overbudgetEvent = true;
const sots::events::ResearchPassResult r =
sots::events::PostResearchPassEvents(log, sots::events::KeylessEventText(), out, nullptr, 3);
CHECK_EQ(r.nextId, 4);
// The scratch copy the region diff reads is a byte copy of the header.
std::vector<char> scratch(A::EventStorage_sizeof);
std::memcpy(scratch.data(), storage(), scratch.size());
WriteBackCounts(scratch.data(), log, &scratch_probe);
std::int32_t nextId = 0;
std::memcpy(&nextId, scratch.data() + A::EventStorage_off_EvNxID, 4);
CHECK_EQ(nextId, 4);
CHECK_EQ(scratch_turns_bytes(scratch), 2u * A::TurnEvents_sizeof);
}
// The same pass on a turn with no bucket yet: `turns` must grow by one, which is what the
// region's turns_bytes reports.
void test_write_back_grows_turns_when_a_bucket_is_created() {
build(3, {2}, {}, 0);
const EventStorageScan s = ScanEventStorage(storage(), 9, &probe, &to_host);
CHECK(!s.turnBucketExists);
sots::events::EventStorage log = SeedFromScan(s);
std::vector<sots::events::ResearchPassOutcome> out(1);
out[0].techName = "144";
out[0].overbudgetEvent = true;
sots::events::PostResearchPassEvents(log, sots::events::KeylessEventText(), out, nullptr, 9);
CHECK_EQ(log.turns().size(), 2u);
std::vector<char> scratch(A::EventStorage_sizeof);
std::memcpy(scratch.data(), storage(), scratch.size());
WriteBackCounts(scratch.data(), log, &scratch_probe);
CHECK_EQ(scratch_turns_bytes(scratch), 2u * A::TurnEvents_sizeof);
}
// A previously empty storage: the header has no pointers to extend, so the write-back has to
// synthesise a base or the span would read 0 where the original allocated one bucket.
void test_write_back_on_a_previously_empty_storage() {
build(0, {}, {}, 0);
const EventStorageScan s = ScanEventStorage(storage(), 3, &probe, &to_host);
sots::events::EventStorage log = SeedFromScan(s);
CHECK_EQ(log.nextId(), 0);
std::vector<sots::events::ResearchPassOutcome> out(1);
out[0].techName = "144";
out[0].overbudgetEvent = true;
sots::events::PostResearchPassEvents(log, sots::events::KeylessEventText(), out, nullptr, 3);
std::vector<char> scratch(A::EventStorage_sizeof);
std::memcpy(scratch.data(), storage(), scratch.size());
WriteBackCounts(scratch.data(), log, &scratch_probe);
std::int32_t nextId = 0;
std::memcpy(&nextId, scratch.data() + A::EventStorage_off_EvNxID, 4);
CHECK_EQ(nextId, 2); // 0 -> promoted to 1 by the post -> post-incremented
CHECK_EQ(scratch_turns_first(scratch), kSyntheticTurnsBase);
CHECK_EQ(scratch_turns_bytes(scratch), 1u * A::TurnEvents_sizeof);
}
// A zero-spend call must leave the header byte-identical: no id, no bucket.
void test_write_back_is_a_no_op_when_nothing_posted() {
build(3, {2, 3}, {"EVENT_SHIPS_BUILT"}, 1);
const EventStorageScan s = ScanEventStorage(storage(), 3, &probe, &to_host);
sots::events::EventStorage log = SeedFromScan(s);
std::vector<sots::events::ResearchPassOutcome> out(2);
out[0].techName = "90";
out[1].techName = "9";
sots::events::PostResearchPassEvents(log, sots::events::KeylessEventText(), out, nullptr, 3);
std::vector<char> before(A::EventStorage_sizeof), scratch(A::EventStorage_sizeof);
std::memcpy(before.data(), storage(), before.size());
std::memcpy(scratch.data(), storage(), scratch.size());
WriteBackCounts(scratch.data(), log, &scratch_probe);
CHECK(std::memcmp(before.data(), scratch.data(), before.size()) == 0);
}
// Both std::string forms read the same, and a misread is reported rather than guessed at.
void test_read_std_string_forms_and_bounds() {
arena().assign(0x200, 0);
std::string out = "untouched";
put_short_string(0, "EVENT_X");
CHECK(ReadStdStringMapped(arena().data(), &probe, &to_host, out));
CHECK(out == "EVENT_X");
// The heap form -- what every real EvImg uses.
arena().assign(0x200, 0);
put_long_string(0, 0x80, "EVENT_RESEARCH_OVERBUDGET");
out = "untouched";
CHECK(ReadStdStringMapped(arena().data(), &probe, &to_host, out));
CHECK(out == "EVENT_RESEARCH_OVERBUDGET");
// A length past the sanity cap.
put_word(kStdStringOffSize, 0x2000);
out = "untouched";
CHECK(!ReadStdStringMapped(arena().data(), &probe, &to_host, out));
CHECK(out == "untouched");
// _Myres < _Mysize is impossible in a well-formed string.
arena().assign(0x200, 0);
put_long_string(0, 0x80, "EVENT_RESEARCH_OVERBUDGET");
put_word(kStdStringOffRes, 4);
CHECK(!ReadStdStringMapped(arena().data(), &probe, &to_host, out));
// A heap string whose buffer address is not in the arena at all.
arena().assign(0x200, 0);
put_addr_raw(0, 0xdead0000);
put_word(kStdStringOffSize, 8);
put_word(kStdStringOffRes, 31);
CHECK(!ReadStdStringMapped(arena().data(), &probe, &to_host, out));
}
} // namespace
int main() {
test_scan_reads_the_real_save_shape();
test_scan_reports_no_dedup_risk_before_the_post();
test_scan_of_an_empty_storage();
test_scan_reads_heap_strings();
test_scan_rejects_a_bad_header();
test_overbudget_call_reaches_next_id_4();
test_write_back_grows_turns_when_a_bucket_is_created();
test_write_back_on_a_previously_empty_storage();
test_write_back_is_a_no_op_when_nothing_posted();
test_read_std_string_forms_and_bounds();
return simtest::finish("shim_events");
}