···55- Palette: 16 sRGB entries; index→RGBA conversion for presentation; no color-space transforms.
66- Text: Default font; 6 px advance within an 8×8 glyph box; variable-width by trimming empty columns when `fixed=false`.
77- Clip: Active clip rectangle constrains all drawing writes; reads are unaffected.
88+ - VRAM mapping: Screen region writes via memory (`poke`/`memcpy`/`memset`) are not affected by `clip` and update pixels directly.
89910Implemented Semantics
1011- `cls(color=0)`: Fills framebuffer with palette index (masked to 0..15). Honors clip by design via `set_pixel` usage in higher-level draws; `cls` itself fills full screen (like TIC-80).
···2829 - Origin: top-left of first drawn column is `(x,y)`.
2930 - Scaling: draws scaled glyphs; returned width includes scaling.
3031 - Newlines: advances by 6 px per line (scale applied); `smallfont` currently unused.
3232+ - Width: returns the width of the longest line (after trimming when `fixed=false`). A one-pixel spacing is applied between variable-width glyphs.
31333234Clip Behavior
3335- `clip(x,y,w,h)`: Sets active clip rectangle; `clip()` resets to full screen.
+3-1
docs/specs/implementation_status.md
···1818 - `print(text,x,y,color=15,fixed=false,scale=1,small=false) -> width`: Default font, variable-width trim, scale applied to return width; newlines advance by 6 px (times scale).
1919- Clip
2020 - `clip(x,y,w,h)` and `clip()`: Set/reset clip rectangle affecting all draw writes; reads are unaffected.
2121+ - VRAM screen mapping (via memory) ignores `clip` and updates pixels directly.
2122 - Memory
2223 - `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).
2324 - `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.
2525+- `memcpy(dst, src, size)`, `memset(dst, value, size)`: Byte-wise operations; overlap-safe memcpy; VRAM ops update on-screen pixels immediately.
25262627Implemented (System)
2728- `trace(message, color=15)`: Prints to console (color informational only in CLI); tests verify trace messages via an internal buffer used only in tests.
···5455- Triangles: top-left inclusion; CCW orientation enforced internally; half-open bounding box prevents shared-edge double draws.
5556- Ellipses vs circles: fill then border may overdraw endpoints; order-dependent at axis rows (parity with TIC-80).
5657- Font: Default TIC-80 bitmap included; LSB-left bits; 6 px advance; trimming for variable width.
5858+ - VRAM writes: VRAM screen nibble pairs update framebuffer ignoring `clip` (matches TIC-80 behavior).
57595860Pending APIs (not implemented yet)
5961- Texturing/tiles
+9
docs/testing/test_catalog.md
···2222 - `tri_top_left_flat_top_inclusion`: Top-left rule on flat-top triangles (endpoints excluded on top edge).
2323 - `tri_top_left_flat_bottom_exclusion`: Bottom edge excluded on flat-bottom triangles.
2424 - `tri_adjacent_rect_no_gaps`: Two triangles tile a rectangle without gaps.
2525+ - `tri_shared_edge_shallow_slope_tiles_rect`: Shallow-slope shared-edge tiling covers rectangle area exactly.
2526 - `tri_degenerate_zero_area_draws_nothing`: Collinear triangles draw nothing.
2727+ - `ellib_cardinals_and_fill_center_row`: Ellipse border cardinals; filled center row interior.
2828+ - `ellib_cardinal_points_aspect_wide`: Wide-aspect ellipse draws expected cardinals; neighbors remain background.
26292730## Lua Bridge Tests
2831- `tic80_rust/tests/lua_api_tests.rs`
···3639 - `lua_default_cart_deterministic_hash`: Default cart produces deterministic frame hashes for fixed tick counts.
3740 - `lua_circ_and_circb`: Circle fill and border via Lua.
3841 - `lua_elli_ellib_and_tri_trib`: Ellipse and triangle APIs via Lua.
4242+ - `lua_line_float_truncates_to_integer_pixels`: Float coordinates truncate to integer pixels identically to TIC-80.
39434044## Memory Tests
4145- `tic80_rust/tests/memory_tests.rs`
···4347 - `peek4_reads_back_nibble`: 4-bit reads reflect framebuffer.
4448 - `memcpy_and_memset_affect_vram`: VRAM writes via memcpy/memset reach the screen.
4549 - `peek_poke_bits_general_ram`: 1/4-bit addressing in general RAM behaves correctly.
5050+ - `vram_writes_ignore_clip`: VRAM screen mapping writes ignore clip and update pixels directly.
5151+ - `two_bit_cross_byte_alignment`: 2-bit writes at end of a byte and start of next do not bleed.
5252+ - `four_bit_unaligned_nibbles`: 4-bit nibble writes across/within bytes pack correctly.
4653- `tic80_rust/tests/memory_bits_roundtrip.rs`
4754 - `roundtrip_peek_poke_bits_general_ram`: Round-trip property-like checks for 1/2/4/8-bit peek/poke across a RAM window.
4855 - `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.
···5562 - `fft_query_peak_at_bin`: Bin-aligned sine produces a distinct raw peak at the expected bin versus neighbors.
5663 - `lua_fft_returns_normalized_bin`: Verifies Lua `fft(k)` returns normalized magnitude by gating a pixel.
5764 - `fft_query_range_clamps_and_sums`: Clamping and inclusive sum behavior matches C (OOB handling and range sums).
6565+ - `fft_single_bin_peak_and_normalization`: Single-bin sine produces clear raw peak and normalized near-1.0 at bin.
6666+ - `vqt_bin_has_higher_energy_than_neighbors`: Sine at a center frequency yields higher raw energy at the target bin than neighbors.
58675968Notes
6069- Tests prefer headless framebuffer inspection over image baselines.
+81
tic80_rust/tests/fft_conformance.rs
···11+use tic80_rust::audio::fft::FFTState;
22+use tic80_rust::audio::vqt::VQTState;
33+44+fn gen_sine(n: usize, k: usize) -> Vec<f32> {
55+ let mut v = Vec::with_capacity(n);
66+ for i in 0..n {
77+ let phase = 2.0_f32 * std::f32::consts::PI * (k as f32) * (i as f32) / (n as f32);
88+ v.push(phase.sin());
99+ }
1010+ v
1111+}
1212+1313+#[test]
1414+fn fft_single_bin_peak_and_normalization() {
1515+ let mut fft = FFTState::new(4096);
1616+ // Generate a sine exactly at bin k
1717+ let n = 2048usize;
1818+ let k = 50usize;
1919+ let s = gen_sine(n, k);
2020+ for &x in &s {
2121+ fft.ingest(x);
2222+ }
2323+ fft.update();
2424+ // Raw peak near k should exceed neighbors significantly
2525+ let mut max_i = 0usize;
2626+ let mut max_v = 0.0f32;
2727+ for i in 0..fft.bins() {
2828+ let v = fft.fft_raw[i];
2929+ if v > max_v {
3030+ max_v = v;
3131+ max_i = i;
3232+ }
3333+ }
3434+ assert!((max_i as i32 - k as i32).abs() <= 1); // allow +/-1 bin tolerance
3535+ // Normalized at max bin should be ~1.0
3636+ assert!(fft.fft_data[max_i] > 0.9);
3737+ // Neighbors should be much smaller
3838+ if max_i > 0 {
3939+ assert!(fft.fft_data[max_i - 1] < 0.5);
4040+ }
4141+ if max_i + 1 < fft.bins() {
4242+ assert!(fft.fft_data[max_i + 1] < 0.5);
4343+ }
4444+}
4545+4646+#[test]
4747+fn vqt_bin_has_higher_energy_than_neighbors() {
4848+ // Pick a target bin in [10..100]
4949+ let sr = 44_100u32;
5050+ let mut vqt = VQTState::new(sr, 16_384);
5151+ // Use bin 60 as target
5252+ let bin = 60usize;
5353+ // Reconstruct center frequency based on implementation schedule
5454+ let f0 = 19.445_f32 * (2.0_f32).powf(bin as f32 / 12.0);
5555+ // Generate 8192 samples of a sine at this frequency
5656+ let n = 8192usize;
5757+ let mut s = Vec::with_capacity(n);
5858+ for i in 0..n {
5959+ let t = i as f32 / (sr as f32);
6060+ let phase = 2.0_f32 * std::f32::consts::PI * f0 * t;
6161+ s.push(phase.sin());
6262+ }
6363+ for &x in &s {
6464+ vqt.ingest(x);
6565+ }
6666+ vqt.update();
6767+ // Target bin should be >= neighbors in both raw and normalized-smoothed paths
6868+ let b = bin;
6969+ let center = vqt.vqt_raw[b];
7070+ let left = if b > 0 { vqt.vqt_raw[b - 1] } else { 0.0 };
7171+ let right = if b + 1 < vqt.bins_count() {
7272+ vqt.vqt_raw[b + 1]
7373+ } else {
7474+ 0.0
7575+ };
7676+ assert!(center >= left);
7777+ assert!(center >= right);
7878+ // Normalized-smoothed not zero
7979+ assert!(vqt.vqt_norm[b] >= 0.0);
8080+ assert!(vqt.vqt_norm[b].is_finite());
8181+}
+26
tic80_rust/tests/lua_numeric_cast_tests.rs
···11+use std::cell::RefCell;
22+use std::rc::Rc;
33+44+use tic80_rust::core::memory::Memory;
55+use tic80_rust::gfx::framebuffer::Framebuffer;
66+use tic80_rust::script::lua_runner::LuaRunner;
77+88+#[test]
99+fn lua_line_float_truncates_to_integer_pixels() {
1010+ let fb = Rc::new(RefCell::new(Framebuffer::new()));
1111+ let mem = Rc::new(RefCell::new(Memory::new(fb.clone())));
1212+ // Lua script draws a vertical line at x=1.9 (~x=1) from y=0.0 to y=5.9 (~y=5)
1313+ let script = r#"
1414+ function TIC()
1515+ cls(0)
1616+ line(1.9, 0.0, 1.9, 5.9, 3)
1717+ end
1818+ "#;
1919+ let runner = LuaRunner::new(fb.clone(), mem.clone(), script).unwrap();
2020+ runner.tick();
2121+ // Expect pixels at x=1, y in [0..5] to be colored, and x=2 blank in that range
2222+ for y in 0..6 {
2323+ assert_eq!(fb.borrow_mut().pix(1, y, None), Some(3));
2424+ assert_eq!(fb.borrow_mut().pix(2, y, None), Some(0));
2525+ }
2626+}
+87
tic80_rust/tests/print_conformance_tests.rs
···11+use std::cell::RefCell;
22+use std::rc::Rc;
33+44+use tic80_rust::gfx::framebuffer::Framebuffer;
55+66+fn count_drawn_in_col(fb: &mut Framebuffer, x: i32, y: i32, h: i32) -> usize {
77+ let mut c = 0;
88+ for yy in y..y + h {
99+ if fb.pix(x, yy, None).unwrap_or(0) != 0 {
1010+ c += 1;
1111+ }
1212+ }
1313+ c
1414+}
1515+1616+#[test]
1717+fn print_fixed_width_and_return_value() {
1818+ let fb = Rc::new(RefCell::new(Framebuffer::new()));
1919+ let mut fbm = fb.borrow_mut();
2020+ fbm.cls(0);
2121+ let x = 10;
2222+ let y = 10;
2323+ let scale = 1;
2424+ // Fixed width: width should be ADV(6) * len * scale
2525+ let text = "im i";
2626+ let w = fbm.print_text(text, x, y, 1, true, scale, false);
2727+ assert_eq!(w, 6 * (text.len() as i32) * scale);
2828+}
2929+3030+#[test]
3131+fn print_variable_space_returns_adv() {
3232+ let fb = Rc::new(RefCell::new(Framebuffer::new()));
3333+ let mut fbm = fb.borrow_mut();
3434+ fbm.cls(0);
3535+ let w = fbm.print_text(" ", 0, 0, 1, false, 1, false);
3636+ assert_eq!(w, 6); // variable-width fallback for empty glyph is ADV (6)
3737+}
3838+3939+#[test]
4040+fn print_width_next_column_clear_variable() {
4141+ let fb = Rc::new(RefCell::new(Framebuffer::new()));
4242+ let mut fbm = fb.borrow_mut();
4343+ fbm.cls(0);
4444+ let x = 5;
4545+ let y = 20;
4646+ let w = fbm.print_text("im", x, y, 1, false, 1, false);
4747+ // Column at x + w should be clear (spacing accounted for in return width)
4848+ let col = count_drawn_in_col(&mut fbm, x + w, y, 8);
4949+ assert_eq!(col, 0);
5050+}
5151+5252+#[test]
5353+fn print_scale2_width_next_column_clear() {
5454+ let fb = Rc::new(RefCell::new(Framebuffer::new()));
5555+ let mut fbm = fb.borrow_mut();
5656+ fbm.cls(0);
5757+ let x = 7;
5858+ let y = 30;
5959+ let w = fbm.print_text("im", x, y, 2, false, 2, false);
6060+ // Scale 2, bounding column at x + w should be empty
6161+ let col = count_drawn_in_col(&mut fbm, x + w, y, 8 * 2);
6262+ assert_eq!(col, 0);
6363+}
6464+6565+#[test]
6666+fn print_newline_advance_scale1() {
6767+ let fb = Rc::new(RefCell::new(Framebuffer::new()));
6868+ let mut fbm = fb.borrow_mut();
6969+ fbm.cls(0);
7070+ let x = 12;
7171+ let y = 12;
7272+ let w = fbm.print_text("A\nA", x, y, 1, false, 1, false);
7373+ // First line draws near y..y+7; second starts at y+6
7474+ // Ensure at least one pixel exists in each line's starting row range within [x, x+w)
7575+ let mut found_top = false;
7676+ let mut found_second = false;
7777+ for xx in x..(x + w) {
7878+ if fbm.pix(xx, y, None).unwrap_or(0) != 0 {
7979+ found_top = true;
8080+ }
8181+ if fbm.pix(xx, y + 6, None).unwrap_or(0) != 0 {
8282+ found_second = true;
8383+ }
8484+ }
8585+ assert!(found_top);
8686+ assert!(found_second);
8787+}
+48
tic80_rust/tests/print_glyph_width_tests.rs
···11+use std::cell::RefCell;
22+use std::rc::Rc;
33+44+use tic80_rust::gfx::framebuffer::Framebuffer;
55+66+#[test]
77+fn variable_width_trimming_i_vs_m() {
88+ let fb = Rc::new(RefCell::new(Framebuffer::new()));
99+ let mut fbm = fb.borrow_mut();
1010+ fbm.cls(0);
1111+ let wi = fbm.print_text("i", 0, 0, 1, false, 1, false);
1212+ let wm = fbm.print_text("m", 0, 10, 1, false, 1, false);
1313+ assert!(wi < wm);
1414+}
1515+1616+#[test]
1717+fn fixed_width_monospace_equal_width() {
1818+ let fb = Rc::new(RefCell::new(Framebuffer::new()));
1919+ let mut fbm = fb.borrow_mut();
2020+ fbm.cls(0);
2121+ let w1 = fbm.print_text("i", 0, 0, 1, true, 1, false);
2222+ let w2 = fbm.print_text("m", 0, 10, 1, true, 1, false);
2323+ let w3 = fbm.print_text("!", 0, 20, 1, true, 1, false);
2424+ assert_eq!(w1, w2);
2525+ assert_eq!(w2, w3);
2626+}
2727+2828+#[test]
2929+fn punctuation_width_and_spacing() {
3030+ let fb = Rc::new(RefCell::new(Framebuffer::new()));
3131+ let mut fbm = fb.borrow_mut();
3232+ fbm.cls(0);
3333+ // Variable width should still advance by width+1 column for inter-glyph spacing
3434+ let w1 = fbm.print_text("!", 10, 10, 1, false, 1, false);
3535+ let w2 = fbm.print_text("!!", 10, 20, 1, false, 1, false);
3636+ assert!(w2 >= w1 * 2 - 1); // allow for glyph trim; spacing yields near double width
3737+}
3838+3939+#[test]
4040+fn multiline_width_is_max_line_width() {
4141+ let fb = Rc::new(RefCell::new(Framebuffer::new()));
4242+ let mut fbm = fb.borrow_mut();
4343+ fbm.cls(0);
4444+ let w = fbm.print_text("mmmm\nmm", 5, 5, 1, false, 1, false);
4545+ // First line longer than second; width should reflect the first line
4646+ let w_first = fbm.print_text("mmmm", 0, 0, 1, false, 1, false);
4747+ assert_eq!(w, w_first);
4848+}
+65
tic80_rust/tests/tri_ellipse_edge_tests.rs
···11+use std::cell::RefCell;
22+use std::rc::Rc;
33+44+use tic80_rust::gfx::framebuffer::Framebuffer;
55+66+#[allow(dead_code)]
77+fn count_nonzero(fb: &mut Framebuffer) -> usize {
88+ let (w, h) = (Framebuffer::WIDTH as i32, Framebuffer::HEIGHT as i32);
99+ let mut c = 0usize;
1010+ for y in 0..h {
1111+ for x in 0..w {
1212+ if fb.pix(x, y, None).unwrap_or(0) != 0 {
1313+ c += 1;
1414+ }
1515+ }
1616+ }
1717+ c
1818+}
1919+2020+#[test]
2121+fn tri_shared_edge_shallow_slope_tiles_rect() {
2222+ let fb = Rc::new(RefCell::new(Framebuffer::new()));
2323+ let mut fbm = fb.borrow_mut();
2424+ fbm.cls(0);
2525+ // Rectangle region 40x23 at (50,20)
2626+ let x0 = 50;
2727+ let y0 = 20;
2828+ let w = 40;
2929+ let h = 23;
3030+ // Two triangles sharing a shallow edge: (x0,y0)->(x0+w,y0+h)->(x0,y0+h) and (x0,y0)->(x0+w,y0)->(x0+w,y0+h)
3131+ fbm.tri(x0, y0, x0 + w, y0 + h, x0, y0 + h, 2);
3232+ fbm.tri(x0, y0, x0 + w, y0, x0 + w, y0 + h, 2);
3333+ // Count colored pixels inside the rectangle bounds
3434+ let mut area = 0usize;
3535+ for yy in y0..y0 + h {
3636+ for xx in x0..x0 + w {
3737+ if fbm.pix(xx, yy, None).unwrap_or(0) != 0 {
3838+ area += 1;
3939+ }
4040+ }
4141+ }
4242+ assert_eq!(area as i32, (w * h));
4343+}
4444+4545+#[test]
4646+fn ellib_cardinal_points_aspect_wide() {
4747+ let fb = Rc::new(RefCell::new(Framebuffer::new()));
4848+ let mut fbm = fb.borrow_mut();
4949+ fbm.cls(0);
5050+ let cx = 100;
5151+ let cy = 60;
5252+ let a = 20; // x radius
5353+ let b = 5; // y radius
5454+ fbm.ellib(cx, cy, a, b, 3);
5555+ // Cardinals should be lit
5656+ assert!(fbm.pix(cx + a, cy, None).unwrap_or(0) != 0);
5757+ assert!(fbm.pix(cx - a, cy, None).unwrap_or(0) != 0);
5858+ assert!(fbm.pix(cx, cy + b, None).unwrap_or(0) != 0);
5959+ assert!(fbm.pix(cx, cy - b, None).unwrap_or(0) != 0);
6060+ // One pixel outside cardinals should remain background
6161+ assert_eq!(fbm.pix(cx + a + 1, cy, None).unwrap_or(0), 0);
6262+ assert_eq!(fbm.pix(cx - a - 1, cy, None).unwrap_or(0), 0);
6363+ assert_eq!(fbm.pix(cx, cy + b + 1, None).unwrap_or(0), 0);
6464+ assert_eq!(fbm.pix(cx, cy - b - 1, None).unwrap_or(0), 0);
6565+}