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:
commit
e8b6ff25cc
14 changed files with 496 additions and 29 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/
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
44
Makefile
44
Makefile
|
|
@ -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
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
98
tests/snippets/_screenshot.py
Normal file
98
tests/snippets/_screenshot.py
Normal 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
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)
|
||||
183
tools/generate_snippet_shots.py
Normal file
183
tools/generate_snippet_shots.py
Normal 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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue