this repo has no description
0
fork

Configure Feed

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

code review pt2 or something

alice 1a15b0ed 3a0cbb3b

+1392 -349
+22
AGENTS.md
··· 18 18 - ALWAYS update the test carts catalog when adding a new cart: 19 19 - Add the cart to `docs/testing/test_carts.md` with purpose, run instructions, and expected behavior. 20 20 - If needed, add a short run snippet to `docs/README.md`. 21 + - Maintain a rolling TODO list from reviews in `docs/roadmap/todos_code_review.md` and tick items as they’re completed. 21 22 22 23 **Context** 23 24 - **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`. ··· 58 59 59 60 **Near-Term Backlog** 60 61 - FFT implementation (cpal + realfft) per `docs/specs/audio_fft_vqt.md` (see Implementation TODOs section); add headless tests and Lua `fft/ffts/fftr/fftrs`. 62 + - 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. 61 63 - Print edge cases: tests for scale>1 baseline/advance and multi‑line width parity. 62 64 - Small font: decide semantics and implement `smallfont=true` in `print` with tests. 63 65 - Lua error paths: add type/arity mismatch tests for core APIs (`pix/line/rect/print`). ··· 102 104 - Integrated a 1s VU peak readout for manual verification. 103 105 - 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. 104 106 - Kept clippy/tests green; documented the FFT/VQT plan and linked TODOs. 107 + - Added clippy policy doc (docs/architecture/clippy_policy.md) and linked in docs index. 108 + - Refactored `main.rs` into helpers: args parsing, window/pixels init, audio init, device listing, script load. Behavior unchanged; code easier to lint/extend. 109 + - Error handling improvements: 110 + - Lua init errors now print once; warn once when cart defines no TIC(). 111 + - BOOT() and TIC() call errors are logged to console instead of being dropped. 112 + - FFT/VQT update now warns once if realfft processing fails. 113 + - Audio capture robustness: 114 + - Selects nearest supported sample rate to requested (default 44100 Hz) and logs the choice. 115 + - 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. 116 + - Memory map clarity: replaced magic numbers with named constants; documented screen nibble packing. 117 + - 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 + - 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. 119 + - Verified hygiene: `cargo clippy --all-targets --all-features -D warnings` is clean; `cargo test` all green; ran `cargo fmt`. 120 + - Tightened clippy setup (pedantic/nursery/cargo) with pragmatic allows: 121 + - 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. 122 + - 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. 123 + - 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). 124 + - Added small docs and `#[must_use]` on relevant fns; marked a few helpers `const` where safe. 125 + - Cargo metadata filled in to silence cargo_common_metadata; clippy now passes with `-D warnings` across all targets. 126 + - Ran `cargo fmt`, `cargo clippy --all-targets --all-features -D warnings`, and `cargo test`: all green. 105 127 106 128 **Docs Index** 107 129 - Start here: `docs/README.md`
+24 -1
docs/README.md
··· 5 5 ## Roadmap 6 6 - `docs/roadmap/overview.md`: High-level phased roadmap and goals (moved from RUST_REWRITE.md). 7 7 - `docs/roadmap/gui_first.md`: Combined GUI-first kickoff + milestones for `winit + pixels` and `cls/pix`. 8 + - `docs/roadmap/editor_livecoding.md`: Livecoding editor plan (TIC‑80 UI vibes): CODE + CONSOLE only. 9 + - `docs/roadmap/todos_code_review.md`: Rolling TODOs from code review (high/medium/low priority) with checkboxes. 8 10 9 11 ## Specs 10 12 - `docs/specs/memory_map.md`: Canonical pointer to the root `MEMORY_MAP.md` and usage notes. ··· 16 18 ## Architecture 17 19 - `docs/architecture/workspace.md`: Crate layout and module boundaries. 18 20 - `docs/architecture/runtime.md`: Fixed-step loop, callbacks, and presentation responsibilities. 21 + - `docs/architecture/clippy_policy.md`: Lint policy (pedantic baseline + curated allows). 19 22 20 23 ## Testing 21 24 - `docs/testing/strategy.md`: Testing and validation strategy across API/VRAM/audio. ··· 45 48 - Time/Trace test cart: 46 49 - `cargo run --manifest-path tic80_rust/Cargo.toml -- tic80_rust/assets/time_trace_test.lua` 47 50 - Shows elapsed ms and emits a trace once per second to the console. 48 - - VQT test cart: 51 + - VQT test cart: 49 52 - `cargo run --manifest-path tic80_rust/Cargo.toml -- tic80_rust/assets/vqt_test.lua --audio-device "<name-substr>"` 50 53 - Visualizes 120 bins (12 octaves) with a 2px per-bin bar chart; auto-toggles between raw and whitened views every ~3 seconds. 54 + 55 + ## CLI 56 + - Usage: `tic80_rust [OPTIONS] [CART.lua]` 57 + - Options: 58 + - `-h, --help`: Show help and exit. 59 + - `--quiet`: Suppress once-only warnings and Lua BOOT()/TIC() error prints (useful for headless runs). 60 + - `--list-audio`: List input audio devices and exit. 61 + - `--audio-device <SUBSTR>`: Select input device by substring match (case-insensitive). 62 + - `--audio-disable`: Disable audio capture and analysis. 63 + - `--audio-vu`: Print VU peak dBFS once per second. 64 + - `--debug-fft`: Print the first 16 FFT bins (smoothed, normalized) roughly every 500 ms. 65 + - `--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. 66 + - Notes: 67 + - CART.lua is optional; defaults to the bundled cart if omitted. 68 + - Audio capture picks the nearest supported sample rate to 44100 Hz and logs the selection. 69 + - Window is fixed 240×136 internal res with integer scaling. 70 + 71 + ## Debug Flags (Audio Analysis) 72 + - `--debug-fft`: First 16 FFT bins (smoothed normalized). 73 + - `--debug-fx`: FX timings plus ring stats (dp/ovf/underrun/cons), EMA samples/tick, estimated occupancy.
+193
docs/adr/codereviews/001.md
··· 1 + ## Comprehensive Code Review of TIC-80 Rust Rewrite 2 + 3 + After thoroughly reviewing the documentation, code architecture, and implementation, here's my comprehensive assessment of the TIC-80 Rust rewrite project. 4 + 5 + ### Overall Assessment 6 + 7 + The project is off to a **strong start** with solid foundations and good engineering practices. The codebase demonstrates: 8 + - Clean architecture and module separation 9 + - Good test coverage with deterministic testing strategies 10 + - Careful attention to TIC-80 compatibility 11 + - Excellent documentation discipline 12 + 13 + ### Strengths 14 + 15 + #### 1. **Architecture & Code Organization** 16 + - Clean module structure (`gfx`, `core`, `script`, `audio`) that aligns well with the planned multi-crate architecture 17 + - Good separation of concerns between presentation (winit+pixels), core logic, and scripting 18 + - Effective use of Rust's ownership model (Rc<RefCell<>> for shared framebuffer/memory state) 19 + 20 + #### 2. **Documentation Excellence** 21 + - Comprehensive documentation structure with clear separation of concerns (roadmap, specs, architecture, testing) 22 + - Excellent ADR (Architecture Decision Records) practice 23 + - Clear implementation status tracking 24 + - Detailed test catalog with intent documentation 25 + - Good worklog maintenance in AGENTS.md 26 + 27 + #### 3. **Testing Strategy** 28 + - Smart use of deterministic frame hashing (FNV-1a) for visual regression testing 29 + - Good coverage of edge cases (clipping, OOB, triangle rasterization rules) 30 + - Headless tests that don't rely on image comparisons 31 + - Clear test intent documentation 32 + 33 + #### 4. **Graphics Implementation** 34 + - Solid framebuffer implementation with proper clipping support 35 + - Correct triangle rasterization with top-left rule 36 + - Careful attention to TIC-80 parity (e.g., color masking to 0x0F) 37 + - Memory-mapped VRAM implementation that correctly bridges framebuffer and memory systems 38 + 39 + #### 5. **Lua Integration** 40 + - Clean mlua integration with Lua 5.3 41 + - Proper handling of optional parameters and default values 42 + - Good error handling patterns 43 + - Support for both BOOT and TIC callbacks 44 + 45 + ### Areas for Improvement 46 + 47 + #### 1. **Error Handling** 48 + - Some silent failures could be more explicit: 49 + ```rust 50 + // In main.rs line 213 51 + if let Some(r) = &lua_runner { 52 + r.tick(); 53 + } 54 + ``` 55 + Consider logging when lua_runner is None or tick fails. 56 + 57 + - FFT/VQT processing errors are silently ignored: 58 + ```rust 59 + // In fft.rs line 101 60 + .process_with_scratch(&mut self.input, &mut self.spectrum, &mut self.scratch) 61 + .ok(); // Silent failure 62 + ``` 63 + 64 + #### 2. **Type Safety & API Design** 65 + - Mixed use of `i32` and `f32` for coordinates in Lua bindings could be more consistent: 66 + ```rust 67 + // line() takes f32 but converts to i32 68 + move |_, (x0, y0, x1, y1, color): (f32, f32, f32, f32, u8)| 69 + ``` 70 + Consider using consistent types or documenting the rationale. 71 + 72 + - Magic numbers could be constants: 73 + ```rust 74 + const VRAM_SCREEN_BYTES: usize = 0x3FC0; // Document why this specific value 75 + ``` 76 + 77 + #### 3. **Performance Considerations** 78 + - Frequent locking/unlocking of framebuffer/memory in tight loops: 79 + ```rust 80 + // Each pix() call borrows mutably 81 + let mut fb = self.fb.borrow_mut(); 82 + ``` 83 + Consider batching operations or using unsafe blocks for performance-critical paths. 84 + 85 + - Audio processing on tick thread could cause frame drops. Consider moving FFT/VQT to a separate thread with double-buffering. 86 + 87 + #### 4. **Memory Safety Edge Cases** 88 + - The memory module's bit-packed operations have complex shifting logic that could benefit from more unit tests: 89 + ```rust 90 + // peek_bits/poke_bits have intricate bit manipulation 91 + let shift = (addr & 0b11) * 2; 92 + let mask = !(0x03u8 << shift); 93 + ``` 94 + 95 + #### 5. **Missing Clippy Lints** 96 + While the code is clippy-clean, consider enabling additional lints: 97 + ```rust 98 + #![warn( 99 + clippy::all, 100 + clippy::pedantic, 101 + clippy::nursery, 102 + clippy::cargo, 103 + rust_2018_idioms 104 + )] 105 + ``` 106 + 107 + ### Technical Debt & Future Considerations 108 + 109 + 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. 110 + 111 + 2. **Fixed Audio Sample Rate**: The hardcoded 44.1kHz preference might not work well on all systems. Consider making this more flexible. 112 + 113 + 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. 114 + 115 + 4. **WASM Target**: Current use of parking_lot and system-specific audio will need abstraction for WASM builds. 116 + 117 + ### Recommendations 118 + 119 + 1. **Immediate Actions**: 120 + - Add error logging for silent failures 121 + - Create constants for magic numbers with documentation 122 + - Add benchmarks for critical paths (framebuffer operations, memory access) 123 + - Consider a `CONTRIBUTING.md` with coding standards 124 + 125 + 2. **Short Term**: 126 + - Move audio processing to a separate thread 127 + - Add integration tests that run full TIC-80 programs 128 + - Implement proper error types instead of using anyhow everywhere 129 + - Add property-based tests for graphics primitives 130 + 131 + 3. **Long Term**: 132 + - Plan the migration to multi-crate architecture 133 + - Design abstraction layer for platform-specific code (audio, input) 134 + - Consider using const generics for compile-time framebuffer dimensions 135 + - Implement a profiling framework for performance regression testing 136 + 137 + ### Compliance with Best Practices 138 + 139 + The project excellently follows the stated guidelines: 140 + - ✅ All tests pass 141 + - ✅ Clippy clean 142 + - ✅ Excellent documentation discipline 143 + - ✅ Test-driven development approach 144 + - ✅ Minimal, focused changes 145 + - ✅ Clear separation of concerns 146 + 147 + ### Summary 148 + 149 + 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. 150 + 151 + ```rust 152 + // In main.rs line 213 153 + if let Some(r) = &lua_runner { 154 + r.tick(); 155 + } 156 + ``` 157 + 158 + ```rust 159 + // In fft.rs line 101 160 + .process_with_scratch(&mut self.input, &mut self.spectrum, &mut self.scratch) 161 + .ok(); // Silent failure 162 + ``` 163 + 164 + ```rust 165 + // line() takes f32 but converts to i32 166 + move |_, (x0, y0, x1, y1, color): (f32, f32, f32, f32, u8)| 167 + ``` 168 + 169 + ```rust 170 + const VRAM_SCREEN_BYTES: usize = 0x3FC0; // Document why this specific value 171 + ``` 172 + 173 + ```rust 174 + // Each pix() call borrows mutably 175 + let mut fb = self.fb.borrow_mut(); 176 + ``` 177 + 178 + ```rust 179 + // peek_bits/poke_bits have intricate bit manipulation 180 + let shift = (addr & 0b11) * 2; 181 + let mask = !(0x03u8 << shift); 182 + ``` 183 + 184 + ```rust 185 + #![warn( 186 + clippy::all, 187 + clippy::pedantic, 188 + clippy::nursery, 189 + clippy::cargo, 190 + rust_2018_idioms 191 + )] 192 + ``` 193 +
+31
docs/architecture/clippy_policy.md
··· 1 + # Clippy Policy (Rust Rewrite) 2 + 3 + Goal: keep `cargo clippy --all-targets --all-features -D warnings` green while balancing ergonomics for TIC-80 style APIs and DSP-heavy code. 4 + 5 + Defaults 6 + - Enabled lints at crate root: `clippy::all`, `clippy::pedantic`, `clippy::nursery`, `clippy::cargo`, `rust_2018_idioms`. 7 + - Treat warnings as errors in CI and local development. 8 + 9 + Curated allows (project-wide) 10 + - TIC API shape: `many_single_char_names`, `too_many_arguments`, `similar_names`. 11 + - 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`. 12 + 13 + Localized allows (module/function scope) 14 + - 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`. 15 + - VQT/FFT inner loops: `suboptimal_flops`, `imprecise_flops`, `cast_possible_truncation` where using `mul_add`, `hypot`, or explicit casts are already applied. 16 + - `missing_const_for_fn`: prefer not to commit to `const` unless there’s a clear use/benefit and the API contract is stable. 17 + 18 + Preferences 19 + - Prefer lossless conversions via `From` over `as` where feasible. 20 + - Prefer `map_or/map_or_else` over `if let`/`else` on `Option` when it improves clarity. 21 + - Use `mul_add`, `hypot`, and `exp_m1` in numeric code where it improves precision/readability. 22 + - Add `#[must_use]` to getters/utilities that return values callers should not ignore. 23 + 24 + Process 25 + - Fix high-signal lints; allow low-signal ones locally with rationale. 26 + - Keep changes surgical; avoid broad global allows unless they’re a deliberate policy. 27 + 28 + See also 29 + - `AGENTS.md` Worklog entries for recent lint policy changes. 30 + - `docs/specs/implementation_status.md` Hygiene section for current status. 31 +
+1 -1
docs/architecture/workspace.md
··· 15 15 - Lua (`tic-lua`) calls into `tic-api` → forwards to `tic-core/gfx/audio/io`. 16 16 - `tic-gfx` writes to VRAM page(s); presenter converts palette indices to RGBA for display. 17 17 - `tic-audio` produces sample blocks; optional capture ring shared with `tic-fx`. 18 - 18 + - `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
··· 1 + # Livecoding Editor Plan (TIC‑80 Vibes) 2 + 3 + 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. 4 + 5 + ## Scope and Goals 6 + - Sequencing: CODE first; CONSOLE after. Console scope may be reduced based on needs. 7 + - Views: CODE, CONSOLE (preview is the running framebuffer as today). 8 + - Parity: Preserve .tic cart semantics. Replace only the code section on save; all other sections remain byte‑for‑byte identical. 9 + - Vibes: Render a pixel‑perfect TIC‑80 UI skin in the 240×136 framebuffer and present via integer scaling. 10 + - Livecoding: Hot‑reload on save; keep last‑good runner if the new code has errors. 11 + 12 + ## Architecture 13 + - 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. 14 + - Input: Read keyboard/mouse from `winit` and feed a simple UI layer (no external widget toolkit). 15 + - Text engine: Rope‑backed buffer for code (e.g., ropey) to keep edits fast and memory stable. 16 + - Runtime coupling: Save swaps LuaRunner safely; error messages go to the console while keeping the last‑good runner active. 17 + - Cart I/O: Code‑only patching on save; everything else preserved exactly. 18 + 19 + ## UI Skin 20 + - Top bar: TIC‑80‑style chrome, CODE/CONSOLE tabs, RUN/STOP/RESET buttons, FPS/ms status. 21 + - CODE: Monospace text area with gutter (line numbers), caret, selection, scrolling, basic syntax coloring. 22 + - CONSOLE: Scrolling list of trace() lines and errors; timestamps; clear button. 23 + - Bottom status: File name, cursor position, modified flag. 24 + 25 + ## Phases and Detailed TODOs (CODE first, then CONSOLE) 26 + 27 + Phase 0 — Shell + Layout 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. 33 + 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. 37 + - [ ] 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. 40 + 41 + Phase 2 — Syntax + UX polish (minimal) 42 + - [ ] Lightweight Lua colorizer (keywords, comments, strings, numbers) with palette colors. 43 + - [ ] Find/replace panel (Ctrl/Cmd+F) with next/prev navigation. 44 + - [ ] Adjustable font scale within 8×8 multiples (e.g., 1×/2×) while preserving 240×136 layout. 45 + 46 + Phase 3 — Console Pane (may be reduced) 47 + - [ ] Console ring buffer model with timestamps and color tags. 48 + - [ ] Route trace() and runtime/loader errors to the console. 49 + - [ ] Clear button and scroll behavior; persist scroll at bottom on new lines. 50 + 51 + Phase 4 — Hot Reload + Cart I/O 52 + - [ ] Code‑only .tic write: patch code section and save; verify other sections preserved. 53 + - [ ] Hot reload: on save, compile+swap LuaRunner; on error, push message to console and keep last‑good runner. 54 + - [ ] Menu/buttons: Run/Stop/Reset wired to runtime control. 55 + 56 + Phase 5 — Tests + Docs 57 + - [ ] Headless test: .tic round‑trip preserves non‑code bytes. 58 + - [ ] Hot reload tests: failing edit keeps last‑good; fixing edit swaps runner. 59 + - [ ] Console tests: trace() lines and error render path covered. 60 + - [ ] Add README section with usage and keybinds; link test carts and debug flags. 61 + 62 + ## Keybinds (initial) 63 + - Save: Ctrl/Cmd+S 64 + - Run/Stop: Ctrl/Cmd+R (and optional F5) 65 + - Find: Ctrl/Cmd+F 66 + - Copy/Paste: Ctrl/Cmd+C/V/X 67 + - Undo/Redo: Ctrl/Cmd+Z / Ctrl/Cmd+Shift+Z 68 + 69 + ## Testing Strategy 70 + - Unit: code buffer ops (insert/delete/undo/redo), scroll viewport, hit‑testing. 71 + - Integration: open→edit→save→open asserts only code changed; hot reload error gating; console logs observed. 72 + - Manual: use existing carts (fft/vqt/time/trace) to validate livecoding flow and console output. 73 + 74 + ## Open Questions 75 + - External file watcher for auto‑reload (deferred). 76 + - Config: theme, font size, autosave debounce. 77 + - Optional MRU list and single‑instance guard.
+47
docs/roadmap/todos_code_review.md
··· 1 + # Code Review TODOs (Live list) 2 + 3 + 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. 4 + 5 + ## High Priority 6 + - [x] Error handling and visibility 7 + - [x] Warn once when Lua runner is absent (main loop) 8 + - [x] Log errors from TIC() calls without crashing 9 + - [x] Log (warn once) if realfft processing fails in FFT/VQT 10 + - [x] Audio capture robustness 11 + - [x] Select nearest supported sample rate to 44100 Hz when exact not available; log choice 12 + - [x] Add ring overflow counters and print under `--debug-fx` 13 + - [x] VQT wiring parity tests 14 + - [x] Ensure `vqt` (instantaneous normalized) != `vqts` (smoothed normalized) on first update 15 + - [x] Same for `vqtw` vs `vqtsw` 16 + - [x] Memory map clarity 17 + - [x] Replace remaining magic numbers with named constants + doc comments where applicable 18 + - [x] Bit manipulation tests 19 + - [x] Property-based round-trip tests for `peek_bits/poke_bits` across 1/2/4/8-bit widths 20 + - [x] Region boundary tests around memory edges 21 + 22 + ## Medium Priority 23 + - [ ] Linting and CI 24 + - [ ] Add stricter lint profile (pedantic/nursery/cargo + rust_2018_idioms) in CI 25 + - [ ] Allowlist noisy lints where appropriate 26 + - [ ] Contributor and design docs 27 + - [ ] CONTRIBUTING.md (fmt/clippy/test, docs discipline, running carts) 28 + - [ ] ADR: Editor approach (CODE first, CONSOLE later, framebuffer UI) 29 + - [ ] WASM feasibility note (platform abstraction for audio/input) 30 + - [ ] Benchmarks (Criterion) 31 + - [ ] Framebuffer ops and blit_to_rgba 32 + - [ ] Memory peek/poke variants 33 + - [ ] FFT (2k) + VQT (8k sparse dots) 34 + - [ ] Integration tests 35 + - [ ] .tic round-trip: only code section changes 36 + - [ ] Hot reload gating: failing edit keeps last-good; recovery on fix 37 + - [ ] Console: trace()/error paths observed deterministically 38 + 39 + ## Lower Priority / Optional 40 + - [ ] Optional analysis worker thread (feature-flag) with double-buffered FFT/VQT outputs 41 + - [ ] Editor prep (UI skin specifics) 42 + - [ ] Confirm font/palette assets and integer scale steps 43 + - [ ] Lock initial keybinds (Save/Run/Find) 44 + - [ ] Define hit-testing rectangles for tabs/buttons in 240×136 space 45 + 46 + Notes 47 + - 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
··· 72 72 - Single‑tone hits expected semitone bin; whitened vs unwhitened differ predictably on broadband inputs. 73 73 - Normalization clamps to [0,1]; whitened/unwhitened use independent peak trackers. 74 74 75 + ## Debugging & Telemetry 76 + - `--debug-fft`: Prints the first 16 FFT bins (smoothed normalized) periodically (~500 ms) for sanity checks. 77 + - `--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. 78 + 75 79 ## Integration Plan (Milestones) 76 80 1) FFT foundation: ring buffer, 2k R2C planner, raw/normalized/smoothed buffers, Lua `fft/ffts/fftr/fftrs`. 77 81 2) VQT kernels: generation + storage; 8k R2C planner; unwhitened path with `vqt/vqts/vqtr/vqtrs`.
+1
docs/specs/implementation_status.md
··· 79 79 - Graphics semantics: `docs/specs/graphics.md`. 80 80 - API parity checklist: `docs/specs/lua_api_parity.md`. 81 81 - Testing strategy and catalog: `docs/testing/strategy.md`, `docs/testing/test_catalog.md`. 82 + - 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
··· 43 43 - `peek4_reads_back_nibble`: 4-bit reads reflect framebuffer. 44 44 - `memcpy_and_memset_affect_vram`: VRAM writes via memcpy/memset reach the screen. 45 45 - `peek_poke_bits_general_ram`: 1/4-bit addressing in general RAM behaves correctly. 46 + - `tic80_rust/tests/memory_bits_roundtrip.rs` 47 + - `roundtrip_peek_poke_bits_general_ram`: Round-trip property-like checks for 1/2/4/8-bit peek/poke across a RAM window. 48 + - `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. 49 + - `tic80_rust/tests/memory_bits_alignment.rs` 50 + - `two_bit_cross_byte_alignment`: 2‑bit writes at end of one byte and start of next do not bleed. 51 + - `four_bit_unaligned_nibbles`: Odd/even nibble writes across bytes and within a byte pack correctly. 46 52 47 53 ## FFT Tests 48 54 - `tic80_rust/tests/fft_tests.rs`
+6
tic80_rust/Cargo.toml
··· 2 2 name = "tic80_rust" 3 3 version = "0.1.0" 4 4 edition = "2021" 5 + description = "Rust rewrite subset of TIC-80: framebuffer, Lua bridge, memory, audio capture + FFT/VQT" 6 + license = "MIT" 7 + repository = "https://github.com/nesbox/TIC-80" 8 + readme = "../README.md" 9 + keywords = ["tic-80", "fantasy-console", "game", "graphics", "audio"] 10 + categories = ["game-engines", "graphics", "multimedia::audio"] 5 11 6 12 [dependencies] 7 13 winit = "0.28"
+119 -21
tic80_rust/src/audio/capture.rs
··· 1 - use std::sync::Arc; 1 + use std::sync::{ 2 + atomic::{AtomicU64, Ordering}, 3 + Arc, 4 + }; 2 5 3 6 use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; 4 7 use cpal::{Device, Sample, SampleFormat, Stream, StreamConfig}; ··· 21 24 pub struct AudioCaptureHandle { 22 25 _stream: Stream, // keep alive 23 26 pub info: AudioInfo, 27 + pub pushed_count: Arc<AtomicU64>, 28 + pub overflow_count: Arc<AtomicU64>, 29 + pub ring_capacity: usize, 24 30 } 25 31 32 + #[must_use] 26 33 pub fn list_input_devices() -> Vec<String> { 27 34 let host = cpal::default_host(); 28 35 let mut out = Vec::new(); ··· 52 59 host.default_input_device() 53 60 } 54 61 55 - pub fn default_ring_capacity() -> usize { 62 + #[must_use] 63 + pub const fn default_ring_capacity() -> usize { 56 64 // Shared buffer sized to max(2*FFT_SIZE, VQT_FFT_SIZE) from C impl: 8192 samples. 57 65 8192 58 66 } 59 67 68 + /// Start audio capture from the selected device and return the handle and consumer. 69 + /// 70 + /// Errors 71 + /// Returns an error if no device is available, device querying fails, or the 72 + /// stream fails to build or start. 73 + #[allow( 74 + clippy::needless_pass_by_value, 75 + clippy::missing_errors_doc, 76 + clippy::too_many_lines 77 + )] 60 78 pub fn start_capture( 61 79 cfg: AudioCaptureConfig, 62 80 ) -> anyhow::Result<(AudioCaptureHandle, Consumer<f32>)> { ··· 68 86 let supported = device 69 87 .supported_input_configs() 70 88 .map_err(|e| anyhow::anyhow!("Failed to get supported configs: {e}"))?; 71 - let mut chosen: Option<cpal::SupportedStreamConfig> = None; 72 89 let req_rate = cfg.sample_rate.unwrap_or(44_100); 90 + let mut best: Option<(u32, _)> = None; 73 91 for sc in supported { 74 - let sr = sc.min_sample_rate().0..=sc.max_sample_rate().0; 75 - if sr.contains(&req_rate) { 76 - chosen = Some(sc.with_sample_rate(cpal::SampleRate(req_rate))); 77 - break; 92 + let min = sc.min_sample_rate().0; 93 + let max = sc.max_sample_rate().0; 94 + let cand = if req_rate < min { 95 + min 96 + } else if req_rate > max { 97 + max 98 + } else { 99 + req_rate 100 + }; 101 + let diff = cand.abs_diff(req_rate); 102 + match &mut best { 103 + Some((best_rate, best_sc)) => { 104 + let best_diff = (*best_rate).abs_diff(req_rate); 105 + if diff < best_diff { 106 + *best_rate = cand; 107 + *best_sc = sc; 108 + } 109 + } 110 + None => best = Some((cand, sc)), 78 111 } 79 112 } 80 - // Fallback to default config, with a notice 81 - let stream_cfg = if let Some(ch) = chosen { 82 - ch 113 + // Use best found or default 114 + let stream_cfg = if let Some((rate, sc)) = best { 115 + if rate != req_rate { 116 + println!( 117 + "Audio: requested {} Hz; selecting nearest supported {} Hz, {:?}, {} ch", 118 + req_rate, 119 + rate, 120 + sc.sample_format(), 121 + sc.channels() 122 + ); 123 + } 124 + sc.with_sample_rate(cpal::SampleRate(rate)) 83 125 } else { 84 126 let def = device 85 127 .default_input_config() 86 128 .map_err(|e| anyhow::anyhow!("No default input config: {e}"))?; 87 129 println!( 88 - "Audio: requested 44100 Hz not supported; using device default: {} Hz, {:?} format, {} ch", 130 + "Audio: falling back to device default: {} Hz, {:?} format, {} ch", 89 131 def.sample_rate().0, 90 132 def.sample_format(), 91 133 def.channels() ··· 94 136 }; 95 137 96 138 let sample_format = stream_cfg.sample_format(); 97 - let cfg_fixed: StreamConfig = stream_cfg.clone().into(); 139 + let cfg_fixed: StreamConfig = stream_cfg.into(); 98 140 let channels = cfg_fixed.channels; 99 141 let sample_rate = cfg_fixed.sample_rate.0; 100 142 ··· 106 148 }; 107 149 let (prod, cons) = RingBuffer::<f32>::new(cap); 108 150 let prod = Arc::new(parking_lot::Mutex::new(prod)); 151 + let pushed = Arc::new(AtomicU64::new(0)); 152 + let overflows = Arc::new(AtomicU64::new(0)); 109 153 110 154 let stream = match sample_format { 111 - SampleFormat::F32 => build_stream::<f32>(&device, &cfg_fixed, channels, prod.clone())?, 112 - SampleFormat::F64 => build_stream::<f64>(&device, &cfg_fixed, channels, prod.clone())?, 113 - SampleFormat::I16 => build_stream::<i16>(&device, &cfg_fixed, channels, prod.clone())?, 114 - SampleFormat::I32 => build_stream::<i32>(&device, &cfg_fixed, channels, prod.clone())?, 115 - SampleFormat::U8 => build_stream::<u8>(&device, &cfg_fixed, channels, prod.clone())?, 116 - SampleFormat::U16 => build_stream::<u16>(&device, &cfg_fixed, channels, prod.clone())?, 155 + SampleFormat::F32 => build_stream::<f32>( 156 + &device, 157 + &cfg_fixed, 158 + channels, 159 + prod, 160 + pushed.clone(), 161 + overflows.clone(), 162 + )?, 163 + SampleFormat::F64 => build_stream::<f64>( 164 + &device, 165 + &cfg_fixed, 166 + channels, 167 + prod, 168 + pushed.clone(), 169 + overflows.clone(), 170 + )?, 171 + SampleFormat::I16 => build_stream::<i16>( 172 + &device, 173 + &cfg_fixed, 174 + channels, 175 + prod, 176 + pushed.clone(), 177 + overflows.clone(), 178 + )?, 179 + SampleFormat::I32 => build_stream::<i32>( 180 + &device, 181 + &cfg_fixed, 182 + channels, 183 + prod, 184 + pushed.clone(), 185 + overflows.clone(), 186 + )?, 187 + SampleFormat::U8 => build_stream::<u8>( 188 + &device, 189 + &cfg_fixed, 190 + channels, 191 + prod, 192 + pushed.clone(), 193 + overflows.clone(), 194 + )?, 195 + SampleFormat::U16 => build_stream::<u16>( 196 + &device, 197 + &cfg_fixed, 198 + channels, 199 + prod, 200 + pushed.clone(), 201 + overflows.clone(), 202 + )?, 117 203 other => return Err(anyhow::anyhow!("Unsupported sample format: {other:?}")), 118 204 }; 119 205 ··· 129 215 AudioCaptureHandle { 130 216 _stream: stream, 131 217 info, 218 + ring_capacity: cap, 219 + pushed_count: pushed, 220 + overflow_count: overflows, 132 221 }, 133 222 cons, 134 223 )) ··· 139 228 cfg: &StreamConfig, 140 229 channels: u16, 141 230 prod: Arc<parking_lot::Mutex<Producer<f32>>>, 231 + pushed: Arc<AtomicU64>, 232 + overflows: Arc<AtomicU64>, 142 233 ) -> anyhow::Result<Stream> 143 234 where 144 235 T: Sample + cpal::SizedSample, ··· 157 248 let mut p = prod.lock(); 158 249 for frame in data.chunks_exact(ch) { 159 250 let mut mono = 0.0f32; 160 - for s in frame.iter() { 161 - let v: f32 = (*s).to_sample(); 251 + for &s in frame { 252 + let v: f32 = s.to_sample(); 162 253 mono += v; 163 254 } 164 255 mono /= ch as f32; 165 256 // Drop if full; consumer will catch up. 166 - let _ = p.push(mono); 257 + match p.push(mono) { 258 + Ok(()) => { 259 + pushed.fetch_add(1, Ordering::Relaxed); 260 + } 261 + Err(_) => { 262 + overflows.fetch_add(1, Ordering::Relaxed); 263 + } 264 + } 167 265 } 168 266 }, 169 267 err_fn,
+30 -14
tic80_rust/src/audio/fft.rs
··· 31 31 32 32 // Smoothing factor for displayed series 33 33 f_smooth_factor: f32, // 0.6 34 + 35 + // Error visibility (warn once if FFT processing fails) 36 + warned_fft_error: bool, 34 37 } 35 38 36 39 impl FFTState { 40 + #[must_use] 37 41 pub fn new(rolling_capacity: usize) -> Self { 38 42 let n = 2048usize; 39 43 let half = n / 2; ··· 61 65 f_peak_value: 0.01, 62 66 f_amplification: 1.0, 63 67 f_smooth_factor: 0.6, 68 + warned_fft_error: false, 64 69 } 65 70 } 66 71 ··· 96 101 } 97 102 self.copy_latest_window(); 98 103 // Forward R2C 99 - self.r2c 100 - .process_with_scratch(&mut self.input, &mut self.spectrum, &mut self.scratch) 101 - .ok(); 104 + if let Err(e) = 105 + self.r2c 106 + .process_with_scratch(&mut self.input, &mut self.spectrum, &mut self.scratch) 107 + { 108 + if !self.warned_fft_error { 109 + eprintln!("FFT update error: {e}"); 110 + self.warned_fft_error = true; 111 + } 112 + return; 113 + } 102 114 103 115 // Magnitudes for 0..half-1 (drop Nyquist at index half) 104 116 let mut peak_raw = self.f_peak_min; 105 117 for k in 0..self.half { 106 118 let c = self.spectrum[k]; 107 - let mag = (c.re * c.re + c.im * c.im).sqrt() * 2.0; 119 + let mag = c.re.hypot(c.im) * 2.0; 108 120 self.fft_raw[k] = mag; 109 121 if mag > peak_raw { 110 122 peak_raw = mag; ··· 115 127 if peak_raw > self.f_peak_value { 116 128 self.f_peak_value = peak_raw; 117 129 } else { 118 - self.f_peak_value = self.f_peak_value * self.f_peak_smoothing 119 - + peak_raw * (1.0 - self.f_peak_smoothing); 130 + self.f_peak_value = self.f_peak_value.mul_add( 131 + self.f_peak_smoothing, 132 + peak_raw * (1.0 - self.f_peak_smoothing), 133 + ); 120 134 } 121 135 if self.f_peak_value < self.f_peak_min { 122 136 self.f_peak_value = self.f_peak_min; ··· 127 141 let a = self.f_smooth_factor; // 0.6 128 142 for k in 0..self.half { 129 143 let raw = self.fft_raw[k]; 130 - let raw_sm = self.fft_raw_sm[k] * a + raw * (1.0 - a); 144 + let raw_sm = self.fft_raw_sm[k].mul_add(a, raw * (1.0 - a)); 131 145 self.fft_raw_sm[k] = raw_sm; 132 146 133 147 let norm = raw * self.f_amplification; 134 - let norm_sm = self.fft_sm[k] * a + norm * (1.0 - a); 148 + let norm_sm = self.fft_sm[k].mul_add(a, norm * (1.0 - a)); 135 149 self.fft_data[k] = norm; 136 150 self.fft_sm[k] = norm_sm; 137 151 } 138 152 } 139 153 140 - pub fn bins(&self) -> usize { 154 + #[must_use] 155 + pub const fn bins(&self) -> usize { 141 156 self.half 142 157 } 143 158 } ··· 156 171 // Helper function to query FFT arrays with C-like clamping semantics. 157 172 // smoothing=false => normalized (fft_data) or raw (fft_raw); 158 173 // smoothing=true => normalized-smoothed (fft_sm) or raw-smoothed (fft_raw_sm). 174 + #[must_use] 159 175 pub fn query_fft(state: &FFTState, start: i32, end: i32, smoothing: bool, raw: bool) -> f64 { 160 - let size = state.half as i32; // 1024 176 + let size = i32::try_from(state.half).unwrap_or(i32::MAX); // 1024 161 177 if end == -1 { 162 178 if start < 0 || start >= size { 163 179 return 0.0; ··· 174 190 } else { 175 191 state.fft_data[idx] 176 192 }; 177 - return v as f64; 193 + return f64::from(v); 178 194 } 179 195 // both out-of-bounds on same side => 0 180 196 if (start < 0 && end < 0) || (start >= size && end >= size) { ··· 197 213 let mut sum = 0.0f64; 198 214 for i in s..=e { 199 215 let u = i as usize; 200 - let v = if raw { 216 + let v_f32 = if raw { 201 217 if smoothing { 202 218 state.fft_raw_sm[u] 203 219 } else { ··· 207 223 state.fft_sm[u] 208 224 } else { 209 225 state.fft_data[u] 210 - } as f64; 211 - sum += v; 226 + }; 227 + sum += f64::from(v_f32); 212 228 } 213 229 sum 214 230 }
+77 -24
tic80_rust/src/audio/vqt.rs
··· 1 + #![allow( 2 + clippy::cognitive_complexity, 3 + clippy::too_many_lines, 4 + clippy::suboptimal_flops, 5 + clippy::imprecise_flops, 6 + clippy::cast_possible_truncation, 7 + clippy::cast_precision_loss 8 + )] 1 9 use realfft::{num_complex::Complex, RealFftPlanner, RealToComplex}; 2 10 3 11 pub struct VqtKernel { ··· 40 48 pub vqt_w_norm: Vec<f32>, 41 49 // Peak normalization for whitened path 42 50 pub vqt_w_peak: f32, 51 + 52 + // Scratch buffers (pre-allocated to avoid per-update allocations) 53 + logm: Vec<f32>, 54 + env: Vec<f32>, 55 + 56 + // Error visibility (warn once if FFT processing fails) 57 + warned_fft_error: bool, 43 58 } 44 59 45 60 const VQT_BINS: usize = 120; ··· 54 69 const VQT_WHITEN_EPS: f32 = 1e-6; 55 70 56 71 impl VQTState { 72 + #[must_use] 57 73 pub fn new(sample_rate: u32, rolling_capacity: usize) -> Self { 58 74 let n = 8192usize; 59 75 let half = n / 2; ··· 84 100 vqt_w_sm: vec![0.0; VQT_BINS], 85 101 vqt_w_norm: vec![0.0; VQT_BINS], 86 102 vqt_w_peak: VQT_PEAK_MIN, 103 + logm: vec![0.0; VQT_BINS], 104 + env: vec![0.0; VQT_BINS], 105 + warned_fft_error: false, 87 106 }; 88 107 s.generate_kernels(); 89 108 s ··· 165 184 // reuse input buffer 166 185 self.input[..self.n].copy_from_slice(&time); 167 186 let mut scratch = self.r2c.make_scratch_vec(); 168 - let _ = self 187 + if let Err(e) = self 169 188 .r2c 170 - .process_with_scratch(&mut self.input, &mut spec, &mut scratch); 189 + .process_with_scratch(&mut self.input, &mut spec, &mut scratch) 190 + { 191 + if !self.warned_fft_error { 192 + eprintln!("VQT kernel FFT error: {e}"); 193 + self.warned_fft_error = true; 194 + } 195 + continue; 196 + } 171 197 172 198 // Build sparse kernel by thresholding magnitude 173 199 let thr = Self::adaptive_threshold(f0); ··· 218 244 return; 219 245 } 220 246 self.copy_latest_window(); 221 - let _ = 247 + if let Err(e) = 222 248 self.r2c 223 - .process_with_scratch(&mut self.input, &mut self.spectrum, &mut self.scratch); 249 + .process_with_scratch(&mut self.input, &mut self.spectrum, &mut self.scratch) 250 + { 251 + if !self.warned_fft_error { 252 + eprintln!("VQT update FFT error: {e}"); 253 + self.warned_fft_error = true; 254 + } 255 + return; 256 + } 224 257 225 258 // Apply kernels 226 259 for (i, ker) in self.kernels.iter().enumerate() { ··· 248 281 let a = VQT_SMOOTHING_FACTOR; 249 282 let mut peak = 0.0f32; 250 283 for i in 0..self.bins { 251 - self.vqt_sm[i] = self.vqt_sm[i] * a + self.vqt_raw[i] * (1.0 - a); 284 + self.vqt_sm[i] = self.vqt_sm[i].mul_add(a, self.vqt_raw[i] * (1.0 - a)); 252 285 if self.vqt_sm[i] > peak { 253 286 peak = self.vqt_sm[i]; 254 287 } ··· 259 292 if peak > self.vqt_peak { 260 293 self.vqt_peak = peak; 261 294 } else { 262 - self.vqt_peak = self.vqt_peak * VQT_PEAK_SMOOTH + peak * (1.0 - VQT_PEAK_SMOOTH); 295 + self.vqt_peak = self 296 + .vqt_peak 297 + .mul_add(VQT_PEAK_SMOOTH, peak * (1.0 - VQT_PEAK_SMOOTH)); 263 298 } 264 299 if self.vqt_peak < VQT_PEAK_MIN { 265 300 self.vqt_peak = VQT_PEAK_MIN; ··· 277 312 } 278 313 279 314 // Whitening path 280 - // log domain 281 - let mut logm = vec![0.0f32; self.bins]; 282 - for (i, mslot) in logm.iter_mut().enumerate().take(self.bins) { 315 + // log domain (reuse pre-allocated scratch) 316 + for (i, mslot) in self.logm.iter_mut().enumerate().take(self.bins) { 283 317 let m = if self.vqt_raw[i].is_finite() && self.vqt_raw[i] >= 0.0 { 284 318 self.vqt_raw[i] 285 319 } else { ··· 289 323 } 290 324 // moving average envelope 291 325 let halfw = VQT_WHITEN_WIDTH / 2; 292 - let mut env = vec![0.0f32; self.bins]; 293 326 for i in 0..self.bins { 294 327 let start = i.saturating_sub(halfw); 295 328 let end = (i + halfw).min(self.bins - 1); 296 329 let mut sum = 0.0f32; 297 330 let mut count = 0; 298 - for val in logm.iter().take(end + 1).skip(start) { 331 + for val in self.logm.iter().take(end + 1).skip(start) { 299 332 sum += *val; 300 333 count += 1; 301 334 } 302 - env[i] = if count > 0 { 335 + self.env[i] = if count > 0 { 303 336 sum / count as f32 304 337 } else { 305 - logm[i] 338 + self.logm[i] 306 339 }; 307 340 } 308 341 // whiten and mix 309 342 for i in 0..self.bins { 310 - let wlog = logm[i] - env[i]; 311 - let mut wamp = wlog.exp() - 1.0; 343 + let wlog = self.logm[i] - self.env[i]; 344 + let mut wamp = wlog.exp_m1(); 312 345 if !wamp.is_finite() || wamp < 0.0 { 313 346 wamp = 0.0; 314 347 } 315 348 let raw = self.vqt_raw[i]; 316 - let mut mixed = (1.0 - VQT_WHITEN_ALPHA) * raw + VQT_WHITEN_ALPHA * wamp; 349 + let mut mixed = (1.0 - VQT_WHITEN_ALPHA).mul_add(raw, VQT_WHITEN_ALPHA * wamp); 317 350 if !mixed.is_finite() || mixed < 0.0 { 318 351 mixed = 0.0; 319 352 } ··· 322 355 // Smooth and normalize whitened 323 356 let mut wpeak = 0.0f32; 324 357 for i in 0..self.bins { 325 - self.vqt_w_sm[i] = self.vqt_w_sm[i] * a + self.vqt_w_raw[i] * (1.0 - a); 358 + self.vqt_w_sm[i] = self.vqt_w_sm[i].mul_add(a, self.vqt_w_raw[i] * (1.0 - a)); 326 359 if self.vqt_w_sm[i] > wpeak { 327 360 wpeak = self.vqt_w_sm[i]; 328 361 } ··· 333 366 if wpeak > self.vqt_w_peak { 334 367 self.vqt_w_peak = wpeak; 335 368 } else { 336 - self.vqt_w_peak = self.vqt_w_peak * VQT_PEAK_SMOOTH + wpeak * (1.0 - VQT_PEAK_SMOOTH); 369 + self.vqt_w_peak = self 370 + .vqt_w_peak 371 + .mul_add(VQT_PEAK_SMOOTH, wpeak * (1.0 - VQT_PEAK_SMOOTH)); 337 372 } 338 373 if self.vqt_w_peak < VQT_PEAK_MIN { 339 374 self.vqt_w_peak = VQT_PEAK_MIN; ··· 351 386 } 352 387 } 353 388 389 + #[must_use] 390 + #[allow(clippy::missing_const_for_fn)] 354 391 pub fn bins_count(&self) -> usize { 355 392 self.bins 356 393 } 394 + 395 + // Expose scratch buffer addresses/capacity (useful for tests and diagnostics). 396 + #[must_use] 397 + #[allow(clippy::missing_const_for_fn)] 398 + pub fn scratch_ptrs(&self) -> (*const f32, *const f32) { 399 + (self.logm.as_ptr(), self.env.as_ptr()) 400 + } 401 + 402 + #[must_use] 403 + #[allow(clippy::missing_const_for_fn)] 404 + pub fn scratch_caps(&self) -> (usize, usize) { 405 + (self.logm.capacity(), self.env.capacity()) 406 + } 357 407 } 358 408 359 409 use parking_lot::RwLock; ··· 368 418 VQT_SHARED.get() 369 419 } 370 420 421 + #[must_use] 371 422 pub fn query_vqt(state: &VQTState, bin: i32, smoothing: bool, whitened: bool) -> f64 { 372 - if bin < 0 || (bin as usize) >= state.bins_count() { 423 + let Ok(i) = usize::try_from(bin) else { 424 + return 0.0; 425 + }; 426 + if i >= state.bins_count() { 373 427 return 0.0; 374 428 } 375 - let i = bin as usize; 376 429 if !whitened { 377 430 if smoothing { 378 - state.vqt_norm[i] as f64 431 + f64::from(state.vqt_norm[i]) 379 432 } else { 380 433 // instantaneous normalized (raw divided by peak) 381 - (state.vqt_raw[i] / state.vqt_peak) as f64 434 + f64::from(state.vqt_raw[i] / state.vqt_peak) 382 435 } 383 436 } else if smoothing { 384 - state.vqt_w_norm[i] as f64 437 + f64::from(state.vqt_w_norm[i]) 385 438 } else { 386 - (state.vqt_w_raw[i] / state.vqt_w_peak) as f64 439 + f64::from(state.vqt_w_raw[i] / state.vqt_w_peak) 387 440 } 388 441 }
+42 -21
tic80_rust/src/core/memory.rs
··· 1 + #![allow(clippy::cast_possible_truncation)] 1 2 use crate::gfx::framebuffer::Framebuffer; 2 3 use std::cell::RefCell; 3 4 use std::rc::Rc; 4 5 6 + /// Total RAM size (bytes) exposed to peek/poke APIs. 5 7 const RAM_TOTAL: usize = 96 * 1024; // 96KB 6 - const VRAM_SIZE: usize = 16 * 1024; // first 16KB of RAM is VRAM window 7 - const VRAM_SCREEN_BYTES: usize = 0x3FC0; // 16320 bytes of screen nibble pairs 8 + /// VRAM window size (bytes). For the prototype, the first 16KB mirrors TIC-80's VRAM. 9 + const VRAM_SIZE: usize = 16 * 1024; 10 + /// Screen dimensions (copied from framebuffer constants for clarity). 11 + const SCREEN_WIDTH: usize = crate::gfx::framebuffer::Framebuffer::WIDTH as usize; 12 + const SCREEN_HEIGHT: usize = crate::gfx::framebuffer::Framebuffer::HEIGHT as usize; 13 + /// Total number of screen pixels. 14 + const SCREEN_PIXELS: usize = SCREEN_WIDTH * SCREEN_HEIGHT; // 240*136 = 32640 15 + /// Pixels are nibble-packed into VRAM screen bytes: 2 pixels per byte (lo = even, hi = odd). 16 + const PIXELS_PER_BYTE: usize = 2; 17 + /// Number of bytes in VRAM dedicated to the screen nibble pairs. 18 + const VRAM_SCREEN_BYTES: usize = SCREEN_PIXELS / PIXELS_PER_BYTE; // 16320 (0x3FC0) 8 19 9 20 pub struct Memory { 10 21 ram: Vec<u8>, ··· 12 23 } 13 24 14 25 impl Memory { 26 + #[must_use] 15 27 pub fn new(fb: Rc<RefCell<Framebuffer>>) -> Self { 16 28 Self { 17 29 ram: vec![0; RAM_TOTAL], ··· 19 31 } 20 32 } 21 33 34 + // Bit masks for sub-byte operations 35 + const NIBBLE_MASK: u8 = 0x0F; // 4 bits 36 + const TWO_BIT_MASK: u8 = 0x03; // 2 bits 37 + const ONE_BIT_MASK: u8 = 0x01; // 1 bit 38 + 22 39 // 8-bit read/write with VRAM screen mapping 23 40 fn get_byte(&self, addr: usize) -> u8 { 24 41 if addr < VRAM_SCREEN_BYTES { 25 - // pack 2 pixels from framebuffer into one byte (low nibble = even pixel) 42 + // Pack 2 pixels from framebuffer into one byte (low nibble = even pixel) 26 43 let p = addr * 2; // pixel index 27 44 let mut fb = self.fb.borrow_mut(); 28 - let (w, h) = (Framebuffer::WIDTH as usize, Framebuffer::HEIGHT as usize); 45 + let (w, h) = (SCREEN_WIDTH, SCREEN_HEIGHT); 29 46 // Framebuffer stores 1 byte per pixel index; map linear order row-major 30 47 // pixel p is (x=p%w, y=p/w) 48 + #[allow(clippy::cast_possible_truncation)] 31 49 let mut get_px = |pi: usize| -> u8 { 32 50 if pi < w * h { 33 - fb.pix((pi % w) as i32, (pi / w) as i32, None).unwrap_or(0) & 0x0F 51 + fb.pix((pi % w) as i32, (pi / w) as i32, None).unwrap_or(0) & Self::NIBBLE_MASK 34 52 } else { 35 53 0 36 54 } 37 55 }; 38 56 let lo = get_px(p); 39 57 let hi = get_px(p + 1); 40 - (lo & 0x0F) | ((hi & 0x0F) << 4) 58 + (lo & Self::NIBBLE_MASK) | ((hi & Self::NIBBLE_MASK) << 4) 41 59 } else if addr < VRAM_SIZE { 42 60 // other VRAM bytes (palette, etc.): just return RAM view for now 43 61 self.ram[addr] ··· 50 68 51 69 fn set_byte(&mut self, addr: usize, val: u8) { 52 70 if addr < VRAM_SCREEN_BYTES { 53 - // unpack to 2 pixels 71 + // Unpack byte to 2 pixels in the framebuffer 54 72 let p = addr * 2; 55 - let lo = val & 0x0F; 56 - let hi = (val >> 4) & 0x0F; 73 + let lo = val & Self::NIBBLE_MASK; 74 + let hi = (val >> 4) & Self::NIBBLE_MASK; 57 75 let mut fbm = self.fb.borrow_mut(); 58 - let w = Framebuffer::WIDTH as usize; 76 + let w = SCREEN_WIDTH; 77 + #[allow(clippy::cast_possible_truncation)] 59 78 let set_px = |fb: &mut Framebuffer, pi: usize, v: u8| { 60 79 let x = (pi % w) as i32; 61 80 let y = (pi / w) as i32; 62 - let _ = fb.pix(x, y, Some(v)); 81 + let _ = fb.set_pixel_unclipped(x, y, v); 63 82 }; 64 83 set_px(&mut fbm, p, lo); 65 84 set_px(&mut fbm, p + 1, hi); ··· 71 90 } 72 91 } 73 92 93 + #[must_use] 74 94 pub fn peek(&self, addr: usize) -> u8 { 75 95 self.get_byte(addr) 76 96 } ··· 79 99 } 80 100 81 101 // bit-packed peeks/pokes across entire 96KB (VRAM included) 102 + #[must_use] 82 103 pub fn peek_bits(&self, addr: usize, bits: u8) -> u8 { 83 104 match bits { 84 105 8 => self.peek(addr), 85 106 4 => { 86 107 let byte = self.peek(addr >> 1); 87 108 if (addr & 1) == 0 { 88 - byte & 0x0F 109 + byte & Self::NIBBLE_MASK 89 110 } else { 90 - (byte >> 4) & 0x0F 111 + (byte >> 4) & Self::NIBBLE_MASK 91 112 } 92 113 } 93 114 2 => { 94 115 let byte = self.peek(addr >> 2); 95 116 let shift = (addr & 0b11) * 2; 96 - (byte >> shift) & 0x03 117 + (byte >> shift) & Self::TWO_BIT_MASK 97 118 } 98 119 1 => { 99 120 let byte = self.peek(addr >> 3); 100 121 let shift = addr & 0b111; 101 - (byte >> shift) & 0x01 122 + (byte >> shift) & Self::ONE_BIT_MASK 102 123 } 103 124 _ => 0, 104 125 } ··· 110 131 4 => { 111 132 let mut byte = self.peek(addr >> 1); 112 133 if (addr & 1) == 0 { 113 - byte = (byte & 0xF0) | (val & 0x0F); 134 + byte = (byte & 0xF0) | (val & Self::NIBBLE_MASK); 114 135 } else { 115 - byte = (byte & 0x0F) | ((val & 0x0F) << 4); 136 + byte = (byte & Self::NIBBLE_MASK) | ((val & Self::NIBBLE_MASK) << 4); 116 137 } 117 138 self.poke(addr >> 1, byte); 118 139 } 119 140 2 => { 120 141 let mut byte = self.peek(addr >> 2); 121 142 let shift = (addr & 0b11) * 2; 122 - let mask = !(0x03u8 << shift); 123 - byte = (byte & mask) | ((val & 0x03) << shift); 143 + let mask = !(Self::TWO_BIT_MASK << shift); 144 + byte = (byte & mask) | ((val & Self::TWO_BIT_MASK) << shift); 124 145 self.poke(addr >> 2, byte); 125 146 } 126 147 1 => { 127 148 let mut byte = self.peek(addr >> 3); 128 149 let shift = addr & 0b111; 129 - let mask = !(1u8 << shift); 130 - byte = (byte & mask) | ((val & 0x01) << shift); 150 + let mask = !(Self::ONE_BIT_MASK << shift); 151 + byte = (byte & mask) | ((val & Self::ONE_BIT_MASK) << shift); 131 152 self.poke(addr >> 3, byte); 132 153 } 133 154 _ => {}
+61 -39
tic80_rust/src/gfx/framebuffer.rs
··· 1 + #![allow( 2 + clippy::cast_sign_loss, 3 + clippy::cast_possible_wrap, 4 + clippy::cast_possible_truncation, 5 + clippy::cast_precision_loss 6 + )] 1 7 use std::sync::OnceLock; 2 8 3 9 // Default 16-color TIC-80 palette (sRGB) as RGBA8 10 + const COLOR_MASK: u8 = 0x0F; 4 11 const PALETTE: [[u8; 4]; 16] = [ 5 12 [0x00, 0x00, 0x00, 0xFF], 6 13 [0x1D, 0x2B, 0x53, 0xFF], ··· 37 44 continue; 38 45 } 39 46 let t = tok.trim(); 40 - let val = if let Some(hex) = t.strip_prefix("0x").or_else(|| t.strip_prefix("0X")) { 41 - u8::from_str_radix(hex, 16).ok() 42 - } else { 43 - t.parse::<u8>().ok() 44 - }; 47 + let val = t 48 + .strip_prefix("0x") 49 + .or_else(|| t.strip_prefix("0X")) 50 + .map_or_else( 51 + || t.parse::<u8>().ok(), 52 + |hex| u8::from_str_radix(hex, 16).ok(), 53 + ); 45 54 if let Some(b) = val { 46 55 out.push(b); 47 56 } ··· 64 73 pub const WIDTH: u32 = WIDTH; 65 74 pub const HEIGHT: u32 = HEIGHT; 66 75 76 + #[must_use] 67 77 pub fn new() -> Self { 68 78 Self { 69 79 idx: vec![0; (WIDTH * HEIGHT) as usize], ··· 76 86 77 87 // cls(color): fill framebuffer with palette index 78 88 pub fn cls(&mut self, color: u8) { 79 - self.idx.fill(color & 0x0F); 89 + self.idx.fill(color & COLOR_MASK); 80 90 } 81 91 82 92 // pix(x,y[,color]): if Some(color) -> write; else -> read ··· 91 101 None 92 102 } else { 93 103 let i = (y as u32 * WIDTH + x as u32) as usize; 94 - Some(self.idx[i] & 0x0F) 104 + Some(self.idx[i] & COLOR_MASK) 95 105 } 96 106 } 97 107 } ··· 111 121 return false; 112 122 } 113 123 let i = (y as u32 * WIDTH + x as u32) as usize; 114 - self.idx[i] = color & 0x0F; 124 + self.idx[i] = color & COLOR_MASK; 125 + true 126 + } 127 + 128 + // Unclipped pixel write for memory-to-VRAM mapping; only bounds-checked. 129 + pub fn set_pixel_unclipped(&mut self, x: i32, y: i32, color: u8) -> bool { 130 + if x < 0 || y < 0 || x as u32 >= WIDTH || y as u32 >= HEIGHT { 131 + return false; 132 + } 133 + let i = (y as u32 * WIDTH + x as u32) as usize; 134 + self.idx[i] = color & COLOR_MASK; 115 135 true 116 136 } 117 137 ··· 134 154 self.clip_y1 = y1.max(self.clip_y0); 135 155 } 136 156 157 + #[allow(clippy::missing_const_for_fn)] 137 158 pub fn clip_reset(&mut self) { 138 159 self.clip_x0 = 0; 139 160 self.clip_y0 = 0; ··· 144 165 // Blit to RGBA buffer for pixels 145 166 pub fn blit_to_rgba(&self, rgba: &mut [u8]) { 146 167 for (px, idx) in rgba.chunks_exact_mut(4).zip(self.idx.iter().copied()) { 147 - let pal = &PALETTE[(idx & 0x0F) as usize]; 168 + let pal = &PALETTE[(idx & COLOR_MASK) as usize]; 148 169 px.copy_from_slice(pal); 149 170 } 150 171 } ··· 158 179 let dy = -(y1 - y0).abs(); 159 180 let sy = if y0 < y1 { 1 } else { -1 }; 160 181 let mut err = dx + dy; 161 - let c = color & 0x0F; 182 + let c = color & COLOR_MASK; 162 183 loop { 163 184 let _ = self.set_pixel(x0, y0, c); 164 185 if x0 == x1 && y0 == y1 { ··· 181 202 if w <= 0 || h <= 0 { 182 203 return; 183 204 } 184 - let c = color & 0x0F; 205 + let c = color & COLOR_MASK; 185 206 let x0 = x.max(self.clip_x0).max(0); 186 207 let y0 = y.max(self.clip_y0).max(0); 187 208 let x1 = (x + w).min(self.clip_x1).min(WIDTH as i32); ··· 203 224 if w <= 0 || h <= 0 { 204 225 return; 205 226 } 206 - let c = color & 0x0F; 227 + let c = color & COLOR_MASK; 207 228 let x0 = x; 208 229 let y0 = y; 209 230 let x1 = x + w - 1; ··· 225 246 if r < 0 { 226 247 return; 227 248 } 228 - let c = color & 0x0F; 249 + let c = color & COLOR_MASK; 229 250 if r == 0 { 230 251 let _ = self.set_pixel(cx, cy, c); 231 252 return; ··· 259 280 if r < 0 { 260 281 return; 261 282 } 262 - let c = color & 0x0F; 283 + let c = color & COLOR_MASK; 263 284 if r == 0 { 264 285 let _ = self.set_pixel(cx, cy, c); 265 286 return; ··· 296 317 } 297 318 298 319 // Ellipse border using midpoint algorithm 320 + #[allow(clippy::cast_possible_truncation)] 299 321 pub fn ellib(&mut self, cx: i32, cy: i32, a: i32, b: i32, color: u8) { 300 322 if a < 0 || b < 0 { 301 323 return; 302 324 } 303 - let c = color & 0x0F; 325 + let c = color & COLOR_MASK; 304 326 if a == 0 && b == 0 { 305 327 let _ = self.set_pixel(cx, cy, c); 306 328 return; 307 329 } 308 330 309 - let a2 = (a as i64) * (a as i64); 310 - let b2 = (b as i64) * (b as i64); 331 + let a2 = i64::from(a) * i64::from(a); 332 + let b2 = i64::from(b) * i64::from(b); 311 333 312 334 let mut x: i64 = 0; 313 - let mut y: i64 = b as i64; 314 - let mut d = b2 - a2 * (b as i64) + a2 / 4; 335 + let mut y: i64 = i64::from(b); 336 + let mut d = b2 - a2 * i64::from(b) + a2 / 4; 315 337 while b2 * x <= a2 * y { 316 338 let xx = x as i32; 317 339 let yy = y as i32; ··· 328 350 x += 1; 329 351 } 330 352 331 - x = a as i64; 353 + x = i64::from(a); 332 354 y = 0; 333 - d = a2 - b2 * (a as i64) + b2 / 4; 355 + d = a2 - b2 * i64::from(a) + b2 / 4; 334 356 while a2 * y <= b2 * x { 335 357 let xx = x as i32; 336 358 let yy = y as i32; ··· 353 375 if a < 0 || b < 0 { 354 376 return; 355 377 } 356 - let c = color & 0x0F; 378 + let c = color & COLOR_MASK; 357 379 if a == 0 && b == 0 { 358 380 let _ = self.set_pixel(cx, cy, c); 359 381 return; ··· 399 421 pub fn tri(&mut self, x0: i32, y0: i32, x1: i32, y1: i32, x2: i32, y2: i32, color: u8) { 400 422 let c = color & 0x0F; 401 423 // Convert to CCW orientation for consistent edge tests 402 - let area = (x1 - x0) as i64 * (y2 - y0) as i64 - (x2 - x0) as i64 * (y1 - y0) as i64; 424 + let area = 425 + i64::from(x1 - x0) * i64::from(y2 - y0) - i64::from(x2 - x0) * i64::from(y1 - y0); 403 426 let (v0x, v0y, v1x, v1y, v2x, v2y) = if area < 0 { 404 427 (x0, y0, x2, y2, x1, y1) 405 428 } else { ··· 413 436 let max_y = v0y.max(v1y).max(v2y); 414 437 415 438 // Doubling coordinates to evaluate edge functions at pixel centers (x+0.5, y+0.5) 416 - let (ax2, ay2) = ((v0x as i64) * 2, (v0y as i64) * 2); 417 - let (bx2, by2) = ((v1x as i64) * 2, (v1y as i64) * 2); 418 - let (cx2, cy2) = ((v2x as i64) * 2, (v2y as i64) * 2); 439 + let (ax2, ay2) = (i64::from(v0x) * 2, i64::from(v0y) * 2); 440 + let (bx2, by2) = (i64::from(v1x) * 2, i64::from(v1y) * 2); 441 + let (cx2, cy2) = (i64::from(v2x) * 2, i64::from(v2y) * 2); 419 442 420 443 // Edge deltas 421 444 let e0_dx = bx2 - ax2; ··· 433 456 // Iterate over pixels in bounding box with top-left rule: y in [min_y, max_y), x in [min_x, max_x) 434 457 for y in min_y..max_y { 435 458 for x in min_x..max_x { 436 - let px = (x as i64) * 2 + 1; 437 - let py = (y as i64) * 2 + 1; 459 + let px = i64::from(x) * 2 + 1; 460 + let py = i64::from(y) * 2 + 1; 438 461 // Edge functions 439 462 let e0 = (py - ay2) * e0_dx - (px - ax2) * e0_dy; 440 463 let e1 = (py - by2) * e1_dx - (px - bx2) * e1_dy; ··· 470 493 if scale <= 0 { 471 494 return 0; 472 495 } 473 - let cidx = color & 0x0F; 496 + let cidx = color & COLOR_MASK; 474 497 let font = font_bytes(); 475 498 476 499 let mut pos = x; ··· 493 516 continue; 494 517 } 495 518 496 - let (start_col, width_cols) = if !fixed { 519 + let (start_col, width_cols) = if fixed { 520 + (0, GLYPH_W) 521 + } else { 497 522 // Variable-width: trim empty columns using LSB-left orientation 498 523 let mut left = GLYPH_W; 499 524 let mut right = 0; ··· 520 545 } 521 546 let width = right.saturating_sub(left); 522 547 (left, width) 523 - } else { 524 - (0, GLYPH_W) 525 548 }; 526 549 527 550 // Draw glyph ··· 542 565 } 543 566 544 567 // Advance 545 - if !fixed { 546 - if width_cols > 0 { 547 - pos += ((width_cols as i32) + 1) * scale; 548 - } else { 549 - pos += ADV * scale; 550 - } 568 + if fixed { 569 + pos += ADV * scale; 570 + } else if width_cols > 0 { 571 + pos += ((width_cols as i32) + 1) * scale; 551 572 } else { 552 573 pos += ADV * scale; 553 574 } ··· 561 582 } 562 583 } 563 584 564 - pub fn dimensions() -> (u32, u32) { 585 + #[must_use] 586 + pub const fn dimensions() -> (u32, u32) { 565 587 (Framebuffer::WIDTH, Framebuffer::HEIGHT) 566 588 } 567 589
+17
tic80_rust/src/lib.rs
··· 1 + #![warn( 2 + clippy::all, 3 + clippy::pedantic, 4 + clippy::nursery, 5 + clippy::cargo, 6 + rust_2018_idioms 7 + )] 8 + #![allow( 9 + clippy::many_single_char_names, 10 + clippy::too_many_arguments, 11 + clippy::similar_names, 12 + clippy::multiple_crate_versions, 13 + clippy::cast_possible_wrap, 14 + clippy::cast_sign_loss, 15 + clippy::cast_precision_loss 16 + )] 17 + 1 18 pub mod gfx { 2 19 pub mod framebuffer; 3 20 }
+299 -105
tic80_rust/src/main.rs
··· 1 + #![warn( 2 + clippy::all, 3 + clippy::pedantic, 4 + clippy::nursery, 5 + clippy::cargo, 6 + rust_2018_idioms 7 + )] 8 + #![allow( 9 + clippy::many_single_char_names, 10 + clippy::too_many_arguments, 11 + clippy::similar_names, 12 + clippy::multiple_crate_versions, 13 + clippy::cast_possible_wrap, 14 + clippy::cast_sign_loss, 15 + clippy::cast_precision_loss, 16 + clippy::too_many_lines, 17 + clippy::items_after_statements, 18 + clippy::cast_lossless, 19 + clippy::case_sensitive_file_extension_comparisons, 20 + clippy::option_if_let_else, 21 + clippy::uninlined_format_args, 22 + clippy::significant_drop_tightening, 23 + clippy::match_same_arms, 24 + clippy::redundant_clone, 25 + clippy::struct_excessive_bools 26 + )] 27 + 1 28 use std::cell::RefCell; 2 29 use std::fs; 3 - use std::path::Path; 30 + use std::path::{Path, PathBuf}; 4 31 use std::rc::Rc; 5 32 use std::time::{Duration, Instant}; 6 33 ··· 8 35 use winit::dpi::LogicalSize; 9 36 use winit::event::{ElementState, Event, KeyboardInput, VirtualKeyCode, WindowEvent}; 10 37 use winit::event_loop::{ControlFlow, EventLoop}; 11 - use winit::window::WindowBuilder; 38 + use winit::window::{Window, WindowBuilder}; 12 39 13 40 use parking_lot::RwLock; 14 41 use std::sync::Arc; ··· 44 71 45 72 const DEFAULT_LUA: &str = include_str!("../assets/default.lua"); 46 73 47 - fn run() -> Result<(), Error> { 48 - let event_loop = EventLoop::new(); 49 - const SCALE: f64 = 3.0; // default integer scaling 50 - let (width, height) = dimensions(); 51 - let size = LogicalSize::new((width as f64) * SCALE, (height as f64) * SCALE); 52 - let window = WindowBuilder::new() 53 - .with_title("rustic") 54 - .with_inner_size(size) 55 - .with_min_inner_size(size) 56 - .build(&event_loop) 57 - .unwrap(); 74 + // CLI args 75 + struct Args { 76 + script_path: Option<PathBuf>, 77 + list_audio: bool, 78 + audio_disable: bool, 79 + audio_device: Option<String>, 80 + audio_vu: bool, 81 + debug_fft: bool, 82 + debug_fx: bool, 83 + quiet: bool, 84 + help: bool, 85 + } 58 86 59 - let window_size = window.inner_size(); 60 - let surface_texture = SurfaceTexture::new(window_size.width, window_size.height, &window); 61 - let mut pixels = Pixels::new(width, height, surface_texture)?; 62 - 63 - let fb = Rc::new(RefCell::new(Framebuffer::new())); 64 - let mem = Rc::new(RefCell::new(Memory::new(fb.clone()))); 65 - let mut ticker = Ticker::new(); 66 - // CLI parsing (minimal): flags + optional .lua path 87 + fn parse_args() -> Args { 67 88 let mut args_iter = std::env::args().skip(1); 68 - let mut script_path: Option<String> = None; 69 - let mut list_audio = false; 70 - let mut audio_disable = false; 71 - let mut audio_device: Option<String> = None; 72 - let mut audio_vu = false; 73 - let mut debug_fft = false; 89 + let mut out = Args { 90 + script_path: None, 91 + list_audio: false, 92 + audio_disable: false, 93 + audio_device: None, 94 + audio_vu: false, 95 + debug_fft: false, 96 + debug_fx: false, 97 + quiet: false, 98 + help: false, 99 + }; 74 100 while let Some(arg) = args_iter.next() { 75 101 match arg.as_str() { 76 - "--list-audio" => list_audio = true, 77 - "--audio-disable" => audio_disable = true, 78 - "--audio-vu" => audio_vu = true, 79 - "--debug-fft" => debug_fft = true, 102 + "-h" | "--help" => out.help = true, 103 + "--list-audio" => out.list_audio = true, 104 + "--audio-disable" => out.audio_disable = true, 105 + "--audio-vu" => out.audio_vu = true, 106 + "--debug-fft" => out.debug_fft = true, 107 + "--debug-fx" => out.debug_fx = true, 108 + "--quiet" => out.quiet = true, 80 109 "--audio-device" => { 81 110 if let Some(val) = args_iter.next() { 82 - audio_device = Some(val); 111 + out.audio_device = Some(val); 83 112 } 84 113 } 85 114 other => { 86 115 if other.ends_with(".lua") && Path::new(other).is_file() { 87 - script_path = Some(other.to_string()); 116 + out.script_path = Some(PathBuf::from(other)); 88 117 } 89 118 } 90 119 } 91 120 } 92 - if list_audio { 93 - let list = audio_cap::list_input_devices(); 94 - if list.is_empty() { 95 - println!("No input devices found."); 96 - } else { 97 - println!("Input devices:"); 98 - for (i, name) in list.iter().enumerate() { 99 - println!(" {}: {}", i, name); 121 + out 122 + } 123 + 124 + fn create_window_and_pixels( 125 + event_loop: &EventLoop<()>, 126 + scale: f64, 127 + ) -> Result<(Window, Pixels), Error> { 128 + let (width, height) = dimensions(); 129 + let size = LogicalSize::new((width as f64) * scale, (height as f64) * scale); 130 + let window = WindowBuilder::new() 131 + .with_title("rustic") 132 + .with_inner_size(size) 133 + .with_min_inner_size(size) 134 + .build(event_loop) 135 + .unwrap(); 136 + let ws = window.inner_size(); 137 + let surface_texture = SurfaceTexture::new(ws.width, ws.height, &window); 138 + let pixels = Pixels::new(width, height, surface_texture)?; 139 + Ok((window, pixels)) 140 + } 141 + 142 + // Optional audio capture 143 + struct AudioState { 144 + handle: audio_cap::AudioCaptureHandle, 145 + cons: rtrb::Consumer<f32>, 146 + vu_enabled: bool, 147 + last_print: Instant, 148 + peak_acc: f32, 149 + fft: Arc<RwLock<FFTState>>, 150 + vqt: Arc<RwLock<tic80_rust::audio::vqt::VQTState>>, 151 + debug_fft: bool, 152 + last_fft_dbg: Instant, 153 + debug_fx: bool, 154 + fx_last: Instant, 155 + fx_fft_acc_ns: u128, 156 + fx_vqt_acc_ns: u128, 157 + fx_count: u64, 158 + last_pushed: u64, 159 + last_overflow: u64, 160 + underrun_count: u64, 161 + last_underrun: u64, 162 + consumed_total: u64, 163 + last_consumed: u64, 164 + ema_samples_per_tick: f64, 165 + } 166 + 167 + fn init_audio(args: &Args) -> Option<AudioState> { 168 + if args.audio_disable { 169 + return None; 170 + } 171 + let cap_cfg = audio_cap::AudioCaptureConfig { 172 + device_substr: args.audio_device.clone(), 173 + sample_rate: Some(44_100), 174 + ring_capacity: audio_cap::default_ring_capacity(), 175 + }; 176 + match audio_cap::start_capture(cap_cfg) { 177 + Ok((handle, cons)) => { 178 + println!( 179 + "Audio capture: '{}' @ {} Hz, {} ch", 180 + handle.info.device_name, handle.info.sample_rate, handle.info.channels 181 + ); 182 + if args.audio_vu { 183 + println!("Audio VU: enabled (prints every ~1s)"); 100 184 } 185 + let fft_arc: Arc<RwLock<FFTState>> = Arc::new(RwLock::new(FFTState::new( 186 + audio_cap::default_ring_capacity(), 187 + ))); 188 + set_global_fft(fft_arc.clone()); 189 + let vqt_arc: Arc<RwLock<tic80_rust::audio::vqt::VQTState>> = 190 + Arc::new(RwLock::new(tic80_rust::audio::vqt::VQTState::new( 191 + handle.info.sample_rate, 192 + audio_cap::default_ring_capacity(), 193 + ))); 194 + tic80_rust::audio::vqt::set_global_vqt(vqt_arc.clone()); 195 + Some(AudioState { 196 + handle, 197 + cons, 198 + vu_enabled: args.audio_vu, 199 + last_print: Instant::now(), 200 + peak_acc: 0.0, 201 + fft: fft_arc, 202 + vqt: vqt_arc, 203 + debug_fft: args.debug_fft, 204 + last_fft_dbg: Instant::now(), 205 + debug_fx: args.debug_fx, 206 + fx_last: Instant::now(), 207 + fx_fft_acc_ns: 0, 208 + fx_vqt_acc_ns: 0, 209 + fx_count: 0, 210 + last_pushed: 0, 211 + last_overflow: 0, 212 + underrun_count: 0, 213 + last_underrun: 0, 214 + consumed_total: 0, 215 + last_consumed: 0, 216 + ema_samples_per_tick: 0.0, 217 + }) 101 218 } 102 - return Ok(()); 219 + Err(e) => { 220 + eprintln!( 221 + "Audio capture disabled ({}). Use --audio-disable to silence this.", 222 + e 223 + ); 224 + None 225 + } 103 226 } 104 - let script = if let Some(path) = script_path.as_ref() { 227 + } 228 + 229 + fn print_devices_and_exit() { 230 + let list = audio_cap::list_input_devices(); 231 + if list.is_empty() { 232 + println!("No input devices found."); 233 + } else { 234 + println!("Input devices:"); 235 + for (i, name) in list.iter().enumerate() { 236 + println!(" {}: {}", i, name); 237 + } 238 + } 239 + } 240 + 241 + fn print_help() { 242 + let prog = std::env::args().next().map_or_else( 243 + || "tic80_rust".to_string(), 244 + |p| { 245 + std::path::Path::new(&p) 246 + .file_name() 247 + .and_then(|s| s.to_str()) 248 + .unwrap_or("tic80_rust") 249 + .to_string() 250 + }, 251 + ); 252 + println!( 253 + "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 " 254 + ); 255 + } 256 + 257 + fn load_script(script_path: Option<&PathBuf>) -> String { 258 + if let Some(path) = script_path { 105 259 match fs::read_to_string(path) { 106 260 Ok(s) => s, 107 261 Err(e) => { 108 262 eprintln!( 109 263 "Failed to read {}: {}. Falling back to default cart.", 110 - path, e 264 + path.display(), 265 + e 111 266 ); 112 267 DEFAULT_LUA.to_string() 113 268 } 114 269 } 115 270 } else { 116 271 DEFAULT_LUA.to_string() 117 - }; 118 - let lua_runner = LuaRunner::new(fb.clone(), mem.clone(), &script).ok(); 272 + } 273 + } 274 + 275 + fn run() -> Result<(), Error> { 276 + let event_loop = EventLoop::new(); 277 + let (window, mut pixels) = create_window_and_pixels(&event_loop, 3.0)?; // default integer scaling 278 + 279 + let fb = Rc::new(RefCell::new(Framebuffer::new())); 280 + let mem = Rc::new(RefCell::new(Memory::new(fb.clone()))); 281 + let mut ticker = Ticker::new(); 119 282 120 - // Optional audio capture 121 - struct AudioState { 122 - _handle: audio_cap::AudioCaptureHandle, 123 - cons: rtrb::Consumer<f32>, 124 - vu_enabled: bool, 125 - last_print: Instant, 126 - peak_acc: f32, 127 - fft: Arc<RwLock<FFTState>>, 128 - vqt: Arc<RwLock<tic80_rust::audio::vqt::VQTState>>, 129 - debug_fft: bool, 130 - last_fft_dbg: Instant, 283 + let args = parse_args(); 284 + if args.help { 285 + print_help(); 286 + return Ok(()); 131 287 } 132 - let mut audio_state: Option<AudioState> = None; 133 - if !audio_disable { 134 - let cap_cfg = audio_cap::AudioCaptureConfig { 135 - device_substr: audio_device.clone(), 136 - sample_rate: Some(44_100), 137 - ring_capacity: audio_cap::default_ring_capacity(), 138 - }; 139 - match audio_cap::start_capture(cap_cfg) { 140 - Ok((handle, cons)) => { 141 - println!( 142 - "Audio capture: '{}' @ {} Hz, {} ch", 143 - handle.info.device_name, handle.info.sample_rate, handle.info.channels 144 - ); 145 - if audio_vu { 146 - println!("Audio VU: enabled (prints every ~1s)"); 147 - } 148 - let fft_arc: Arc<RwLock<FFTState>> = Arc::new(RwLock::new(FFTState::new( 149 - audio_cap::default_ring_capacity(), 150 - ))); 151 - set_global_fft(fft_arc.clone()); 152 - let vqt_arc: Arc<RwLock<tic80_rust::audio::vqt::VQTState>> = 153 - Arc::new(RwLock::new(tic80_rust::audio::vqt::VQTState::new( 154 - handle.info.sample_rate, 155 - audio_cap::default_ring_capacity(), 156 - ))); 157 - tic80_rust::audio::vqt::set_global_vqt(vqt_arc.clone()); 158 - audio_state = Some(AudioState { 159 - _handle: handle, 160 - cons, 161 - vu_enabled: audio_vu, 162 - last_print: Instant::now(), 163 - peak_acc: 0.0, 164 - fft: fft_arc, 165 - vqt: vqt_arc, 166 - debug_fft, 167 - last_fft_dbg: Instant::now(), 168 - }); 169 - } 170 - Err(e) => { 171 - eprintln!( 172 - "Audio capture disabled ({}). Use --audio-disable to silence this.", 173 - e 174 - ); 175 - } 176 - } 288 + if args.list_audio { 289 + print_devices_and_exit(); 290 + return Ok(()); 177 291 } 292 + let script = load_script(args.script_path.as_ref()); 293 + tic80_rust::script::lua_runner::set_quiet(args.quiet); 294 + let lua_runner = match LuaRunner::new(fb.clone(), mem.clone(), &script) { 295 + Ok(r) => Some(r), 296 + Err(e) => { 297 + eprintln!("Lua initialization error: {e}"); 298 + None 299 + } 300 + }; 301 + let mut audio_state = init_audio(&args); 302 + let mut warned_no_tic = false; 178 303 179 304 event_loop.run(move |event, _, control_flow| { 180 305 *control_flow = ControlFlow::Poll; ··· 199 324 if ticker.should_tick() { 200 325 if let Some(r) = &lua_runner { 201 326 r.tick(); 327 + } else if !warned_no_tic && !args.quiet { 328 + eprintln!("No TIC() to run; idle"); 329 + warned_no_tic = true; 202 330 } 203 331 // Simple VU meter from audio ring 204 332 if let Some(a) = audio_state.as_mut() { 205 333 // Drain available samples, feed analyzer with a single write lock, track peak 206 334 { 335 + let t0 = Instant::now(); 207 336 let mut w = a.fft.write(); 337 + if a.cons.is_empty() { 338 + a.underrun_count += 1; 339 + } 340 + let mut consumed_this_tick: u64 = 0; 208 341 while let Ok(s) = a.cons.pop() { 209 342 a.peak_acc = a.peak_acc.max(s.abs()); 210 343 w.ingest(s); 211 344 // Also feed VQT buffer 212 345 a.vqt.write().ingest(s); 346 + consumed_this_tick += 1; 213 347 } 348 + a.consumed_total = a.consumed_total.saturating_add(consumed_this_tick); 349 + // EMA of samples/tick (smoothing factor 0.9) 350 + let alpha = 0.9f64; 351 + a.ema_samples_per_tick = a 352 + .ema_samples_per_tick 353 + .mul_add(alpha, (consumed_this_tick as f64) * (1.0 - alpha)); 214 354 w.update(); 355 + let t1 = Instant::now(); 215 356 a.vqt.write().update(); 357 + let t2 = Instant::now(); 358 + if a.debug_fx { 359 + a.fx_fft_acc_ns += (t1 - t0).as_nanos(); 360 + a.fx_vqt_acc_ns += (t2 - t1).as_nanos(); 361 + a.fx_count += 1; 362 + if a.fx_last.elapsed() >= Duration::from_millis(1000) { 363 + let n = a.fx_count.max(1); 364 + #[allow(clippy::cast_precision_loss)] 365 + let avg_fft_ms = (a.fx_fft_acc_ns as f64) / 1.0e6 / (n as f64); 366 + #[allow(clippy::cast_precision_loss)] 367 + let avg_vqt_ms = (a.fx_vqt_acc_ns as f64) / 1.0e6 / (n as f64); 368 + // ring stats (if audio enabled) 369 + if let Some(h) = Some(&a.handle) { 370 + let pushed = h 371 + .pushed_count 372 + .load(std::sync::atomic::Ordering::Relaxed); 373 + let ovf = h 374 + .overflow_count 375 + .load(std::sync::atomic::Ordering::Relaxed); 376 + let dp = pushed.saturating_sub(a.last_pushed); 377 + let dofv = ovf.saturating_sub(a.last_overflow); 378 + let du = a.underrun_count.saturating_sub(a.last_underrun); 379 + let dc = a.consumed_total.saturating_sub(a.last_consumed); 380 + let occ_est = pushed 381 + .saturating_sub(ovf) 382 + .saturating_sub(a.consumed_total); 383 + #[allow(clippy::cast_possible_truncation)] 384 + let occ = occ_est.min(h.ring_capacity as u64) as usize; 385 + let occ_pct = (occ as f64) / (h.ring_capacity as f64) * 100.0; 386 + println!( 387 + "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}%)", 388 + uc = a.underrun_count, 389 + ema = a.ema_samples_per_tick, 390 + cap = h.ring_capacity, 391 + occ_pct = occ_pct, 392 + occ = occ 393 + ); 394 + a.last_pushed = pushed; 395 + a.last_overflow = ovf; 396 + a.last_underrun = a.underrun_count; 397 + a.last_consumed = a.consumed_total; 398 + } else { 399 + println!( 400 + "FX avg ms: fft={avg_fft_ms:.3}, vqt={avg_vqt_ms:.3} ({n} ticks)" 401 + ); 402 + } 403 + a.fx_fft_acc_ns = 0; 404 + a.fx_vqt_acc_ns = 0; 405 + a.fx_count = 0; 406 + a.fx_last = Instant::now(); 407 + } 408 + } 216 409 } 217 410 if a.debug_fft && a.last_fft_dbg.elapsed() >= Duration::from_millis(500) { 218 411 // Print a small subset of normalized bins ··· 224 417 { 225 418 let r = a.fft.read(); 226 419 for i in 0..bins { 227 - line.push_str(&format!("{:.2} ", r.fft_sm[i])); 420 + use std::fmt::Write as _; 421 + let _ = write!(&mut line, "{:.2} ", r.fft_sm[i]); 228 422 } 229 423 } 230 - println!("{}", line); 424 + println!("{line}"); 231 425 a.last_fft_dbg = Instant::now(); 232 426 } 233 427 if a.vu_enabled && a.last_print.elapsed() >= Duration::from_millis(1000) { 234 428 let peak = a.peak_acc.max(1e-9); 235 429 let db = 20.0 * peak.log10(); 236 - println!("VU: peak {:.3} ({:.1} dBFS)", peak, db); 430 + println!("VU: peak {peak:.3} ({db:.1} dBFS)"); 237 431 a.peak_acc = 0.0; 238 432 a.last_print = Instant::now(); 239 433 }
+138 -123
tic80_rust/src/script/lua_runner.rs
··· 1 1 use std::cell::RefCell; 2 2 use std::rc::Rc; 3 - use std::sync::{Mutex, OnceLock}; 3 + use std::sync::{ 4 + atomic::{AtomicBool, Ordering}, 5 + Mutex, OnceLock, 6 + }; 4 7 use std::time::Instant; 5 8 6 9 use mlua::{Function, Lua, MultiValue, RegistryKey, Result as LuaResult, Value}; ··· 17 20 18 21 // Optional trace buffer (used by tests); if present, trace() will also append messages here. 19 22 static TRACE_BUFFER: OnceLock<Mutex<Vec<String>>> = OnceLock::new(); 23 + static QUIET: AtomicBool = AtomicBool::new(false); 24 + 25 + pub fn set_quiet(v: bool) { 26 + QUIET.store(v, Ordering::Relaxed); 27 + } 20 28 21 29 impl LuaRunner { 30 + /// Construct a Lua runtime, install TIC-80 APIs, and load the cart script. 31 + /// 32 + /// Errors 33 + /// Returns an `mlua::Error` if Lua fails to initialize or the provided script 34 + /// fails to load/execute (including errors thrown by `BOOT()` if present). 35 + #[allow( 36 + clippy::too_many_lines, 37 + clippy::needless_pass_by_value, 38 + clippy::missing_errors_doc, 39 + clippy::cast_possible_truncation, 40 + clippy::redundant_clone 41 + )] 22 42 pub fn new( 23 43 fb: Rc<RefCell<Framebuffer>>, 24 44 mem: Rc<RefCell<Memory>>, 25 45 script_src: &str, 26 46 ) -> LuaResult<Self> { 47 + // Arguments structure for print(); define before statements to satisfy clippy. 48 + #[derive(Default)] 49 + struct PrintArgs { 50 + text: String, 51 + x: i32, 52 + y: i32, 53 + color: u8, 54 + fixed: bool, 55 + scale: i32, 56 + small: bool, 57 + } 58 + impl PrintArgs { 59 + fn from_lua(args: &MultiValue<'_>) -> LuaResult<Self> { 60 + let mut out = Self { 61 + color: 15, 62 + scale: 1, 63 + ..Default::default() 64 + }; 65 + for (i, v) in args.iter().enumerate() { 66 + match (i, v) { 67 + (0, Value::String(s)) => out.text = s.to_str()?.to_string(), 68 + (1, Value::Integer(n)) => out.x = *n as i32, 69 + (2, Value::Integer(n)) => out.y = *n as i32, 70 + (3, Value::Integer(n)) => out.color = (*n).clamp(0, 255) as u8, 71 + (4, Value::Boolean(b)) => out.fixed = *b, 72 + (5, Value::Integer(n)) => out.scale = (*n as i32).max(1), 73 + (6, Value::Boolean(b)) => out.small = *b, 74 + _ => {} 75 + } 76 + } 77 + Ok(out) 78 + } 79 + } 80 + 81 + // Small helper for FFT arg parsing. 82 + fn parse_fft_args(args: &MultiValue<'_>) -> (i32, i32) { 83 + let start = match args.get(0) { 84 + Some(Value::Integer(n)) => *n as i32, 85 + _ => -1, 86 + }; 87 + let end = match args.get(1) { 88 + Some(Value::Integer(n)) => *n as i32, 89 + _ => -1, 90 + }; 91 + (start, end) 92 + } 93 + 27 94 let lua = Lua::new(); 28 95 let start_time = Instant::now(); 29 96 let tic_key = { ··· 95 162 96 163 // clip(x,y,w,h) or clip() to reset 97 164 let fb_clip = fb.clone(); 98 - let clip_fn = lua.create_function(move |_, args: MultiValue| { 165 + let clip_fn = lua.create_function(move |_, args: MultiValue<'_>| { 99 166 if args.is_empty() { 100 167 fb_clip.borrow_mut().clip_reset(); 101 168 } else { ··· 122 189 globals.set("clip", clip_fn)?; 123 190 124 191 // print(text, x=0, y=0, color=15, fixed=false, scale=1, small=false) -> width 125 - #[derive(Default)] 126 - struct PrintArgs { 127 - text: String, 128 - x: i32, 129 - y: i32, 130 - color: u8, 131 - fixed: bool, 132 - scale: i32, 133 - small: bool, 134 - } 135 - 136 - impl PrintArgs { 137 - fn from_lua(args: &MultiValue) -> LuaResult<Self> { 138 - let mut out = PrintArgs { 139 - color: 15, 140 - scale: 1, 141 - ..Default::default() 142 - }; 143 - for (i, v) in args.iter().enumerate() { 144 - match (i, v) { 145 - (0, Value::String(s)) => out.text = s.to_str()?.to_string(), 146 - (1, Value::Integer(n)) => out.x = *n as i32, 147 - (2, Value::Integer(n)) => out.y = *n as i32, 148 - (3, Value::Integer(n)) => out.color = (*n).clamp(0, 255) as u8, 149 - (4, Value::Boolean(b)) => out.fixed = *b, 150 - (5, Value::Integer(n)) => out.scale = (*n as i32).max(1), 151 - (6, Value::Boolean(b)) => out.small = *b, 152 - _ => {} 153 - } 154 - } 155 - Ok(out) 156 - } 157 - } 158 192 159 193 let fb_print = fb.clone(); 160 - let print_fn = lua.create_function(move |_, args: MultiValue| { 194 + let print_fn = lua.create_function(move |_, args: MultiValue<'_>| { 161 195 let p = PrintArgs::from_lua(&args)?; 162 196 let width = fb_print 163 197 .borrow_mut() ··· 167 201 globals.set("print", print_fn)?; 168 202 169 203 // FFT APIs: fft/ffts/fftr/fftrs 170 - fn parse_fft_args(args: &MultiValue) -> (i32, i32) { 171 - let start = match args.get(0) { 172 - Some(Value::Integer(n)) => *n as i32, 173 - _ => -1, 174 - }; 175 - let end = match args.get(1) { 176 - Some(Value::Integer(n)) => *n as i32, 177 - _ => -1, 178 - }; 179 - (start, end) 180 - } 181 - 182 - let fft_fn = lua.create_function(move |_, args: MultiValue| { 204 + let fft_fn = lua.create_function(move |_, args: MultiValue<'_>| { 183 205 let (start, end) = parse_fft_args(&args); 184 - let val = if let Some(arc) = get_global_fft() { 206 + let val = get_global_fft().map_or(0.0, |arc| { 185 207 let guard = arc.read(); 186 208 query_fft(&guard, start, end, false, false) 187 - } else { 188 - 0.0 189 - }; 209 + }); 190 210 Ok(val) 191 211 })?; 192 212 globals.set("fft", fft_fn)?; 193 213 194 - let ffts_fn = lua.create_function(move |_, args: MultiValue| { 214 + let ffts_fn = lua.create_function(move |_, args: MultiValue<'_>| { 195 215 let (start, end) = parse_fft_args(&args); 196 - let val = if let Some(arc) = get_global_fft() { 216 + let val = get_global_fft().map_or(0.0, |arc| { 197 217 let guard = arc.read(); 198 218 query_fft(&guard, start, end, true, false) 199 - } else { 200 - 0.0 201 - }; 219 + }); 202 220 Ok(val) 203 221 })?; 204 222 globals.set("ffts", ffts_fn)?; 205 223 206 - let fftr_fn = lua.create_function(move |_, args: MultiValue| { 224 + let fftr_fn = lua.create_function(move |_, args: MultiValue<'_>| { 207 225 let (start, end) = parse_fft_args(&args); 208 - let val = if let Some(arc) = get_global_fft() { 226 + let val = get_global_fft().map_or(0.0, |arc| { 209 227 let guard = arc.read(); 210 228 query_fft(&guard, start, end, false, true) 211 - } else { 212 - 0.0 213 - }; 229 + }); 214 230 Ok(val) 215 231 })?; 216 232 globals.set("fftr", fftr_fn)?; 217 233 218 - let fftrs_fn = lua.create_function(move |_, args: MultiValue| { 234 + let fftrs_fn = lua.create_function(move |_, args: MultiValue<'_>| { 219 235 let (start, end) = parse_fft_args(&args); 220 - let val = if let Some(arc) = get_global_fft() { 236 + let val = get_global_fft().map_or(0.0, |arc| { 221 237 let guard = arc.read(); 222 238 query_fft(&guard, start, end, true, true) 223 - } else { 224 - 0.0 225 - }; 239 + }); 226 240 Ok(val) 227 241 })?; 228 242 globals.set("fftrs", fftrs_fn)?; 229 243 230 244 // VQT APIs: vqt/vqts/vqtr/vqtrs and whitened variants vqtw/vqtsw/vqtrw/vqtrsw 231 245 let vqt_fn = lua.create_function(move |_, bin: i32| { 232 - let val = if let Some(arc) = get_global_vqt() { 246 + let val = get_global_vqt().map_or(0.0, |arc| { 233 247 let guard = arc.read(); 234 - // normalized instantaneous (may exceed 1.0) 235 248 if bin >= 0 && (bin as usize) < guard.bins_count() { 236 - (guard.vqt_raw[bin as usize] / guard.vqt_peak) as f64 249 + f64::from(guard.vqt_raw[bin as usize] / guard.vqt_peak) 237 250 } else { 238 251 0.0 239 252 } 240 - } else { 241 - 0.0 242 - }; 253 + }); 243 254 Ok(val) 244 255 })?; 245 256 globals.set("vqt", vqt_fn)?; 246 257 247 258 let vqts_fn = lua.create_function(move |_, bin: i32| { 248 - let val = if let Some(arc) = get_global_vqt() { 259 + let val = get_global_vqt().map_or(0.0, |arc| { 249 260 let guard = arc.read(); 250 261 if bin >= 0 && (bin as usize) < guard.bins_count() { 251 - guard.vqt_norm[bin as usize] as f64 262 + f64::from(guard.vqt_norm[bin as usize]) 252 263 } else { 253 264 0.0 254 265 } 255 - } else { 256 - 0.0 257 - }; 266 + }); 258 267 Ok(val) 259 268 })?; 260 269 globals.set("vqts", vqts_fn)?; 261 270 262 271 let vqtr_fn = lua.create_function(move |_, bin: i32| { 263 - let val = if let Some(arc) = get_global_vqt() { 272 + let val = get_global_vqt().map_or(0.0, |arc| { 264 273 let guard = arc.read(); 265 274 if bin >= 0 && (bin as usize) < guard.bins_count() { 266 - guard.vqt_raw[bin as usize] as f64 275 + f64::from(guard.vqt_raw[bin as usize]) 267 276 } else { 268 277 0.0 269 278 } 270 - } else { 271 - 0.0 272 - }; 279 + }); 273 280 Ok(val) 274 281 })?; 275 282 globals.set("vqtr", vqtr_fn)?; 276 283 277 284 let vqtrs_fn = lua.create_function(move |_, bin: i32| { 278 - let val = if let Some(arc) = get_global_vqt() { 285 + let val = get_global_vqt().map_or(0.0, |arc| { 279 286 let guard = arc.read(); 280 287 if bin >= 0 && (bin as usize) < guard.bins_count() { 281 - guard.vqt_sm[bin as usize] as f64 288 + f64::from(guard.vqt_sm[bin as usize]) 282 289 } else { 283 290 0.0 284 291 } 285 - } else { 286 - 0.0 287 - }; 292 + }); 288 293 Ok(val) 289 294 })?; 290 295 globals.set("vqtrs", vqtrs_fn)?; 291 296 292 297 let vqtw_fn = lua.create_function(move |_, bin: i32| { 293 - let val = if let Some(arc) = get_global_vqt() { 298 + let val = get_global_vqt().map_or(0.0, |arc| { 294 299 let guard = arc.read(); 295 300 if bin >= 0 && (bin as usize) < guard.bins_count() { 296 - (guard.vqt_w_raw[bin as usize] / guard.vqt_w_peak) as f64 301 + f64::from(guard.vqt_w_raw[bin as usize] / guard.vqt_w_peak) 297 302 } else { 298 303 0.0 299 304 } 300 - } else { 301 - 0.0 302 - }; 305 + }); 303 306 Ok(val) 304 307 })?; 305 308 globals.set("vqtw", vqtw_fn)?; 306 309 307 310 let vqtsw_fn = lua.create_function(move |_, bin: i32| { 308 - let val = if let Some(arc) = get_global_vqt() { 311 + let val = get_global_vqt().map_or(0.0, |arc| { 309 312 let guard = arc.read(); 310 313 if bin >= 0 && (bin as usize) < guard.bins_count() { 311 - guard.vqt_w_norm[bin as usize] as f64 314 + f64::from(guard.vqt_w_norm[bin as usize]) 312 315 } else { 313 316 0.0 314 317 } 315 - } else { 316 - 0.0 317 - }; 318 + }); 318 319 Ok(val) 319 320 })?; 320 321 globals.set("vqtsw", vqtsw_fn)?; 321 322 322 323 let vqtrw_fn = lua.create_function(move |_, bin: i32| { 323 - let val = if let Some(arc) = get_global_vqt() { 324 + let val = get_global_vqt().map_or(0.0, |arc| { 324 325 let guard = arc.read(); 325 326 if bin >= 0 && (bin as usize) < guard.bins_count() { 326 - guard.vqt_w_raw[bin as usize] as f64 327 + f64::from(guard.vqt_w_raw[bin as usize]) 327 328 } else { 328 329 0.0 329 330 } 330 - } else { 331 - 0.0 332 - }; 331 + }); 333 332 Ok(val) 334 333 })?; 335 334 globals.set("vqtrw", vqtrw_fn)?; 336 335 337 336 let vqtrsw_fn = lua.create_function(move |_, bin: i32| { 338 - let val = if let Some(arc) = get_global_vqt() { 337 + let val = get_global_vqt().map_or(0.0, |arc| { 339 338 let guard = arc.read(); 340 339 if bin >= 0 && (bin as usize) < guard.bins_count() { 341 - guard.vqt_w_sm[bin as usize] as f64 340 + f64::from(guard.vqt_w_sm[bin as usize]) 342 341 } else { 343 342 0.0 344 343 } 345 - } else { 346 - 0.0 347 - }; 344 + }); 348 345 Ok(val) 349 346 })?; 350 347 globals.set("vqtrsw", vqtrsw_fn)?; 351 348 352 349 // trace(message, color=15) 353 - let trace_fn = lua.create_function(move |_, args: MultiValue| { 350 + let trace_fn = lua.create_function(move |_, args: MultiValue<'_>| { 354 351 let msg = match args.get(0) { 355 352 Some(Value::String(s)) => s.to_str()?.to_string(), 356 353 Some(Value::Number(n)) => n.to_string(), 357 354 Some(Value::Integer(i)) => i.to_string(), 358 355 Some(Value::Boolean(b)) => b.to_string(), 359 - Some(Value::Nil) | None => String::new(), 360 356 _ => String::new(), 361 357 }; 362 358 let color = match args.get(1) { ··· 365 361 _ => 15, 366 362 }; 367 363 // Print to console; color is informational only here. 368 - println!("[trace:{}] {}", color, msg); 364 + println!("[trace:{color}] {msg}"); 369 365 if let Some(buf) = TRACE_BUFFER.get() { 370 366 if let Ok(mut b) = buf.lock() { 371 367 b.push(msg); ··· 393 389 } else { 394 390 mem_peek.borrow().peek_bits(a, b) 395 391 }; 396 - Ok(v as u32) 392 + Ok(u32::from(v)) 397 393 })?; 398 394 globals.set("peek", peek_fn)?; 399 395 ··· 416 412 globals.set( 417 413 "peek1", 418 414 lua.create_function(move |_, addr: u32| { 419 - Ok(mem_peek1.borrow().peek_bits(addr as usize, 1) as u32) 415 + Ok(u32::from(mem_peek1.borrow().peek_bits(addr as usize, 1))) 420 416 })?, 421 417 )?; 422 418 let mem_peek2 = mem.clone(); 423 419 globals.set( 424 420 "peek2", 425 421 lua.create_function(move |_, addr: u32| { 426 - Ok(mem_peek2.borrow().peek_bits(addr as usize, 2) as u32) 422 + Ok(u32::from(mem_peek2.borrow().peek_bits(addr as usize, 2))) 427 423 })?, 428 424 )?; 429 425 let mem_peek4 = mem.clone(); 430 426 globals.set( 431 427 "peek4", 432 428 lua.create_function(move |_, addr: u32| { 433 - Ok(mem_peek4.borrow().peek_bits(addr as usize, 4) as u32) 429 + Ok(u32::from(mem_peek4.borrow().peek_bits(addr as usize, 4))) 434 430 })?, 435 431 )?; 436 432 ··· 528 524 lua.load(script_src).set_name("cart").exec()?; 529 525 530 526 // Call BOOT() if present 531 - if let Ok(boot) = globals.get::<_, Function>("BOOT") { 532 - let _ = boot.call::<_, ()>(()); 527 + if let Ok(boot) = globals.get::<_, Function<'_>>("BOOT") { 528 + if let Err(e) = boot.call::<_, ()>(()) { 529 + if !QUIET.load(Ordering::Relaxed) { 530 + eprintln!("Lua BOOT() error: {e}"); 531 + } 532 + } 533 533 } 534 534 535 535 // Cache TIC if present 536 - match globals.get::<_, Option<Function>>("TIC")? { 537 - Some(f) => Some(lua.create_registry_value(f)?), 538 - None => None, 536 + if let Some(f) = globals.get::<_, Option<Function<'_>>>("TIC")? { 537 + Some(lua.create_registry_value(f)?) 538 + } else { 539 + if !QUIET.load(Ordering::Relaxed) { 540 + eprintln!("Cart defines no TIC() function; nothing to tick"); 541 + } 542 + None 539 543 } 540 544 }; 541 545 ··· 544 548 545 549 pub fn tick(&self) { 546 550 if let Some(key) = &self.tic_key { 547 - if let Ok(func) = self.lua.registry_value::<Function>(key) { 548 - let _ = func.call::<_, ()>(()); 551 + match self.lua.registry_value::<Function<'_>>(key) { 552 + Ok(func) => { 553 + if let Err(e) = func.call::<_, ()>(()) { 554 + if !QUIET.load(Ordering::Relaxed) { 555 + eprintln!("Lua TIC() error: {e}"); 556 + } 557 + } 558 + } 559 + Err(e) => { 560 + if !QUIET.load(Ordering::Relaxed) { 561 + eprintln!("Lua error resolving TIC(): {e}"); 562 + } 563 + } 549 564 } 550 565 } 551 566 }
+68
tic80_rust/tests/memory_bits_alignment.rs
··· 1 + use std::cell::RefCell; 2 + use std::rc::Rc; 3 + 4 + use tic80_rust::core::memory::Memory; 5 + use tic80_rust::gfx::framebuffer::Framebuffer; 6 + 7 + #[test] 8 + fn two_bit_cross_byte_alignment() { 9 + let fb = Rc::new(RefCell::new(Framebuffer::new())); 10 + let mut mem = Memory::new(fb); 11 + let base = 70_000usize; // general RAM region 12 + mem.memset(base, 0x00, 2); 13 + // Write last 2 bits (shift=6) of first byte via 2-bit addressing 14 + // addr = byte_idx*4 + slot, slot in [0..3] 15 + let a0 = base * 4 + 3; 16 + mem.poke_bits(a0, 2, 0b01); 17 + assert_eq!(mem.peek(base), 0b01 << 6); 18 + // Write first 2 bits (shift=0) of next byte via 2-bit addressing 19 + let a1 = (base + 1) * 4; 20 + mem.poke_bits(a1, 2, 0b10); 21 + assert_eq!(mem.peek(base + 1), 0b10); 22 + // Confirm first byte unchanged by second write 23 + assert_eq!(mem.peek(base), 0b01 << 6); 24 + } 25 + 26 + #[test] 27 + fn four_bit_unaligned_nibbles() { 28 + let fb = Rc::new(RefCell::new(Framebuffer::new())); 29 + let mut mem = Memory::new(fb); 30 + let base = 65_000usize; // general RAM region 31 + mem.memset(base, 0x00, 3); 32 + // Write high nibble of first byte (odd nibble address) 33 + let a_hi = base * 2 + 1; 34 + mem.poke_bits(a_hi, 4, 0xA); 35 + assert_eq!(mem.peek(base), 0xA0); 36 + // Write low nibble of next byte (even nibble address for next byte) 37 + let a_lo_next = (base + 1) * 2; 38 + mem.poke_bits(a_lo_next, 4, 0x5); 39 + assert_eq!(mem.peek(base + 1) & 0x0F, 0x05); 40 + // Ensure first byte unchanged 41 + assert_eq!(mem.peek(base), 0xA0); 42 + 43 + // Now write low then high nibble within the same byte 44 + mem.memset(base + 2, 0x00, 1); 45 + let a_lo = (base + 2) * 2; 46 + let a_hi_same = (base + 2) * 2 + 1; 47 + mem.poke_bits(a_lo, 4, 0x3); 48 + mem.poke_bits(a_hi_same, 4, 0xC); 49 + assert_eq!(mem.peek(base + 2), 0xC3); 50 + } 51 + 52 + #[test] 53 + fn one_bit_cross_byte_alignment_edges() { 54 + let fb = Rc::new(RefCell::new(Framebuffer::new())); 55 + let mut mem = Memory::new(fb); 56 + let base = 75_000usize; // general RAM region 57 + mem.memset(base, 0x00, 2); 58 + // Set MSB of first byte 59 + let a0 = base * 8 + 7; 60 + mem.poke_bits(a0, 1, 1); 61 + assert_eq!(mem.peek(base), 0b1000_0000); 62 + // Set LSB of next byte 63 + let a1 = (base + 1) * 8; 64 + mem.poke_bits(a1, 1, 1); 65 + assert_eq!(mem.peek(base + 1), 0b0000_0001); 66 + // Ensure first byte unchanged by second write 67 + assert_eq!(mem.peek(base), 0b1000_0000); 68 + }
+87
tic80_rust/tests/memory_bits_roundtrip.rs
··· 1 + use std::cell::RefCell; 2 + use std::rc::Rc; 3 + 4 + use tic80_rust::core::memory::Memory; 5 + use tic80_rust::gfx::framebuffer::Framebuffer; 6 + 7 + fn mask_for_bits(bits: u8) -> u8 { 8 + match bits { 9 + 1 => 0x01, 10 + 2 => 0x03, 11 + 4 => 0x0F, 12 + 8 => 0xFF, 13 + _ => 0, 14 + } 15 + } 16 + 17 + #[test] 18 + fn roundtrip_peek_poke_bits_general_ram() { 19 + let fb = Rc::new(RefCell::new(Framebuffer::new())); 20 + let mut mem = Memory::new(fb); 21 + let base = 80_000usize; // within 96KB RAM (beyond VRAM) 22 + // exercise a range of addresses for each bit-width with a deterministic pattern 23 + for &bits in &[1u8, 2, 4, 8] { 24 + let m = mask_for_bits(bits); 25 + // ensure a clean slate 26 + mem.memset(base, 0x00, 128); 27 + for i in 0..512usize { 28 + let addr = match bits { 29 + 8 => base + i, 30 + 4 => base * 2 + i, // address in nibbles 31 + 2 => base * 4 + i, // address in 2-bit units 32 + 1 => base * 8 + i, // address in bits 33 + _ => unreachable!(), 34 + }; 35 + let val = ((i as u8).wrapping_mul(37)) & m; 36 + mem.poke_bits(addr, bits, val); 37 + assert_eq!(mem.peek_bits(addr, bits), val); 38 + } 39 + } 40 + } 41 + 42 + #[test] 43 + fn vram_screen_boundary_write_does_not_bleed() { 44 + let fb = Rc::new(RefCell::new(Framebuffer::new())); 45 + let mut mem = Memory::new(fb.clone()); 46 + // Compute VRAM screen byte count from framebuffer size (2 pixels per byte) 47 + let w = Framebuffer::WIDTH as usize; 48 + let h = Framebuffer::HEIGHT as usize; 49 + let screen_bytes = (w * h) / 2; 50 + 51 + // Set last byte in screen region to a known pattern 52 + let last_byte_index = screen_bytes - 1; 53 + mem.poke(last_byte_index, 0xAB); 54 + // Verify the last two pixels updated accordingly: low nibble -> even pixel (x=w-2), high -> odd (x=w-1) 55 + let px0 = fb 56 + .borrow_mut() 57 + .pix((w - 1) as i32, (h - 1) as i32, None) 58 + .unwrap(); 59 + let px1 = fb 60 + .borrow_mut() 61 + .pix((w - 2) as i32, (h - 1) as i32, None) 62 + .unwrap(); 63 + assert_eq!(px0, 0x0A); // last pixel (odd index) gets high nibble 64 + assert_eq!(px1, 0x0B); // previous pixel (even index) gets low nibble 65 + 66 + // Now write the very next VRAM byte (first non-screen VRAM byte) 67 + let before0 = fb 68 + .borrow_mut() 69 + .pix((w - 1) as i32, (h - 1) as i32, None) 70 + .unwrap(); 71 + let before1 = fb 72 + .borrow_mut() 73 + .pix((w - 2) as i32, (h - 1) as i32, None) 74 + .unwrap(); 75 + mem.poke(screen_bytes, 0xFF); 76 + // Framebuffer should not change for non-screen VRAM writes 77 + let after0 = fb 78 + .borrow_mut() 79 + .pix((w - 1) as i32, (h - 1) as i32, None) 80 + .unwrap(); 81 + let after1 = fb 82 + .borrow_mut() 83 + .pix((w - 2) as i32, (h - 1) as i32, None) 84 + .unwrap(); 85 + assert_eq!(before0, after0); 86 + assert_eq!(before1, after1); 87 + }
+12
tic80_rust/tests/memory_tests.rs
··· 62 62 mem.poke_bits(base * 8 + 7, 1, 1); // set MSB of first byte 63 63 assert_eq!(mem.peek(base), 0x8F); 64 64 } 65 + 66 + #[test] 67 + fn vram_writes_ignore_clip() { 68 + let fb = Rc::new(RefCell::new(Framebuffer::new())); 69 + let mut mem = Memory::new(fb.clone()); 70 + // Set a clip that excludes the first pixel (0,0) 71 + fb.borrow_mut().clip(10, 10, 10, 10); 72 + // Write to VRAM screen first byte low nibble -> pixel (0,0) 73 + mem.poke_bits(0, 4, 0xC); 74 + // Despite clip, the pixel must update 75 + assert_eq!(fb.borrow_mut().pix(0, 0, None), Some(0xC)); 76 + }
+30
tic80_rust/tests/vqt_tests.rs
··· 116 116 assert!(vqt.vqt_w_norm[i].is_finite()); 117 117 } 118 118 } 119 + 120 + #[test] 121 + fn vqt_whitening_scratch_not_reallocated() { 122 + let cap = 8192; 123 + let sr = 44_100; 124 + let mut vqt = VQTState::new(sr, cap); 125 + let (p_logm, p_env) = vqt.scratch_ptrs(); 126 + let (c_logm, c_env) = vqt.scratch_caps(); 127 + // Run multiple updates to exercise whitening path 128 + for _ in 0..10 { 129 + for i in 0..9000 { 130 + let t = i as f32 / (sr as f32); 131 + let s = (2.0 * std::f32::consts::PI * 440.0 * t).sin(); 132 + vqt.ingest(s); 133 + } 134 + vqt.update(); 135 + } 136 + let (p_logm2, p_env2) = vqt.scratch_ptrs(); 137 + let (c_logm2, c_env2) = vqt.scratch_caps(); 138 + assert_eq!( 139 + p_logm, p_logm2, 140 + "logm pointer changed (allocation likely occurred)" 141 + ); 142 + assert_eq!( 143 + p_env, p_env2, 144 + "env pointer changed (allocation likely occurred)" 145 + ); 146 + assert_eq!(c_logm, c_logm2, "logm capacity changed (reallocation)"); 147 + assert_eq!(c_env, c_env2, "env capacity changed (reallocation)"); 148 + }