···1919- Validate with tests: `cd tic80_rust && cargo test` (and run the specific failing test during fixes).
2020- Keep changes minimal and focused; don’t expand scope while tests are red.
21212222+**Testing**
2323+- Strategy: see `docs/testing/strategy.md` for layers (framebuffer, Lua bridge, deterministic hashes) and future plans (audio, conformance, fuzzing).
2424+- Catalog: see `docs/testing/test_catalog.md` for a concise list of existing tests and their intent.
2525+- Run: `cd tic80_rust && cargo test` for unit + Lua tests; `cargo clippy --all-targets --all-features -D warnings` for linting.
2626+- Determinism: VRAM hashes use FNV-1a over 240×136 palette indices (see strategy doc for rationale and helper snippet).
2727+2228**Decisions (Locked for prototype)**
2329- **Presentation:** `winit + pixels` with integer scaling (2x/3x/4x), RGBA palette conversion from 16-color default.
2424-- **Lua Engine:** `mlua` with vendored Lua 5.4, behavior aligned to 5.2 where needed (compat noted in docs).
3030+- **Lua Engine:** `mlua` with vendored Lua 5.3 (ADR 0003), targeting TIC-80 semantics; enable/replicate 5.1/5.2 compatibility where needed and cover with tests.
2531- **Framebuffer:** Single VRAM bank, palette indices in CPU memory; palette map/border/vbank deferred.
26322733**Current Status**
···7076 - Merged GUI-first docs into `docs/roadmap/gui_first.md`.
7177 - Renamed API parity to `docs/specs/lua_api_parity.md` and added specs stubs.
7278 - Added docs index at `docs/README.md`, architecture/testing pages, and ADRs.
7979+ - Added detailed testing docs (`docs/testing/strategy.md`) and a test catalog (`docs/testing/test_catalog.md`).
73807481**Docs Index**
7582- Start here: `docs/README.md`
···7784- Specs: `docs/specs/memory_map.md`, `docs/specs/lua_api_parity.md`, `docs/specs/graphics.md`, `docs/specs/audio_fft_vqt.md`
7885- Architecture: `docs/architecture/workspace.md`, `docs/architecture/runtime.md`
7986- Testing: `docs/testing/strategy.md`, `docs/testing/frame_hashes.md`
8080-- ADRs: `docs/adr/0001-winit-pixels.md`, `docs/adr/0002-mlua-lua54-compat.md`
8787+- ADRs: `docs/adr/0001-winit-pixels.md`, `docs/adr/0002-mlua-lua54-compat.md` (superseded), `docs/adr/0003-lua53-with-compat.md`
+3-2
docs/README.md
···1919## Testing
2020- `docs/testing/strategy.md`: Testing and validation strategy across API/VRAM/audio.
2121- `docs/testing/frame_hashes.md`: Conventions for deterministic frame/audio hashing (stub).
2222+- `docs/testing/test_catalog.md`: Summary of current tests and their intent.
22232324## Decisions (ADR)
2425- `docs/adr/0001-winit-pixels.md`: Windowing/presentation stack decision.
2525-- `docs/adr/0002-mlua-lua54-compat.md`: Lua engine choice and compatibility stance.
2626+- `docs/adr/0002-mlua-lua54-compat.md`: Lua 5.4 choice (superseded).
2727+- `docs/adr/0003-lua53-with-compat.md`: Lua 5.3 with 5.1/5.2 compatibility.
26282729Notes
2830- `MEMORY_MAP.md` at repo root remains the canonical reference for layout. Specs here link to it rather than duplicating.
···3537- Load a `.lua` file:
3638 - `cargo run --manifest-path tic80_rust/Cargo.toml -- tic80_rust/assets/alt.lua`
3739 - In crate dir: `cargo run -- assets/alt.lua`
3838-
+1-2
docs/adr/0002-mlua-lua54-compat.md
···11# ADR 0002: Lua Engine and Compatibility
2233-Status: Accepted
33+Status: Superseded by ADR 0003
44Date: 2025-08-26
5566Context
···1212Consequences
1313- Portable across supported platforms; avoids LuaJIT portability trade-offs initially.
1414- Add tests around numeric semantics and iteration order to guard against drift.
1515-
+19
docs/adr/0003-lua53-with-compat.md
···11+# ADR 0003: Lua 5.3 With 5.1/5.2 Compatibility
22+33+Status: Accepted (supersedes ADR 0002)
44+Date: 2025-08-26
55+66+Context
77+- TIC-80 cartridges historically target Lua 5.3 semantics with selected 5.1/5.2 compatibility. Matching those semantics reduces drift across carts and the reference C build.
88+99+Decision
1010+- Use `mlua` with vendored Lua 5.3: `mlua = { version = "0.9", features = ["lua53", "vendored"] }`.
1111+- Enable 5.1/5.2 compatibility macros during Lua build (LUA_COMPAT_5_2/5_1) where feasible. If the vendored build lacks direct flags, emulate required behaviors at the API boundary and cover with tests.
1212+1313+Consequences
1414+- Closer behavioral parity with TIC-80 vs. Lua 5.4.
1515+- Some compatibility shims may still be required; capture deltas with tests.
1616+1717+Notes
1818+- ADR 0002 (Lua 5.4) is superseded by this ADR. Keep it for historical context.
1919+
+53-9
docs/testing/strategy.md
···11# Testing and Validation Strategy
2233-Layers
44-- API goldens: Unit tests per API including edge cases and errors.
55-- Frame hashes: Deterministic VRAM hashes per frame to compare against baselines.
66-- Audio blocks: Hash mixed audio buffers with tolerances for FP differences.
77-- Conformance carts: Automated headless runs comparing traces/hashes to reference.
88-- Fuzzing: `.tic` loader and selected APIs for robustness.
33+**Layers**
44+- **Framebuffer unit tests:** Validate drawing primitives (`cls`, `pix`, `line`, `rect`, `rectb`), clipping, palette → RGBA mapping, and OOB behavior.
55+- **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.
66+- **Deterministic snapshots:** Compute a simple hash of the 240×136 palette-index framebuffer (per tick) to assert determinism and enable future golden comparisons.
77+- **Audio blocks (later):** Hash mixed audio blocks with tolerance windows for FP differences.
88+- **Conformance carts (later):** Automate selected carts (headless) and compare traces/hashes to baselines.
99+- **Fuzzing (later):** Fuzz `.tic` loader and selected APIs for robustness.
9101010-Notes
1111-- Prefer headless tests with minimal dependencies.
1212-- Keep baselines small and documented; record how they are generated.
1111+**Deterministic Frame Hash**
1212+- We use an FNV‑1a hash over palette indices for the entire 240×136 buffer.
1313+- Rationale: cheap, stable across platforms, and independent of presentation (RGBA conversion is not hashed).
1414+- Helper pseudocode (Rust):
1515+1616+ ```rust
1717+ fn fb_hash(fb: &mut Framebuffer) -> u64 {
1818+ let (w, h) = dimensions();
1919+ let mut hash: u64 = 0xcbf29ce484222325; // FNV offset
2020+ const FNV_PRIME: u64 = 0x00000100000001B3;
2121+ for y in 0..(h as i32) {
2222+ for x in 0..(w as i32) {
2323+ let b = fb.pix(x, y, None).unwrap_or(0);
2424+ hash ^= b as u64;
2525+ hash = hash.wrapping_mul(FNV_PRIME);
2626+ }
2727+ }
2828+ hash
2929+ }
3030+ ```
3131+3232+**What We Test Today**
3333+- Framebuffer
3434+ - **cls/pix:** Fill/reads; OOB reads return `None`; writes masked to 0..15.
3535+ - **rect (fill):** In-bounds, clipping to viewport and active clip rectangle.
3636+ - **rectb (border):** One‑pixel perimeter; clipped to viewport and active clip.
3737+ - **line:** Endpoints and pixel counts for axis-aligned and sloped lines; robustness with far‑OOB endpoints.
3838+ - **clip:** Set/reset and enforcement in `set_pixel`, `rect`, `rectb`, and `pix` write mode.
3939+ - **palette blit:** Index→RGBA conversion correctness for selected samples.
4040+- Lua bridge
4141+ - **API wiring:** `cls/pix/line/rect/print/rectb/clip` exposed and working.
4242+ - **OOB semantics:** `pix` OOB read returns `nil` in Lua.
4343+ - **File loading:** Alternate cart (`assets/alt.lua`) loads and runs via in-memory string.
4444+ - **Determinism:** Default cart’s frame hash is stable for N ticks; different tick counts produce different hashes.
4545+4646+See `docs/testing/test_catalog.md` for the current test list and intent.
4747+4848+**How To Run**
4949+- Unit + Lua tests: `cd tic80_rust && cargo test`
5050+- Clippy (treat warnings as errors): `cd tic80_rust && cargo clippy --all-targets --all-features -D warnings`
5151+5252+**Future Additions**
5353+- Expand primitives coverage (`circb/circ/elli/ellib/tri/trib`, `print` edge cases: scale>1 areas, baseline/advance, small font).
5454+- Add frame-hash goldens for selected demo sequences (stable seeds and scripts).
5555+- Introduce error-path tests for Lua type/arity mismatches and unknown APIs.
5656+- Add input semantics tests (`key/keyp/btn/btnp/mouse`) with fixed-step repeat timing.
1357
+32
docs/testing/test_catalog.md
···11+# Test Catalog
22+33+This document summarizes the current test coverage with file paths and intent.
44+55+## Framebuffer Unit Tests
66+- `tic80_rust/tests/gfx_framebuffer_tests.rs`
77+ - `cls_fills_entire_buffer`: `cls` fills the entire VRAM with the color index.
88+ - `pix_read_write_and_bounds`: `pix` read/write semantics; OOB reads return `None` and writes are ignored.
99+ - `rect_fill_and_clipping`: `rect` fills and clips to viewport; fully OOB rects are no‑ops.
1010+ - `line_basic_counts_and_endpoints`: Line endpoints colored; counts match `max(dx,dy)+1` both directions.
1111+ - `blit_to_rgba_maps_palette`: Palette index→RGBA mapping matches expected sRGB bytes.
1212+ - `rectb_draws_border`: `rectb` draws a 1‑px border; interior remains unchanged.
1313+ - `clip_limits_drawing_and_reset`: Clip restricts drawing; reset restores full viewport.
1414+ - `print_width_fixed_vs_variable_and_newline`: `print_text` width (fixed vs variable), newline row advance, and scale behavior.
1515+ - `clip_affects_pix_write`: `pix(x,y,color)` respects the active clip.
1616+ - `robust_oob_line_and_rectb`: OOB line still draws in-bounds; border rect crossing viewport produces in-bounds edges.
1717+1818+## Lua Bridge Tests
1919+- `tic80_rust/tests/lua_api_tests.rs`
2020+ - `lua_cls_and_pix`: `BOOT` clears; `TIC` writes a pixel; verify both states.
2121+ - `lua_line_and_rect`: Lines and filled rect via Lua.
2222+ - `lua_print_width_marker`: `print` returns width; script uses it to place a marker.
2323+ - `lua_print_defaults_and_pix_read`: `print` defaults and `pix` read mode; verifies glyphs drawn near origin.
2424+ - `lua_clip_and_rectb`: `clip()` and `rectb` via Lua; clip restricts drawing.
2525+ - `lua_runs_alt_cart_file`: Loads `assets/alt.lua`, checks background/marker/square.
2626+ - `lua_pix_oob_read_returns_nil`: OOB `pix` read returns `nil` in Lua.
2727+ - `lua_default_cart_deterministic_hash`: Default cart produces deterministic frame hashes for fixed tick counts.
2828+2929+Notes
3030+- Tests prefer headless framebuffer inspection over image baselines.
3131+- Hashing uses FNV‑1a over VRAM palette indices for portability and stability.
3232+
+1-1
tic80_rust/Cargo.toml
···66[dependencies]
77winit = "0.28"
88pixels = "0.14"
99-mlua = { version = "0.9", features = ["lua54", "vendored"] }
99+mlua = { version = "0.9", features = ["lua53", "vendored"] }
+1-1
tic80_rust/src/main.rs
···4545 let (width, height) = dimensions();
4646 let size = LogicalSize::new((width as f64) * SCALE, (height as f64) * SCALE);
4747 let window = WindowBuilder::new()
4848- .with_title("tic80_rust – Milestone 1 (GUI + cls/pix)")
4848+ .with_title("rustic")
4949 .with_inner_size(size)
5050 .with_min_inner_size(size)
5151 .build(&event_loop)
+77
tic80_rust/tests/gfx_framebuffer_tests.rs
···8383 c
8484}
85858686+#[allow(dead_code)]
8787+fn fb_hash(fb: &mut Framebuffer) -> u64 {
8888+ // Simple FNV-1a over pixel indices via pix reads
8989+ let (w, h) = dimensions();
9090+ let mut hash: u64 = 0xcbf29ce484222325;
9191+ const FNV_PRIME: u64 = 0x00000100000001B3;
9292+ for y in 0..(h as i32) {
9393+ for x in 0..(w as i32) {
9494+ let b = fb.pix(x, y, None).unwrap_or(0);
9595+ hash ^= b as u64;
9696+ hash = hash.wrapping_mul(FNV_PRIME);
9797+ }
9898+ }
9999+ hash
100100+}
101101+86102#[test]
87103fn line_basic_counts_and_endpoints() {
88104 // Horizontal line
···171187 assert_eq!(fb.pix(0, 0, None), Some(9));
172188 assert_eq!(fb.pix(1, 1, None), Some(9));
173189}
190190+191191+#[test]
192192+fn print_width_fixed_vs_variable_and_newline() {
193193+ let mut fb = Framebuffer::new();
194194+ fb.cls(0);
195195+ // Fixed width: width = len * 6 * scale
196196+ let w1 = fb.print_text("AB", 10, 10, 1, true, 1, false);
197197+ assert_eq!(w1, 2 * 6);
198198+ let w2 = fb.print_text("AB", 10, 10, 1, true, 2, false);
199199+ assert_eq!(w2, 2 * 6 * 2);
200200+ // Variable width should be <= fixed width for same string/scale
201201+ let v1 = fb.print_text("AB", 10, 10, 1, false, 1, false);
202202+ assert!(v1 <= w1);
203203+204204+ // Newlines: expect drawing on initial row and on y+6 (scale=1)
205205+ let mut fb2 = Framebuffer::new();
206206+ fb2.cls(0);
207207+ let _ = fb2.print_text("A\nA", 0, 0, 15, true, 1, false);
208208+ // Something on row 0
209209+ let mut any_row0 = false;
210210+ for x in 0..8 {
211211+ if fb2.pix(x, 0, None) == Some(15) { any_row0 = true; break; }
212212+ }
213213+ assert!(any_row0);
214214+ // And something on row 6
215215+ let mut any_row6 = false;
216216+ for x in 0..8 {
217217+ if fb2.pix(x, 6, None) == Some(15) { any_row6 = true; break; }
218218+ }
219219+ assert!(any_row6);
220220+}
221221+222222+#[test]
223223+fn clip_affects_pix_write() {
224224+ let mut fb = Framebuffer::new();
225225+ fb.cls(2);
226226+ fb.clip(1, 1, 1, 1); // only (1,1)
227227+ // Write outside clip
228228+ let _ = fb.pix(0, 0, Some(7));
229229+ // Write inside clip
230230+ let _ = fb.pix(1, 1, Some(7));
231231+ // Validate
232232+ assert_eq!(fb.pix(0, 0, None), Some(2));
233233+ assert_eq!(fb.pix(1, 1, None), Some(7));
234234+}
235235+236236+#[test]
237237+fn robust_oob_line_and_rectb() {
238238+ let mut fb = Framebuffer::new();
239239+ fb.cls(0);
240240+ // Very long line across/outside bounds
241241+ fb.line(-100, -100, 1000, 2000, 9);
242242+ // Should produce some in-bounds pixels
243243+ assert!(count_color(&mut fb, 9) > 0);
244244+245245+ // Rect border with negative origin that crosses viewport
246246+ fb.rectb(-5, -5, 12, 12, 4);
247247+ // Perimeter segments that lie in-bounds should be colored
248248+ assert_eq!(fb.pix(6, 0, None), Some(4)); // right edge
249249+ assert_eq!(fb.pix(0, 6, None), Some(4)); // bottom edge
250250+}
+59
tic80_rust/tests/lua_api_tests.rs
···165165 assert_eq!(fbm.pix(1, 0, None), Some(1));
166166 assert_eq!(fbm.pix(0, 1, None), Some(1));
167167}
168168+169169+#[test]
170170+fn lua_pix_oob_read_returns_nil() {
171171+ let script = r#"
172172+ function BOOT() cls(0) end
173173+ function TIC()
174174+ local ok = true
175175+ if pix(-1, 0) ~= nil then ok = false end
176176+ if pix(0, -1) ~= nil then ok = false end
177177+ if pix(240, 0) ~= nil then ok = false end
178178+ if pix(0, 136) ~= nil then ok = false end
179179+ if ok then pix(0, 0, 5) end
180180+ end
181181+ "#;
182182+ let fb = run_lua(script, 1);
183183+ // Marker set only if nil checks passed
184184+ assert_eq!(fb.borrow_mut().pix(0, 0, None), Some(5));
185185+}
186186+187187+fn fb_hash(fb: &mut Framebuffer) -> u64 {
188188+ let (w, h) = dimensions();
189189+ let mut hash: u64 = 0xcbf29ce484222325;
190190+ const FNV_PRIME: u64 = 0x00000100000001B3;
191191+ for y in 0..(h as i32) {
192192+ for x in 0..(w as i32) {
193193+ let b = fb.pix(x, y, None).unwrap_or(0);
194194+ hash ^= b as u64;
195195+ hash = hash.wrapping_mul(FNV_PRIME);
196196+ }
197197+ }
198198+ hash
199199+}
200200+201201+#[test]
202202+fn lua_default_cart_deterministic_hash() {
203203+ // Load default cart and run N frames twice; hashes should match
204204+ let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
205205+ path.push("assets/default.lua");
206206+ let script = std::fs::read_to_string(&path).expect("read default.lua");
207207+208208+ let run_hash = |ticks: usize| -> u64 {
209209+ let fb = Rc::new(RefCell::new(Framebuffer::new()));
210210+ let runner = LuaRunner::new(fb.clone(), &script).expect("lua init");
211211+ for _ in 0..ticks { runner.tick(); }
212212+ let mut borrowed = fb.borrow_mut();
213213+ fb_hash(&mut *borrowed)
214214+ };
215215+216216+ let h1 = run_hash(1);
217217+ let h1_again = run_hash(1);
218218+ assert_eq!(h1, h1_again, "hash should be deterministic for 1 tick");
219219+220220+ let h2 = run_hash(2);
221221+ let h2_again = run_hash(2);
222222+ assert_eq!(h2, h2_again, "hash should be deterministic for 2 ticks");
223223+224224+ // Different frame counts should usually yield different hashes for this cart
225225+ assert_ne!(h1, h2, "different ticks should yield different frame hashes");
226226+}