// 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 #include #include #include #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& arena() { static std::vector 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(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(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(s.size())); put_word(off + kStdStringOffRes, static_cast(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(s.size())); put_word(off + kStdStringOffRes, static_cast(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& bucketTurns, const std::vector& 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& 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& 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 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 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 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 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 out(1); out[0].techName = "144"; out[0].overbudgetEvent = true; sots::events::PostResearchPassEvents(log, sots::events::KeylessEventText(), out, nullptr, 3); std::vector 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 out(2); out[0].techName = "90"; out[1].techName = "9"; sots::events::PostResearchPassEvents(log, sots::events::KeylessEventText(), out, nullptr, 3); std::vector 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"); }