acron.testing

Drivers for proving a game against its real engine — offscreen and in-window.

Three tiers of proof a headless suite cannot give, each grown out of a shipped game’s tools:

  • Offscreen app driving — boot the real app with no window, pin the clock, step frames, screenshot. The rules that make it behave: offscreen() must run BEFORE ShowBase (import time, run as a script, never from a live app); the clock must be non-real-time or the fixed-step loop under-runs (offscreen renders as fast as it can); anything the app drives per frame (a chase rig, an input poll) is overridden after its own update, not fought.

  • Frame-diff probing — consecutive rendered frames compared in RGB, the only detector for the render-blindness class (state-order flicker, winding, dead uniforms) that value-comparing tests cannot see.

  • Pointer/click injection — a REAL window, the pointer moved onto a control and the press injected through the window’s own input device (GraphicsWindowInputDevice.button_down). Synthesising at the OS level instead (Quartz CGEventPost) is silently dropped without an Accessibility grant — a false pass waiting to happen. Element offsets are already in WINDOW pixels; do not scale them by the dp ratio.

Checklist()

PASS/FAIL lines with a failure tally — the shape of a proving tool.

ClickDriver(base, *[, settle_frames])

Inject pointer moves and mouse presses through a real window.

Frame(width, height, data)

One rendered frame as 8-bit RGB, TOP row first (the drawing frame: pixel(x, y) with y down — a screenshot's RAM image is bottom row first, and the flip happens once, here).

Global Functions

after_call(obj, name: str, extra) → None

Wrap obj.name so extra() runs AFTER it, every call.

The way to drive through a layer that rewrites its outputs per frame (an input poll, a touch overlay): setting fields directly does not work — the layer overwrites them — so the override rides behind its own update.

fixed_clock(base, dt: float = 0.016666666666666666) → None

Pin the global clock to a fixed dt: offscreen renders as fast as it can, and a real-time clock makes a fixed-step loop under-run.

flicker_scan(base, frames: int = 40) → list[tuple[float, float]]

Step frames frames and compare each to the one before.

Returns per-pair (mean, pct) stats; the caller picks its threshold (mean |ΔRGB| > 1 on an 8-bit frame is the proven “flashing” bar for a static scene). Everything that legitimately animates — sun, grain, a chase rig — must be stood down first, or the probe measures the game instead of the renderer.

frame_rgb(win) → tuple

The window’s last-rendered frame: (texture, Frame).

gl_spam_frame(base, path)

Render ONE frame with the engine’s GL call stream spammed to path (glUseProgram, every glBindTexture, each program’s altered-uniform mask), then restore. Returns frame_rgb() of the frame, so the caller can keep only pairs that differ on screen.

Spam is on only while the frame renders — the screenshot’s own readback stays out of the stream.

memory_footprint() → int

The process’s physical memory footprint, in bytes — what a memory invariant should measure.

On macOS this is task_info(TASK_VM_INFO).phys_footprint (Activity Monitor’s “Memory”): resident pages the process is charged for. RSS is the wrong number there — pages a freed allocation left behind stay resident-but-reusable until the kernel wants them, so RSS barely moves when a scene is released even though every object is gone. Elsewhere the resident set from /proc/self/statm (Linux) or ru_maxrss (a peak, the last resort).

offscreen(size: str | None = None) → None

Ask for an offscreen window. MUST run before ShowBase — call it at import time in a tool script. size like "960x540".

pair_stats(a: Frame, b: Frame) → tuple[float, float]

(mean |ΔRGB|, % of pixels that differ) for two frames.

step(base, frames: int) → None

Advance the app frames frames (each one renders).