this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

screenshot and e2e

alice f4c012cf 81f6e812

+631 -29
+15 -1
AGENTS.md
··· 56 56 - CLI loads bundled default cart or a provided `.lua` path. 57 57 - Audio capture foundation: `cpal` input stream with mono downmix into a lock‑free ring buffer (8192 samples); CLI flags `--list-audio`, `--audio-device`, `--audio-vu`, `--audio-disable`; simple VU feedback prints peak dBFS once per second. 58 58 - FFT: 2k R2C (`realfft`) on tick thread; maintains raw/smoothed/normalized buffers with peak tracking; `--debug-fft` throttled print; Lua `fft/ffts/fftr/fftrs` wired with C-identical clamping/sum semantics; headless tests added; simple cart at `assets/fft_test.lua`. 59 + - Screenshots: CLI supports `--screenshot <path> [--screenshot-scale N] [--screenshot-frame N]` and `--headless` offscreen capture. In windowed mode, F12 saves to `./screenshots/scr-YYYYmmdd-HHMMSS.png` without exiting. 59 60 60 61 **Near-Term Backlog** 61 62 - FFT implementation (cpal + realfft) per `docs/specs/audio_fft_vqt.md` (see Implementation TODOs section); add headless tests and Lua `fft/ffts/fftr/fftrs`. ··· 99 100 - Added docs index at `docs/README.md`, architecture/testing pages, and ADRs. 100 101 - Added detailed testing docs (`docs/testing/strategy.md`) and a test catalog (`docs/testing/test_catalog.md`). 101 102 - 2025-08-27: 103 + - Screenshots completed: 104 + - CLI flags implemented: `--screenshot`, `--screenshot-scale`, `--screenshot-frame`, `--headless`. 105 + - Windowed one-off capture wired; exits after saving when path is provided. 106 + - F12 hotkey saves to `./screenshots/scr-YYYYmmdd-HHMMSS.png` (auto-creates directory). 107 + - Headless renderer: draws editor UI/code view or runs cart ticks offscreen, then saves. 108 + - Tests: added `tests/screenshot_smoke.rs` to verify scaled PNG dimensions; extended util image tests. 109 + - Hygiene verified: `cargo fmt`, `cargo clippy --all-targets --all-features -- -D warnings` clean; `cargo test` all green. 102 110 - Implemented audio capture with `cpal`: device selection/listing, mono downmix, ring buffer. 103 111 - Added CLI: `--list-audio`, `--audio-device`, `--audio-vu`, `--audio-disable`. 104 112 - Integrated a 1s VU peak readout for manual verification. ··· 116 124 - Memory map clarity: replaced magic numbers with named constants; documented screen nibble packing. 117 125 - Bit manipulation tests: added round-trip tests for 1/2/4/8‑bit ops, VRAM screen boundary, and unaligned 4‑bit sequences (nibble order). 118 126 - Lua runner quiet mode: added `--quiet` flag and a global switch; suppresses once-only init/missing TIC and TIC/BOOT error prints for headless runs. 127 + - Editor (Phase 0 + Phase 1 partial): 128 + - Added `--editor` flag to launch a framebuffer UI with TIC‑80‑style chrome and tabs; top‑bar buttons auto‑size and center labels. 129 + - CODE view: rope‑backed buffer (ropey), read‑only viewport rendering, gutter with line numbers, arrow‑key caret navigation with auto‑scroll. 130 + - Caret: TIC‑style red box with 1px drop shadow; glyph under caret drawn dark to simulate inversion; aligned to a true 6×8 cell grid (fixed‑width glyphs render only left 6 columns, advance 6 px). 131 + - Tests: editor smoke (draw + tab switch), code viewport rendering (gutter/text pixels). 132 + - Kept clippy/tests green. 119 133 - Verified hygiene: `cargo clippy --all-targets --all-features -D warnings` is clean; `cargo test` all green; ran `cargo fmt`. 120 134 - Tightened clippy setup (pedantic/nursery/cargo) with pragmatic allows: 121 135 - Crate-level allows for TIC-style APIs and numeric DSP: `many_single_char_names`, `too_many_arguments`, `similar_names`, selected numeric cast lints, and multiple crate versions. ··· 131 145 - Start here: `docs/README.md` 132 146 - Roadmap: `docs/roadmap/overview.md`, `docs/roadmap/gui_first.md` 133 147 - Specs: `docs/specs/memory_map.md`, `docs/specs/lua_api_parity.md`, `docs/specs/graphics.md`, `docs/specs/audio_fft_vqt.md` 134 - - Architecture: `docs/architecture/workspace.md`, `docs/architecture/runtime.md` 148 + - Architecture: `docs/architecture/workspace.md`, `docs/architecture/runtime.md`, `docs/architecture/screenshot_plan.md` 135 149 - Testing: `docs/testing/strategy.md`, `docs/testing/frame_hashes.md` 136 150 - ADRs: `docs/adr/0001-winit-pixels.md`, `docs/adr/0002-mlua-lua54-compat.md` (superseded), `docs/adr/0003-lua53-with-compat.md`
+16
docs/README.md
··· 60 60 - Options: 61 61 - `-h, --help`: Show help and exit. 62 62 - `--quiet`: Suppress once-only warnings and Lua BOOT()/TIC() error prints (useful for headless runs). 63 + - `--editor`: Launch the livecoding editor UI (CODE/CONSOLE tabs) rendering in the 240×136 framebuffer. 63 64 - `--list-audio`: List input audio devices and exit. 64 65 - `--audio-device <SUBSTR>`: Select input device by substring match (case-insensitive). 65 66 - `--audio-disable`: Disable audio capture and analysis. ··· 74 75 ## Debug Flags (Audio Analysis) 75 76 - `--debug-fft`: First 16 FFT bins (smoothed normalized). 76 77 - `--debug-fx`: FX timings plus ring stats (dp/ovf/underrun/cons), EMA samples/tick, estimated occupancy. 78 + 79 + ## Editor (Livecoding) 80 + - Launch with `--editor` to open the framebuffer UI with CODE/CONSOLE tabs. 81 + - CODE view: 82 + - Rope-backed text buffer for the loaded cart code (read-only viewport initially). 83 + - Monospace grid 6×8 per cell (fixed-width glyphs render left 6 columns and advance 6 px). 84 + - Gutter with 1-based line numbers; arrow keys move the caret; viewport auto-scrolls. 85 + - Caret matches TIC‑80 style: red box slightly larger than the glyph, with 1 px drop shadow; underlying glyph drawn dark to simulate inversion. 86 + - Roadmap: see `docs/roadmap/editor_livecoding.md` for phases (editing, undo/redo, colorizer, find, console, hot reload). 87 + 88 + ## Screenshots 89 + - Save during run: press F12 to write to `./screenshots/scr-YYYYmmdd-HHMMSS.png` (respects `--screenshot-scale` if set). 90 + - One-off capture: `--screenshot out.png [--screenshot-scale 3]` (exits after saving). 91 + - Headless capture: `--headless --screenshot out.png [--screenshot-frame 60]`. 92 + - Plan: see `docs/architecture/screenshot_plan.md`.
+34
docs/architecture/editor.md
··· 1 + # Editor Architecture (CODE/CONSOLE) 2 + 3 + The editor renders a TIC‑80‑style UI inside the 240×136 framebuffer and handles input from `winit`. No external widget toolkit is used; this keeps rendering fully deterministic and testable. 4 + 5 + ## Rendering 6 + - Top bar (12 px tall): tabs and buttons drawn with existing gfx primitives; button widths are computed from label length (ADV=6) and padding. 7 + - Panels: 8 + - CODE: text viewport + gutter. 9 + - CONSOLE: placeholder; a ring buffer model will feed this view. 10 + - Integer scaling: `winit + pixels` provides window scaling; mouse coordinates are unprojected to 240×136 for hit testing. 11 + 12 + ## Text Model 13 + - Rope: `ropey` stores the cart source; enables fast inserts/deletes and stable indices. 14 + - Viewport: maps `scroll_line`/`scroll_col` to rows/cols on screen; uses a 6×8 monospace grid. 15 + - Fixed‑width glyphs draw only the left 6 columns of the 8×8 font and advance 6 px. 16 + - Gutter (left 24 px) shows 1‑based line numbers. 17 + 18 + ## Caret 19 + - TIC‑80 style: a red box slightly larger than the glyph cell with a 1px drop shadow; glyph under the caret is redrawn in dark to simulate inversion. 20 + - Position stays aligned to the 6×8 grid; auto-scrolling keeps caret visible. 21 + 22 + ## Input 23 + - Tabs and buttons: rectangle hit testing in framebuffer space. 24 + - Caret navigation: arrows (more keybinds to be added). 25 + - Editing (upcoming): insert/delete/backspace/newline; selection, undo/redo. 26 + 27 + ## Hot Reload and Cart I/O (upcoming) 28 + - CODE‑only `.tic` save; preserve other sections; hot reload swaps `LuaRunner` and gates on errors to keep last‑good. 29 + 30 + ## Testing Strategy 31 + - Pixel assertions for UI scaffolding and code viewport. 32 + - Buffer operation tests for text edits and viewport mapping. 33 + - Integration tests for `.tic` round‑trip and hot reload gating. 34 +
+45
docs/architecture/screenshot_plan.md
··· 1 + # Screenshot Feature — Plan 2 + 3 + Status 4 + - Implemented in runner CLI and windowed modes; covered by unit + E2E tests. 5 + 6 + Goals 7 + - Enable saving a framebuffer screenshot during normal runs (hotkey) and when launched in headless mode. 8 + - Provide deterministic, minimal-overhead capture for CI and debugging, with optional integer scaling. 9 + 10 + CLI 11 + - `--screenshot <path>`: capture once at first redraw (or selected frame) and exit. 12 + - `--screenshot-scale <N>`: integer scale for output PNG (default 1). 13 + - `--headless`: no window; render offscreen and save a screenshot. 14 + - `--screenshot-frame <N>` (optional): capture after N frames/ticks, then exit. 15 + - Hotkey: F12 saves to `./screenshots/scr-YYYYmmdd-HHMMSS.png` while running (does not exit). 16 + 17 + Implementation Outline 18 + - Add a tiny image helper (`util::image`): 19 + - `save_png_rgba(path, w, h, rgba)` using `png` crate. 20 + - `scale_rgba_nn(src, w, h, scale)` (nearest-neighbor integer scale). 21 + - Windowed path: 22 + - Track a `ScreenshotState { path, scale, frame_target, saved }` in `main.rs`. 23 + - On `RedrawRequested`, after `fb.blit_to_rgba(frame)`, copy RGBA to Vec, optionally scale, save, and optionally exit. 24 + - F12 hotkey builds a timestamped path and saves immediately using the same helper. 25 + - Headless path: 26 + - Initialize framebuffer and state without `winit + pixels`. 27 + - If `--editor`: render Editor UI + code viewport once; else load cart and run `TIC()` for `N` frames. 28 + - Convert framebuffer to RGBA, optionally scale, and save. 29 + 30 + Testing 31 + - Unit tests: 32 + - Verify `scale_rgba_nn` expands a small RGBA image correctly. 33 + - Encode to PNG in-memory and decode back to compare pixels. 34 + - Integration smoke: 35 + - Headless render of a small scene (e.g., clear + draw) and save to a temp file; validate file existence and dimensions. 36 + - E2E CLI tests invoke the binary with `--headless --screenshot` and decode the output PNG. 37 + 38 + Docs 39 + - README CLI: add flags and a small “Screenshots” section with examples. 40 + - Test Catalog: reference screenshot unit tests. 41 + - Worklog (AGENTS.md): log the feature with brief UX. 42 + 43 + Follow-ups 44 + - `--screenshot-delay <ticks>` convenience when not using frames. 45 + - Optionally embed metadata (frame/tick/flags) into PNG `tEXt`.
+10 -9
docs/roadmap/editor_livecoding.md
··· 26 26 27 27 Phase 0 — Shell + Layout 28 28 - [ ] Create `docs/` UI skin reference (palette, font, margins, tab geometry) (optional diagrams). 29 - - [ ] Add a minimal UI state model (active tab, focus, scroll positions). 30 - - [ ] Render top chrome, tabs, and placeholder panels into the framebuffer. 31 - - [ ] Wire tab switching and button hit‑testing (rect hit tests in 240×136 space). 32 - - [ ] Integrate integer scaling to window and mouse coordinate unprojection. 29 + - [x] Add a minimal UI state model (active tab, focus, scroll positions). 30 + - [x] Render top chrome, tabs, and placeholder panels into the framebuffer. 31 + - [x] Wire tab switching and button hit‑testing (rect hit tests in 240×136 space). 32 + - [x] Integrate integer scaling to window and mouse coordinate unprojection. 33 33 34 34 Phase 1 — Text Engine + Editor Basics 35 - - [ ] Integrate a rope‑backed buffer (ropey) and load cart code into it. 36 - - [ ] Caret movement (arrows, home/end), insert/delete/backspace, newlines. 35 + - [x] Integrate a rope‑backed buffer (ropey) and load cart code into it. 36 + - [x] Caret movement (arrows) and auto-scroll; Home/End pending. 37 + - [ ] Insert/delete/backspace, newlines. 37 38 - [ ] Selection (shift+arrows), clipboard (Ctrl/Cmd+C/V/X), undo/redo (local stack for code buffer). 38 - - [ ] Horizontal/vertical scrolling; viewport mapping from text rows to framebuffer pixels. 39 - - [ ] Draw gutter (line numbers) and current line highlight. 39 + - [x] Horizontal/vertical scrolling; viewport mapping from text rows to framebuffer pixels. 40 + - [x] Draw gutter (line numbers). 40 41 41 42 Phase 2 — Syntax + UX polish (minimal) 42 43 - [ ] Lightweight Lua colorizer (keywords, comments, strings, numbers) with palette colors. ··· 57 58 - [ ] Headless test: .tic round‑trip preserves non‑code bytes. 58 59 - [ ] Hot reload tests: failing edit keeps last‑good; fixing edit swaps runner. 59 60 - [ ] Console tests: trace() lines and error render path covered. 60 - - [ ] Add README section with usage and keybinds; link test carts and debug flags. 61 + - [x] Add README section with usage and keybinds; link test carts and debug flags. 61 62 62 63 ## Keybinds (initial) 63 64 - Save: Ctrl/Cmd+S
+12
docs/roadmap/todos_screenshots.md
··· 1 + # TODO — Screenshot Feature 2 + 3 + - [x] Write plan (`docs/architecture/screenshot_plan.md`). 4 + - [x] Update README with CLI and usage. 5 + - [x] Add `png` helper (`util::image`) with `save_png_rgba` and `scale_rgba_nn`. 6 + - [x] CLI flags: `--screenshot`, `--screenshot-scale`, `--headless`, `--screenshot-frame`. 7 + - [x] Windowed capture: implement one-off capture and F12 hotkey. 8 + - [x] Headless capture: render offscreen and save; support `--editor` and cart run (up to frame N). 9 + - [x] Unit tests: scaling + PNG encode/decode round-trip. 10 + - [x] Integration smoke: headless render + save to a temp file. 11 + - [x] Polish: create `./screenshots` dir and unique names; friendly errors. 12 + - [x] Docs: add “Screenshots” examples to README and Test Catalog.
+10 -1
docs/specs/implementation_status.md
··· 32 32 - `.lua` loader: First CLI arg as a `.lua` path runs external script; fallback to bundled `assets/default.lua`. 33 33 - Window title: “rustic”. 34 34 - Audio capture scaffolding: `cpal` input stream (44.1 kHz if supported), stereo→mono downmix, lock‑free ring buffer (8192 samples); CLI flags to list/select devices and optional VU meter output. 35 - - VU behavior: Peak meter observed ~-180 dBFS at silence (BlackHole 2ch on macOS), responsive under Multi‑Output device routing. 35 + - VU behavior: Peak meter observed ~-180 dBFS at silence (BlackHole 2ch on macOS), responsive under Multi‑Output device routing. 36 + - Screenshots: 37 + - One‑off capture via `--screenshot <path>` with optional `--screenshot-scale <N>`; saves after frame `N` specified by `--screenshot-frame` (default first frame) and exits. 38 + - Headless path `--headless` renders offscreen (editor UI or Lua cart ticks) and saves. 39 + - Hotkey F12 saves to `./screenshots/scr-YYYYmmdd-HHMMSS.png` during windowed runs (no exit). 36 40 37 41 Implemented (Analysis) 38 42 - FFT (2k): ··· 71 75 - (none for VQT); whitening completed. 72 76 - Sprite flags 73 77 - `fget`, `fset`. 78 + 79 + Implemented (Editor — initial) 80 + - UI shell: top bar (tabs + buttons) rendered in framebuffer; integer-scaling window. 81 + - CODE view: rope‑backed buffer; read‑only viewport rendering; gutter; caret navigation (arrows) with auto‑scroll; TIC‑style caret (red box + shadow with inverted glyph); 6×8 cell grid. 82 + - CLI: `--editor` launches the editor. 74 83 75 84 Test Coverage (summary) 76 85 - Framebuffer unit tests cover: cls/pix/line/rect/rectb/circ/circb/elli/ellib/tri/trib, clip behavior, OOB, print width/newlines, palette blit, triangle edge rules.
+2 -1
docs/testing/strategy.md
··· 4 4 - **Framebuffer unit tests:** Validate drawing primitives (`cls`, `pix`, `line`, `rect`, `rectb`), clipping, palette → RGBA mapping, and OOB behavior. 5 5 - **Lua bridge tests:** Verify exposed APIs (`cls/pix/line/rect/print/rectb/clip`) and lifecycle (`BOOT`/`TIC`), and that Lua semantics (e.g., OOB `pix` read returns `nil`) are preserved. 6 6 - **Deterministic snapshots:** Compute a simple hash of the 240×136 palette-index framebuffer (per tick) to assert determinism and enable future golden comparisons. 7 + - **Headless E2E (CLI):** Invoke the binary with `--headless --screenshot` to generate PNGs and decode them to validate success and dimensions. 7 8 - **Audio blocks (later):** Hash mixed audio blocks with tolerance windows for FP differences. 8 9 - **Conformance carts (later):** Automate selected carts (headless) and compare traces/hashes to baselines. 9 10 - **Fuzzing (later):** Fuzz `.tic` loader and selected APIs for robustness. ··· 48 49 **How To Run** 49 50 - Unit + Lua tests: `cd tic80_rust && cargo test` 50 51 - Clippy (treat warnings as errors): `cd tic80_rust && cargo clippy --all-targets --all-features -D warnings` 52 + - E2E screenshots only: `cd tic80_rust && cargo test -q e2e_headless_cli` 51 53 52 54 **Future Additions** 53 55 - Expand primitives coverage (`circb/circ/elli/ellib/tri/trib`, `print` edge cases: scale>1 areas, baseline/advance, small font). 54 56 - Add frame-hash goldens for selected demo sequences (stable seeds and scripts). 55 57 - Introduce error-path tests for Lua type/arity mismatches and unknown APIs. 56 58 - Add input semantics tests (`key/keyp/btn/btnp/mouse`) with fixed-step repeat timing. 57 -
+11
docs/testing/test_catalog.md
··· 69 69 - Tests prefer headless framebuffer inspection over image baselines. 70 70 - Hashing uses FNV‑1a over VRAM palette indices for portability and stability. 71 71 - For manual carts, see `docs/testing/test_carts.md`. 72 + 73 + ## Editor Tests 74 + - `tic80_rust/tests/editor_smoke.rs`: UI shell draws and tabs switch on click; verifies top-bar pixels. 75 + - `tic80_rust/tests/editor_code_view_tests.rs`: CODE viewport renders gutter digits and text cells. 76 + 77 + ## Screenshot Tests 78 + - `tic80_rust/tests/screenshot_smoke.rs` 79 + - `save_scaled_png_has_expected_dimensions`: Draw to framebuffer, encode/decode PNG in-memory; save a 3x scaled PNG to temp and verify dimensions. 80 + - `tic80_rust/tests/e2e_headless_cli.rs` 81 + - `e2e_headless_default_cart_screenshot`: Runs the CLI binary with `--headless --screenshot` and decodes the PNG. 82 + - `e2e_headless_editor_screenshot`: Runs with `--headless --editor --screenshot` and verifies the PNG.
+99 -2
tic80_rust/Cargo.lock
··· 107 107 checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" 108 108 109 109 [[package]] 110 + name = "android-tzdata" 111 + version = "0.1.1" 112 + source = "registry+https://github.com/rust-lang/crates.io-index" 113 + checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" 114 + 115 + [[package]] 110 116 name = "android_system_properties" 111 117 version = "0.1.5" 112 118 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 312 318 version = "0.1.1" 313 319 source = "registry+https://github.com/rust-lang/crates.io-index" 314 320 checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" 321 + 322 + [[package]] 323 + name = "chrono" 324 + version = "0.4.41" 325 + source = "registry+https://github.com/rust-lang/crates.io-index" 326 + checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d" 327 + dependencies = [ 328 + "android-tzdata", 329 + "iana-time-zone", 330 + "num-traits", 331 + "windows-link", 332 + ] 315 333 316 334 [[package]] 317 335 name = "clang-sys" ··· 701 719 checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" 702 720 703 721 [[package]] 722 + name = "iana-time-zone" 723 + version = "0.1.63" 724 + source = "registry+https://github.com/rust-lang/crates.io-index" 725 + checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8" 726 + dependencies = [ 727 + "android_system_properties", 728 + "core-foundation-sys", 729 + "iana-time-zone-haiku", 730 + "js-sys", 731 + "log", 732 + "wasm-bindgen", 733 + "windows-core 0.61.2", 734 + ] 735 + 736 + [[package]] 737 + name = "iana-time-zone-haiku" 738 + version = "0.1.2" 739 + source = "registry+https://github.com/rust-lang/crates.io-index" 740 + checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" 741 + dependencies = [ 742 + "cc", 743 + ] 744 + 745 + [[package]] 704 746 name = "indexmap" 705 747 version = "1.9.3" 706 748 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1762 1804 version = "0.1.0" 1763 1805 dependencies = [ 1764 1806 "anyhow", 1807 + "chrono", 1765 1808 "cpal", 1766 1809 "mlua", 1767 1810 "parking_lot", 1768 1811 "pixels", 1812 + "png", 1769 1813 "realfft", 1770 1814 "ropey", 1771 1815 "rtrb", ··· 2220 2264 source = "registry+https://github.com/rust-lang/crates.io-index" 2221 2265 checksum = "9252e5725dbed82865af151df558e754e4a3c2c30818359eb17465f1346a1b49" 2222 2266 dependencies = [ 2223 - "windows-core", 2267 + "windows-core 0.54.0", 2224 2268 "windows-targets 0.52.6", 2225 2269 ] 2226 2270 ··· 2230 2274 source = "registry+https://github.com/rust-lang/crates.io-index" 2231 2275 checksum = "12661b9c89351d684a50a8a643ce5f608e20243b9fb84687800163429f161d65" 2232 2276 dependencies = [ 2233 - "windows-result", 2277 + "windows-result 0.1.2", 2234 2278 "windows-targets 0.52.6", 2235 2279 ] 2236 2280 2237 2281 [[package]] 2282 + name = "windows-core" 2283 + version = "0.61.2" 2284 + source = "registry+https://github.com/rust-lang/crates.io-index" 2285 + checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" 2286 + dependencies = [ 2287 + "windows-implement", 2288 + "windows-interface", 2289 + "windows-link", 2290 + "windows-result 0.3.4", 2291 + "windows-strings", 2292 + ] 2293 + 2294 + [[package]] 2295 + name = "windows-implement" 2296 + version = "0.60.0" 2297 + source = "registry+https://github.com/rust-lang/crates.io-index" 2298 + checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" 2299 + dependencies = [ 2300 + "proc-macro2", 2301 + "quote", 2302 + "syn 2.0.106", 2303 + ] 2304 + 2305 + [[package]] 2306 + name = "windows-interface" 2307 + version = "0.59.1" 2308 + source = "registry+https://github.com/rust-lang/crates.io-index" 2309 + checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" 2310 + dependencies = [ 2311 + "proc-macro2", 2312 + "quote", 2313 + "syn 2.0.106", 2314 + ] 2315 + 2316 + [[package]] 2238 2317 name = "windows-link" 2239 2318 version = "0.1.3" 2240 2319 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2247 2326 checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" 2248 2327 dependencies = [ 2249 2328 "windows-targets 0.52.6", 2329 + ] 2330 + 2331 + [[package]] 2332 + name = "windows-result" 2333 + version = "0.3.4" 2334 + source = "registry+https://github.com/rust-lang/crates.io-index" 2335 + checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" 2336 + dependencies = [ 2337 + "windows-link", 2338 + ] 2339 + 2340 + [[package]] 2341 + name = "windows-strings" 2342 + version = "0.4.2" 2343 + source = "registry+https://github.com/rust-lang/crates.io-index" 2344 + checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" 2345 + dependencies = [ 2346 + "windows-link", 2250 2347 ] 2251 2348 2252 2349 [[package]]
+2
tic80_rust/Cargo.toml
··· 19 19 anyhow = "1" 20 20 realfft = "3" 21 21 ropey = "1" 22 + png = "0.17" 23 + chrono = { version = "0.4", default-features = false, features = ["clock"] }
+4
tic80_rust/src/lib.rs
··· 37 37 pub mod code; 38 38 pub mod ui; 39 39 } 40 + 41 + pub mod util { 42 + pub mod image; 43 + }
+164 -15
tic80_rust/src/main.rs
··· 31 31 use std::rc::Rc; 32 32 use std::time::{Duration, Instant}; 33 33 34 - use pixels::{Error, Pixels, SurfaceTexture}; 34 + use pixels::{Pixels, SurfaceTexture}; 35 35 use winit::dpi::LogicalSize; 36 36 use winit::event::{ElementState, Event, KeyboardInput, VirtualKeyCode, WindowEvent}; 37 37 use winit::event_loop::{ControlFlow, EventLoop}; ··· 85 85 quiet: bool, 86 86 help: bool, 87 87 editor: bool, 88 + // Screenshot/headless 89 + headless: bool, 90 + screenshot_path: Option<PathBuf>, 91 + screenshot_scale: u32, 92 + screenshot_frame: Option<u32>, 88 93 } 89 94 90 95 fn parse_args() -> Args { ··· 100 105 quiet: false, 101 106 help: false, 102 107 editor: false, 108 + headless: false, 109 + screenshot_path: None, 110 + screenshot_scale: 1, 111 + screenshot_frame: None, 103 112 }; 104 113 while let Some(arg) = args_iter.next() { 105 114 match arg.as_str() { ··· 111 120 "--debug-fx" => out.debug_fx = true, 112 121 "--quiet" => out.quiet = true, 113 122 "--editor" => out.editor = true, 123 + "--headless" => out.headless = true, 124 + "--screenshot" => { 125 + if let Some(val) = args_iter.next() { 126 + out.screenshot_path = Some(PathBuf::from(val)); 127 + } 128 + } 129 + "--screenshot-scale" => { 130 + if let Some(val) = args_iter.next() { 131 + if let Ok(n) = val.parse::<u32>() { 132 + out.screenshot_scale = n.max(1); 133 + } 134 + } 135 + } 136 + "--screenshot-frame" => { 137 + if let Some(val) = args_iter.next() { 138 + if let Ok(n) = val.parse::<u32>() { 139 + out.screenshot_frame = Some(n); 140 + } 141 + } 142 + } 114 143 "--audio-device" => { 115 144 if let Some(val) = args_iter.next() { 116 145 out.audio_device = Some(val); ··· 129 158 fn create_window_and_pixels( 130 159 event_loop: &EventLoop<()>, 131 160 scale: f64, 132 - ) -> Result<(Window, Pixels), Error> { 161 + ) -> anyhow::Result<(Window, Pixels)> { 133 162 let (width, height) = dimensions(); 134 163 let size = LogicalSize::new((width as f64) * scale, (height as f64) * scale); 135 164 let window = WindowBuilder::new() ··· 255 284 }, 256 285 ); 257 286 println!( 258 - "Usage: {prog} [OPTIONS] [CART.lua]\n\nOptions:\n -h, --help Show this help message and exit\n --quiet Suppress once-only warnings and Lua BOOT()/TIC() error prints\n --list-audio List input audio devices and exit\n --audio-device <SUBSTR> Select input device by substring match (case-insensitive)\n --audio-disable Disable audio capture and analysis\n --audio-vu Print VU peak dBFS once per second\n --debug-fft Print first 16 FFT bins (smoothed, normalized) ~every 500 ms\n --debug-fx Print per-second FX timings plus ring stats (dp/ovf/underrun/consumed, EMA samples/tick, occupancy)\n\nArguments:\n CART.lua Optional path to a Lua cart; defaults to bundled demo when omitted\n\nNotes:\n- Window: fixed 240x136 internal resolution with integer scaling in a desktop window.\n- Audio: selects nearest supported sample rate to 44100 Hz and logs the choice.\n- Ring stats (with --debug-fx):\n dp = pushed samples since last report\n ovf = overflows delta (and total) from producer\n underrun = consumer had no data to read\n consumed = samples pulled into analyzers (delta)\n EMA samples/tick = moving average of consumed samples per game tick\n occupancy = estimated ring fill vs. capacity\n\nExamples:\n {prog} # run bundled cart\n {prog} assets/alt.lua # run a local cart\n {prog} --list-audio # show devices and exit\n {prog} --audio-device BlackHole --audio-vu\n {prog} assets/fft_test.lua --debug-fft --debug-fx\n {prog} assets/your_cart.lua --quiet\n " 259 - ); 287 + "Usage: {prog} [OPTIONS] [CART.lua]\n\nOptions:\n -h, --help Show this help message and exit\n --quiet Suppress once-only warnings and Lua BOOT()/TIC() error prints\n --editor Launch the editor UI (CODE/CONSOLE)\n --headless Run offscreen without opening a window (for screenshots/CI)\n --screenshot <PATH> Save a screenshot and exit (first frame by default)\n --screenshot-scale <N> Integer scale for screenshot (default 1)\n --screenshot-frame <N> Capture after N frames (windowed/headless)\n --list-audio List input audio devices and exit\n --audio-device <SUBSTR> Select input device by substring match (case-insensitive)\n --audio-disable Disable audio capture and analysis\n --audio-vu Print VU peak dBFS once per second\n --debug-fft Print first 16 FFT bins (smoothed, normalized) ~every 500 ms\n --debug-fx Print per-second FX timings plus ring stats (dp/ovf/underrun/consumed, EMA samples/tick, occupancy)\n\nArguments:\n CART.lua Optional path to a Lua cart; defaults to bundled demo when omitted\n\nNotes:\n- Window: fixed 240x136 internal resolution with integer scaling in a desktop window.\n- Audio: selects nearest supported sample rate to 44100 Hz and logs the choice.\n- Ring stats (with --debug-fx): dp=pushed, ovf=overflows, underrun=no-data, consumed=samples, EMA samples/tick, occupancy.\n\nExamples:\n {prog} --screenshot out.png --screenshot-scale 3\n {prog} --headless --screenshot out.png --screenshot-frame 60\n {prog} --editor\n "); 260 288 } 261 289 262 290 fn load_script(script_path: Option<&PathBuf>) -> String { ··· 277 305 } 278 306 } 279 307 280 - fn run() -> Result<(), Error> { 308 + fn run() -> anyhow::Result<()> { 309 + // Parse early to allow headless path 310 + let args = parse_args(); 311 + if args.help { 312 + print_help(); 313 + return Ok(()); 314 + } 315 + if args.headless { 316 + return run_headless(&args); 317 + } 318 + 281 319 let event_loop = EventLoop::new(); 282 320 let (window, mut pixels) = create_window_and_pixels(&event_loop, 3.0)?; // default integer scaling 283 321 let window_scale = 3.0f64; ··· 285 323 let fb = Rc::new(RefCell::new(Framebuffer::new())); 286 324 let mem = Rc::new(RefCell::new(Memory::new(fb.clone()))); 287 325 let mut ticker = Ticker::new(); 288 - 289 - let args = parse_args(); 290 - if args.help { 291 - print_help(); 292 - return Ok(()); 293 - } 294 - if args.help { 295 - print_help(); 296 - return Ok(()); 297 - } 298 326 if args.list_audio { 299 327 print_devices_and_exit(); 300 328 return Ok(()); ··· 321 349 let mut audio_state = init_audio(&args); 322 350 let mut warned_no_tic = false; 323 351 let mut last_cursor_fb: Option<(i32, i32)> = None; 352 + let mut frame_counter: u32 = 0; 353 + let mut one_off_saved = false; 324 354 325 355 #[allow(clippy::cognitive_complexity)] 326 356 event_loop.run(move |event, _, control_flow| { ··· 348 378 }, 349 379 .. 350 380 } => *control_flow = ControlFlow::Exit, 381 + WindowEvent::KeyboardInput { 382 + input: 383 + KeyboardInput { 384 + virtual_keycode: Some(VirtualKeyCode::F12), 385 + state: ElementState::Pressed, 386 + .. 387 + }, 388 + .. 389 + } => { 390 + // Save a timestamped screenshot under ./screenshots 391 + let (w, h) = dimensions(); 392 + let mut rgba = vec![0u8; (w * h * 4) as usize]; 393 + fb.borrow().blit_to_rgba(&mut rgba); 394 + if let Err(e) = save_timestamped_screenshot(&args, &rgba, w, h) { 395 + eprintln!("Screenshot error: {e}"); 396 + } 397 + } 351 398 WindowEvent::KeyboardInput { input, .. } => { 352 399 if let (Some(ui), Some(cb)) = (editor_ui.as_ref(), code_buf.as_mut()) { 353 400 if ui.active == tic80_rust::editor::ui::Tab::Code ··· 500 547 } 501 548 } 502 549 fb.borrow().blit_to_rgba(frame); 550 + // One-off screenshot capture if requested 551 + if !one_off_saved { 552 + let target = args.screenshot_frame.unwrap_or(0); 553 + if args.screenshot_path.is_some() && frame_counter >= target { 554 + if let Err(e) = save_screenshot_from_rgba(&args, frame) { eprintln!("Screenshot error: {e}"); } 555 + one_off_saved = true; 556 + *control_flow = ControlFlow::Exit; 557 + } 558 + } 503 559 if let Err(err) = pixels.render() { 504 560 eprintln!("Render error: {err}"); 505 561 *control_flow = ControlFlow::Exit; 506 562 } 563 + frame_counter = frame_counter.saturating_add(1); 507 564 } 508 565 _ => {} 509 566 } ··· 515 572 eprintln!("Application error: {err}"); 516 573 } 517 574 } 575 + 576 + // -- Screenshot helpers and headless path ------------------------------------------------------- 577 + 578 + fn save_screenshot_from_rgba(args: &Args, rgba: &[u8]) -> anyhow::Result<()> { 579 + use tic80_rust::util::image::{save_png_rgba, scale_rgba_nn}; 580 + let path = args 581 + .screenshot_path 582 + .as_ref() 583 + .ok_or_else(|| anyhow::anyhow!("screenshot path not provided"))?; 584 + let (w, h) = dimensions(); 585 + let scaled = if args.screenshot_scale <= 1 { 586 + rgba.to_vec() 587 + } else { 588 + scale_rgba_nn(rgba, w, h, args.screenshot_scale) 589 + }; 590 + let sw = w * args.screenshot_scale; 591 + let sh = h * args.screenshot_scale; 592 + save_png_rgba(path, sw, sh, &scaled)?; 593 + println!( 594 + "Saved screenshot: {} ({}x{}, scale {})", 595 + path.display(), 596 + sw, 597 + sh, 598 + args.screenshot_scale 599 + ); 600 + Ok(()) 601 + } 602 + 603 + fn save_timestamped_screenshot(args: &Args, rgba: &[u8], w: u32, h: u32) -> anyhow::Result<()> { 604 + use tic80_rust::util::image::{save_png_rgba, scale_rgba_nn}; 605 + let dir = PathBuf::from("screenshots"); 606 + if !dir.exists() { 607 + std::fs::create_dir_all(&dir)?; 608 + } 609 + let now = chrono::Local::now(); 610 + let fname = format!("scr-{}.png", now.format("%Y%m%d-%H%M%S")); 611 + let path = dir.join(fname); 612 + let scale = args.screenshot_scale.max(1); 613 + let scaled = if scale <= 1 { 614 + rgba.to_vec() 615 + } else { 616 + scale_rgba_nn(rgba, w, h, scale) 617 + }; 618 + let sw = w * scale; 619 + let sh = h * scale; 620 + save_png_rgba(&path, sw, sh, &scaled)?; 621 + println!( 622 + "Saved screenshot: {} ({}x{}, scale {})", 623 + path.display(), 624 + sw, 625 + sh, 626 + scale 627 + ); 628 + Ok(()) 629 + } 630 + 631 + fn run_headless(args: &Args) -> anyhow::Result<()> { 632 + // Prepare framebuffer + memory 633 + let fb = Rc::new(RefCell::new(Framebuffer::new())); 634 + let mem = Rc::new(RefCell::new(Memory::new(fb.clone()))); 635 + // Render either editor UI or cart ticks 636 + if args.editor { 637 + let initial = load_script(args.script_path.as_ref()); 638 + let ui = EditorUi::new(1.0); 639 + let mut code = CodeBuffer::from_text(&initial); 640 + { 641 + let mut fbb = fb.borrow_mut(); 642 + ui.draw(&mut fbb); 643 + let area = CodeArea { 644 + x: 0, 645 + y: 12, 646 + w: 240, 647 + h: 124, 648 + }; 649 + code.draw(&mut fbb, area); 650 + } 651 + } else { 652 + let script = load_script(args.script_path.as_ref()); 653 + let lr = LuaRunner::new(fb.clone(), mem.clone(), &script)?; 654 + // Match windowed timing: capture on redraw number target+1 655 + let ticks = args.screenshot_frame.unwrap_or(0).saturating_add(1); 656 + for _ in 0..ticks { 657 + lr.tick(); 658 + } 659 + } 660 + // Save from framebuffer 661 + let (w, h) = dimensions(); 662 + let mut rgba = vec![0u8; (w * h * 4) as usize]; 663 + fb.borrow().blit_to_rgba(&mut rgba); 664 + save_screenshot_from_rgba(args, &rgba)?; 665 + Ok(()) 666 + }
+111
tic80_rust/src/util/image.rs
··· 1 + use std::fs::File; 2 + use std::io::Write; 3 + use std::path::Path; 4 + 5 + #[must_use] 6 + pub fn scale_rgba_nn(src: &[u8], w: u32, h: u32, scale: u32) -> Vec<u8> { 7 + if scale <= 1 { 8 + return src.to_vec(); 9 + } 10 + let w = w as usize; 11 + let h = h as usize; 12 + let s = scale as usize; 13 + let mut out = vec![0u8; w * s * h * s * 4]; 14 + for y in 0..h { 15 + for x in 0..w { 16 + let src_idx = (y * w + x) * 4; 17 + let px = &src[src_idx..src_idx + 4]; 18 + let ox = x * s; 19 + let oy = y * s; 20 + for dy in 0..s { 21 + let row = (oy + dy) * (w * s) * 4; 22 + for dx in 0..s { 23 + let dst = row + (ox + dx) * 4; 24 + out[dst..dst + 4].copy_from_slice(px); 25 + } 26 + } 27 + } 28 + } 29 + out 30 + } 31 + 32 + /// # Errors 33 + /// - Returns an error if the file cannot be created or PNG encoding fails. 34 + pub fn save_png_rgba(path: &Path, w: u32, h: u32, rgba: &[u8]) -> anyhow::Result<()> { 35 + let file = File::create(path)?; 36 + write_png_rgba(file, w, h, rgba)?; 37 + Ok(()) 38 + } 39 + 40 + /// # Errors 41 + /// - Returns an error if PNG encoding fails. 42 + pub fn encode_png_rgba_to_vec(w: u32, h: u32, rgba: &[u8]) -> anyhow::Result<Vec<u8>> { 43 + let mut buf = Vec::new(); 44 + write_png_rgba(&mut buf, w, h, rgba)?; 45 + Ok(buf) 46 + } 47 + 48 + fn write_png_rgba<W: Write>(wtr: W, w: u32, h: u32, rgba: &[u8]) -> anyhow::Result<()> { 49 + let mut encoder = png::Encoder::new(wtr, w, h); 50 + encoder.set_color(png::ColorType::Rgba); 51 + encoder.set_depth(png::BitDepth::Eight); 52 + let mut writer = encoder.write_header()?; 53 + writer.write_image_data(rgba)?; 54 + Ok(()) 55 + } 56 + 57 + #[cfg(test)] 58 + mod tests { 59 + use super::*; 60 + 61 + #[test] 62 + fn scale_nn_2x() { 63 + // 2x2 checkerboard RGBA 64 + let w = 2u32; 65 + let h = 2u32; 66 + let black = [0, 0, 0, 255]; 67 + let white = [255, 255, 255, 255]; 68 + let mut src = Vec::new(); 69 + src.extend_from_slice(&black); 70 + src.extend_from_slice(&white); 71 + src.extend_from_slice(&white); 72 + src.extend_from_slice(&black); 73 + let out = scale_rgba_nn(&src, w, h, 2); 74 + assert_eq!(out.len(), (w * 2 * h * 2 * 4) as usize); 75 + // Check corners 76 + let stride = (w as usize) * 2 * 4; 77 + // top-left 78 + assert_eq!(&out[0..4], &black); 79 + // top-right 80 + assert_eq!(&out[stride - 4..stride], &white); 81 + // bottom-left 82 + let last_row = (h as usize) * 2 - 1; 83 + let idx = last_row * stride; 84 + assert_eq!(&out[idx..idx + 4], &white); 85 + // bottom-right 86 + assert_eq!(&out[idx + stride - 4..idx + stride], &black); 87 + } 88 + 89 + #[test] 90 + fn png_roundtrip() { 91 + let w = 3u32; 92 + let h = 2u32; 93 + let mut rgba = vec![0u8; (w * h * 4) as usize]; 94 + for y in 0..h { 95 + for x in 0..w { 96 + let i = ((y * w + x) * 4) as usize; 97 + rgba[i] = u8::try_from((x * 40) % 256).unwrap(); 98 + rgba[i + 1] = u8::try_from((y * 80) % 256).unwrap(); 99 + rgba[i + 2] = 128; 100 + rgba[i + 3] = 255; 101 + } 102 + } 103 + let bytes = encode_png_rgba_to_vec(w, h, &rgba).unwrap(); 104 + let decoder = png::Decoder::new(std::io::Cursor::new(bytes)); 105 + let mut reader = decoder.read_info().unwrap(); 106 + let mut buf = vec![0u8; reader.output_buffer_size()]; 107 + let info = reader.next_frame(&mut buf).unwrap(); 108 + assert_eq!(info.width, w); 109 + assert_eq!(info.height, h); 110 + } 111 + }
+2
tic80_rust/src/util/mod.rs
··· 1 + pub mod image; 2 +
+50
tic80_rust/tests/e2e_headless_cli.rs
··· 1 + use std::path::PathBuf; 2 + use std::process::Command; 3 + 4 + use png::Decoder; 5 + 6 + fn run_and_capture(args: &[&str], out_path: &PathBuf) { 7 + let bin = env!("CARGO_BIN_EXE_tic80_rust"); 8 + let status = Command::new(bin) 9 + .args(args) 10 + .status() 11 + .expect("failed to spawn binary"); 12 + assert!(status.success(), "runner exited with failure: {status:?}"); 13 + 14 + // Decode and verify dimensions > 0 15 + let file = std::fs::File::open(out_path).expect("screenshot file not created"); 16 + let mut reader = Decoder::new(file).read_info().expect("decode header"); 17 + let mut buf = vec![0u8; reader.output_buffer_size()]; 18 + let info = reader.next_frame(&mut buf).expect("read frame"); 19 + assert!(info.width > 0 && info.height > 0); 20 + } 21 + 22 + #[test] 23 + fn e2e_headless_default_cart_screenshot() { 24 + let mut p = std::env::temp_dir(); 25 + p.push(format!("tic80_e2e_default_{}.png", std::process::id())); 26 + let args = [ 27 + "--headless", 28 + "--screenshot", 29 + p.to_str().unwrap(), 30 + "--screenshot-frame", 31 + "1", 32 + ]; 33 + run_and_capture(&args, &p); 34 + let _ = std::fs::remove_file(&p); 35 + } 36 + 37 + #[test] 38 + fn e2e_headless_editor_screenshot() { 39 + let mut p = std::env::temp_dir(); 40 + p.push(format!("tic80_e2e_editor_{}.png", std::process::id())); 41 + let args = [ 42 + "--headless", 43 + "--editor", 44 + "--screenshot", 45 + p.to_str().unwrap(), 46 + ]; 47 + run_and_capture(&args, &p); 48 + let _ = std::fs::remove_file(&p); 49 + } 50 +
+44
tic80_rust/tests/screenshot_smoke.rs
··· 1 + use std::path::PathBuf; 2 + 3 + use png::Decoder; 4 + use tic80_rust::gfx::framebuffer::{dimensions, Framebuffer}; 5 + use tic80_rust::util::image::{encode_png_rgba_to_vec, save_png_rgba, scale_rgba_nn}; 6 + 7 + #[test] 8 + fn save_scaled_png_has_expected_dimensions() { 9 + let (w, h) = dimensions(); 10 + let mut fb = Framebuffer::new(); 11 + // Draw something deterministic 12 + fb.cls(1); 13 + fb.rect(10, 10, 50, 30, 12); 14 + let mut rgba = vec![0u8; (w * h * 4) as usize]; 15 + fb.blit_to_rgba(&mut rgba); 16 + 17 + // 1) In-memory roundtrip and decode 18 + let bytes = encode_png_rgba_to_vec(w, h, &rgba).expect("encode"); 19 + let mut reader = Decoder::new(std::io::Cursor::new(bytes)) 20 + .read_info() 21 + .expect("decoder"); 22 + let mut buf = vec![0u8; reader.output_buffer_size()]; 23 + let info = reader.next_frame(&mut buf).expect("frame"); 24 + assert_eq!(info.width, w); 25 + assert_eq!(info.height, h); 26 + 27 + // 2) Scaled save to a temp file and decode 28 + let scale = 3; 29 + let scaled = scale_rgba_nn(&rgba, w, h, scale); 30 + let sw = w * scale; 31 + let sh = h * scale; 32 + let mut p = std::env::temp_dir(); 33 + p.push(format!("tic80_screenshot_test_{}.png", std::process::id())); 34 + save_png_rgba(&PathBuf::from(&p), sw, sh, &scaled).expect("save"); 35 + let mut reader = Decoder::new(std::fs::File::open(&p).expect("open")) 36 + .read_info() 37 + .unwrap(); 38 + let mut buf = vec![0u8; reader.output_buffer_size()]; 39 + let info = reader.next_frame(&mut buf).unwrap(); 40 + assert_eq!(info.width, sw); 41 + assert_eq!(info.height, sh); 42 + // Cleanup best-effort 43 + let _ = std::fs::remove_file(&p); 44 + }