#!/usr/bin/env python3 """ SHADES — Lane E screenshot capture. Python stdlib only, same house rule as server.py: no pip, no venv, no build step. WHY THIS EXISTS --------------- There is no way to get a game screenshot onto disk. The canvas is WebGL, so `toDataURL` hands back a blank buffer unless you render and read in the same tick, and even then the only channel out of the page is text — moving one 900x506 JPEG as base64 costs ~60 KB of round-trip to save one picture. DESIGN.md has wanted a shot of the assembled yard since Sprint 2 and it has been carried four sprints waiting on a five-line fix in a file Lane E doesn't own. So this serves the repo exactly like server.py, and additionally accepts `POST /shot?name=`, writing the raw body to docs/.png. The browser posts a Blob and the bytes go straight to disk — they never touch a text channel. python3 tools/yardshot/shot_server.py --port 8815 then, from the page: SHADES.render(); // same tick as the read! document.getElementById('c').toBlob( (b) => fetch('/shot?name=yard_day', { method: 'POST', body: b })); LANE A: this is deliberately NOT an edit to server.py — that's your file, and §6 says post the need rather than reach into it. I posted it twice; nobody had the spare hands, which is fair. `do_POST` below is the whole fix: lift it verbatim into server.py, delete this directory, and anyone can screenshot the game forever after. Until then this stays a Lane E tool and touches nothing. Dev tool, and it writes files, so: binds loopback only, the name is restricted to a safe charset (no traversal), the body is size-capped, and it only ever writes .png into docs/. """ from __future__ import annotations import argparse import http.server import re from pathlib import Path from urllib.parse import parse_qs, urlparse ROOT = Path(__file__).resolve().parents[2] OUT_DIR = ROOT / "docs" SAFE_NAME = re.compile(r"^[a-z0-9_\-]{1,64}$") MAX_BYTES = 20 * 1024 * 1024 class Handler(http.server.SimpleHTTPRequestHandler): def __init__(self, *args, **kwargs): super().__init__(*args, directory=str(ROOT), **kwargs) def do_POST(self): # noqa: N802 (stdlib naming) url = urlparse(self.path) if url.path != "/shot": self.send_error(404, "only POST /shot") return name = (parse_qs(url.query).get("name") or ["shot"])[0] if not SAFE_NAME.match(name): self.send_error(400, "name must match [a-z0-9_-]{1,64}") return length = int(self.headers.get("Content-Length") or 0) if not 0 < length <= MAX_BYTES: self.send_error(413, "empty or too large") return data = self.rfile.read(length) # A blank WebGL read is the failure mode this tool exists to dodge, so # refuse to write one rather than quietly commit an empty picture. if data.startswith(b"\x89PNG"): ext = "png" elif data.startswith(b"\xff\xd8\xff"): ext = "jpg" else: self.send_error(415, "body is neither PNG nor JPEG") return OUT_DIR.mkdir(exist_ok=True) out = OUT_DIR / f"{name}.{ext}" out.write_bytes(data) print(f" shot -> {out.relative_to(ROOT)} ({len(data) // 1024} KB)") self.send_response(200) self.send_header("Content-Type", "text/plain") self.send_header("Content-Length", "2") self.end_headers() self.wfile.write(b"ok") def log_message(self, fmt, *args): pass # the shot line above is the only output worth having def main(): ap = argparse.ArgumentParser(description="Serve the repo + accept POST /shot") ap.add_argument("--port", type=int, default=8815) args = ap.parse_args() srv = http.server.ThreadingHTTPServer(("127.0.0.1", args.port), Handler) print(f"serving {ROOT} on http://127.0.0.1:{args.port}/") print(f" game http://127.0.0.1:{args.port}/web/world/index.html") print(f" shots -> {OUT_DIR}") srv.serve_forever() if __name__ == "__main__": main()