···1515 - Reference new/updated plans from AGENTS.md and the docs index.
1616- Keep hygiene visible: mention clippy/test status with each change.
1717- ALWAYS format code with `cargo fmt` after changes, in addition to fixing all compiler/clippy warnings and errors.
1818+- ALWAYS update the test carts catalog when adding a new cart:
1919+ - Add the cart to `docs/testing/test_carts.md` with purpose, run instructions, and expected behavior.
2020+ - If needed, add a short run snippet to `docs/README.md`.
18211922**Context**
2023- **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`.
+4
docs/README.md
···2121- `docs/testing/strategy.md`: Testing and validation strategy across API/VRAM/audio.
2222- `docs/testing/frame_hashes.md`: Conventions for deterministic frame/audio hashing (stub).
2323- `docs/testing/test_catalog.md`: Summary of current tests and their intent.
2424+- `docs/testing/test_carts.md`: Manual test carts (Lua) for quick verification (FFT, time/trace).
24252526## Decisions (ADR)
2627- `docs/adr/0001-winit-pixels.md`: Windowing/presentation stack decision.
···4142- Audio FFT test cart:
4243 - `cargo run --manifest-path tic80_rust/Cargo.toml -- tic80_rust/assets/fft_test.lua --audio-device "<name-substr>"`
4344 - Add `--audio-vu` to print a 1s peak; add `--debug-fft` to print the first few bins.
4545+- Time/Trace test cart:
4646+ - `cargo run --manifest-path tic80_rust/Cargo.toml -- tic80_rust/assets/time_trace_test.lua`
4747+ - Shows elapsed ms and emits a trace once per second to the console.
+5-1
docs/specs/implementation_status.md
···2121 - Memory
2222 - `peek(addr[,bits=8])`, `poke(addr, value[,bits=8])`: 8/4/2/1-bit addressing across full 96 KB; VRAM screen region mapped to live framebuffer (nibble-packed 2 px/byte).
2323 - `peek1/peek2/peek4`, `poke1/poke2/poke4`: Bit-specific helpers.
2424- - `memcpy(dst, src, size)`, `memset(dst, value, size)`: Byte-wise operations; overlap-safe memcpy; VRAM ops update on-screen pixels immediately.
2424+ - `memcpy(dst, src, size)`, `memset(dst, value, size)`: Byte-wise operations; overlap-safe memcpy; VRAM ops update on-screen pixels immediately.
2525+2626+Implemented (System)
2727+- `trace(message, color=15)`: Prints to console (color informational only in CLI); tests verify trace messages via an internal buffer used only in tests.
2828+- `time() -> milliseconds`: Monotonic milliseconds since cart start (tick-thread time origin); tested for monotonic increase across ticks.
25292630Implemented (Runner/CLI)
2731- `.lua` loader: First CLI arg as a `.lua` path runs external script; fallback to bundled `assets/default.lua`.
+2
docs/specs/lua_api_parity.md
···133133134134- trace: `trace(message color=15)`
135135 - Effect: Print to console (not screen) in color.
136136+ - Status: Implemented (color accepted; prints to console; tests use internal buffer).
136137 - Subsystem: tic-core (logger).
137138- time: `time() -> ticks`
138139 - Effect: Milliseconds since cart start (double); used for animation/timing.
140140+ - Status: Implemented (monotonic; tick-thread time origin).
139141 - Subsystem: tic-core (timer).
140142- tstamp: `tstamp() -> timestamp`
141143 - Effect: Seconds since Unix epoch.
+33
docs/testing/test_carts.md
···11+# Test Carts (Manual Verification)
22+33+This page lists small Lua carts in `tic80_rust/assets/` intended for quick, manual checks of subsystems.
44+55+- `tic80_rust/assets/default.lua`
66+ - Purpose: Graphics primitives demo (cls, pix, line, rect/rectb, circ/circb, elli/ellib, tri/trib, clip, print) with gentle animation.
77+ - How to run:
88+ - `cargo run --manifest-path tic80_rust/Cargo.toml` (default cart), or
99+ - `cargo run --manifest-path tic80_rust/Cargo.toml -- tic80_rust/assets/default.lua`
1010+ - Expected: Crosshair, shapes, clipping box toggling every few seconds, and sprinkled “stars”.
1111+1212+- `tic80_rust/assets/alt.lua`
1313+ - Purpose: Minimal cart to validate `cls`, `rect`, `pix` origin behavior.
1414+ - How to run:
1515+ - `cargo run --manifest-path tic80_rust/Cargo.toml -- tic80_rust/assets/alt.lua`
1616+ - Expected: Background fill, small filled square at origin, and a single marker pixel at (0,0).
1717+1818+- `tic80_rust/assets/fft_test.lua`
1919+ - Purpose: Visualize normalized FFT bins as 32 bars.
2020+ - How to run:
2121+ - `cargo run --manifest-path tic80_rust/Cargo.toml -- tic80_rust/assets/fft_test.lua --audio-device "<name-substr>"`
2222+ - Optional: `--audio-vu` to print a 1s peak; `--debug-fft` to print the first 16 bins (throttled).
2323+ - Expected: Bars respond to audio; silence trends to near-zero. VU ~-180 dBFS with silence.
2424+2525+- `tic80_rust/assets/time_trace_test.lua`
2626+ - Purpose: Exercise `time()` and `trace()` APIs.
2727+ - How to run:
2828+ - `cargo run --manifest-path tic80_rust/Cargo.toml -- tic80_rust/assets/time_trace_test.lua`
2929+ - Expected: On-screen elapsed ms text; a small marker toggles color every second; console prints `sec=<n>` lines via `trace()`.
3030+3131+Notes
3232+- These carts are designed for fast feedback during local development. They complement headless tests and can reveal platform quirks (devices, timing).
3333+- Keep carts small, single-purpose, and deterministic where possible.
+1
docs/testing/test_catalog.md
···5353Notes
5454- Tests prefer headless framebuffer inspection over image baselines.
5555- Hashing uses FNV‑1a over VRAM palette indices for portability and stability.
5656+- For manual carts, see `docs/testing/test_carts.md`.
+32
tic80_rust/assets/time_trace_test.lua
···11+-- Simple time/trace test cart
22+-- Shows elapsed ms since start and emits a trace every second.
33+44+local last_ms = 0
55+local last_sec = -1
66+77+function BOOT()
88+ cls(0)
99+end
1010+1111+function TIC()
1212+ cls(0)
1313+ local ms = math.floor(time())
1414+ local sec = math.floor(ms / 1000)
1515+1616+ -- Draw elapsed ms and a ticking marker
1717+ print("time(ms): " .. ms, 2, 2, 14, false, 1, false)
1818+ if (sec % 2) == 0 then
1919+ rect(2, 14, 10, 10, 6)
2020+ else
2121+ rect(2, 14, 10, 10, 12)
2222+ end
2323+2424+ -- Emit a trace once per new second
2525+ if sec ~= last_sec then
2626+ trace("sec=" .. tostring(sec), 7)
2727+ last_sec = sec
2828+ end
2929+3030+ last_ms = ms
3131+end
3232+
+62
tic80_rust/src/script/lua_runner.rs
···11use std::cell::RefCell;
22use std::rc::Rc;
33+use std::sync::{Mutex, OnceLock};
44+use std::time::Instant;
3546use mlua::{Function, Lua, MultiValue, RegistryKey, Result as LuaResult, Value};
57···1214 tic_key: Option<RegistryKey>,
1315}
14161717+// Optional trace buffer (used by tests); if present, trace() will also append messages here.
1818+static TRACE_BUFFER: OnceLock<Mutex<Vec<String>>> = OnceLock::new();
1919+1520impl LuaRunner {
1621 pub fn new(
1722 fb: Rc<RefCell<Framebuffer>>,
···1924 script_src: &str,
2025 ) -> LuaResult<Self> {
2126 let lua = Lua::new();
2727+ let start_time = Instant::now();
2228 let tic_key = {
2329 let globals = lua.globals();
2430···220226 })?;
221227 globals.set("fftrs", fftrs_fn)?;
222228229229+ // trace(message, color=15)
230230+ let trace_fn = lua.create_function(move |_, args: MultiValue| {
231231+ let msg = match args.get(0) {
232232+ Some(Value::String(s)) => s.to_str()?.to_string(),
233233+ Some(Value::Number(n)) => n.to_string(),
234234+ Some(Value::Integer(i)) => i.to_string(),
235235+ Some(Value::Boolean(b)) => b.to_string(),
236236+ Some(Value::Nil) | None => String::new(),
237237+ _ => String::new(),
238238+ };
239239+ let color = match args.get(1) {
240240+ Some(Value::Integer(n)) => *n as i32,
241241+ Some(Value::Number(n)) => *n as i32,
242242+ _ => 15,
243243+ };
244244+ // Print to console; color is informational only here.
245245+ println!("[trace:{}] {}", color, msg);
246246+ if let Some(buf) = TRACE_BUFFER.get() {
247247+ if let Ok(mut b) = buf.lock() {
248248+ b.push(msg);
249249+ }
250250+ }
251251+ Ok(())
252252+ })?;
253253+ globals.set("trace", trace_fn)?;
254254+255255+ // time() -> milliseconds since cart start
256256+ let start_copy = start_time;
257257+ let time_fn = lua.create_function(move |_, ()| {
258258+ let ms = start_copy.elapsed().as_millis() as f64;
259259+ Ok(ms)
260260+ })?;
261261+ globals.set("time", time_fn)?;
262262+223263 // memory: peek/poke + bit variants + memcpy/memset
224264 let mem_peek = mem.clone();
225265 let peek_fn = lua.create_function(move |_, (addr, bits): (u32, Option<u8>)| {
···387427 }
388428 }
389429}
430430+431431+// Test support: initialize a shared trace buffer (clears any existing messages).
432432+pub fn trace_buffer_init() {
433433+ let _ = TRACE_BUFFER.set(Mutex::new(Vec::new()));
434434+ if let Some(b) = TRACE_BUFFER.get() {
435435+ if let Ok(mut v) = b.lock() {
436436+ v.clear();
437437+ }
438438+ }
439439+}
440440+441441+// Test support: take and clear all trace messages.
442442+pub fn trace_buffer_take() -> Vec<String> {
443443+ if let Some(b) = TRACE_BUFFER.get() {
444444+ if let Ok(mut v) = b.lock() {
445445+ let out = v.clone();
446446+ v.clear();
447447+ return out;
448448+ }
449449+ }
450450+ Vec::new()
451451+}
+48
tic80_rust/tests/time_trace_tests.rs
···11+use std::cell::RefCell;
22+use std::rc::Rc;
33+use std::thread;
44+use std::time::Duration;
55+66+use tic80_rust::core::memory::Memory;
77+use tic80_rust::gfx::framebuffer::Framebuffer;
88+use tic80_rust::script::lua_runner::{trace_buffer_init, trace_buffer_take, LuaRunner};
99+1010+#[test]
1111+fn lua_trace_buffers_and_prints() {
1212+ trace_buffer_init();
1313+ let script = r#"
1414+ function BOOT() cls(0) end
1515+ function TIC()
1616+ trace("hello", 7)
1717+ end
1818+ "#;
1919+ let fb = Rc::new(RefCell::new(Framebuffer::new()));
2020+ let mem = Rc::new(RefCell::new(Memory::new(fb.clone())));
2121+ let runner = LuaRunner::new(fb, mem, script).expect("lua init");
2222+ runner.tick();
2323+ let out = trace_buffer_take();
2424+ assert!(
2525+ out.iter().any(|s| s.contains("hello")),
2626+ "expected trace buffer to contain 'hello'"
2727+ );
2828+}
2929+3030+#[test]
3131+fn lua_time_increases_over_real_time() {
3232+ let script = r#"
3333+ tprev = nil
3434+ function BOOT() cls(0) end
3535+ function TIC()
3636+ local t = time()
3737+ if tprev ~= nil and t > tprev + 1 then pix(0,0,9) end
3838+ tprev = t
3939+ end
4040+ "#;
4141+ let fb = Rc::new(RefCell::new(Framebuffer::new()));
4242+ let mem = Rc::new(RefCell::new(Memory::new(fb.clone())));
4343+ let runner = LuaRunner::new(fb.clone(), mem, script).expect("lua init");
4444+ runner.tick();
4545+ thread::sleep(Duration::from_millis(10));
4646+ runner.tick();
4747+ assert_eq!(fb.borrow_mut().pix(0, 0, None), Some(9));
4848+}