Headless Timer epoch uses wall clock (runtime), not simulation_time — first fire under step() is nondeterministic #383
Labels
No labels
Alpha Release Requirement
Bugfix
Demo Target
Documentation
Major Feature
Minor Feature
priority:tier1-active
priority:tier2-foundation
priority:tier3-future
priority:tier4-deferred
Refactoring & Cleanup
system:animation
system:documentation
system:grid
system:input
system:performance
system:procgen
system:python-binding
system:rendering
system:ui-hierarchy
Tiny Feature
workflow:blocked
workflow:needs-benchmark
workflow:needs-documentation
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
john/McRogueFace#383
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
In headless mode, a
mcrfpy.Timer's first fire undermcrfpy.step()is nondeterministic — it depends on process-startup wall-clock timing, not just the number/size ofstep()calls. This contradicts the documented headless contract ("timers fire only viastep(dt), one event per step") and makes headless timer tests occasionally flaky.Root cause
There are two clocks:
GameEngine::simulation_time(int ms) — the deterministic headless clock, advanced bystep(dt)(GameEngine.cpp:905).GameEngine::runtime— ansf::Clock, i.e. wall time (GameEngine.h:270).GameEngine::step()ticks timers againstsimulation_time:But
PyTimerstamps the timer's epoch (last_ran) from the wall clock at creation and at every control method (PyTimer.cpp:80,139,180,195,210,269,310):So a timer created at wall-time ≈45 ms has
last_ran ≈ 45, but the step loop measureselapsed = simulation_time - last_ranwithsimulation_timestarting near 0. The two clocks are in unrelated reference frames, so how manystep()calls occur before the first fire depends on the wall-clock reading at creation — which varies with process load/startup. After the first fire,last_ranis re-stamped and subsequent fires are simulation-driven (hence steady-state behaviour looks deterministic; only the first fire is contaminated).Reproduction
Surfaced by the #381 snippet-screenshot oracle. Timer-driven snippets
048_timer_controland191_ui_health_bar_4produced byte-identical PNGs across 30 isolated runs and most full runs, then flipped once in a full run — a rare first-fire race. Both areTimer-driven with a capture only a few steps in, so the first-fire timing lands right at the capture window.Fix
Epoch timers from the same clock
step()measures them against. A helper:replacing the 8
runtime.getElapsedTime().asMilliseconds()reads inPyTimer.cpp. Windowed mode is unchanged; headless becomes fully deterministic (a timer created before anystep()getslast_ran = 0, and the firststep(0.016)fires a 16 ms timer exactly as expected).Needs a regression test: create a timer,
step()a fixed number of times, assert an exact fire count — reproducibly.Impact
Blocks the #381 screenshot oracle's reliability (false-positive diffs), and more broadly means existing headless timer tests can flake on first-fire timing.
getSimulationTime()(GameEngine.h:334) is already public, so the fix is self-contained.