harness.pyΒΆ

Part of Clear Code Zelda.

 1#!/usr/bin/env python3
 2"""Scripted input harness for the Zelda port: 15 stages, one screenshot each.
 3
 4Walks, slashes grass, attacks enemies, swaps weapon and spell, casts, and
 5drives the upgrade screen, so a manual review is a matter of flipping through
 6the captures.
 7
 8Run from the repo root:
 9    uv run python examples/ports/clear_code_zelda/harness.py
10"""
11
12from __future__ import annotations
13
14import sys
15from collections.abc import Callable
16from pathlib import Path
17
18_PORT_DIR = Path(__file__).resolve().parent
19if str(_PORT_DIR) not in sys.path:
20    sys.path.insert(0, str(_PORT_DIR))
21
22from main import ZeldaRoot  # noqa: E402
23from settings import HEIGHT, WIDTH  # noqa: E402
24
25from simvx.core import Key, Node  # noqa: E402
26from simvx.core.testing import InputSimulator  # noqa: E402
27from simvx.graphics import App, save_png  # noqa: E402
28
29
30def harness():
31    out = _PORT_DIR / "screenshots"
32    out.mkdir(exist_ok=True)
33
34    app = App(width=WIDTH, height=HEIGHT, title="Zelda harness", visible=False)
35    sim = InputSimulator()
36    root = ZeldaRoot(show_menu=False)
37
38    stages = [
39        # name, frames-to-advance, prep callback
40        ("01_initial", 30, lambda: None),
41        ("02_walk_down", 60, lambda: sim.press_key(Key.S)),
42        ("03_release_walk", 5, lambda: sim.release_key(Key.S)),
43        ("04_slash_grass", 20, lambda: sim.tap_key(Key.SPACE)),
44        ("05_attack_enemy", 5, lambda: sim.tap_key(Key.SPACE)),  # capture mid-swing
45        ("05b_attack_settle", 25, lambda: None),
46        ("06_attack_enemy_2", 5, lambda: sim.tap_key(Key.SPACE)),
47        ("06b_attack_settle", 25, lambda: None),
48        ("07_swap_weapon", 20, lambda: sim.tap_key(Key.Q)),
49        ("08_swap_magic", 20, lambda: sim.tap_key(Key.E)),
50        ("09_cast_heal", 30, lambda: sim.tap_key(Key.LEFT_CONTROL)),
51        ("10_open_upgrade", 20, lambda: sim.tap_key(Key.M)),
52        ("11_upgrade_right", 25, lambda: sim.tap_key(Key.D)),
53        ("12_upgrade_select", 25, lambda: sim.tap_key(Key.SPACE)),
54        ("13_close_upgrade", 20, lambda: sim.tap_key(Key.M)),
55    ]
56
57    # First frame of each stage runs that stage's prep; the capture lands on the
58    # last frame of the stage, so the stage's effects are still on screen.
59    stage_starts: dict[int, Callable[[], None]] = {}
60    target_frames = []
61    cumulative = 0
62    for _name, count, prep in stages:
63        stage_starts[cumulative] = prep
64        cumulative += count
65        target_frames.append(cumulative - 1)
66
67    # A few buffer frames so the very last capture lands cleanly.
68    total = cumulative + 5
69    frame_counter = {"n": 0}
70
71    class HarnessDriver(Node):
72        """Fires each stage's prep on the first frame of that stage."""
73
74        def on_update(self, dt):
75            # `tap_key` queues its release for the next frame boundary, and this
76            # loop is not SceneRunner, so the queue has to be drained by hand:
77            # a key left held never produces another just-pressed edge.
78            InputSimulator.flush_pending_releases()
79            prep = stage_starts.get(frame_counter["n"])
80            if prep is not None:
81                prep()
82            frame_counter["n"] += 1
83
84    root.add_child(HarnessDriver())
85
86    frames = app.run_headless(root, frames=total, capture_frames=target_frames)
87    for (name, _count, _prep), frame in zip(stages, frames, strict=False):
88        path = out / f"harness_{name}.png"
89        save_png(frame, str(path))
90        print(f"saved {path}")
91
92
93if __name__ == "__main__":
94    harness()