// Lane T adapter: the ServerPlayer state `Game::ServerPlayer::ProcessTurn` reads and writes. // // `ServerPlayer::ProcessTurn(float dt)` is the per-player half of the turn driver. It is // called once per player from `StrategyServer::ProcessTurn`'s player loop, and it is where // three separately verified slices meet: `ComputeBudget` (B1), `TechTree::ProcessResearch` // (B3/U) and, through `SetResearched`, `ServerPlayer::OnTechResearched` (B2). Between them it // performs a handful of small steps nothing has modelled yet. // // This file is the pure half: it turns a snapshot of a ServerPlayer into the post-state the // driver's own body would write, and describes both for the trace. No OS or game dependency, // so it builds and is unit-tested on the host. // // --------------------------------------------------------------------------------------------- // THE DECLARED BOUNDARY, and why it is narrower than the function. // // The twelve phases are listed in sots-re/findings/control-flow/turn-driver.md ยง2.1. Three of // them -- the savings apply (2), the aid/trade records (3) and the research refund (6) -- are // pure functions of `ComputeBudget`'s 22-slot output and of `ProcessResearch`'s `overBudget` // out-parameter. **Both live in the original's own stack frame** ([ebp-0x90] and [ebp-0x14]), // and nothing outside that frame can read them. There are exactly three ways to get them and // each is worse than not having them: // // * call `ComputeBudget` ourselves -- it is NOT read-only: `ServerSystem::ComputeOutput` // repairs damaged ships in orbit (harness-audit #6). A compare run would double-repair. // * read them back out of the nested B1 / B3 hooks -- cross-hook state, and it makes our // answer a function of the original's own output. That is harness-audit #5, the // self-fulfilling compare, which the audit already flags as `risk: high`. // * infer them from the observed `Sav` delta -- the same trap, with a coat on. // // So this adapter models **only the three phases that need nothing but the player**: // // phase 7 TRM / TRA / TRP / +0xdc / +0xe0 = 0 // phase 8 RebOutMod = clamp(RebOutMod - 0.04f, 1.0f, 2.0f), for a RebAI player // phase 9 the descending timed-research-bonus sweep, which refills TRM and erases expired // elements from the `NumPR` vector // // Everything else -- ComputeBudget, ProcessSpecialProjects, ConstructionSpend, // RollResearchAccident, ProcessResearch and its whole cascade, the ResearchRollPending roll, // the EVENT_NO_RESEARCH post and PruneRaidTargets -- stays an input, exactly as B4 leaves // ServerSystem::ProcessTurn's callees. `Sav`, the two aid records and the trade-income // write-through are deliberately NOT Result regions: the whole-player guard reports them // moving, which is an honest "we watched it" rather than a green tick on an empty check. // // The three budget-driven formulas ARE written and unit-tested below, because they were read // off the instruction stream and the next lane will need them the moment the budget becomes // reachable (a hook on ComputeBudget's *return* would do it). They are simply not wired into // the compare. // --------------------------------------------------------------------------------------------- // // dt IS IGNORED ON PURPOSE. The original takes one float argument (`ret 4`) and never reads // it: the full instruction decode of 0x00891340..0x00891783 contains zero `[ebp+N]` // references. It is recorded as an argument and nothing more. #pragma once #include #include #include #include "shim/trace/emitter.h" namespace shim::hooks { // Budget slots ComputeBudget produces. Indices are B1's; see the boundary note above for why // this hook cannot see them. constexpr int kBudgetSlots = 22; // One element of ServerPlayer's timed-research-bonus vector (+0x3a4), stride 8. struct TimedResearchBonus { float amount = 0; // PRm std::int32_t turns = 0; // PRBt }; // How many bonus elements the snapshot carries before it reports a count only. constexpr std::size_t kMaxTimedBonuses = 64; struct PlayerTurnSnapshot { // identity / context (recorded, never compared) std::int32_t playerIndex = -1; std::int32_t playerSlot = -1; // position in the server's Players vector std::int32_t species = -1; std::uint8_t isAI = 0; // +0xf9 std::uint8_t isNPC = 0; // +0xfb std::uint8_t rebAI = 0; // +0xfc std::uint8_t pad0 = 0; std::int32_t modCount = 0; // the server turn counter the research code stamps std::int32_t phaseCounter = 0; // the other per-turn counter float dt = 0; // forwarded by the driver, never read by the original // the words phases 7-9 own -- these ARE the compare float trm = 0; // +0xd0 std::int32_t tra = 0; // +0xd4 std::int32_t trp = 0; // +0xd8 std::int32_t perTurnDc = 0; // +0xdc float perTurnE0 = 0; // +0xe0 float rebOutMod = 0; // +0x128 // watched but not modelled (see the boundary note) std::int32_t savings = 0; // +0x284 Sav std::int32_t researchAidGiven = 0; // +0xc8 std::int32_t savingsAidGiven = 0; // +0xcc std::int32_t tradeIncomeOut = 0; // (+0x3d8)->+0x10 // the research state that decides the roll. READ PRE-CALL -- every one of these is // rewritten by the original, and reading them afterwards gives a plausible wrong answer. std::uint32_t researchTarget = 0; // +0x294 ResT, as an opaque pointer value std::uint8_t rollPendingIn = 0; // +0x3b4 ResErrRoll std::uint8_t rebellionArmedIn = 0; // +0x3b5 cta, the rebellion branch's own gate std::uint8_t predictRoll = 0; // our prediction that site A fires this call std::uint8_t haveRatio = 0; // false when the ratio could not be measured float progressRatioIn = 0; // TechTree::ResearchProgressRatio(tree, ResT) // the timed-bonus vector std::int32_t bonusCount = 0; std::int32_t bonusOverflow = 0; // count beyond kMaxTimedBonuses, reported not carried TimedResearchBonus bonuses[kMaxTimedBonuses] = {}; // generator + FPU context (the rng itself is a declared region) std::int32_t rngLeftIn = 0; std::uint32_t fpuControlWord = 0; }; // Pinned: the snapshot is memcpy'd into a declared region, so a padding surprise would shift // every field the describer reports (M2's +0x14 lesson). static_assert(sizeof(PlayerTurnSnapshot) % 8 == 0, "PlayerTurnSnapshot has no tail padding surprise"); // ---- verified formulas, not wired into the compare (see the boundary note) ------------------- // ServerPlayer::SatAdd (0x00817990), verified instruction by instruction: an overflow-checked // add clamped to +/-2,000,000,000. std::int32_t SatAdd(std::int32_t a, std::int32_t b); // The net the driver applies to savings, verified operand by operand at 0x008913d6..0x0089140f: // [1]+[2]+[3]+[4]+[5]+[6] - [7]-[8]-[9]-[10]-[11]-[12]-[13]-[14] std::int32_t BudgetNet(const std::int32_t budget[kBudgetSlots]); // The research refund (phase 6, 0x008914aa): unspent research points converted back to money // at the turn's own points-per-credit rate. Returns 0 when the branch is not taken. std::int32_t ResearchRefund(std::int32_t overBudget, std::int32_t researchPoints, std::int32_t researchMoney); // ---- the modelled arithmetic ----------------------------------------------------------------- // RebOutMod = clamp(RebOutMod - 0.04f, 1.0f, 2.0f), in float32 at every step. float DecayRebOutMod(float rebOutMod); // Site A's predicate, so the hook can state BEFORE the call whether it expects a draw: // ResT != 0 && rollPending && 0.5f < progressRatio (strict <) bool PredictResearchRoll(std::uint32_t researchTarget, bool rollPending, float progressRatio); struct PlayerTurnResult { float trm = 0; std::int32_t tra = 0; std::int32_t trp = 0; std::int32_t perTurnDc = 0; float perTurnE0 = 0; float rebOutMod = 0; std::int32_t bonusCount = 0; TimedResearchBonus bonuses[kMaxTimedBonuses] = {}; }; // Phases 7, 8 and 9 of ServerPlayer::ProcessTurn -- everything the hook can check. PlayerTurnResult ProcessPlayerTurnBody(const PlayerTurnSnapshot& s); // Trace describers. trace::Tv DescribePlayerTurnSnapshot(const void* data, std::size_t size, unsigned inline_max); trace::Tv DescribeTimedBonuses(const TimedResearchBonus* v, std::int32_t n); } // namespace shim::hooks