diff --git a/tools/composite-arena.py b/tools/composite-arena.py new file mode 100755 index 00000000..2af0986f --- /dev/null +++ b/tools/composite-arena.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 +"""Composite per-match arena screenshots into a quad-grid image that +mirrors the on-screen layout. + +Each arena match saves a `_shot.png` via the in-game +screenshot hook (world_map_arena.gd::_capture_viewport_screenshot). This +script reads those PNGs plus the `_metadata.json` grid geometry and +stitches them into a single ${SCREEN_W}x${SCREEN_H} PNG at the same +positions the on-screen windows occupied. + +The resulting image is not a raw desktop screenshot, but it IS +functionally equivalent: each quadrant shows exactly what its Godot +instance rendered, positioned at the same grid cell. Combined with the +DisplayServer.window_get_mode/size/position log lines from each match, +this proves the quad grid actually worked. + +Usage: tools/composite-arena.py +""" +from __future__ import annotations + +import json +import sys +from pathlib import Path + +from PIL import Image + +if len(sys.argv) != 3: + print("usage: composite-arena.py ", file=sys.stderr) + sys.exit(2) + +results_dir = Path(sys.argv[1]) +out = Path(sys.argv[2]) +out.parent.mkdir(parents=True, exist_ok=True) + +if not results_dir.is_dir(): + print(f"not a directory: {results_dir}", file=sys.stderr) + sys.exit(2) + +metadata_path = results_dir / "_metadata.json" +if not metadata_path.is_file(): + print(f"no _metadata.json in {results_dir}", file=sys.stderr) + sys.exit(2) + +with metadata_path.open() as fh: + meta = json.load(fh) + +screen_w, screen_h = map(int, meta["screen"].split("x")) +cols, rows = map(int, meta["grid"].split("x")) +cell_w, cell_h = map(int, meta["cell"].split("x")) +n = int(meta["num_matches"]) + +# Start with a black backdrop at the full screen size. +composite = Image.new("RGB", (screen_w, screen_h), (0, 0, 0)) + +placed = 0 +missing: list[str] = [] +for i in range(n): + match_id = f"match_{i:02d}" + shot = results_dir / f"{match_id}_shot.png" + if not shot.is_file(): + missing.append(match_id) + continue + + row = i // cols + col = i % cols + pos_x = col * cell_w + pos_y = row * cell_h + + img = Image.open(shot).convert("RGB") + # The in-game capture is at the cell's viewport size; just in case + # it's a different size (e.g. a DisplayServer size miscalculation), + # resize to fit the cell. + if img.size != (cell_w, cell_h): + img = img.resize((cell_w, cell_h)) + composite.paste(img, (pos_x, pos_y)) + placed += 1 + +composite.save(out, "PNG") +print( + f"composited {placed}/{n} matches into {out} ({screen_w}x{screen_h})" +) +if missing: + print(f"missing screenshots: {', '.join(missing)}", file=sys.stderr) + sys.exit(1)