···1818- ALWAYS update the test carts catalog when adding a new cart:
1919 - Add the cart to `docs/testing/test_carts.md` with purpose, run instructions, and expected behavior.
2020 - If needed, add a short run snippet to `docs/README.md`.
2121+- Maintain a rolling TODO list from reviews in `docs/roadmap/todos_code_review.md` and tick items as they’re completed.
21222223**Context**
2324- **Rewrite code location:** All Rust rewrite code lives under `tic80_rust/` (crate root). Tests live in `tic80_rust/tests/`. The windowed demo binary is `tic80_rust/src/main.rs`.
···58595960**Near-Term Backlog**
6061- FFT implementation (cpal + realfft) per `docs/specs/audio_fft_vqt.md` (see Implementation TODOs section); add headless tests and Lua `fft/ffts/fftr/fftrs`.
6262+- Livecoding editor (UI plan): Implement `tic-studio` per `docs/roadmap/editor_livecoding.md` — deliver CODE first, then CONSOLE (console scope may be reduced); TIC‑80 skin in framebuffer; hot reload; .tic code‑only round‑trip.
6163- Print edge cases: tests for scale>1 baseline/advance and multi‑line width parity.
6264- Small font: decide semantics and implement `smallfont=true` in `print` with tests.
6365- Lua error paths: add type/arity mismatch tests for core APIs (`pix/line/rect/print`).
···102104 - Integrated a 1s VU peak readout for manual verification.
103105 - Implemented 2k FFT analysis (realfft) with normalized/smoothed buffers and debug print flag; wired Lua FFT APIs; added headless tests and an FFT test cart.
104106 - Kept clippy/tests green; documented the FFT/VQT plan and linked TODOs.
107107+ - Added clippy policy doc (docs/architecture/clippy_policy.md) and linked in docs index.
108108+ - Refactored `main.rs` into helpers: args parsing, window/pixels init, audio init, device listing, script load. Behavior unchanged; code easier to lint/extend.
109109+ - Error handling improvements:
110110+ - Lua init errors now print once; warn once when cart defines no TIC().
111111+ - BOOT() and TIC() call errors are logged to console instead of being dropped.
112112+ - FFT/VQT update now warns once if realfft processing fails.
113113+ - Audio capture robustness:
114114+ - Selects nearest supported sample rate to requested (default 44100 Hz) and logs the choice.
115115+ - Added ring buffer counters (pushes/overflows) and consumer underrun + consumed counters; `--debug-fx` prints per‑second deltas and totals with avg samples/tick and estimated occupancy.
116116+ - Memory map clarity: replaced magic numbers with named constants; documented screen nibble packing.
117117+ - Bit manipulation tests: added round-trip tests for 1/2/4/8‑bit ops, VRAM screen boundary, and unaligned 4‑bit sequences (nibble order).
118118+ - 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.
119119+ - Verified hygiene: `cargo clippy --all-targets --all-features -D warnings` is clean; `cargo test` all green; ran `cargo fmt`.
120120+ - Tightened clippy setup (pedantic/nursery/cargo) with pragmatic allows:
121121+ - 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.
122122+ - Module/function allows where appropriate: `too_many_lines`, `items_after_statements`, `missing_errors_doc`, `needless_pass_by_value`, `redundant_clone`, `option_if_let_else` refactors or allows where needed.
123123+ - Fixed numerous lints in code (lossless casts via `From`, `mul_add`/`hypot`, `map_or(_else)`, format arg inlining, moved inner items to top of scopes).
124124+ - Added small docs and `#[must_use]` on relevant fns; marked a few helpers `const` where safe.
125125+ - Cargo metadata filled in to silence cargo_common_metadata; clippy now passes with `-D warnings` across all targets.
126126+ - Ran `cargo fmt`, `cargo clippy --all-targets --all-features -D warnings`, and `cargo test`: all green.
105127106128**Docs Index**
107129- Start here: `docs/README.md`
+24-1
docs/README.md
···55## Roadmap
66- `docs/roadmap/overview.md`: High-level phased roadmap and goals (moved from RUST_REWRITE.md).
77- `docs/roadmap/gui_first.md`: Combined GUI-first kickoff + milestones for `winit + pixels` and `cls/pix`.
88+- `docs/roadmap/editor_livecoding.md`: Livecoding editor plan (TIC‑80 UI vibes): CODE + CONSOLE only.
99+ - `docs/roadmap/todos_code_review.md`: Rolling TODOs from code review (high/medium/low priority) with checkboxes.
810911## Specs
1012- `docs/specs/memory_map.md`: Canonical pointer to the root `MEMORY_MAP.md` and usage notes.
···1618## Architecture
1719- `docs/architecture/workspace.md`: Crate layout and module boundaries.
1820- `docs/architecture/runtime.md`: Fixed-step loop, callbacks, and presentation responsibilities.
2121+- `docs/architecture/clippy_policy.md`: Lint policy (pedantic baseline + curated allows).
19222023## Testing
2124- `docs/testing/strategy.md`: Testing and validation strategy across API/VRAM/audio.
···4548- Time/Trace test cart:
4649 - `cargo run --manifest-path tic80_rust/Cargo.toml -- tic80_rust/assets/time_trace_test.lua`
4750 - Shows elapsed ms and emits a trace once per second to the console.
4848- - VQT test cart:
5151+- VQT test cart:
4952 - `cargo run --manifest-path tic80_rust/Cargo.toml -- tic80_rust/assets/vqt_test.lua --audio-device "<name-substr>"`
5053 - Visualizes 120 bins (12 octaves) with a 2px per-bin bar chart; auto-toggles between raw and whitened views every ~3 seconds.
5454+5555+## CLI
5656+- Usage: `tic80_rust [OPTIONS] [CART.lua]`
5757+- Options:
5858+ - `-h, --help`: Show help and exit.
5959+ - `--quiet`: Suppress once-only warnings and Lua BOOT()/TIC() error prints (useful for headless runs).
6060+ - `--list-audio`: List input audio devices and exit.
6161+ - `--audio-device <SUBSTR>`: Select input device by substring match (case-insensitive).
6262+ - `--audio-disable`: Disable audio capture and analysis.
6363+ - `--audio-vu`: Print VU peak dBFS once per second.
6464+ - `--debug-fft`: Print the first 16 FFT bins (smoothed, normalized) roughly every 500 ms.
6565+ - `--debug-fx`: Print per-second FX timings and ring stats: pushed delta (dp), overflow delta/total (ovf), underrun delta/total, consumed delta (cons), EMA samples/tick, and estimated ring occupancy.
6666+- Notes:
6767+ - CART.lua is optional; defaults to the bundled cart if omitted.
6868+ - Audio capture picks the nearest supported sample rate to 44100 Hz and logs the selection.
6969+ - Window is fixed 240×136 internal res with integer scaling.
7070+7171+## Debug Flags (Audio Analysis)
7272+- `--debug-fft`: First 16 FFT bins (smoothed normalized).
7373+- `--debug-fx`: FX timings plus ring stats (dp/ovf/underrun/cons), EMA samples/tick, estimated occupancy.
+193
docs/adr/codereviews/001.md
···11+## Comprehensive Code Review of TIC-80 Rust Rewrite
22+33+After thoroughly reviewing the documentation, code architecture, and implementation, here's my comprehensive assessment of the TIC-80 Rust rewrite project.
44+55+### Overall Assessment
66+77+The project is off to a **strong start** with solid foundations and good engineering practices. The codebase demonstrates:
88+- Clean architecture and module separation
99+- Good test coverage with deterministic testing strategies
1010+- Careful attention to TIC-80 compatibility
1111+- Excellent documentation discipline
1212+1313+### Strengths
1414+1515+#### 1. **Architecture & Code Organization**
1616+- Clean module structure (`gfx`, `core`, `script`, `audio`) that aligns well with the planned multi-crate architecture
1717+- Good separation of concerns between presentation (winit+pixels), core logic, and scripting
1818+- Effective use of Rust's ownership model (Rc<RefCell<>> for shared framebuffer/memory state)
1919+2020+#### 2. **Documentation Excellence**
2121+- Comprehensive documentation structure with clear separation of concerns (roadmap, specs, architecture, testing)
2222+- Excellent ADR (Architecture Decision Records) practice
2323+- Clear implementation status tracking
2424+- Detailed test catalog with intent documentation
2525+- Good worklog maintenance in AGENTS.md
2626+2727+#### 3. **Testing Strategy**
2828+- Smart use of deterministic frame hashing (FNV-1a) for visual regression testing
2929+- Good coverage of edge cases (clipping, OOB, triangle rasterization rules)
3030+- Headless tests that don't rely on image comparisons
3131+- Clear test intent documentation
3232+3333+#### 4. **Graphics Implementation**
3434+- Solid framebuffer implementation with proper clipping support
3535+- Correct triangle rasterization with top-left rule
3636+- Careful attention to TIC-80 parity (e.g., color masking to 0x0F)
3737+- Memory-mapped VRAM implementation that correctly bridges framebuffer and memory systems
3838+3939+#### 5. **Lua Integration**
4040+- Clean mlua integration with Lua 5.3
4141+- Proper handling of optional parameters and default values
4242+- Good error handling patterns
4343+- Support for both BOOT and TIC callbacks
4444+4545+### Areas for Improvement
4646+4747+#### 1. **Error Handling**
4848+- Some silent failures could be more explicit:
4949+ ```rust
5050+ // In main.rs line 213
5151+ if let Some(r) = &lua_runner {
5252+ r.tick();
5353+ }
5454+ ```
5555+ Consider logging when lua_runner is None or tick fails.
5656+5757+- FFT/VQT processing errors are silently ignored:
5858+ ```rust
5959+ // In fft.rs line 101
6060+ .process_with_scratch(&mut self.input, &mut self.spectrum, &mut self.scratch)
6161+ .ok(); // Silent failure
6262+ ```
6363+6464+#### 2. **Type Safety & API Design**
6565+- Mixed use of `i32` and `f32` for coordinates in Lua bindings could be more consistent:
6666+ ```rust
6767+ // line() takes f32 but converts to i32
6868+ move |_, (x0, y0, x1, y1, color): (f32, f32, f32, f32, u8)|
6969+ ```
7070+ Consider using consistent types or documenting the rationale.
7171+7272+- Magic numbers could be constants:
7373+ ```rust
7474+ const VRAM_SCREEN_BYTES: usize = 0x3FC0; // Document why this specific value
7575+ ```
7676+7777+#### 3. **Performance Considerations**
7878+- Frequent locking/unlocking of framebuffer/memory in tight loops:
7979+ ```rust
8080+ // Each pix() call borrows mutably
8181+ let mut fb = self.fb.borrow_mut();
8282+ ```
8383+ Consider batching operations or using unsafe blocks for performance-critical paths.
8484+8585+- Audio processing on tick thread could cause frame drops. Consider moving FFT/VQT to a separate thread with double-buffering.
8686+8787+#### 4. **Memory Safety Edge Cases**
8888+- The memory module's bit-packed operations have complex shifting logic that could benefit from more unit tests:
8989+ ```rust
9090+ // peek_bits/poke_bits have intricate bit manipulation
9191+ let shift = (addr & 0b11) * 2;
9292+ let mask = !(0x03u8 << shift);
9393+ ```
9494+9595+#### 5. **Missing Clippy Lints**
9696+While the code is clippy-clean, consider enabling additional lints:
9797+```rust
9898+#![warn(
9999+ clippy::all,
100100+ clippy::pedantic,
101101+ clippy::nursery,
102102+ clippy::cargo,
103103+ rust_2018_idioms
104104+)]
105105+```
106106+107107+### Technical Debt & Future Considerations
108108+109109+1. **Single Crate Structure**: The current single-crate approach will need refactoring as the project grows. The planned multi-crate architecture is well-designed but migration could be disruptive.
110110+111111+2. **Fixed Audio Sample Rate**: The hardcoded 44.1kHz preference might not work well on all systems. Consider making this more flexible.
112112+113113+3. **Thread Safety**: Current design uses Rc<RefCell<>> which limits to single-threaded execution. Future audio processing might benefit from Arc<Mutex<>> or lock-free structures.
114114+115115+4. **WASM Target**: Current use of parking_lot and system-specific audio will need abstraction for WASM builds.
116116+117117+### Recommendations
118118+119119+1. **Immediate Actions**:
120120+ - Add error logging for silent failures
121121+ - Create constants for magic numbers with documentation
122122+ - Add benchmarks for critical paths (framebuffer operations, memory access)
123123+ - Consider a `CONTRIBUTING.md` with coding standards
124124+125125+2. **Short Term**:
126126+ - Move audio processing to a separate thread
127127+ - Add integration tests that run full TIC-80 programs
128128+ - Implement proper error types instead of using anyhow everywhere
129129+ - Add property-based tests for graphics primitives
130130+131131+3. **Long Term**:
132132+ - Plan the migration to multi-crate architecture
133133+ - Design abstraction layer for platform-specific code (audio, input)
134134+ - Consider using const generics for compile-time framebuffer dimensions
135135+ - Implement a profiling framework for performance regression testing
136136+137137+### Compliance with Best Practices
138138+139139+The project excellently follows the stated guidelines:
140140+- ✅ All tests pass
141141+- ✅ Clippy clean
142142+- ✅ Excellent documentation discipline
143143+- ✅ Test-driven development approach
144144+- ✅ Minimal, focused changes
145145+- ✅ Clear separation of concerns
146146+147147+### Summary
148148+149149+This is a **well-architected and thoughtfully implemented** rewrite that demonstrates strong Rust expertise and careful attention to TIC-80 compatibility. The code quality is high, the testing strategy is sound, and the documentation is exemplary. The identified improvements are mostly refinements rather than fundamental issues. The project is well-positioned for continued development toward feature parity with TIC-80.
150150+151151+```rust
152152+ // In main.rs line 213
153153+ if let Some(r) = &lua_runner {
154154+ r.tick();
155155+ }
156156+```
157157+158158+```rust
159159+ // In fft.rs line 101
160160+ .process_with_scratch(&mut self.input, &mut self.spectrum, &mut self.scratch)
161161+ .ok(); // Silent failure
162162+```
163163+164164+```rust
165165+ // line() takes f32 but converts to i32
166166+ move |_, (x0, y0, x1, y1, color): (f32, f32, f32, f32, u8)|
167167+```
168168+169169+```rust
170170+ const VRAM_SCREEN_BYTES: usize = 0x3FC0; // Document why this specific value
171171+```
172172+173173+```rust
174174+ // Each pix() call borrows mutably
175175+ let mut fb = self.fb.borrow_mut();
176176+```
177177+178178+```rust
179179+ // peek_bits/poke_bits have intricate bit manipulation
180180+ let shift = (addr & 0b11) * 2;
181181+ let mask = !(0x03u8 << shift);
182182+```
183183+184184+```rust
185185+#![warn(
186186+ clippy::all,
187187+ clippy::pedantic,
188188+ clippy::nursery,
189189+ clippy::cargo,
190190+ rust_2018_idioms
191191+)]
192192+```
193193+
+31
docs/architecture/clippy_policy.md
···11+# Clippy Policy (Rust Rewrite)
22+33+Goal: keep `cargo clippy --all-targets --all-features -D warnings` green while balancing ergonomics for TIC-80 style APIs and DSP-heavy code.
44+55+Defaults
66+- Enabled lints at crate root: `clippy::all`, `clippy::pedantic`, `clippy::nursery`, `clippy::cargo`, `rust_2018_idioms`.
77+- Treat warnings as errors in CI and local development.
88+99+Curated allows (project-wide)
1010+- TIC API shape: `many_single_char_names`, `too_many_arguments`, `similar_names`.
1111+- DSP and platform crates: `multiple_crate_versions` (transitive deps), select numeric casts where hot paths need clarity: `cast_possible_wrap`, `cast_sign_loss`, `cast_precision_loss`.
1212+1313+Localized allows (module/function scope)
1414+- Long installers/glue (e.g., Lua API setup, CLI `main`): `too_many_lines`, `items_after_statements`, `missing_errors_doc`, `needless_pass_by_value`, `redundant_clone`, `option_if_let_else`, `uninlined_format_args`, `significant_drop_tightening`.
1515+- VQT/FFT inner loops: `suboptimal_flops`, `imprecise_flops`, `cast_possible_truncation` where using `mul_add`, `hypot`, or explicit casts are already applied.
1616+- `missing_const_for_fn`: prefer not to commit to `const` unless there’s a clear use/benefit and the API contract is stable.
1717+1818+Preferences
1919+- Prefer lossless conversions via `From` over `as` where feasible.
2020+- Prefer `map_or/map_or_else` over `if let`/`else` on `Option` when it improves clarity.
2121+- Use `mul_add`, `hypot`, and `exp_m1` in numeric code where it improves precision/readability.
2222+- Add `#[must_use]` to getters/utilities that return values callers should not ignore.
2323+2424+Process
2525+- Fix high-signal lints; allow low-signal ones locally with rationale.
2626+- Keep changes surgical; avoid broad global allows unless they’re a deliberate policy.
2727+2828+See also
2929+- `AGENTS.md` Worklog entries for recent lint policy changes.
3030+- `docs/specs/implementation_status.md` Hygiene section for current status.
3131+
+1-1
docs/architecture/workspace.md
···1515- Lua (`tic-lua`) calls into `tic-api` → forwards to `tic-core/gfx/audio/io`.
1616- `tic-gfx` writes to VRAM page(s); presenter converts palette indices to RGBA for display.
1717- `tic-audio` produces sample blocks; optional capture ring shared with `tic-fx`.
1818-1818+- `tic-studio` (planned): A framebuffer‑rendered TIC‑80‑style UI. We will deliver CODE first, then CONSOLE (console scope may be reduced). Integrates with `tic-runner`/`tic-core` for hot reload and .tic code‑only round‑trip. See `docs/roadmap/editor_livecoding.md`.
+77
docs/roadmap/editor_livecoding.md
···11+# Livecoding Editor Plan (TIC‑80 Vibes)
22+33+This plan describes a minimal, purpose‑built editor focused on livecoding with TIC‑80’s look and feel. Scope is limited to a Code view (delivered first) and a Console view (delivered after CODE; scope may be reduced), tightly integrated with the runtime and preserving .tic semantics. Sprite/Map/SFX/Music editors are out‑of‑scope for this rewrite.
44+55+## Scope and Goals
66+- Sequencing: CODE first; CONSOLE after. Console scope may be reduced based on needs.
77+- Views: CODE, CONSOLE (preview is the running framebuffer as today).
88+- Parity: Preserve .tic cart semantics. Replace only the code section on save; all other sections remain byte‑for‑byte identical.
99+- Vibes: Render a pixel‑perfect TIC‑80 UI skin in the 240×136 framebuffer and present via integer scaling.
1010+- Livecoding: Hot‑reload on save; keep last‑good runner if the new code has errors.
1111+1212+## Architecture
1313+- Rendering: Draw the full UI (chrome, tabs, editor, console, status) into our 240×136 palette framebuffer using existing gfx primitives; present with `winit + pixels` integer scaling.
1414+- Input: Read keyboard/mouse from `winit` and feed a simple UI layer (no external widget toolkit).
1515+- Text engine: Rope‑backed buffer for code (e.g., ropey) to keep edits fast and memory stable.
1616+- Runtime coupling: Save swaps LuaRunner safely; error messages go to the console while keeping the last‑good runner active.
1717+- Cart I/O: Code‑only patching on save; everything else preserved exactly.
1818+1919+## UI Skin
2020+- Top bar: TIC‑80‑style chrome, CODE/CONSOLE tabs, RUN/STOP/RESET buttons, FPS/ms status.
2121+- CODE: Monospace text area with gutter (line numbers), caret, selection, scrolling, basic syntax coloring.
2222+- CONSOLE: Scrolling list of trace() lines and errors; timestamps; clear button.
2323+- Bottom status: File name, cursor position, modified flag.
2424+2525+## Phases and Detailed TODOs (CODE first, then CONSOLE)
2626+2727+Phase 0 — Shell + Layout
2828+- [ ] Create `docs/` UI skin reference (palette, font, margins, tab geometry) (optional diagrams).
2929+- [ ] Add a minimal UI state model (active tab, focus, scroll positions).
3030+- [ ] Render top chrome, tabs, and placeholder panels into the framebuffer.
3131+- [ ] Wire tab switching and button hit‑testing (rect hit tests in 240×136 space).
3232+- [ ] Integrate integer scaling to window and mouse coordinate unprojection.
3333+3434+Phase 1 — Text Engine + Editor Basics
3535+- [ ] Integrate a rope‑backed buffer (ropey) and load cart code into it.
3636+- [ ] Caret movement (arrows, home/end), insert/delete/backspace, newlines.
3737+- [ ] Selection (shift+arrows), clipboard (Ctrl/Cmd+C/V/X), undo/redo (local stack for code buffer).
3838+- [ ] Horizontal/vertical scrolling; viewport mapping from text rows to framebuffer pixels.
3939+- [ ] Draw gutter (line numbers) and current line highlight.
4040+4141+Phase 2 — Syntax + UX polish (minimal)
4242+- [ ] Lightweight Lua colorizer (keywords, comments, strings, numbers) with palette colors.
4343+- [ ] Find/replace panel (Ctrl/Cmd+F) with next/prev navigation.
4444+- [ ] Adjustable font scale within 8×8 multiples (e.g., 1×/2×) while preserving 240×136 layout.
4545+4646+Phase 3 — Console Pane (may be reduced)
4747+- [ ] Console ring buffer model with timestamps and color tags.
4848+- [ ] Route trace() and runtime/loader errors to the console.
4949+- [ ] Clear button and scroll behavior; persist scroll at bottom on new lines.
5050+5151+Phase 4 — Hot Reload + Cart I/O
5252+- [ ] Code‑only .tic write: patch code section and save; verify other sections preserved.
5353+- [ ] Hot reload: on save, compile+swap LuaRunner; on error, push message to console and keep last‑good runner.
5454+- [ ] Menu/buttons: Run/Stop/Reset wired to runtime control.
5555+5656+Phase 5 — Tests + Docs
5757+- [ ] Headless test: .tic round‑trip preserves non‑code bytes.
5858+- [ ] Hot reload tests: failing edit keeps last‑good; fixing edit swaps runner.
5959+- [ ] Console tests: trace() lines and error render path covered.
6060+- [ ] Add README section with usage and keybinds; link test carts and debug flags.
6161+6262+## Keybinds (initial)
6363+- Save: Ctrl/Cmd+S
6464+- Run/Stop: Ctrl/Cmd+R (and optional F5)
6565+- Find: Ctrl/Cmd+F
6666+- Copy/Paste: Ctrl/Cmd+C/V/X
6767+- Undo/Redo: Ctrl/Cmd+Z / Ctrl/Cmd+Shift+Z
6868+6969+## Testing Strategy
7070+- Unit: code buffer ops (insert/delete/undo/redo), scroll viewport, hit‑testing.
7171+- Integration: open→edit→save→open asserts only code changed; hot reload error gating; console logs observed.
7272+- Manual: use existing carts (fft/vqt/time/trace) to validate livecoding flow and console output.
7373+7474+## Open Questions
7575+- External file watcher for auto‑reload (deferred).
7676+- Config: theme, font size, autosave debounce.
7777+- Optional MRU list and single‑instance guard.
+47
docs/roadmap/todos_code_review.md
···11+# Code Review TODOs (Live list)
22+33+This list tracks follow-ups from the code review (see `docs/adr/codereviews/001.md`). Items are prioritized. We tick items off as they land.
44+55+## High Priority
66+- [x] Error handling and visibility
77+ - [x] Warn once when Lua runner is absent (main loop)
88+ - [x] Log errors from TIC() calls without crashing
99+ - [x] Log (warn once) if realfft processing fails in FFT/VQT
1010+- [x] Audio capture robustness
1111+ - [x] Select nearest supported sample rate to 44100 Hz when exact not available; log choice
1212+ - [x] Add ring overflow counters and print under `--debug-fx`
1313+- [x] VQT wiring parity tests
1414+ - [x] Ensure `vqt` (instantaneous normalized) != `vqts` (smoothed normalized) on first update
1515+ - [x] Same for `vqtw` vs `vqtsw`
1616+- [x] Memory map clarity
1717+ - [x] Replace remaining magic numbers with named constants + doc comments where applicable
1818+- [x] Bit manipulation tests
1919+ - [x] Property-based round-trip tests for `peek_bits/poke_bits` across 1/2/4/8-bit widths
2020+ - [x] Region boundary tests around memory edges
2121+2222+## Medium Priority
2323+- [ ] Linting and CI
2424+ - [ ] Add stricter lint profile (pedantic/nursery/cargo + rust_2018_idioms) in CI
2525+ - [ ] Allowlist noisy lints where appropriate
2626+- [ ] Contributor and design docs
2727+ - [ ] CONTRIBUTING.md (fmt/clippy/test, docs discipline, running carts)
2828+ - [ ] ADR: Editor approach (CODE first, CONSOLE later, framebuffer UI)
2929+ - [ ] WASM feasibility note (platform abstraction for audio/input)
3030+- [ ] Benchmarks (Criterion)
3131+ - [ ] Framebuffer ops and blit_to_rgba
3232+ - [ ] Memory peek/poke variants
3333+ - [ ] FFT (2k) + VQT (8k sparse dots)
3434+- [ ] Integration tests
3535+ - [ ] .tic round-trip: only code section changes
3636+ - [ ] Hot reload gating: failing edit keeps last-good; recovery on fix
3737+ - [ ] Console: trace()/error paths observed deterministically
3838+3939+## Lower Priority / Optional
4040+- [ ] Optional analysis worker thread (feature-flag) with double-buffered FFT/VQT outputs
4141+- [ ] Editor prep (UI skin specifics)
4242+ - [ ] Confirm font/palette assets and integer scale steps
4343+ - [ ] Lock initial keybinds (Save/Run/Find)
4444+ - [ ] Define hit-testing rectangles for tabs/buttons in 240×136 space
4545+4646+Notes
4747+- Keep docs/worklog disciplined: whenever an item is completed, tick it here, add a note in AGENTS.md Worklog, and update relevant specs.
+4
docs/specs/audio_fft_vqt.md
···7272 - Single‑tone hits expected semitone bin; whitened vs unwhitened differ predictably on broadband inputs.
7373 - Normalization clamps to [0,1]; whitened/unwhitened use independent peak trackers.
74747575+## Debugging & Telemetry
7676+- `--debug-fft`: Prints the first 16 FFT bins (smoothed normalized) periodically (~500 ms) for sanity checks.
7777+- `--debug-fx`: Prints average processing time per tick (milliseconds) for FFT and VQT once per second (uses tick-thread timings). Useful to monitor headroom and spot regressions.
7878+7579## Integration Plan (Milestones)
76801) FFT foundation: ring buffer, 2k R2C planner, raw/normalized/smoothed buffers, Lua `fft/ffts/fftr/fftrs`.
77812) VQT kernels: generation + storage; 8k R2C planner; unwhitened path with `vqt/vqts/vqtr/vqtrs`.
+1
docs/specs/implementation_status.md
···7979- Graphics semantics: `docs/specs/graphics.md`.
8080- API parity checklist: `docs/specs/lua_api_parity.md`.
8181- Testing strategy and catalog: `docs/testing/strategy.md`, `docs/testing/test_catalog.md`.
8282+ - Hygiene: clippy pedantic baseline enforced with curated allows for DSP and TIC-style APIs; see `AGENTS.md` for current lint policy and status.
+6
docs/testing/test_catalog.md
···4343 - `peek4_reads_back_nibble`: 4-bit reads reflect framebuffer.
4444 - `memcpy_and_memset_affect_vram`: VRAM writes via memcpy/memset reach the screen.
4545 - `peek_poke_bits_general_ram`: 1/4-bit addressing in general RAM behaves correctly.
4646+- `tic80_rust/tests/memory_bits_roundtrip.rs`
4747+ - `roundtrip_peek_poke_bits_general_ram`: Round-trip property-like checks for 1/2/4/8-bit peek/poke across a RAM window.
4848+ - `vram_screen_boundary_write_does_not_bleed`: Last screen byte maps to the last two pixels; next byte (non-screen VRAM) does not affect framebuffer.
4949+- `tic80_rust/tests/memory_bits_alignment.rs`
5050+ - `two_bit_cross_byte_alignment`: 2‑bit writes at end of one byte and start of next do not bleed.
5151+ - `four_bit_unaligned_nibbles`: Odd/even nibble writes across bytes and within a byte pack correctly.
46524753## FFT Tests
4854- `tic80_rust/tests/fft_tests.rs`
···11+#![warn(
22+ clippy::all,
33+ clippy::pedantic,
44+ clippy::nursery,
55+ clippy::cargo,
66+ rust_2018_idioms
77+)]
88+#![allow(
99+ clippy::many_single_char_names,
1010+ clippy::too_many_arguments,
1111+ clippy::similar_names,
1212+ clippy::multiple_crate_versions,
1313+ clippy::cast_possible_wrap,
1414+ clippy::cast_sign_loss,
1515+ clippy::cast_precision_loss,
1616+ clippy::too_many_lines,
1717+ clippy::items_after_statements,
1818+ clippy::cast_lossless,
1919+ clippy::case_sensitive_file_extension_comparisons,
2020+ clippy::option_if_let_else,
2121+ clippy::uninlined_format_args,
2222+ clippy::significant_drop_tightening,
2323+ clippy::match_same_arms,
2424+ clippy::redundant_clone,
2525+ clippy::struct_excessive_bools
2626+)]
2727+128use std::cell::RefCell;
229use std::fs;
33-use std::path::Path;
3030+use std::path::{Path, PathBuf};
431use std::rc::Rc;
532use std::time::{Duration, Instant};
633···835use winit::dpi::LogicalSize;
936use winit::event::{ElementState, Event, KeyboardInput, VirtualKeyCode, WindowEvent};
1037use winit::event_loop::{ControlFlow, EventLoop};
1111-use winit::window::WindowBuilder;
3838+use winit::window::{Window, WindowBuilder};
12391340use parking_lot::RwLock;
1441use std::sync::Arc;
···44714572const DEFAULT_LUA: &str = include_str!("../assets/default.lua");
46734747-fn run() -> Result<(), Error> {
4848- let event_loop = EventLoop::new();
4949- const SCALE: f64 = 3.0; // default integer scaling
5050- let (width, height) = dimensions();
5151- let size = LogicalSize::new((width as f64) * SCALE, (height as f64) * SCALE);
5252- let window = WindowBuilder::new()
5353- .with_title("rustic")
5454- .with_inner_size(size)
5555- .with_min_inner_size(size)
5656- .build(&event_loop)
5757- .unwrap();
7474+// CLI args
7575+struct Args {
7676+ script_path: Option<PathBuf>,
7777+ list_audio: bool,
7878+ audio_disable: bool,
7979+ audio_device: Option<String>,
8080+ audio_vu: bool,
8181+ debug_fft: bool,
8282+ debug_fx: bool,
8383+ quiet: bool,
8484+ help: bool,
8585+}
58865959- let window_size = window.inner_size();
6060- let surface_texture = SurfaceTexture::new(window_size.width, window_size.height, &window);
6161- let mut pixels = Pixels::new(width, height, surface_texture)?;
6262-6363- let fb = Rc::new(RefCell::new(Framebuffer::new()));
6464- let mem = Rc::new(RefCell::new(Memory::new(fb.clone())));
6565- let mut ticker = Ticker::new();
6666- // CLI parsing (minimal): flags + optional .lua path
8787+fn parse_args() -> Args {
6788 let mut args_iter = std::env::args().skip(1);
6868- let mut script_path: Option<String> = None;
6969- let mut list_audio = false;
7070- let mut audio_disable = false;
7171- let mut audio_device: Option<String> = None;
7272- let mut audio_vu = false;
7373- let mut debug_fft = false;
8989+ let mut out = Args {
9090+ script_path: None,
9191+ list_audio: false,
9292+ audio_disable: false,
9393+ audio_device: None,
9494+ audio_vu: false,
9595+ debug_fft: false,
9696+ debug_fx: false,
9797+ quiet: false,
9898+ help: false,
9999+ };
74100 while let Some(arg) = args_iter.next() {
75101 match arg.as_str() {
7676- "--list-audio" => list_audio = true,
7777- "--audio-disable" => audio_disable = true,
7878- "--audio-vu" => audio_vu = true,
7979- "--debug-fft" => debug_fft = true,
102102+ "-h" | "--help" => out.help = true,
103103+ "--list-audio" => out.list_audio = true,
104104+ "--audio-disable" => out.audio_disable = true,
105105+ "--audio-vu" => out.audio_vu = true,
106106+ "--debug-fft" => out.debug_fft = true,
107107+ "--debug-fx" => out.debug_fx = true,
108108+ "--quiet" => out.quiet = true,
80109 "--audio-device" => {
81110 if let Some(val) = args_iter.next() {
8282- audio_device = Some(val);
111111+ out.audio_device = Some(val);
83112 }
84113 }
85114 other => {
86115 if other.ends_with(".lua") && Path::new(other).is_file() {
8787- script_path = Some(other.to_string());
116116+ out.script_path = Some(PathBuf::from(other));
88117 }
89118 }
90119 }
91120 }
9292- if list_audio {
9393- let list = audio_cap::list_input_devices();
9494- if list.is_empty() {
9595- println!("No input devices found.");
9696- } else {
9797- println!("Input devices:");
9898- for (i, name) in list.iter().enumerate() {
9999- println!(" {}: {}", i, name);
121121+ out
122122+}
123123+124124+fn create_window_and_pixels(
125125+ event_loop: &EventLoop<()>,
126126+ scale: f64,
127127+) -> Result<(Window, Pixels), Error> {
128128+ let (width, height) = dimensions();
129129+ let size = LogicalSize::new((width as f64) * scale, (height as f64) * scale);
130130+ let window = WindowBuilder::new()
131131+ .with_title("rustic")
132132+ .with_inner_size(size)
133133+ .with_min_inner_size(size)
134134+ .build(event_loop)
135135+ .unwrap();
136136+ let ws = window.inner_size();
137137+ let surface_texture = SurfaceTexture::new(ws.width, ws.height, &window);
138138+ let pixels = Pixels::new(width, height, surface_texture)?;
139139+ Ok((window, pixels))
140140+}
141141+142142+// Optional audio capture
143143+struct AudioState {
144144+ handle: audio_cap::AudioCaptureHandle,
145145+ cons: rtrb::Consumer<f32>,
146146+ vu_enabled: bool,
147147+ last_print: Instant,
148148+ peak_acc: f32,
149149+ fft: Arc<RwLock<FFTState>>,
150150+ vqt: Arc<RwLock<tic80_rust::audio::vqt::VQTState>>,
151151+ debug_fft: bool,
152152+ last_fft_dbg: Instant,
153153+ debug_fx: bool,
154154+ fx_last: Instant,
155155+ fx_fft_acc_ns: u128,
156156+ fx_vqt_acc_ns: u128,
157157+ fx_count: u64,
158158+ last_pushed: u64,
159159+ last_overflow: u64,
160160+ underrun_count: u64,
161161+ last_underrun: u64,
162162+ consumed_total: u64,
163163+ last_consumed: u64,
164164+ ema_samples_per_tick: f64,
165165+}
166166+167167+fn init_audio(args: &Args) -> Option<AudioState> {
168168+ if args.audio_disable {
169169+ return None;
170170+ }
171171+ let cap_cfg = audio_cap::AudioCaptureConfig {
172172+ device_substr: args.audio_device.clone(),
173173+ sample_rate: Some(44_100),
174174+ ring_capacity: audio_cap::default_ring_capacity(),
175175+ };
176176+ match audio_cap::start_capture(cap_cfg) {
177177+ Ok((handle, cons)) => {
178178+ println!(
179179+ "Audio capture: '{}' @ {} Hz, {} ch",
180180+ handle.info.device_name, handle.info.sample_rate, handle.info.channels
181181+ );
182182+ if args.audio_vu {
183183+ println!("Audio VU: enabled (prints every ~1s)");
100184 }
185185+ let fft_arc: Arc<RwLock<FFTState>> = Arc::new(RwLock::new(FFTState::new(
186186+ audio_cap::default_ring_capacity(),
187187+ )));
188188+ set_global_fft(fft_arc.clone());
189189+ let vqt_arc: Arc<RwLock<tic80_rust::audio::vqt::VQTState>> =
190190+ Arc::new(RwLock::new(tic80_rust::audio::vqt::VQTState::new(
191191+ handle.info.sample_rate,
192192+ audio_cap::default_ring_capacity(),
193193+ )));
194194+ tic80_rust::audio::vqt::set_global_vqt(vqt_arc.clone());
195195+ Some(AudioState {
196196+ handle,
197197+ cons,
198198+ vu_enabled: args.audio_vu,
199199+ last_print: Instant::now(),
200200+ peak_acc: 0.0,
201201+ fft: fft_arc,
202202+ vqt: vqt_arc,
203203+ debug_fft: args.debug_fft,
204204+ last_fft_dbg: Instant::now(),
205205+ debug_fx: args.debug_fx,
206206+ fx_last: Instant::now(),
207207+ fx_fft_acc_ns: 0,
208208+ fx_vqt_acc_ns: 0,
209209+ fx_count: 0,
210210+ last_pushed: 0,
211211+ last_overflow: 0,
212212+ underrun_count: 0,
213213+ last_underrun: 0,
214214+ consumed_total: 0,
215215+ last_consumed: 0,
216216+ ema_samples_per_tick: 0.0,
217217+ })
101218 }
102102- return Ok(());
219219+ Err(e) => {
220220+ eprintln!(
221221+ "Audio capture disabled ({}). Use --audio-disable to silence this.",
222222+ e
223223+ );
224224+ None
225225+ }
103226 }
104104- let script = if let Some(path) = script_path.as_ref() {
227227+}
228228+229229+fn print_devices_and_exit() {
230230+ let list = audio_cap::list_input_devices();
231231+ if list.is_empty() {
232232+ println!("No input devices found.");
233233+ } else {
234234+ println!("Input devices:");
235235+ for (i, name) in list.iter().enumerate() {
236236+ println!(" {}: {}", i, name);
237237+ }
238238+ }
239239+}
240240+241241+fn print_help() {
242242+ let prog = std::env::args().next().map_or_else(
243243+ || "tic80_rust".to_string(),
244244+ |p| {
245245+ std::path::Path::new(&p)
246246+ .file_name()
247247+ .and_then(|s| s.to_str())
248248+ .unwrap_or("tic80_rust")
249249+ .to_string()
250250+ },
251251+ );
252252+ println!(
253253+ "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 "
254254+ );
255255+}
256256+257257+fn load_script(script_path: Option<&PathBuf>) -> String {
258258+ if let Some(path) = script_path {
105259 match fs::read_to_string(path) {
106260 Ok(s) => s,
107261 Err(e) => {
108262 eprintln!(
109263 "Failed to read {}: {}. Falling back to default cart.",
110110- path, e
264264+ path.display(),
265265+ e
111266 );
112267 DEFAULT_LUA.to_string()
113268 }
114269 }
115270 } else {
116271 DEFAULT_LUA.to_string()
117117- };
118118- let lua_runner = LuaRunner::new(fb.clone(), mem.clone(), &script).ok();
272272+ }
273273+}
274274+275275+fn run() -> Result<(), Error> {
276276+ let event_loop = EventLoop::new();
277277+ let (window, mut pixels) = create_window_and_pixels(&event_loop, 3.0)?; // default integer scaling
278278+279279+ let fb = Rc::new(RefCell::new(Framebuffer::new()));
280280+ let mem = Rc::new(RefCell::new(Memory::new(fb.clone())));
281281+ let mut ticker = Ticker::new();
119282120120- // Optional audio capture
121121- struct AudioState {
122122- _handle: audio_cap::AudioCaptureHandle,
123123- cons: rtrb::Consumer<f32>,
124124- vu_enabled: bool,
125125- last_print: Instant,
126126- peak_acc: f32,
127127- fft: Arc<RwLock<FFTState>>,
128128- vqt: Arc<RwLock<tic80_rust::audio::vqt::VQTState>>,
129129- debug_fft: bool,
130130- last_fft_dbg: Instant,
283283+ let args = parse_args();
284284+ if args.help {
285285+ print_help();
286286+ return Ok(());
131287 }
132132- let mut audio_state: Option<AudioState> = None;
133133- if !audio_disable {
134134- let cap_cfg = audio_cap::AudioCaptureConfig {
135135- device_substr: audio_device.clone(),
136136- sample_rate: Some(44_100),
137137- ring_capacity: audio_cap::default_ring_capacity(),
138138- };
139139- match audio_cap::start_capture(cap_cfg) {
140140- Ok((handle, cons)) => {
141141- println!(
142142- "Audio capture: '{}' @ {} Hz, {} ch",
143143- handle.info.device_name, handle.info.sample_rate, handle.info.channels
144144- );
145145- if audio_vu {
146146- println!("Audio VU: enabled (prints every ~1s)");
147147- }
148148- let fft_arc: Arc<RwLock<FFTState>> = Arc::new(RwLock::new(FFTState::new(
149149- audio_cap::default_ring_capacity(),
150150- )));
151151- set_global_fft(fft_arc.clone());
152152- let vqt_arc: Arc<RwLock<tic80_rust::audio::vqt::VQTState>> =
153153- Arc::new(RwLock::new(tic80_rust::audio::vqt::VQTState::new(
154154- handle.info.sample_rate,
155155- audio_cap::default_ring_capacity(),
156156- )));
157157- tic80_rust::audio::vqt::set_global_vqt(vqt_arc.clone());
158158- audio_state = Some(AudioState {
159159- _handle: handle,
160160- cons,
161161- vu_enabled: audio_vu,
162162- last_print: Instant::now(),
163163- peak_acc: 0.0,
164164- fft: fft_arc,
165165- vqt: vqt_arc,
166166- debug_fft,
167167- last_fft_dbg: Instant::now(),
168168- });
169169- }
170170- Err(e) => {
171171- eprintln!(
172172- "Audio capture disabled ({}). Use --audio-disable to silence this.",
173173- e
174174- );
175175- }
176176- }
288288+ if args.list_audio {
289289+ print_devices_and_exit();
290290+ return Ok(());
177291 }
292292+ let script = load_script(args.script_path.as_ref());
293293+ tic80_rust::script::lua_runner::set_quiet(args.quiet);
294294+ let lua_runner = match LuaRunner::new(fb.clone(), mem.clone(), &script) {
295295+ Ok(r) => Some(r),
296296+ Err(e) => {
297297+ eprintln!("Lua initialization error: {e}");
298298+ None
299299+ }
300300+ };
301301+ let mut audio_state = init_audio(&args);
302302+ let mut warned_no_tic = false;
178303179304 event_loop.run(move |event, _, control_flow| {
180305 *control_flow = ControlFlow::Poll;
···199324 if ticker.should_tick() {
200325 if let Some(r) = &lua_runner {
201326 r.tick();
327327+ } else if !warned_no_tic && !args.quiet {
328328+ eprintln!("No TIC() to run; idle");
329329+ warned_no_tic = true;
202330 }
203331 // Simple VU meter from audio ring
204332 if let Some(a) = audio_state.as_mut() {
205333 // Drain available samples, feed analyzer with a single write lock, track peak
206334 {
335335+ let t0 = Instant::now();
207336 let mut w = a.fft.write();
337337+ if a.cons.is_empty() {
338338+ a.underrun_count += 1;
339339+ }
340340+ let mut consumed_this_tick: u64 = 0;
208341 while let Ok(s) = a.cons.pop() {
209342 a.peak_acc = a.peak_acc.max(s.abs());
210343 w.ingest(s);
211344 // Also feed VQT buffer
212345 a.vqt.write().ingest(s);
346346+ consumed_this_tick += 1;
213347 }
348348+ a.consumed_total = a.consumed_total.saturating_add(consumed_this_tick);
349349+ // EMA of samples/tick (smoothing factor 0.9)
350350+ let alpha = 0.9f64;
351351+ a.ema_samples_per_tick = a
352352+ .ema_samples_per_tick
353353+ .mul_add(alpha, (consumed_this_tick as f64) * (1.0 - alpha));
214354 w.update();
355355+ let t1 = Instant::now();
215356 a.vqt.write().update();
357357+ let t2 = Instant::now();
358358+ if a.debug_fx {
359359+ a.fx_fft_acc_ns += (t1 - t0).as_nanos();
360360+ a.fx_vqt_acc_ns += (t2 - t1).as_nanos();
361361+ a.fx_count += 1;
362362+ if a.fx_last.elapsed() >= Duration::from_millis(1000) {
363363+ let n = a.fx_count.max(1);
364364+ #[allow(clippy::cast_precision_loss)]
365365+ let avg_fft_ms = (a.fx_fft_acc_ns as f64) / 1.0e6 / (n as f64);
366366+ #[allow(clippy::cast_precision_loss)]
367367+ let avg_vqt_ms = (a.fx_vqt_acc_ns as f64) / 1.0e6 / (n as f64);
368368+ // ring stats (if audio enabled)
369369+ if let Some(h) = Some(&a.handle) {
370370+ let pushed = h
371371+ .pushed_count
372372+ .load(std::sync::atomic::Ordering::Relaxed);
373373+ let ovf = h
374374+ .overflow_count
375375+ .load(std::sync::atomic::Ordering::Relaxed);
376376+ let dp = pushed.saturating_sub(a.last_pushed);
377377+ let dofv = ovf.saturating_sub(a.last_overflow);
378378+ let du = a.underrun_count.saturating_sub(a.last_underrun);
379379+ let dc = a.consumed_total.saturating_sub(a.last_consumed);
380380+ let occ_est = pushed
381381+ .saturating_sub(ovf)
382382+ .saturating_sub(a.consumed_total);
383383+ #[allow(clippy::cast_possible_truncation)]
384384+ let occ = occ_est.min(h.ring_capacity as u64) as usize;
385385+ let occ_pct = (occ as f64) / (h.ring_capacity as f64) * 100.0;
386386+ println!(
387387+ "FX avg ms: fft={avg_fft_ms:.3}, vqt={avg_vqt_ms:.3} ({n} ticks), dp={dp}, ovf+={dofv} (tot {ovf}), underrun+={du} (tot {uc}), cons+={dc}, ema_spt={ema:.1}, occ={occ}/{cap} ({occ_pct:.0}%)",
388388+ uc = a.underrun_count,
389389+ ema = a.ema_samples_per_tick,
390390+ cap = h.ring_capacity,
391391+ occ_pct = occ_pct,
392392+ occ = occ
393393+ );
394394+ a.last_pushed = pushed;
395395+ a.last_overflow = ovf;
396396+ a.last_underrun = a.underrun_count;
397397+ a.last_consumed = a.consumed_total;
398398+ } else {
399399+ println!(
400400+ "FX avg ms: fft={avg_fft_ms:.3}, vqt={avg_vqt_ms:.3} ({n} ticks)"
401401+ );
402402+ }
403403+ a.fx_fft_acc_ns = 0;
404404+ a.fx_vqt_acc_ns = 0;
405405+ a.fx_count = 0;
406406+ a.fx_last = Instant::now();
407407+ }
408408+ }
216409 }
217410 if a.debug_fft && a.last_fft_dbg.elapsed() >= Duration::from_millis(500) {
218411 // Print a small subset of normalized bins
···224417 {
225418 let r = a.fft.read();
226419 for i in 0..bins {
227227- line.push_str(&format!("{:.2} ", r.fft_sm[i]));
420420+ use std::fmt::Write as _;
421421+ let _ = write!(&mut line, "{:.2} ", r.fft_sm[i]);
228422 }
229423 }
230230- println!("{}", line);
424424+ println!("{line}");
231425 a.last_fft_dbg = Instant::now();
232426 }
233427 if a.vu_enabled && a.last_print.elapsed() >= Duration::from_millis(1000) {
234428 let peak = a.peak_acc.max(1e-9);
235429 let db = 20.0 * peak.log10();
236236- println!("VU: peak {:.3} ({:.1} dBFS)", peak, db);
430430+ println!("VU: peak {peak:.3} ({db:.1} dBFS)");
237431 a.peak_acc = 0.0;
238432 a.last_print = Instant::now();
239433 }
+138-123
tic80_rust/src/script/lua_runner.rs
···11use std::cell::RefCell;
22use std::rc::Rc;
33-use std::sync::{Mutex, OnceLock};
33+use std::sync::{
44+ atomic::{AtomicBool, Ordering},
55+ Mutex, OnceLock,
66+};
47use std::time::Instant;
5869use mlua::{Function, Lua, MultiValue, RegistryKey, Result as LuaResult, Value};
···17201821// Optional trace buffer (used by tests); if present, trace() will also append messages here.
1922static TRACE_BUFFER: OnceLock<Mutex<Vec<String>>> = OnceLock::new();
2323+static QUIET: AtomicBool = AtomicBool::new(false);
2424+2525+pub fn set_quiet(v: bool) {
2626+ QUIET.store(v, Ordering::Relaxed);
2727+}
20282129impl LuaRunner {
3030+ /// Construct a Lua runtime, install TIC-80 APIs, and load the cart script.
3131+ ///
3232+ /// Errors
3333+ /// Returns an `mlua::Error` if Lua fails to initialize or the provided script
3434+ /// fails to load/execute (including errors thrown by `BOOT()` if present).
3535+ #[allow(
3636+ clippy::too_many_lines,
3737+ clippy::needless_pass_by_value,
3838+ clippy::missing_errors_doc,
3939+ clippy::cast_possible_truncation,
4040+ clippy::redundant_clone
4141+ )]
2242 pub fn new(
2343 fb: Rc<RefCell<Framebuffer>>,
2444 mem: Rc<RefCell<Memory>>,
2545 script_src: &str,
2646 ) -> LuaResult<Self> {
4747+ // Arguments structure for print(); define before statements to satisfy clippy.
4848+ #[derive(Default)]
4949+ struct PrintArgs {
5050+ text: String,
5151+ x: i32,
5252+ y: i32,
5353+ color: u8,
5454+ fixed: bool,
5555+ scale: i32,
5656+ small: bool,
5757+ }
5858+ impl PrintArgs {
5959+ fn from_lua(args: &MultiValue<'_>) -> LuaResult<Self> {
6060+ let mut out = Self {
6161+ color: 15,
6262+ scale: 1,
6363+ ..Default::default()
6464+ };
6565+ for (i, v) in args.iter().enumerate() {
6666+ match (i, v) {
6767+ (0, Value::String(s)) => out.text = s.to_str()?.to_string(),
6868+ (1, Value::Integer(n)) => out.x = *n as i32,
6969+ (2, Value::Integer(n)) => out.y = *n as i32,
7070+ (3, Value::Integer(n)) => out.color = (*n).clamp(0, 255) as u8,
7171+ (4, Value::Boolean(b)) => out.fixed = *b,
7272+ (5, Value::Integer(n)) => out.scale = (*n as i32).max(1),
7373+ (6, Value::Boolean(b)) => out.small = *b,
7474+ _ => {}
7575+ }
7676+ }
7777+ Ok(out)
7878+ }
7979+ }
8080+8181+ // Small helper for FFT arg parsing.
8282+ fn parse_fft_args(args: &MultiValue<'_>) -> (i32, i32) {
8383+ let start = match args.get(0) {
8484+ Some(Value::Integer(n)) => *n as i32,
8585+ _ => -1,
8686+ };
8787+ let end = match args.get(1) {
8888+ Some(Value::Integer(n)) => *n as i32,
8989+ _ => -1,
9090+ };
9191+ (start, end)
9292+ }
9393+2794 let lua = Lua::new();
2895 let start_time = Instant::now();
2996 let tic_key = {
···9516296163 // clip(x,y,w,h) or clip() to reset
97164 let fb_clip = fb.clone();
9898- let clip_fn = lua.create_function(move |_, args: MultiValue| {
165165+ let clip_fn = lua.create_function(move |_, args: MultiValue<'_>| {
99166 if args.is_empty() {
100167 fb_clip.borrow_mut().clip_reset();
101168 } else {
···122189 globals.set("clip", clip_fn)?;
123190124191 // print(text, x=0, y=0, color=15, fixed=false, scale=1, small=false) -> width
125125- #[derive(Default)]
126126- struct PrintArgs {
127127- text: String,
128128- x: i32,
129129- y: i32,
130130- color: u8,
131131- fixed: bool,
132132- scale: i32,
133133- small: bool,
134134- }
135135-136136- impl PrintArgs {
137137- fn from_lua(args: &MultiValue) -> LuaResult<Self> {
138138- let mut out = PrintArgs {
139139- color: 15,
140140- scale: 1,
141141- ..Default::default()
142142- };
143143- for (i, v) in args.iter().enumerate() {
144144- match (i, v) {
145145- (0, Value::String(s)) => out.text = s.to_str()?.to_string(),
146146- (1, Value::Integer(n)) => out.x = *n as i32,
147147- (2, Value::Integer(n)) => out.y = *n as i32,
148148- (3, Value::Integer(n)) => out.color = (*n).clamp(0, 255) as u8,
149149- (4, Value::Boolean(b)) => out.fixed = *b,
150150- (5, Value::Integer(n)) => out.scale = (*n as i32).max(1),
151151- (6, Value::Boolean(b)) => out.small = *b,
152152- _ => {}
153153- }
154154- }
155155- Ok(out)
156156- }
157157- }
158192159193 let fb_print = fb.clone();
160160- let print_fn = lua.create_function(move |_, args: MultiValue| {
194194+ let print_fn = lua.create_function(move |_, args: MultiValue<'_>| {
161195 let p = PrintArgs::from_lua(&args)?;
162196 let width = fb_print
163197 .borrow_mut()
···167201 globals.set("print", print_fn)?;
168202169203 // FFT APIs: fft/ffts/fftr/fftrs
170170- fn parse_fft_args(args: &MultiValue) -> (i32, i32) {
171171- let start = match args.get(0) {
172172- Some(Value::Integer(n)) => *n as i32,
173173- _ => -1,
174174- };
175175- let end = match args.get(1) {
176176- Some(Value::Integer(n)) => *n as i32,
177177- _ => -1,
178178- };
179179- (start, end)
180180- }
181181-182182- let fft_fn = lua.create_function(move |_, args: MultiValue| {
204204+ let fft_fn = lua.create_function(move |_, args: MultiValue<'_>| {
183205 let (start, end) = parse_fft_args(&args);
184184- let val = if let Some(arc) = get_global_fft() {
206206+ let val = get_global_fft().map_or(0.0, |arc| {
185207 let guard = arc.read();
186208 query_fft(&guard, start, end, false, false)
187187- } else {
188188- 0.0
189189- };
209209+ });
190210 Ok(val)
191211 })?;
192212 globals.set("fft", fft_fn)?;
193213194194- let ffts_fn = lua.create_function(move |_, args: MultiValue| {
214214+ let ffts_fn = lua.create_function(move |_, args: MultiValue<'_>| {
195215 let (start, end) = parse_fft_args(&args);
196196- let val = if let Some(arc) = get_global_fft() {
216216+ let val = get_global_fft().map_or(0.0, |arc| {
197217 let guard = arc.read();
198218 query_fft(&guard, start, end, true, false)
199199- } else {
200200- 0.0
201201- };
219219+ });
202220 Ok(val)
203221 })?;
204222 globals.set("ffts", ffts_fn)?;
205223206206- let fftr_fn = lua.create_function(move |_, args: MultiValue| {
224224+ let fftr_fn = lua.create_function(move |_, args: MultiValue<'_>| {
207225 let (start, end) = parse_fft_args(&args);
208208- let val = if let Some(arc) = get_global_fft() {
226226+ let val = get_global_fft().map_or(0.0, |arc| {
209227 let guard = arc.read();
210228 query_fft(&guard, start, end, false, true)
211211- } else {
212212- 0.0
213213- };
229229+ });
214230 Ok(val)
215231 })?;
216232 globals.set("fftr", fftr_fn)?;
217233218218- let fftrs_fn = lua.create_function(move |_, args: MultiValue| {
234234+ let fftrs_fn = lua.create_function(move |_, args: MultiValue<'_>| {
219235 let (start, end) = parse_fft_args(&args);
220220- let val = if let Some(arc) = get_global_fft() {
236236+ let val = get_global_fft().map_or(0.0, |arc| {
221237 let guard = arc.read();
222238 query_fft(&guard, start, end, true, true)
223223- } else {
224224- 0.0
225225- };
239239+ });
226240 Ok(val)
227241 })?;
228242 globals.set("fftrs", fftrs_fn)?;
229243230244 // VQT APIs: vqt/vqts/vqtr/vqtrs and whitened variants vqtw/vqtsw/vqtrw/vqtrsw
231245 let vqt_fn = lua.create_function(move |_, bin: i32| {
232232- let val = if let Some(arc) = get_global_vqt() {
246246+ let val = get_global_vqt().map_or(0.0, |arc| {
233247 let guard = arc.read();
234234- // normalized instantaneous (may exceed 1.0)
235248 if bin >= 0 && (bin as usize) < guard.bins_count() {
236236- (guard.vqt_raw[bin as usize] / guard.vqt_peak) as f64
249249+ f64::from(guard.vqt_raw[bin as usize] / guard.vqt_peak)
237250 } else {
238251 0.0
239252 }
240240- } else {
241241- 0.0
242242- };
253253+ });
243254 Ok(val)
244255 })?;
245256 globals.set("vqt", vqt_fn)?;
246257247258 let vqts_fn = lua.create_function(move |_, bin: i32| {
248248- let val = if let Some(arc) = get_global_vqt() {
259259+ let val = get_global_vqt().map_or(0.0, |arc| {
249260 let guard = arc.read();
250261 if bin >= 0 && (bin as usize) < guard.bins_count() {
251251- guard.vqt_norm[bin as usize] as f64
262262+ f64::from(guard.vqt_norm[bin as usize])
252263 } else {
253264 0.0
254265 }
255255- } else {
256256- 0.0
257257- };
266266+ });
258267 Ok(val)
259268 })?;
260269 globals.set("vqts", vqts_fn)?;
261270262271 let vqtr_fn = lua.create_function(move |_, bin: i32| {
263263- let val = if let Some(arc) = get_global_vqt() {
272272+ let val = get_global_vqt().map_or(0.0, |arc| {
264273 let guard = arc.read();
265274 if bin >= 0 && (bin as usize) < guard.bins_count() {
266266- guard.vqt_raw[bin as usize] as f64
275275+ f64::from(guard.vqt_raw[bin as usize])
267276 } else {
268277 0.0
269278 }
270270- } else {
271271- 0.0
272272- };
279279+ });
273280 Ok(val)
274281 })?;
275282 globals.set("vqtr", vqtr_fn)?;
276283277284 let vqtrs_fn = lua.create_function(move |_, bin: i32| {
278278- let val = if let Some(arc) = get_global_vqt() {
285285+ let val = get_global_vqt().map_or(0.0, |arc| {
279286 let guard = arc.read();
280287 if bin >= 0 && (bin as usize) < guard.bins_count() {
281281- guard.vqt_sm[bin as usize] as f64
288288+ f64::from(guard.vqt_sm[bin as usize])
282289 } else {
283290 0.0
284291 }
285285- } else {
286286- 0.0
287287- };
292292+ });
288293 Ok(val)
289294 })?;
290295 globals.set("vqtrs", vqtrs_fn)?;
291296292297 let vqtw_fn = lua.create_function(move |_, bin: i32| {
293293- let val = if let Some(arc) = get_global_vqt() {
298298+ let val = get_global_vqt().map_or(0.0, |arc| {
294299 let guard = arc.read();
295300 if bin >= 0 && (bin as usize) < guard.bins_count() {
296296- (guard.vqt_w_raw[bin as usize] / guard.vqt_w_peak) as f64
301301+ f64::from(guard.vqt_w_raw[bin as usize] / guard.vqt_w_peak)
297302 } else {
298303 0.0
299304 }
300300- } else {
301301- 0.0
302302- };
305305+ });
303306 Ok(val)
304307 })?;
305308 globals.set("vqtw", vqtw_fn)?;
306309307310 let vqtsw_fn = lua.create_function(move |_, bin: i32| {
308308- let val = if let Some(arc) = get_global_vqt() {
311311+ let val = get_global_vqt().map_or(0.0, |arc| {
309312 let guard = arc.read();
310313 if bin >= 0 && (bin as usize) < guard.bins_count() {
311311- guard.vqt_w_norm[bin as usize] as f64
314314+ f64::from(guard.vqt_w_norm[bin as usize])
312315 } else {
313316 0.0
314317 }
315315- } else {
316316- 0.0
317317- };
318318+ });
318319 Ok(val)
319320 })?;
320321 globals.set("vqtsw", vqtsw_fn)?;
321322322323 let vqtrw_fn = lua.create_function(move |_, bin: i32| {
323323- let val = if let Some(arc) = get_global_vqt() {
324324+ let val = get_global_vqt().map_or(0.0, |arc| {
324325 let guard = arc.read();
325326 if bin >= 0 && (bin as usize) < guard.bins_count() {
326326- guard.vqt_w_raw[bin as usize] as f64
327327+ f64::from(guard.vqt_w_raw[bin as usize])
327328 } else {
328329 0.0
329330 }
330330- } else {
331331- 0.0
332332- };
331331+ });
333332 Ok(val)
334333 })?;
335334 globals.set("vqtrw", vqtrw_fn)?;
336335337336 let vqtrsw_fn = lua.create_function(move |_, bin: i32| {
338338- let val = if let Some(arc) = get_global_vqt() {
337337+ let val = get_global_vqt().map_or(0.0, |arc| {
339338 let guard = arc.read();
340339 if bin >= 0 && (bin as usize) < guard.bins_count() {
341341- guard.vqt_w_sm[bin as usize] as f64
340340+ f64::from(guard.vqt_w_sm[bin as usize])
342341 } else {
343342 0.0
344343 }
345345- } else {
346346- 0.0
347347- };
344344+ });
348345 Ok(val)
349346 })?;
350347 globals.set("vqtrsw", vqtrsw_fn)?;
351348352349 // trace(message, color=15)
353353- let trace_fn = lua.create_function(move |_, args: MultiValue| {
350350+ let trace_fn = lua.create_function(move |_, args: MultiValue<'_>| {
354351 let msg = match args.get(0) {
355352 Some(Value::String(s)) => s.to_str()?.to_string(),
356353 Some(Value::Number(n)) => n.to_string(),
357354 Some(Value::Integer(i)) => i.to_string(),
358355 Some(Value::Boolean(b)) => b.to_string(),
359359- Some(Value::Nil) | None => String::new(),
360356 _ => String::new(),
361357 };
362358 let color = match args.get(1) {
···365361 _ => 15,
366362 };
367363 // Print to console; color is informational only here.
368368- println!("[trace:{}] {}", color, msg);
364364+ println!("[trace:{color}] {msg}");
369365 if let Some(buf) = TRACE_BUFFER.get() {
370366 if let Ok(mut b) = buf.lock() {
371367 b.push(msg);
···393389 } else {
394390 mem_peek.borrow().peek_bits(a, b)
395391 };
396396- Ok(v as u32)
392392+ Ok(u32::from(v))
397393 })?;
398394 globals.set("peek", peek_fn)?;
399395···416412 globals.set(
417413 "peek1",
418414 lua.create_function(move |_, addr: u32| {
419419- Ok(mem_peek1.borrow().peek_bits(addr as usize, 1) as u32)
415415+ Ok(u32::from(mem_peek1.borrow().peek_bits(addr as usize, 1)))
420416 })?,
421417 )?;
422418 let mem_peek2 = mem.clone();
423419 globals.set(
424420 "peek2",
425421 lua.create_function(move |_, addr: u32| {
426426- Ok(mem_peek2.borrow().peek_bits(addr as usize, 2) as u32)
422422+ Ok(u32::from(mem_peek2.borrow().peek_bits(addr as usize, 2)))
427423 })?,
428424 )?;
429425 let mem_peek4 = mem.clone();
430426 globals.set(
431427 "peek4",
432428 lua.create_function(move |_, addr: u32| {
433433- Ok(mem_peek4.borrow().peek_bits(addr as usize, 4) as u32)
429429+ Ok(u32::from(mem_peek4.borrow().peek_bits(addr as usize, 4)))
434430 })?,
435431 )?;
436432···528524 lua.load(script_src).set_name("cart").exec()?;
529525530526 // Call BOOT() if present
531531- if let Ok(boot) = globals.get::<_, Function>("BOOT") {
532532- let _ = boot.call::<_, ()>(());
527527+ if let Ok(boot) = globals.get::<_, Function<'_>>("BOOT") {
528528+ if let Err(e) = boot.call::<_, ()>(()) {
529529+ if !QUIET.load(Ordering::Relaxed) {
530530+ eprintln!("Lua BOOT() error: {e}");
531531+ }
532532+ }
533533 }
534534535535 // Cache TIC if present
536536- match globals.get::<_, Option<Function>>("TIC")? {
537537- Some(f) => Some(lua.create_registry_value(f)?),
538538- None => None,
536536+ if let Some(f) = globals.get::<_, Option<Function<'_>>>("TIC")? {
537537+ Some(lua.create_registry_value(f)?)
538538+ } else {
539539+ if !QUIET.load(Ordering::Relaxed) {
540540+ eprintln!("Cart defines no TIC() function; nothing to tick");
541541+ }
542542+ None
539543 }
540544 };
541545···544548545549 pub fn tick(&self) {
546550 if let Some(key) = &self.tic_key {
547547- if let Ok(func) = self.lua.registry_value::<Function>(key) {
548548- let _ = func.call::<_, ()>(());
551551+ match self.lua.registry_value::<Function<'_>>(key) {
552552+ Ok(func) => {
553553+ if let Err(e) = func.call::<_, ()>(()) {
554554+ if !QUIET.load(Ordering::Relaxed) {
555555+ eprintln!("Lua TIC() error: {e}");
556556+ }
557557+ }
558558+ }
559559+ Err(e) => {
560560+ if !QUIET.load(Ordering::Relaxed) {
561561+ eprintln!("Lua error resolving TIC(): {e}");
562562+ }
563563+ }
549564 }
550565 }
551566 }
+68
tic80_rust/tests/memory_bits_alignment.rs
···11+use std::cell::RefCell;
22+use std::rc::Rc;
33+44+use tic80_rust::core::memory::Memory;
55+use tic80_rust::gfx::framebuffer::Framebuffer;
66+77+#[test]
88+fn two_bit_cross_byte_alignment() {
99+ let fb = Rc::new(RefCell::new(Framebuffer::new()));
1010+ let mut mem = Memory::new(fb);
1111+ let base = 70_000usize; // general RAM region
1212+ mem.memset(base, 0x00, 2);
1313+ // Write last 2 bits (shift=6) of first byte via 2-bit addressing
1414+ // addr = byte_idx*4 + slot, slot in [0..3]
1515+ let a0 = base * 4 + 3;
1616+ mem.poke_bits(a0, 2, 0b01);
1717+ assert_eq!(mem.peek(base), 0b01 << 6);
1818+ // Write first 2 bits (shift=0) of next byte via 2-bit addressing
1919+ let a1 = (base + 1) * 4;
2020+ mem.poke_bits(a1, 2, 0b10);
2121+ assert_eq!(mem.peek(base + 1), 0b10);
2222+ // Confirm first byte unchanged by second write
2323+ assert_eq!(mem.peek(base), 0b01 << 6);
2424+}
2525+2626+#[test]
2727+fn four_bit_unaligned_nibbles() {
2828+ let fb = Rc::new(RefCell::new(Framebuffer::new()));
2929+ let mut mem = Memory::new(fb);
3030+ let base = 65_000usize; // general RAM region
3131+ mem.memset(base, 0x00, 3);
3232+ // Write high nibble of first byte (odd nibble address)
3333+ let a_hi = base * 2 + 1;
3434+ mem.poke_bits(a_hi, 4, 0xA);
3535+ assert_eq!(mem.peek(base), 0xA0);
3636+ // Write low nibble of next byte (even nibble address for next byte)
3737+ let a_lo_next = (base + 1) * 2;
3838+ mem.poke_bits(a_lo_next, 4, 0x5);
3939+ assert_eq!(mem.peek(base + 1) & 0x0F, 0x05);
4040+ // Ensure first byte unchanged
4141+ assert_eq!(mem.peek(base), 0xA0);
4242+4343+ // Now write low then high nibble within the same byte
4444+ mem.memset(base + 2, 0x00, 1);
4545+ let a_lo = (base + 2) * 2;
4646+ let a_hi_same = (base + 2) * 2 + 1;
4747+ mem.poke_bits(a_lo, 4, 0x3);
4848+ mem.poke_bits(a_hi_same, 4, 0xC);
4949+ assert_eq!(mem.peek(base + 2), 0xC3);
5050+}
5151+5252+#[test]
5353+fn one_bit_cross_byte_alignment_edges() {
5454+ let fb = Rc::new(RefCell::new(Framebuffer::new()));
5555+ let mut mem = Memory::new(fb);
5656+ let base = 75_000usize; // general RAM region
5757+ mem.memset(base, 0x00, 2);
5858+ // Set MSB of first byte
5959+ let a0 = base * 8 + 7;
6060+ mem.poke_bits(a0, 1, 1);
6161+ assert_eq!(mem.peek(base), 0b1000_0000);
6262+ // Set LSB of next byte
6363+ let a1 = (base + 1) * 8;
6464+ mem.poke_bits(a1, 1, 1);
6565+ assert_eq!(mem.peek(base + 1), 0b0000_0001);
6666+ // Ensure first byte unchanged by second write
6767+ assert_eq!(mem.peek(base), 0b1000_0000);
6868+}
+87
tic80_rust/tests/memory_bits_roundtrip.rs
···11+use std::cell::RefCell;
22+use std::rc::Rc;
33+44+use tic80_rust::core::memory::Memory;
55+use tic80_rust::gfx::framebuffer::Framebuffer;
66+77+fn mask_for_bits(bits: u8) -> u8 {
88+ match bits {
99+ 1 => 0x01,
1010+ 2 => 0x03,
1111+ 4 => 0x0F,
1212+ 8 => 0xFF,
1313+ _ => 0,
1414+ }
1515+}
1616+1717+#[test]
1818+fn roundtrip_peek_poke_bits_general_ram() {
1919+ let fb = Rc::new(RefCell::new(Framebuffer::new()));
2020+ let mut mem = Memory::new(fb);
2121+ let base = 80_000usize; // within 96KB RAM (beyond VRAM)
2222+ // exercise a range of addresses for each bit-width with a deterministic pattern
2323+ for &bits in &[1u8, 2, 4, 8] {
2424+ let m = mask_for_bits(bits);
2525+ // ensure a clean slate
2626+ mem.memset(base, 0x00, 128);
2727+ for i in 0..512usize {
2828+ let addr = match bits {
2929+ 8 => base + i,
3030+ 4 => base * 2 + i, // address in nibbles
3131+ 2 => base * 4 + i, // address in 2-bit units
3232+ 1 => base * 8 + i, // address in bits
3333+ _ => unreachable!(),
3434+ };
3535+ let val = ((i as u8).wrapping_mul(37)) & m;
3636+ mem.poke_bits(addr, bits, val);
3737+ assert_eq!(mem.peek_bits(addr, bits), val);
3838+ }
3939+ }
4040+}
4141+4242+#[test]
4343+fn vram_screen_boundary_write_does_not_bleed() {
4444+ let fb = Rc::new(RefCell::new(Framebuffer::new()));
4545+ let mut mem = Memory::new(fb.clone());
4646+ // Compute VRAM screen byte count from framebuffer size (2 pixels per byte)
4747+ let w = Framebuffer::WIDTH as usize;
4848+ let h = Framebuffer::HEIGHT as usize;
4949+ let screen_bytes = (w * h) / 2;
5050+5151+ // Set last byte in screen region to a known pattern
5252+ let last_byte_index = screen_bytes - 1;
5353+ mem.poke(last_byte_index, 0xAB);
5454+ // Verify the last two pixels updated accordingly: low nibble -> even pixel (x=w-2), high -> odd (x=w-1)
5555+ let px0 = fb
5656+ .borrow_mut()
5757+ .pix((w - 1) as i32, (h - 1) as i32, None)
5858+ .unwrap();
5959+ let px1 = fb
6060+ .borrow_mut()
6161+ .pix((w - 2) as i32, (h - 1) as i32, None)
6262+ .unwrap();
6363+ assert_eq!(px0, 0x0A); // last pixel (odd index) gets high nibble
6464+ assert_eq!(px1, 0x0B); // previous pixel (even index) gets low nibble
6565+6666+ // Now write the very next VRAM byte (first non-screen VRAM byte)
6767+ let before0 = fb
6868+ .borrow_mut()
6969+ .pix((w - 1) as i32, (h - 1) as i32, None)
7070+ .unwrap();
7171+ let before1 = fb
7272+ .borrow_mut()
7373+ .pix((w - 2) as i32, (h - 1) as i32, None)
7474+ .unwrap();
7575+ mem.poke(screen_bytes, 0xFF);
7676+ // Framebuffer should not change for non-screen VRAM writes
7777+ let after0 = fb
7878+ .borrow_mut()
7979+ .pix((w - 1) as i32, (h - 1) as i32, None)
8080+ .unwrap();
8181+ let after1 = fb
8282+ .borrow_mut()
8383+ .pix((w - 2) as i32, (h - 1) as i32, None)
8484+ .unwrap();
8585+ assert_eq!(before0, after0);
8686+ assert_eq!(before1, after1);
8787+}
+12
tic80_rust/tests/memory_tests.rs
···6262 mem.poke_bits(base * 8 + 7, 1, 1); // set MSB of first byte
6363 assert_eq!(mem.peek(base), 0x8F);
6464}
6565+6666+#[test]
6767+fn vram_writes_ignore_clip() {
6868+ let fb = Rc::new(RefCell::new(Framebuffer::new()));
6969+ let mut mem = Memory::new(fb.clone());
7070+ // Set a clip that excludes the first pixel (0,0)
7171+ fb.borrow_mut().clip(10, 10, 10, 10);
7272+ // Write to VRAM screen first byte low nibble -> pixel (0,0)
7373+ mem.poke_bits(0, 4, 0xC);
7474+ // Despite clip, the pixel must update
7575+ assert_eq!(fb.borrow_mut().pix(0, 0, None), Some(0xC));
7676+}
+30
tic80_rust/tests/vqt_tests.rs
···116116 assert!(vqt.vqt_w_norm[i].is_finite());
117117 }
118118}
119119+120120+#[test]
121121+fn vqt_whitening_scratch_not_reallocated() {
122122+ let cap = 8192;
123123+ let sr = 44_100;
124124+ let mut vqt = VQTState::new(sr, cap);
125125+ let (p_logm, p_env) = vqt.scratch_ptrs();
126126+ let (c_logm, c_env) = vqt.scratch_caps();
127127+ // Run multiple updates to exercise whitening path
128128+ for _ in 0..10 {
129129+ for i in 0..9000 {
130130+ let t = i as f32 / (sr as f32);
131131+ let s = (2.0 * std::f32::consts::PI * 440.0 * t).sin();
132132+ vqt.ingest(s);
133133+ }
134134+ vqt.update();
135135+ }
136136+ let (p_logm2, p_env2) = vqt.scratch_ptrs();
137137+ let (c_logm2, c_env2) = vqt.scratch_caps();
138138+ assert_eq!(
139139+ p_logm, p_logm2,
140140+ "logm pointer changed (allocation likely occurred)"
141141+ );
142142+ assert_eq!(
143143+ p_env, p_env2,
144144+ "env pointer changed (allocation likely occurred)"
145145+ );
146146+ assert_eq!(c_logm, c_logm2, "logm capacity changed (reallocation)");
147147+ assert_eq!(c_env, c_env2, "env capacity changed (reallocation)");
148148+}