Goal: a headless way to drive the hero with sustained, combinable key input and assert on the game's response, so an input-handling regression can be caught in CI instead of on a real pad. The counterpart engine goal: no input edge is ever lost, whatever the frame/sim cadence.
This is the build plan that follows the findings in
INPUT_REPLAY_RESEARCH.md (there was no recorded-input subsystem to
reuse; the seam is MyGetInput/Control_TickHook). Scope agreed with Ivan: phases 0 to 2
(harness simulator plus the engine latch that retires the force-step whitelist). The render/sim
interpolation half of issue #412 (which also cures the movement stutter) is out of scope here.
Two facts in the current code, plus a measured harness gap.
-
Edge detection lives in the render domain, but is consumed in the sim domain.
LastInput = Inputruns every rendered frame (PERSO.CPP MainLoop, ~L950), but the hero object loop that reads edges (LastInput & I_THROW,LastInput & I_ACTION_Min OBJECT.CPP MOVE_MANUAL) only runs on stepped frames. A press or release straddling a skipped frame is never observed. -
The current fix is a whitelist, not a cure.
Timer_ForceStepIfPending(PERSO.CPP ~L1968) forces a sim step only when a named one-frame signal is pending:(InventoryAction >= 0) || (FirstTime == AFF_ALL_FLIP) || ((Input ^ LastInput) & I_ACTION_EDGE). That OR grew one term per bug: #358 item-use, #411 scene-flip, #407 input edges (I_ACTION_EDGE, INPUT.H). Any edge-consuming bit not in the whitelist still drops.Scope check (done for this plan): the bits outside
I_ACTION_EDGEthat could matter (weapon selectI_WEAPON_*, behaviour selectI_NORMAL/SPORTIF/AGRESSIF/DISCRET,I_HOLOMAP,I_PAUSE,I_MENUS) are all read at PERSO.CPP:1221-1842, which is before the throttle gate, so they run every rendered frame and the throttle cannot drop them. The only post-gateInputedge consumers are the object-loop combat/action edges, and those are exactly the setI_ACTION_EDGEcovers. So the engine's live drop exposure today is narrow and mostly patched. The problem the whitelist leaves open is structural: it is a hand-maintained list, so the next code that reads a new input edge post-gate drops silently until someone adds a term. -
The harness injector has the same failure it is meant to test, and a metering bug on top. The
inputconsole command ORsDbgInjectInputintoInputafterMyGetInput(PERSO.CPP ~L956), every rendered frame, counting downDbgInjectInputFramesin rendered frames. So a "hold" is metered in render iterations, not sim ticks, and injected input rides the render domain (subject to the same drop as real input). This makes the simulator unusable as a regression net across throttle settings.
Save Desert Island.LBA (steam_classic_2023 corpus), --fixed-dt 8, --tick 60, hero position
from --dump-state. Baseline with no input is (13646, 1360, 15311) at every throttle setting
(deterministic, so the scene is not a confound).
Same command input up 30, varying only the throttle:
--fixed-timestep |
hero end position | distance walked |
|---|---|---|
| 0 (no throttle) | (13679, 1360, 15436) | ~129 units |
| 16 | (13676, 1360, 15430) | ~123 units |
| 100 | (13646, 1360, 15311) | 0 units |
At fixed-timestep 100 the 30-render-frame hold expired before any sim step consumed it, so the
hero did not move at all. It is a metering artifact, not terrain: input up 300 at fixed-timestep
100 does walk the hero (to (13715, 1360, 15548) in 60 ticks, and far further over 300 ticks,
comparably to no-throttle). So the simulator cannot express a sustained hold that means the same
thing across cadences, which is precisely the stated requirement ("hold keys and combinations for
a sustained time").
Note this case is movement (I_JOY), which is held state the sim throttle is correct to skip; the
bug here is the harness metering, independent of the engine. The engine-side edge drop is the
separate, narrow, whitelist-managed case in fact 2.
Invariant: between two consecutive sim steps, no input edge is lost.
Split the edge baseline out of the render domain. Keep render-domain Input/LastInput for
view-only reads (camera, mouse-look) as now. Add a sim-domain accumulator: every rendered frame
after MyGetInput, SimInputAccum |= Input; the edge baseline SimLastInput advances only when
a sim step actually runs. The object loop reads edges against SimInputAccum/SimLastInput, not
the render-frame snapshot. On a skipped frame nothing is consumed, so the latch carries the edge
to the next natural step.
This is the general form of the I_ACTION_EDGE whitelist: instead of forcing a step so the live
edge is seen now, latch the edge so the next step sees it, for any bit. It retires the input term
from the force-step OR (the item-use and scene-flip terms stay: those are engine signals, not
Input bits). Byte-exact at --fixed-dt 16: one step per frame means SimInputAccum == Input
and SimLastInput == LastInput every frame, so behaviour differs only on skip frames, which is
the buggy path (same argument that cleared #407/#411/#358).
Core implementation risk: reconciling level reads (Input & I_UP) with edge reads
(Input ^ LastInput) under a union accumulator. Proposed semantics: on a step the sim sees
SimInputAccum (union of held bits since last step, so a brief press is not missed) against
baseline SimLastInput; after the step SimLastInput = SimInputAccum, the accumulator clears and
re-primes from the current render Input. The byte-exactness proof concentrates here.
Generalised DbgInjectInput into a sim-tick-metered, scriptable timeline (Control_Input* in
CONTROL.CPP, driven from the console input command). Command surface:
input <flags> [ticks]holds the OR of the flags for N sim ticks (default 1). Combine flags with+or,(e.g.input up+throw 30), or give a raw0xNNNmask.input seq <t:flags:dur/...>schedules a timeline of events at absolute start ticks, e.g.input seq 0:up:60/60:up+throw:30; a one-tick event is a press, a bit dropped between events is a release. Chained through the single--execbuffer with;(so events use/, not;).input fseq <f:flags:dur/...>is the same but measured in RENDERED frames, not sim ticks, on a separate frame clock that advances every rendered frame (skipped ones included). This is the tool for reproducing the throttle edge-drop:input fseq 3:action:4places a press on rendered frame 3, which at a high refresh rate is a frame the sim never steps, so the object loop never sees it.input trace <0|1>logs the injected mask + herogen_anim/position each sim tick (the "watch the hero to confirm" half), andinput offclears.
Metering unit: SIM TICKS, not rendered frames. The hold's countdown advances only on frames the
throttle actually steps (Control_InputAdvance(nSimSteps) before the sim runs; a skipped frame
gotos past it and consumes nothing), while the mask is OR'd into Input every rendered frame so
whatever step lands sees it. This is the gobble-proof unit: it guarantees N consumptions whatever
the frame/step cadence. It is a no-op at the common --fixed-dt 16 cadence (one step per frame, so
tick == frame), which is why the existing input tests are unchanged; only test_blowgun_release_ throttle needed a larger --tick budget, because 6 fire ticks at --fixed-timestep 100 span ~75
rendered frames.
Why sim ticks and not game-time. A held input's distance-per-tick scales with the timestep (a
step at --fixed-timestep 100 advances 100 ms of sim, vs 16 ms at 16), so "30 ticks" walks farther
at a coarser throttle. That is the throttle's real coarseness, not a metering error, and it is not
the property a test asserts. The property that IS invariant, and the one the tool guarantees, is
that a sustained hold is consumed on every step: hold forward for the same GAME-TIME (pin it with
--fixed-dt and bound the run by --tick) and the hero floats the same distance at any throttle.
Measured on V Jump.LBA (a high-altitude glide), 30 s of forward hold floats 8612 units at
--fixed-timestep 16 and 8515 at 100 (1.1% apart) where the old render-frame count collapsed to
zero. test_input_hold_throttle.sh is that regression.
Two behaviours to know: a hold needs a few warm-up ticks before movement registers (the walk anim
ramps up after load), and at a high --fixed-timestep a hold needs a correspondingly large
--tick budget for its ticks to elapse.
Constraint that keeps the harness honest: it must not force a sim step just because it injected. It injects and lets the throttle do its worst; Layer A's latch is what will save a dropped edge. If the harness force-stepped, a test would pass while real players still dropped edges.
Author a timeline that presses/releases on skip frames at --fixed-dt 100, paired with the hero
probe. The test fails today (edge dropped) and passes with the latch. It is the reusable
regression net for every future input-drop report, replacing the per-signal whitelist entry plus
bespoke corpus-save hunt.
The throttle only drops input when rendered frames outpace sim steps, i.e. on a high-refresh
display with vsync off. The harness reproduces that deterministically: --fixed-dt N sets the
game-time each rendered frame advances (a stand-in for frame pacing), so a high refresh rate is a
SMALL --fixed-dt. The throttle skips whenever --fixed-dt < --fixed-timestep, and the skip ratio
(rendered frames per sim step) is FixedTimestep / fixed-dt. Model a real display with
--fixed-dt = 1000/refreshHz and the real --fixed-timestep 16 (144 Hz -> --fixed-dt 7, ~2.3
frames/step; 240 Hz -> --fixed-dt 4); or exaggerate for a near-certain skip with
--fixed-dt 8 --fixed-timestep 100 (~12.5 frames/step). Verified: at --fixed-dt 8 --fixed-timestep 100, 120 rendered frames run only 10 sim steps.
That the harness genuinely exercises the drop was proven by temporarily neutering the #407 edge
force-step (a stand-in for the broken engine): input throw 6 then leaves the blowgun stuck firing
(gen_anim 35) at --fixed-timestep 100 while --fixed-timestep 0 settles to 0 -- the release
edge landed on a skipped frame and was gobbled. Restoring the force-step makes both settle to 0.
Level vs edge, and why fseq exists. Weapon fire start is LEVEL-triggered (Input & I_THROW),
so holding fire through a skip frame still fires at the next step -- a sim-tick hold cannot gobble
it. The genuine press-EDGE cases are melee (I_ACTION_M press edge, gated by an anti-repeat on
LastInput) and the fire release edge. A sim-tick hold always lands a press on a step, so it can
never reproduce a press-edge drop; that is what fseq is for -- place the press on a chosen skip
frame. The melee press-edge drop is captured by tests/automation/test_melee_throttle.sh: a
walking, aggressive hero (input fseq 0:0x10000:6/0:up:120/<f>:up+action:6) must fire a
GEN_ANIM_COUP (17/18/19, seen in the input trace) whether the action press lands on a stepped frame
(30) or a skipped one (31). At --fixed-dt 8 --fixed-timestep 24 the current engine fires both
(COUP=2/2); neuter the I_ACTION_EDGE term and the skip-frame press drops to COUP=0. So the test
is a real guard, and it confirms #407's melee fix that was never auto-tested.
- Phase 0, done: falsify first. The reproducible gap is the harness metering (table above); the engine's live post-gate exposure is narrow and whitelist-managed. This reprioritises Phase 2 from "fix a rampant drop" to "retire the fragile whitelist and let injected input test the real path".
- Phase 1, DONE, branch
feat/input-sim-timeline(harness-only): injection metered in sim ticks;input <flags> [ticks]/input seq/input fseq(frame-precise) /input trace/input offwith+/,combos; per-tick hero-trace probe. No engine-behaviour change (no-op at--fixed-dt 16, so existing tests unchanged bar the blowgun--tickbudget). Delivers the sustained-hold-and-combo requirement (test_input_hold_throttle.sh) and the frame-precise edge-placement (fseq) that Phase 2's red test needs. - Phase 2, RESCOPED after measuring -- latch NOT built. The measure-first pass falsified the
premise that there is a live edge to fix: the
#413whitelist (I_ACTION_EDGE) turns out to be complete for every post-gate edge that can be found and tested. Verified on the current engine at--fixed-timestep 24/100: the melee press edge fires on a throttle-skipped frame (the force-step catches it), and the blowgun release edge stops firing; both drop only when the whitelist term is neutered. So the latch would be a pure refactor (retire the hand-maintained whitelist) carrying byte-exact risk for zero live bugs. Decision (match test weight to fix weight): skip it. Instead lock intests/automation/test_melee_throttle.sh-- built withfseq+ a GEN_ANIM_COUP trace, it confirms #407's melee fix, which until now was "covered by construction" and never auto-tested (it fails with the whitelist term neutered, passes with it). The latch stays a documented option the day a future post-gate edge consumer lands outside the whitelist.
- Spell edges (
I_PINGOUIN/I_JETPACK/I_PROTECTION/I_FOUDRE, theInput2word) are read viaCheckKey/SpellKeyDown, not theInputbitfield, so they are outside both the whitelist and the latch. Confirm whether they need their own handling or are genuinely out of scope. - Modal frames (menus, dialogue) sample input differently; a timeline that crosses a modal needs
care, and
skipmodalsmay be the right pairing. Resolved for presses inside a modal: the timeline ORs its mask intoInputfromMainLoop, which a modal spinning in its ownMyGetInputloop never reaches, so it could not press anything in a menu at all. Thekeycommand holds a raw scancode inTabKeysfrom theApplyHarnessKeyshook inLIB386/SYSTEM/KEYBOARD.CPP, which everyManageKeyboardruns and therefore every modal loop sees. Metered in input polls, one perManageKeyboard, rather than sim ticks, because a modal advances no ticks and a tick-based delay inside one would never elapse. Note that a menu latches whatever is already held when it opens (InitWaitNoInput), so a press must be armed to land after the modal starts, not before;tests/automation/test_askchoice_menus_consume.shis the worked example. - The latch delays an edge by up to one sim period (it is seen at the next step, not forced
immediately). That is the fixed-timestep-correct behaviour and a no-op at
--fixed-dt 16; confirm it does not regress feel on a real high-refresh device. - The coarse sim at a high
--fixed-timestepcan diverge on physics for reasons unrelated to input delivery: at--fixed-timestep 100a 10 Hz step can tunnel collision. Measured onZeelich.LBA, the same 30 s forward hold floats 5001 units at 16 but 22792 at 100 (the hero clips terrain a fine step would have stopped at). So a sustained-hold regression must use an open-air fixture, and this divergence is issue #412 territory (interpolation / smaller effective step), not an input bug. - Record/replay (log latched sim-input per tick, replay at the same seam) and the render/sim interpolation that cures the stutter are the issue #412 follow-ons, deliberately out of scope.