Merge feat/381-snippet-shots: snippet-screenshot oracle, #url= playground loader, IDBFS fix

Snippet-screenshot oracle (#381 P1+P2), headless timer determinism (#383),
wasm serve/ship fix (#384), #url= raw-file playground loader (#382), and the
Emscripten 5 IDBFS export fix (#385).

closes #382
closes #383
closes #384
closes #385
This commit is contained in:
John McCardle 2026-07-16 00:42:41 -04:00
commit e8b6ff25cc
14 changed files with 496 additions and 29 deletions

3
.gitignore vendored
View file

@ -19,6 +19,9 @@ my_games
# images are produced by many tests
*.png
# generated snippet preview screenshots (#381); consumed/committed by the doc-site repo
snippet-shots/
# WASM stdlib for Emscripten build
!wasm_stdlib/

View file

@ -435,7 +435,7 @@ if(EMSCRIPTEN)
-sUSE_SQLITE3=1
-sALLOW_MEMORY_GROWTH=1
-sSTACK_SIZE=2097152
-sEXPORTED_RUNTIME_METHODS=ccall,cwrap,FS
-sEXPORTED_RUNTIME_METHODS=ccall,cwrap,FS,IDBFS
-sEXPORTED_FUNCTIONS=_main,_run_python_string,_run_python_string_with_output,_reset_python_environment,_notify_canvas_resize,_sync_storage
-lidbfs.js
-sASSERTIONS=2

View file

@ -12,11 +12,12 @@
# make callgrind SCRIPT=tests/benchmarks/foo.py - Callgrind a headless benchmark
#
# WebAssembly / Emscripten:
# make wasm - Build full game for web (requires emsdk activated)
# make wasm-game - Build game for web with fullscreen canvas (no REPL)
# make wasm - Build full game for web WITH the REPL console (dev/debug build)
# make wasm-game - Build the clean full game for web (fullscreen, no REPL) -- shipped
# make playground - Build minimal playground for web REPL
# make serve - Serve wasm build locally on port 8080
# make serve-game - Serve wasm-game build locally on port 8080
# make serve - Serve the clean game (build-wasm-game) locally on port 8080
# make serve-game - Serve the clean game (build-wasm-game) locally on port 8080
# make serve-dev - Serve the wasm dev build with REPL console (build-emscripten)
# make clean-wasm - Clean Emscripten builds
#
# Packaging:
@ -31,7 +32,7 @@
# Tags HEAD with current version, builds all packages, bumps to NEXT_VERSION
.PHONY: all linux windows windows-debug clean clean-windows clean-dist run
.PHONY: wasm wasm-game wasm-debug playground playground-debug serve serve-game serve-playground clean-wasm
.PHONY: wasm wasm-game wasm-debug playground playground-debug serve serve-game serve-dev serve-playground clean-wasm
.PHONY: package-windows-light package-windows-full package-linux-light package-linux-full package-all
.PHONY: version-bump
.PHONY: debug debug-test asan asan-test tsan tsan-test valgrind-test massif-test analyze clean-debug
@ -139,6 +140,12 @@ stamp-snippets: linux
check-snippets: linux
@python3 tools/stamp_snippets.py --check
# Render a deterministic preview PNG for every snippet into snippet-shots/ (gitignored;
# the images belong in the doc-site repo, which pulls them from here -- see #381). The
# images are a visual-regression oracle: a changed PNG means behaviour changed.
snippet-shots: linux
@python3 tools/generate_snippet_shots.py
# What changed in the API since the last release, and which site pages that
# obligates you to update (resolved via each page's `mcrf.objects` frontmatter).
api-delta:
@ -151,7 +158,7 @@ api-delta:
#
# Deliberately NOT automatic: it does not commit, and it does not touch the
# hand-written pages. It tells you what it found and leaves the judgment to you.
release-docs: docs stamp-snippets
release-docs: docs stamp-snippets snippet-shots
@echo ""
@echo "=== Regenerating the site against this engine (SITE_DIR=$(SITE_DIR)) ==="
@test -d "$(SITE_DIR)" || { echo "SITE_DIR not found: $(SITE_DIR)"; exit 1; }
@ -166,7 +173,7 @@ release-docs: docs stamp-snippets
@echo " then commit both repos. For a Gitea checklist issue instead, run:"
@echo " python3 tools/api_delta.py $(BASE_REF) . --site-dir $(SITE_DIR) --format gitea"
.PHONY: docs stamp-snippets check-snippets api-delta release-docs
.PHONY: docs stamp-snippets check-snippets snippet-shots api-delta release-docs
# Debug and sanitizer targets
debug:
@ -393,8 +400,8 @@ wasm:
fi
@echo "Building McRogueFace for WebAssembly..."
@emmake make -C build-emscripten -j$(JOBS)
@echo "WebAssembly build complete! Files in build-emscripten/"
@echo "Run 'make serve' to test locally"
@echo "WebAssembly dev build complete (game + REPL console)! Files in build-emscripten/"
@echo "Run 'make serve-dev' to test locally. For the clean shipped game, use 'make wasm-game' + 'make serve'."
playground:
@if ! command -v emcmake >/dev/null 2>&1; then \
@ -415,7 +422,14 @@ playground:
@echo "Run 'make serve-playground' to test locally"
serve:
@echo "Serving WebAssembly build at http://localhost:8080"
@echo "Serving the clean game build (build-wasm-game) at http://localhost:8080"
@echo "(Run 'make wasm-game' first if it is not built. For the REPL dev build, use 'make serve-dev'.)"
@echo "Press Ctrl+C to stop"
@cd build-wasm-game && python3 -m http.server 8080
serve-dev:
@echo "Serving the wasm DEV build with REPL console (build-emscripten) at http://localhost:8080"
@echo "(Run 'make wasm' first if it is not built.)"
@echo "Press Ctrl+C to stop"
@cd build-emscripten && python3 -m http.server 8080
@ -439,8 +453,8 @@ wasm-game:
fi
@echo "Building McRogueFace game for WebAssembly..."
@emmake make -C build-wasm-game -j$(JOBS)
@echo "Game build complete! Files in build-wasm-game/"
@echo "Run 'make serve-game' to test locally"
@echo "Clean game build complete (no REPL console)! Files in build-wasm-game/"
@echo "Run 'make serve' (or 'make serve-game') to test locally"
serve-game:
@echo "Serving game build at http://localhost:8080"
@ -557,10 +571,10 @@ endif
fi
$(MAKE) package-linux-full
$(MAKE) package-windows-full
$(MAKE) wasm
@echo "Packaging WASM build..."
$(MAKE) wasm-game
@echo "Packaging WASM build (clean game, no REPL console)..."
@mkdir -p dist
cd build-emscripten && zip -r ../dist/McRogueFace-$(CURRENT_VERSION)-WASM.zip \
cd build-wasm-game && zip -r ../dist/McRogueFace-$(CURRENT_VERSION)-WASM.zip \
mcrogueface.html mcrogueface.js mcrogueface.wasm mcrogueface.data
@echo ""
@echo "Bumping version: $(CURRENT_VERSION) -> $(NEXT_VERSION)"

File diff suppressed because one or more lines are too long

View file

@ -6,6 +6,20 @@
#include "McRFPy_Doc.h"
#include <sstream>
// #383: A timer's epoch must come from the SAME clock that ticks it. In headless mode
// that is GameEngine::simulation_time -- the deterministic clock step() advances
// explicitly -- NOT runtime, which is an sf::Clock reading wall time. step() tests
// timers against simulation_time; if a timer stamps its epoch from wall time, the two
// clocks are in unrelated frames and the first fire depends on process-startup timing,
// making headless replay nondeterministic. Windowed mode has no step() and keeps using
// runtime.
static int timer_now() {
if (!Resources::game) return 0;
return Resources::game->isHeadless()
? Resources::game->getSimulationTime()
: Resources::game->runtime.getElapsedTime().asMilliseconds();
}
PyObject* PyTimer::repr(PyObject* self) {
PyTimerObject* timer = (PyTimerObject*)self;
std::ostringstream oss;
@ -23,7 +37,7 @@ PyObject* PyTimer::repr(PyObject* self) {
// Get current time to show remaining
int current_time = 0;
if (Resources::game) {
current_time = Resources::game->runtime.getElapsedTime().asMilliseconds();
current_time = timer_now();
}
oss << " (remaining=" << timer->data->getRemaining(current_time) << "ms)";
} else if (timer->data->isActive()) {
@ -77,7 +91,7 @@ int PyTimer::init(PyTimerObject* self, PyObject* args, PyObject* kwds) {
// Get current time from game engine
int current_time = 0;
if (Resources::game) {
current_time = Resources::game->runtime.getElapsedTime().asMilliseconds();
current_time = timer_now();
}
// Create the timer with start parameter and name (#180)
@ -136,7 +150,7 @@ PyObject* PyTimer::start(PyTimerObject* self, PyObject* Py_UNUSED(ignored)) {
int current_time = 0;
if (Resources::game) {
current_time = Resources::game->runtime.getElapsedTime().asMilliseconds();
current_time = timer_now();
// If another timer has this name, stop it first
auto it = Resources::game->timers.find(self->name);
@ -177,7 +191,7 @@ PyObject* PyTimer::pause(PyTimerObject* self, PyObject* Py_UNUSED(ignored)) {
int current_time = 0;
if (Resources::game) {
current_time = Resources::game->runtime.getElapsedTime().asMilliseconds();
current_time = timer_now();
}
self->data->pause(current_time);
@ -192,7 +206,7 @@ PyObject* PyTimer::resume(PyTimerObject* self, PyObject* Py_UNUSED(ignored)) {
int current_time = 0;
if (Resources::game) {
current_time = Resources::game->runtime.getElapsedTime().asMilliseconds();
current_time = timer_now();
}
self->data->resume(current_time);
@ -207,7 +221,7 @@ PyObject* PyTimer::restart(PyTimerObject* self, PyObject* Py_UNUSED(ignored)) {
int current_time = 0;
if (Resources::game) {
current_time = Resources::game->runtime.getElapsedTime().asMilliseconds();
current_time = timer_now();
// Ensure timer is in engine map
auto it = Resources::game->timers.find(self->name);
@ -266,7 +280,7 @@ PyObject* PyTimer::get_remaining(PyTimerObject* self, void* closure) {
int current_time = 0;
if (Resources::game) {
current_time = Resources::game->runtime.getElapsedTime().asMilliseconds();
current_time = timer_now();
}
return PyLong_FromLong(self->data->getRemaining(current_time));
@ -307,7 +321,7 @@ int PyTimer::set_active(PyTimerObject* self, PyObject* value, void* closure) {
int current_time = 0;
if (Resources::game) {
current_time = Resources::game->runtime.getElapsedTime().asMilliseconds();
current_time = timer_now();
}
if (want_active) {

View file

@ -488,9 +488,41 @@
return { type: 'gist', gistId: gistParam, file: file };
}
// Handle #url=<encoded raw file url>. Used for snippets whose
// inline #src= fragment would be impractically long: the docs site
// links to the raw file at a pinned commit and we fetch it here.
if (params.has('url')) {
return { type: 'url', url: params.get('url') };
}
return null;
}
// Only these hosts may be fetched by #url=. Keeping this strict stops
// the playground from being turned into a generic cross-origin fetch
// proxy; raw.githubusercontent.com serves `Access-Control-Allow-Origin: *`.
const ALLOWED_FETCH_HOSTS = ['raw.githubusercontent.com'];
async function loadRawUrl(rawUrl) {
let u;
try {
u = new URL(rawUrl);
} catch (e) {
throw new Error(`Malformed url: ${rawUrl}`);
}
if (u.protocol !== 'https:' ||
!ALLOWED_FETCH_HOSTS.includes(u.hostname)) {
throw new Error(
`Refusing to fetch from ${u.hostname || rawUrl}; only ` +
`${ALLOWED_FETCH_HOSTS.join(', ')} is allowed.`);
}
const resp = await fetch(u.href);
if (!resp.ok) {
throw new Error(`Fetch failed (${resp.status}) for ${u.href}`);
}
return { code: await resp.text(), url: u.href };
}
async function loadGist(gistId, preferredFile) {
const id = gistId.includes('/') ? gistId.split('/').pop() : gistId;
@ -664,6 +696,19 @@
} catch (e) {
editor.setValue(`# Failed to load gist: ${e.message}\n# Check the console for details.`);
}
} else if (source.type === 'url') {
editor.setValue(`# Loading ${source.url} ...`);
try {
const loaded = await loadRawUrl(source.url);
editor.setValue(loaded.code);
const name = loaded.url.split('/').pop();
sourceIndicator.innerHTML = `From: <a href="${loaded.url}" target="_blank">${name}</a>`;
sourceIndicator.classList.remove('hidden');
} catch (e) {
editor.setValue(`# Failed to load ${source.url}: ${e.message}\n# Check the console for details.`);
}
}
}
}

View file

@ -0,0 +1,76 @@
#!/usr/bin/env python3
"""
Regression test for #383: headless Timer epoch must come from simulation_time, not the
wall clock (runtime).
Before the fix, PyTimer stamped a timer's epoch from GameEngine::runtime (an sf::Clock,
wall time) while step() ticked timers against simulation_time. The two clocks are in
unrelated frames, so a timer's FIRST fire under step() depended on process-startup wall
timing -- headless replay was nondeterministic (it surfaced as rare byte-diffs in the
#381 snippet screenshots).
This test pins the deterministic contract: given a fixed sequence of step() calls, the
fire count and the exact callback runtimes are a pure function of simulation time.
Note simulation_time is a single clock that ACCUMULATES across the whole process -- it
does not reset between timers -- so expectations are tracked relative to a running clock
that mirrors the engine's `simulation_time += int(dt*1000)`.
Run: ./build/mcrogueface --headless --exec tests/regression/issue_383_..._test.py
"""
import sys
import mcrfpy
def fail(msg):
print(f"FAIL: {msg}")
sys.exit(1)
sim_ms = 0 # mirrors GameEngine::simulation_time
def step_ms(ms):
"""Advance the sim by an exact number of milliseconds, tracking the clock."""
global sim_ms
mcrfpy.step(ms / 1000.0)
sim_ms += ms
# --- Case 1: fires land exactly on simulation-time interval boundaries ----------------
# interval 100ms, stepped 50ms at a time for 400ms -> boundaries at epoch+100..epoch+400.
epoch1 = sim_ms
fires = []
mcrfpy.Timer("case1", lambda t, rt: fires.append(rt), 100)
for _ in range(8):
step_ms(50)
expected1 = [epoch1 + 100 * i for i in range(1, 5)]
if fires != expected1:
fail(f"case1: expected fires at {expected1}, got {fires}")
# --- Case 2: the FIRST fire is deterministic (the exact bug #383 exposed) -------------
# A 16ms timer created before its first step has epoch = current simulation_time, so the
# very next 16ms step fires it. Ten steps -> ten fires, one interval apart. Under the old
# wall-clock epoch the first fire could slip a step depending on startup timing.
epoch2 = sim_ms
fires2 = []
mcrfpy.Timer("case2", lambda t, rt: fires2.append(rt), 16)
for _ in range(10):
step_ms(16)
expected2 = [epoch2 + 16 * i for i in range(1, 11)]
if fires2 != expected2:
fail(f"case2: expected first-fire-deterministic {expected2}, got {fires2}")
# --- Case 3: one-shot timer fires exactly once and stops ------------------------------
epoch3 = sim_ms
fires3 = []
mcrfpy.Timer("case3", lambda t, rt: fires3.append(rt), 50, once=True)
for _ in range(6):
step_ms(50) # 300ms of stepping, well past the single 50ms fire
if fires3 != [epoch3 + 50]:
fail(f"case3: one-shot expected [{epoch3 + 50}], got {fires3}")
print("PASS: headless timer fires are deterministic on simulation_time (#383)")
sys.exit(0)

View file

@ -26,7 +26,7 @@ for y in range(12):
# Create BSP tree (pos and size as tuples)
bsp = mcrfpy.BSP(pos=(0, 0), size=(16, 12))
bsp.split_recursive(depth=3, min_size=(3, 3))
bsp.split_recursive(depth=3, min_size=(3, 3), seed=42)
# Room colors
colors = [

View file

@ -9,7 +9,7 @@ scene.children.append(mcrfpy.Frame(pos=(0, 0), size=(1024, 768), fill_color=mcrf
# Create BSP tree
bsp = mcrfpy.BSP(pos=(0, 0), size=(16, 12))
bsp.split_recursive(depth=3, min_size=(3, 3))
bsp.split_recursive(depth=3, min_size=(3, 3), seed=42)
# Visualize in a grid
grid = mcrfpy.Grid(

View file

@ -17,7 +17,7 @@ scene.children.append(grid)
bsp = mcrfpy.BSP(pos=(0, 0), size=(80, 50))
# Split recursively into rooms
bsp.split_recursive(depth=4, min_size=(8, 8))
bsp.split_recursive(depth=4, min_size=(8, 8), seed=42)
# Iterate over leaf nodes (rooms)
room_layer = mcrfpy.ColorLayer(z_index=1, name="rooms")

View file

@ -23,7 +23,7 @@ hmap.multiply(noise_map) # Apply as mask
# Convert BSP to heightmap
bsp = mcrfpy.BSP(pos=(0, 0), size=(20, 20))
bsp.split_recursive(depth=3, min_size=(4, 4))
bsp.split_recursive(depth=3, min_size=(4, 4), seed=42)
rooms = bsp.to_heightmap(select='leaves', shrink=1)
hmap.add(rooms)

View file

@ -0,0 +1,98 @@
#!/usr/bin/env python3
"""
Screenshot harness -- chained as the LAST --exec, AFTER a documentation snippet, to
capture a rendered preview of the scene the snippet built:
./mcrogueface --headless \
--exec tests/snippets/_seed.py \
--exec tests/snippets/001_hello_frame.py \
--exec tests/snippets/_screenshot.py
This is the screenshot-mode counterpart to _harness.py. The normal test suite chains
_harness.py (pass/fail: does the snippet run and leave a populated scene?). Screenshot
mode chains THIS instead, so the pass/fail gate stays fast and untouched by capture.
The snippet has already run (it is an earlier --exec, sharing this interpreter and
engine state), so its scene is live and inspectable here.
Per-snippet parameters arrive by environment, set by tools/generate_snippet_shots.py
from its OVERRIDES table:
MCRF_SHOT_OUT output PNG path (required)
MCRF_SHOT_SETUP_STEPS static-capture frames, step(0.016) each (default 3)
MCRF_SHOT_AT explicit capture time in seconds (overrides auto-derivation)
MCRF_SHOT_FRACTION fraction of an animation's duration to capture at (default 0.6)
CAPTURE TIME. When to grab the frame is decided in this order:
1. MCRF_SHOT_AT set -> step to exactly that sim time. Used for effects the
animation list can't see -- timer-driven pulses/flashes
(they're Timers, not .animate() calls) and fades whose
60%-of-duration frame is nearly blank.
2. active .animate() found -> step to FRACTION * (longest active animation's duration),
so the capture lands mid-effect, not on the opening frame.
Fully automatic: the engine reports each animation's
duration via mcrfpy.animations (#381 Phase 2).
3. otherwise (STATIC) -> step MCRF_SHOT_SETUP_STEPS frames and capture.
Scripted interaction (`action`) is a later phase; a snippet needing it gets whichever of
the above applies for now.
"""
import os
import sys
import mcrfpy
from mcrfpy import automation
DT = 0.016
FAIL = "SHOT_FAIL"
OK = "SHOT_OK"
def fail(msg):
print(f"{FAIL}: {msg}")
sys.exit(1)
out = os.environ.get("MCRF_SHOT_OUT")
if not out:
fail("MCRF_SHOT_OUT not set")
# Decide how many frames to advance before capturing (see the module docstring). This
# must be computed BEFORE stepping: mcrfpy.animations reports full durations only while
# elapsed is still ~0, i.e. before the clock has run.
def resolve_steps():
at = os.environ.get("MCRF_SHOT_AT")
if at is not None:
return max(1, round(float(at) / DT)) # 1. explicit time
durations = [a.duration for a in mcrfpy.animations if not a.is_complete]
if durations:
fraction = float(os.environ.get("MCRF_SHOT_FRACTION", "0.6"))
return max(1, round(fraction * max(durations) / DT)) # 2. mid-animation
return int(os.environ.get("MCRF_SHOT_SETUP_STEPS", "3")) # 3. static
# The snippet built its scene at import. Advance the resolved number of frames so the
# capture lands where we want it -- a first timer fire that fills a caption, a layout
# pass, or mid-animation. The headless screenshot forces its own synchronous
# renderScene() (Automation.cpp #153), so a pure static scene captures correctly even
# at the 3-step floor; the steps are for whatever needs the clock.
for _ in range(resolve_steps()):
try:
mcrfpy.step(DT)
except Exception as exc: # noqa: BLE001 -- a raise under the clock is a real failure
import traceback
traceback.print_exc()
fail(f"step() raised {type(exc).__name__}: {exc}")
scene = mcrfpy.current_scene
if scene is None:
fail("snippet left no active scene (mcrfpy.current_scene is None)")
if len(scene.children) == 0:
fail(f"snippet activated scene {scene.name!r} but added nothing to it")
if automation.screenshot(out) is not True:
fail(f"screenshot({out!r}) returned falsy -- capture failed")
print(f"{OK}: {out} scene={scene.name!r} children={len(scene.children)}")
sys.exit(0)

34
tests/snippets/_seed.py Normal file
View file

@ -0,0 +1,34 @@
#!/usr/bin/env python3
"""
Seed pre-script -- chained as the FIRST --exec, BEFORE a documentation snippet,
when generating deterministic screenshots:
./mcrogueface --headless \
--exec tests/snippets/_seed.py \
--exec tests/snippets/086_procgen_cellular.py \
--exec tests/snippets/_screenshot.py
Why before, not after: a snippet runs to completion at import -- it builds its scene
and draws its procedural content the moment it is exec'd. Anything that consumes
randomness (`random.random()` cave fills, `random.choice` scatter, ...) has already
drawn by the time the *next* --exec runs. So to make a snippet's pixels reproducible,
the interpreter's global RNG must be seeded BEFORE the snippet executes.
Chaining works because all --exec scripts share one interpreter: this script's
`import random; random.seed(N)` seeds the same module object the snippet later gets
back from its own `import random`. (Snippets that pass an explicit `seed=` to
NoiseSource / HeightMap / BSP are already deterministic and unaffected by this; those
generators use a separate TCOD RNG that `random.seed` does not touch -- see #381.)
This script is intentionally a no-op for the pass/fail suite; it is only chained by
tools/generate_snippet_shots.py.
"""
import os
import random
# Same seed for every snippet: a screenshot is a visual-regression ORACLE, so the only
# thing that should ever change a snippet's image is a change in the snippet or the
# engine -- never the RNG. Overridable for debugging a specific capture.
SEED = int(os.environ.get("MCRF_SEED", "42"))
random.seed(SEED)

View file

@ -0,0 +1,183 @@
#!/usr/bin/env python3
"""
Generate a rendered preview PNG for every documentation snippet.
Each snippet in tests/snippets/ is a display script that builds a scene (see
_harness.py). This tool captures what that scene looks like, so the docs site can show
the rendered result next to the code. The images are a VISUAL-REGRESSION ORACLE: they
are deterministic, so a changed image means the snippet's behaviour changed and wants
review -- see Gitea #381.
python3 tools/generate_snippet_shots.py # capture all
python3 tools/generate_snippet_shots.py 086 224 # capture a subset (by number)
For each snippet it runs a THREE-stage --exec chain in headless mode:
_seed.py -> <snippet>.py -> _screenshot.py
_seed.py seeds the interpreter RNG before the snippet draws (procedural snippets draw
at import); _screenshot.py advances a few frames and saves the PNG. Output lands in a
gitignored SHOT_DIR -- the images belong in the doc-site repo, not this one; the site
build pulls them from here.
CAPTURE MODEL
Every snippet defaults to a static capture: step a few frames, then screenshot -- correct
for the ~180 snippets whose visual is fully drawn at load. Snippets driven by .animate()
are handled automatically by _screenshot.py: it reads mcrfpy.animations and captures at
60% of the longest animation's duration, so no per-snippet entry is needed for those.
The remaining exceptions live in OVERRIDES below:
* noshot=True -- no meaningful single frame (API/audio/text-only demos); skip.
* setup_steps=N -- needs more static frames than default (e.g. a first timer fire that
fills a caption at t>=0.1s).
* shot_at=SECS -- explicit capture time, for effects the animation list can't see:
timer-driven pulses/flashes (Timers, not .animate()) and fades whose
60%-of-duration frame is nearly blank.
Scripted interaction (action) is a later phase; snippets needing it get whichever of the
above applies for now.
"""
import argparse
import os
import subprocess
import sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
SNIPPET_DIR = os.path.join(ROOT, "tests", "snippets")
BUILD_DIR = os.path.join(ROOT, "build")
MCROGUEFACE = os.path.join(BUILD_DIR, "mcrogueface")
SEED = os.path.join(SNIPPET_DIR, "_seed.py")
SCREENSHOT = os.path.join(SNIPPET_DIR, "_screenshot.py")
SHOT_DIR = os.path.join(ROOT, "snippet-shots")
DEFAULT_SETUP_STEPS = 3
TIMEOUT = 30
# Per-snippet capture overrides, keyed by the snippet's numeric prefix. Anything not
# listed here uses the STATIC default (setup_steps=3, capture). Grounded in the
# 4-agent taxonomy audit on #381.
OVERRIDES = {
# noshot: no meaningful single-frame visual (opt-out)
142: {"noshot": True}, # scene-switch API demo, one default frame
161: {"noshot": True}, # dijkstra heightmap: scalar value print
225: {"noshot": True}, # BSP traversal: status caption only
226: {"noshot": True}, # BSP adjacency: status caption only
251: {"noshot": True}, # mcrfpy.mouse state text
253: {"noshot": True}, # audio demo, no visual
254: {"noshot": True}, # NoiseSource numbers printed to a caption
255: {"noshot": True}, # NoiseSource size printed to a caption
265: {"noshot": True}, # BSP traversal: status caption only
268: {"noshot": True}, # Window API demo, calls window.screenshot()
# setup_steps: a first timer fire (>=0.1s) fills a caption that is otherwise
# placeholder text at the default 3 steps (~0.048s).
85: {"setup_steps": 12}, # entity_visibility status caption
91: {"setup_steps": 12}, # metrics_display labels
# shot_at: timer-driven effects (not in mcrfpy.animations, so auto-derivation can't
# see them) and fade-to-invisible animations whose 60% frame is nearly blank.
46: {"shot_at": 1.6}, # timer ticker -- past a few 500ms fires
47: {"shot_at": 2.3}, # timer once=True at 2000ms -- show the fired state
75: {"shot_at": 3.5}, # message log Timer 1000ms -- accumulate a few lines
79: {"shot_at": 0.5}, # pulse Timer 50ms -- a representative phase
129: {"shot_at": 1.0}, # Scene.update orbiter (not .animate) -- offset visible
132: {"shot_at": 0.75}, # color pulse peak
133: {"shot_at": 0.75},
134: {"shot_at": 0.75},
135: {"shot_at": 0.75},
136: {"shot_at": 0.75},
137: {"shot_at": 0.1}, # damage flash -- flash-on frame
138: {"shot_at": 0.1},
139: {"shot_at": 0.1},
227: {"shot_at": 0.4}, # caption fade-out (2.0s) -- early, before it vanishes
}
def snippet_paths():
names = sorted(
n for n in os.listdir(SNIPPET_DIR)
if n.endswith(".py") and not n.startswith("_")
)
return [os.path.join(SNIPPET_DIR, n) for n in names]
def number_of(name):
"""Leading integer of a snippet filename, or None."""
head = os.path.basename(name).split("_", 1)[0]
return int(head) if head.isdigit() else None
def capture(path, env_base):
"""Run one snippet through the seed->snippet->screenshot chain."""
num = number_of(path)
cfg = OVERRIDES.get(num, {})
if cfg.get("noshot"):
return "skip", "noshot"
base = os.path.basename(path)[:-3] # drop .py
out = os.path.join(SHOT_DIR, base + ".png")
env = env_base.copy()
env["MCRF_SHOT_OUT"] = out
env["MCRF_SHOT_SETUP_STEPS"] = str(cfg.get("setup_steps", DEFAULT_SETUP_STEPS))
if "shot_at" in cfg:
env["MCRF_SHOT_AT"] = str(cfg["shot_at"])
try:
proc = subprocess.run(
[MCROGUEFACE, "--headless",
"--exec", SEED, "--exec", path, "--exec", SCREENSHOT],
capture_output=True, text=True, timeout=TIMEOUT, cwd=BUILD_DIR, env=env,
)
except subprocess.TimeoutExpired:
return "fail", "timeout"
log = proc.stdout + proc.stderr
if "SHOT_OK" in log and os.path.exists(out):
return "ok", out
if "Traceback (most recent call last)" in log:
last = [l for l in log.strip().splitlines() if l.strip()]
return "fail", last[-1][:120] if last else "traceback"
if "SHOT_FAIL" in log:
line = next((l for l in log.splitlines() if "SHOT_FAIL" in l), "")
return "fail", line.split("SHOT_FAIL:", 1)[-1].strip()[:120]
if "SNIPPET_FAIL" in log:
return "fail", "snippet raised before capture"
return "fail", f"no SHOT_OK (exit {proc.returncode})"
def main():
ap = argparse.ArgumentParser(description="Generate snippet preview screenshots.")
ap.add_argument("only", nargs="*",
help="capture only these snippet numbers (e.g. 086 224)")
args = ap.parse_args()
if not os.path.exists(MCROGUEFACE):
sys.exit(f"engine not built: {MCROGUEFACE} (run `make` first)")
os.makedirs(SHOT_DIR, exist_ok=True)
env_base = os.environ.copy()
lib = os.path.join(BUILD_DIR, "lib")
env_base["LD_LIBRARY_PATH"] = os.pathsep.join(
p for p in (lib, env_base.get("LD_LIBRARY_PATH", "")) if p
)
wanted = {int(x) for x in args.only} if args.only else None
paths = [p for p in snippet_paths()
if wanted is None or number_of(p) in wanted]
captured, skipped, failed = 0, 0, []
for path in paths:
status, detail = capture(path, env_base)
name = os.path.basename(path)
if status == "ok":
captured += 1
elif status == "skip":
skipped += 1
else:
failed.append((name, detail))
print(f" FAIL {name}: {detail}")
print(f"\nsnippet-shots: {captured} captured, {skipped} skipped (noshot), "
f"{len(failed)} failed -> {SHOT_DIR}")
sys.exit(1 if failed else 0)
if __name__ == "__main__":
main()