// Lane T adapter tests: the three phases of Game::ServerPlayer::ProcessTurn this hook // declares, plus the three budget-driven formulas that were read off the instruction stream // but are deliberately NOT wired into the compare (see player_turn_inputs.h). // // Hand-written fixtures only. Every expected value below is derived from the disassembly in // sots-re/findings/control-flow/turn-driver.md ยง2.1, not from a run. #include "shim/hooks/player_turn_inputs.h" #include #include "check.h" using namespace shim::hooks; namespace { void test_satadd() { // 0x00817990: clamps to +/-2,000,000,000, and detects wraparound before clamping. CHECK_EQ(SatAdd(0, 0), 0); CHECK_EQ(SatAdd(289688, 242681), 532369); // B1's observed turn on player 0 CHECK_EQ(SatAdd(-5, 3), -2); CHECK_EQ(SatAdd(1900000000, 1900000000), 2000000000); CHECK_EQ(SatAdd(-1900000000, -1900000000), -2000000000); // b > 0 but the sum wrapped negative -> the positive rail, not the negative one. CHECK_EQ(SatAdd(std::numeric_limits::max(), 1), 2000000000); CHECK_EQ(SatAdd(std::numeric_limits::min(), -1), -2000000000); // b == 0 is neither wrap case; the value still gets clamped. CHECK_EQ(SatAdd(2100000000, 0), 2000000000); } void test_budget_net() { // net = [1]+[2]+[3]+[4]+[5]+[6] - [7]-[8]-[9]-[10]-[11]-[12]-[13]-[14] std::int32_t b[kBudgetSlots] = {}; for (int i = 0; i < kBudgetSlots; ++i) b[i] = 0; b[0] = 999999; // slot 0 is Sav and must NOT appear in the net b[1] = 100; b[2] = 20; b[3] = 3; b[4] = 4; b[5] = 5; b[6] = 6; b[7] = 7; b[8] = 8; b[9] = 9; b[10] = 10; b[11] = 11; b[12] = 12; b[13] = 13; b[14] = 14; b[15] = 500000; // avail is not in the net either b[17] = 1234; CHECK_EQ(BudgetNet(b), 100 + 20 + 3 + 4 + 5 + 6 - 7 - 8 - 9 - 10 - 11 - 12 - 13 - 14); // The reference-save shape: income and interest only, nothing deducted (B1's player 0, // which has no research target, so slot 9 stays 0). std::int32_t r[kBudgetSlots] = {}; r[0] = 289688; r[1] = 239785; r[5] = 2896; CHECK_EQ(BudgetNet(r), 242681); CHECK_EQ(SatAdd(r[0], BudgetNet(r)), 532369); } void test_research_refund() { // if (over > 0 && RP > 0) refund = ftol(min(over,RP)/RP * money) CHECK_EQ(ResearchRefund(0, 1000, 5000), 0); // nothing over budget CHECK_EQ(ResearchRefund(-5, 1000, 5000), 0); // signed: over can be negative CHECK_EQ(ResearchRefund(100, 0, 5000), 0); // no research points -> no rate CHECK_EQ(ResearchRefund(100, 1000, 5000), 500); // 10% of the money back CHECK_EQ(ResearchRefund(5000, 1000, 5000), 5000); // min() caps at the full allocation // truncation toward zero, not rounding: 3/7 * 10 = 4.28... CHECK_EQ(ResearchRefund(3, 7, 10), 4); } void test_reb_out_mod() { CHECK_NEAR(DecayRebOutMod(2.0f), 1.96f, 1e-6f); CHECK_NEAR(DecayRebOutMod(1.0f), 1.0f, 0.0f); // already at the floor CHECK_NEAR(DecayRebOutMod(1.02f), 1.0f, 0.0f); // clamped up to the floor CHECK_NEAR(DecayRebOutMod(5.0f), 2.0f, 0.0f); // clamped down to the ceiling } void test_predict_roll() { // ResT != 0 && ResErrRoll && 0.5f < ratio, strictly. CHECK(!PredictResearchRoll(0, true, 0.9f)); // no research target CHECK(!PredictResearchRoll(0x1000, false, 0.9f)); // flag not set CHECK(!PredictResearchRoll(0x1000, true, 0.5f)); // exactly at the threshold: NO roll CHECK(!PredictResearchRoll(0x1000, true, 0.49f)); CHECK(PredictResearchRoll(0x1000, true, 0.5001f)); CHECK(PredictResearchRoll(0x1000, true, 1.0f)); // NaN must answer false, the way `fcomp` + `test ah,5` + `jp skip` does. CHECK(!PredictResearchRoll(0x1000, true, std::numeric_limits::quiet_NaN())); } void test_clears_and_decay() { PlayerTurnSnapshot s; s.trm = 3.5f; s.tra = 11; s.trp = 22; s.perTurnDc = 33; s.perTurnE0 = 4.5f; s.rebAI = 0; s.rebOutMod = 1.5f; PlayerTurnResult r = ProcessPlayerTurnBody(s); CHECK_NEAR(r.trm, 0.0f, 0.0f); // no bonuses -> the clear is the whole story CHECK_EQ(r.tra, 0); CHECK_EQ(r.trp, 0); CHECK_EQ(r.perTurnDc, 0); CHECK_NEAR(r.perTurnE0, 0.0f, 0.0f); CHECK_NEAR(r.rebOutMod, 1.5f, 0.0f); // not a RebAI player: untouched s.rebAI = 1; r = ProcessPlayerTurnBody(s); CHECK_NEAR(r.rebOutMod, 1.46f, 1e-6f); } void test_bonus_sweep() { // The sweep runs LAST TO FIRST, decrements every PRBt, and erases the ones that hit 0. PlayerTurnSnapshot s; s.bonusCount = 3; s.bonuses[0] = {0.25f, 1}; // expires this turn s.bonuses[1] = {0.50f, 2}; s.bonuses[2] = {0.125f, 1}; // expires this turn PlayerTurnResult r = ProcessPlayerTurnBody(s); // every element contributes, including the ones erased on the same pass CHECK_NEAR(r.trm, 0.875f, 1e-6f); CHECK_EQ(r.bonusCount, 1); CHECK_NEAR(r.bonuses[0].amount, 0.50f, 0.0f); CHECK_EQ(r.bonuses[0].turns, 1); // A single long-lived bonus survives with its counter decremented. PlayerTurnSnapshot t; t.bonusCount = 1; t.bonuses[0] = {2.0f, 5}; PlayerTurnResult u = ProcessPlayerTurnBody(t); CHECK_NEAR(u.trm, 2.0f, 0.0f); CHECK_EQ(u.bonusCount, 1); CHECK_EQ(u.bonuses[0].turns, 4); // Empty vector: TRM ends at the phase-7 clear. PlayerTurnSnapshot e; PlayerTurnResult v = ProcessPlayerTurnBody(e); CHECK_EQ(v.bonusCount, 0); CHECK_NEAR(v.trm, 0.0f, 0.0f); } void test_bonus_sweep_order_matters() { // Float addition is not associative, so the descending order is part of the answer. Pick // magnitudes where forward and backward summation genuinely differ in float32. PlayerTurnSnapshot s; s.bonusCount = 3; s.bonuses[0] = {1.0f, 9}; s.bonuses[1] = {1e-8f, 9}; s.bonuses[2] = {1e-8f, 9}; PlayerTurnResult r = ProcessPlayerTurnBody(s); // last-to-first: (0 + 1e-8) + 1e-8 = 2e-8, then + 1.0 -> 1.0f (the small terms are lost // only at the final add). Forward would lose them one at a time against 1.0. float back = 0.0f; back = static_cast(back + 1e-8f); back = static_cast(back + 1e-8f); back = static_cast(back + 1.0f); CHECK_NEAR(r.trm, back, 0.0f); CHECK_EQ(r.bonusCount, 3); for (int i = 0; i < 3; ++i) CHECK_EQ(r.bonuses[i].turns, 8); } void test_snapshot_describer_is_size_safe() { PlayerTurnSnapshot s; s.playerIndex = 4; // A short buffer must not read past it. shim::trace::Tv small = DescribePlayerTurnSnapshot(&s, sizeof(s) - 1, 256); shim::trace::Tv full = DescribePlayerTurnSnapshot(&s, sizeof(s), 256); (void)small; (void)full; CHECK(sizeof(PlayerTurnSnapshot) % 8 == 0); } } // namespace int main() { test_satadd(); test_budget_net(); test_research_refund(); test_reb_out_mod(); test_predict_roll(); test_clears_and_decay(); test_bonus_sweep(); test_bonus_sweep_order_matters(); test_snapshot_describer_is_size_safe(); return simtest::finish("shim_player_turn_unit"); }