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.
169 lines
8.4 KiB
C++
169 lines
8.4 KiB
C++
// 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 <cstddef>
|
|
#include <cstdint>
|
|
#include <vector>
|
|
|
|
#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
|