[Bugfix] Headless mcrfpy.step() bypasses updatePythonScenes() — scene.update() and lifecycle callbacks never fire in headless tests #350

Closed
opened 2026-07-11 03:02:18 +00:00 by john · 0 comments
Owner

Summary

In headless mode, mcrfpy.step() drives GameEngine::step(), which advances animations and timers only. It does not call McRFPy_API::updatePythonScenes(). That call lives exclusively in GameEngine::doFrame() (the windowed/run() frame path). As a result, in headless --exec scripts that drive time with mcrfpy.step():

  • Scene.update(self, dt) overrides are never invoked
  • (same code path) the per-frame Python scene update hook is entirely skipped

This was discovered while benchmarking #343 (which optimizes PyScene::update's per-frame GetAttrString("update")): the step()-only benchmark couldn't see the fix at all, because call_update never runs under step(). The #343 win had to be measured on the real doFrame() loop instead (4,559 → 3 Ir/frame).

Why it matters

  • Testability gap: headless tests (the CI path) cannot exercise Scene.update() logic. Any game that puts per-frame logic in update() is untestable headless.
  • Surprising asymmetry: step() runs animations + timers but silently omits scene update, so a scene behaves differently under step() vs. a real frame.

Code references

  • src/GameEngine.cppGameEngine::step() advances AnimationManager + timers, no updatePythonScenes().
  • src/GameEngine.cppGameEngine::doFrame() wraps McRFPy_API::updatePythonScenes(frameTime) in a ScopedTimer (this is the only caller).
  • src/PySceneObject.cppMcRFPy_API::updatePythonScenes()PySceneClass::call_update().

Proposed fix

Have GameEngine::step() call McRFPy_API::updatePythonScenes(actual_dt) on the active scene (guarded by the same actual_dt sanity check used for animations), so headless step() faithfully mirrors a doFrame() tick for Python-visible state. Add a regression test: a Scene subclass whose update() increments a counter, driven by N step() calls, asserting the counter advanced.

Open question: whether step() should also fire currentScene()->update() (the C++ scene) and other doFrame side effects, or strictly the Python scene update — needs a decision to keep step() semantics well-defined.

Context

Spun out of the #342–#348 perf sprint. The #343 fast-path guard (skip the per-frame update lookup for non-subclassed scenes) is orthogonal and already merged; this issue is about the missing headless invocation, not the optimization.

## Summary In headless mode, `mcrfpy.step()` drives `GameEngine::step()`, which advances **animations and timers only**. It does **not** call `McRFPy_API::updatePythonScenes()`. That call lives exclusively in `GameEngine::doFrame()` (the windowed/`run()` frame path). As a result, in headless `--exec` scripts that drive time with `mcrfpy.step()`: - `Scene.update(self, dt)` overrides are **never invoked** - (same code path) the per-frame Python scene update hook is entirely skipped This was discovered while benchmarking #343 (which optimizes `PyScene::update`'s per-frame `GetAttrString("update")`): the step()-only benchmark couldn't see the fix at all, because `call_update` never runs under `step()`. The #343 win had to be measured on the real `doFrame()` loop instead (4,559 → 3 Ir/frame). ## Why it matters - **Testability gap**: headless tests (the CI path) cannot exercise `Scene.update()` logic. Any game that puts per-frame logic in `update()` is untestable headless. - **Surprising asymmetry**: `step()` runs animations + timers but silently omits scene update, so a scene behaves differently under `step()` vs. a real frame. ## Code references - `src/GameEngine.cpp` — `GameEngine::step()` advances `AnimationManager` + timers, no `updatePythonScenes()`. - `src/GameEngine.cpp` — `GameEngine::doFrame()` wraps `McRFPy_API::updatePythonScenes(frameTime)` in a `ScopedTimer` (this is the only caller). - `src/PySceneObject.cpp` — `McRFPy_API::updatePythonScenes()` → `PySceneClass::call_update()`. ## Proposed fix Have `GameEngine::step()` call `McRFPy_API::updatePythonScenes(actual_dt)` on the active scene (guarded by the same `actual_dt` sanity check used for animations), so headless `step()` faithfully mirrors a `doFrame()` tick for Python-visible state. Add a regression test: a `Scene` subclass whose `update()` increments a counter, driven by N `step()` calls, asserting the counter advanced. Open question: whether `step()` should also fire `currentScene()->update()` (the C++ scene) and other `doFrame` side effects, or strictly the Python scene update — needs a decision to keep `step()` semantics well-defined. ## Context Spun out of the #342–#348 perf sprint. The #343 fast-path guard (skip the per-frame `update` lookup for non-subclassed scenes) is orthogonal and already merged; this issue is about the missing headless invocation, not the optimization.
john closed this issue 2026-07-14 12:01:03 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
john/McRogueFace#350
No description provided.