feat(docs): deterministic snippet screenshots — Phase 1 (STATIC capture)

Generate a byte-stable preview PNG for every docs snippet, as the visual-
regression oracle foundation of #381. A changed image means the snippet's
behaviour changed and wants review.

Pipeline (no C++ change; existing headless build suffices):
- _seed.py: chained as the FIRST --exec so random.seed(42) governs the
  snippet's RNG before it draws (snippets draw at import; seeding after is
  too late). Shared-interpreter seeding covers all 25 random-module snippets.
- _screenshot.py: chained as the LAST --exec; steps setup frames then
  automation.screenshot(). Kept separate from _harness.py so the pass/fail
  suite stays fast and untouched.
- tools/generate_snippet_shots.py: orchestrator; runs seed->snippet->shot,
  writes gitignored snippet-shots/ (images belong in the doc-site repo,
  which pulls them from here). Per-snippet capture params live in a sidecar
  OVERRIDES table, not the mcrf: header (which would fight stamp_snippets
  and can't hold Phase-3 interaction scripts).
- make snippet-shots target; snippet-shots/ gitignored.

The 4 BSP snippets that needed deterministic pixels (060, 102, 224, 243)
now pass seed=42 — libtcod's global RNG is time(0)-seeded and unreachable
from Python, and seed= also documents reproducible generation for readers.

Validated: 272 captured, 10 skipped (noshot opt-outs), 0 failed; all 272
byte-identical across two full runs (~49s).

Addresses #381.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LTh2ZW7bd3XSd9qK86Z2CE
This commit is contained in:
John McCardle 2026-07-15 20:57:04 -04:00
commit cf9512e128
9 changed files with 282 additions and 5 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

@ -139,6 +139,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:
@ -166,7 +172,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:

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,74 @@
#!/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,
which parses each snippet's `# mcrf:` header:
MCRF_SHOT_OUT output PNG path (required)
MCRF_SHOT_SETUP_STEPS step(0.016) calls before capture (default 3)
Phase 1 implements STATIC capture only: advance a few frames so timers/layout settle,
then screenshot. Animation target-time (`shot_at`) and scripted interaction (`action`)
are later phases; a snippet that needs them is not chained here yet.
"""
import os
import sys
import mcrfpy
from mcrfpy import automation
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")
setup_steps = int(os.environ.get("MCRF_SHOT_SETUP_STEPS", "3"))
# The snippet built its scene at import. Advance a few frames so anything that only
# resolves under the clock -- a first timer fire that fills a caption, a layout pass,
# an animation's opening frames -- has settled before we capture. The headless
# screenshot forces its own synchronous renderScene() (Automation.cpp #153), so a pure
# static scene would capture correctly with zero steps; the steps are for the snippets
# that need the clock, and cost ~nothing.
for _ in range(setup_steps):
try:
mcrfpy.step(0.016)
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,160 @@
#!/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 (Phase 1: STATIC only)
Every snippet defaults to a static capture: step a few frames, then screenshot. That is
correct for the ~180 snippets whose visual is fully drawn at load. The exceptions live
in OVERRIDES below:
* noshot=True -- no meaningful single frame (API/audio/text-only demos); skip.
* setup_steps=N -- needs more frames than default (e.g. a first timer fire that
fills a caption at t>=0.1s).
Animation target-time (shot_at) and scripted interaction (action) are later phases and
are not captured yet; snippets needing them get a default static frame 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
}
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))
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()