Descriptor + pure adapter + host tests for the per-player turn driver. Not deployed; the WIN32 half is unbuilt here (no cross-compiler on this host). The declared boundary is narrower than the function on purpose. Phases 2, 3 and 6 -- the savings apply, the aid records and the research refund -- are pure functions of ComputeBudget's 22 slots and ProcessResearch's overBudget, and both live in the original's own stack frame. Reaching them would mean calling ComputeBudget ourselves (it repairs ships in orbit, audit #6), reading the nested B1/B3 hooks (audit #5, the self-fulfilling compare), or inferring them from the Sav delta. So they are guarded, not checked, and the three formulas are written and unit-tested but not wired into the verdict. Declared: the phase-7 clear, the RebAI decay, the descending timed-bonus sweep, plus roll_flags and rng as observations ours never writes. Guards over the whole ServerPlayer and the TechTree header. docs/T-turn-driver.md states, before any run: which regions must not diverge, which checks are weak by construction on the reference save, what falsifies the ResearchRollPending reading, and the save that would finally fire the branch nobody has seen. host ctest 36/36 (was 35/35); clean_room_check OK.
190 lines
7 KiB
C++
190 lines
7 KiB
C++
// 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 <limits>
|
|
|
|
#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<std::int32_t>::max(), 1), 2000000000);
|
|
CHECK_EQ(SatAdd(std::numeric_limits<std::int32_t>::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<float>::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<float>(back + 1e-8f);
|
|
back = static_cast<float>(back + 1e-8f);
|
|
back = static_cast<float>(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");
|
|
}
|