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:
parent
0afbab8a49
commit
cf9512e128
9 changed files with 282 additions and 5 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -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/
|
||||
|
||||
|
|
|
|||
8
Makefile
8
Makefile
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
74
tests/snippets/_screenshot.py
Normal file
74
tests/snippets/_screenshot.py
Normal 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
34
tests/snippets/_seed.py
Normal 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)
|
||||
160
tools/generate_snippet_shots.py
Normal file
160
tools/generate_snippet_shots.py
Normal 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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue