···88- **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`.
99- **Plan Docs:** `RUST_REWRITE.md` defines phased roadmap; `docs/` contains early GUI-first specs and API parity checklist.
1010- **Prototype Crate:** `tic80_rust` (single crate for now).
1111- - `gfx::framebuffer`: 240×136 u8-index framebuffer; `cls`, `pix`, `line` (Bresenham), `rect`, `blit_to_rgba`, `print_text` (default font).
1212- - `script::lua_runner`: `mlua` (Lua 5.4, vendored) binding for `cls/pix/line/rect/print` + `BOOT/TIC` flow.
1313- - `main.rs`: `winit + pixels` presenter, fixed-step tick (~60 FPS), demo cart.
1414-- **Testing:** Rust unit tests under `tic80_rust/tests/` covering gfx and minimal Lua APIs.
1111+ - `gfx::framebuffer`: 240×136 palette-index framebuffer and primitives: `cls`, `pix`, `line`, `rect`, `rectb`, `circ`, `circb`, `elli`, `ellib`, `tri`, `trib`, `clip`, `print_text`, `blit_to_rgba`.
1212+ - `core::memory`: 96 KB RAM view with `peek/poke` (1/2/4/8‑bit), `memcpy`, `memset`; VRAM screen region bridged to framebuffer.
1313+ - `script::lua_runner`: `mlua` (vendored Lua 5.3) binding for `BOOT/TIC` and APIs: `cls/pix/line/rect/rectb/circ/circb/elli/ellib/tri/trib/clip/print` + memory helpers.
1414+ - `main.rs`: `winit + pixels` presenter, fixed‑step tick (~60 FPS), demo cart or external `.lua`.
1515+- **Testing:** Tests under `tic80_rust/tests/` cover framebuffer, Lua bridge, and memory; deterministic VRAM hash helpers.
15161617**Build Hygiene (always do this)**
1718- Fix all compiler warnings before landing changes (treat warnings as errors).
···3132- **Framebuffer:** Single VRAM bank, palette indices in CPU memory; palette map/border/vbank deferred.
32333334**Current Status**
3434-- Minimal end-to-end loop works: window opens, demo script runs (`cls/pix/line/rect/print`).
3535-- Tests run locally; 1 Lua API test currently failing (see below).
3636-- Docs/specs in place for early milestones: GUI + `cls/pix` and Lua + `cls/pix`.
3737-3838-**Failing Test (Next Task)**
3939-- `tic80_rust/tests/lua_api_tests.rs::lua_print_defaults_and_pix_read`
4040- - Symptom: After `print("A")` at (0,0), script checks `pix(0,0)==15` to place a marker at `(w,0)`. Marker not found on row 0.
4141- - Likely Cause: Glyph rendering/metrics mismatch at origin. Candidates:
4242- - Bit orientation when decoding `src/core/font.inl` (LSB/MSB) may be inverted.
4343- - Trim logic for variable-width glyphs (`fixed=false`) may misalign the leftmost drawn column vs expected TIC-80 behavior.
4444- - Row baseline/advance constants (ADV vs actual TIC font metrics) could be off by one.
4545- - Plan: Verify bit order against TIC font reference, ensure left trim maps the first non-empty glyph column to `x`, confirm row 0 draws the top row of the glyph, adjust ADV/height semantics as needed.
3535+- All tests pass (`cargo test`).
3636+- Clippy clean (`cargo clippy --all-targets --all-features -D warnings`).
3737+- Drawing primitives implemented and exposed to Lua; clipping enforced on writes; OOB reads return `nil` in Lua.
3838+- `print` implemented with default font (variable/fixed width, scale, newline advance) and returns width.
3939+- Memory ops (`peek/poke` 1/2/4/8‑bit, `memcpy`, `memset`) implemented; VRAM updates reflect on screen.
4040+- CLI loads bundled default cart or a provided `.lua` path.
46414742**Near-Term Backlog**
4848-- Fix `lua_print_defaults_and_pix_read` by aligning `print_text` to TIC-80 semantics.
4949-- Add unit tests for `print_text` width, left trim, and origin pixel behavior (non-Lua) to localize failures.
5050-- Expose `clip` and `rectb` to Lua; add basic tests.
5151-- Confirm palette correctness end-to-end (index→RGBA) against known swatches; keep golden samples.
4343+- Print edge cases: tests for scale>1 baseline/advance and multi‑line width parity.
4444+- Small font: decide semantics and implement `smallfont=true` in `print` with tests.
4545+- Lua error paths: add type/arity mismatch tests for core APIs (`pix/line/rect/print`).
4646+- Docs: align Lua version references (5.3) and flesh out `docs/specs/graphics.md` text rules.
4747+- Optional: add CI step for `cargo test` + clippy.
52485349**Mid-Term Backlog**
5454-- Flesh out `tic-gfx` primitives (`circb/circ/elli/ellib/tri/trib/font/map/spr`) per `docs/api_parity_checklist.md`.
5555-- Start `tic-api` facade layering (separating binding from core) as we add more APIs.
5656-- Add frame-hash snapshot tests for deterministic VRAM state.
5757-- Begin input semantics (`btn/key/keyp/btnp/mouse`) with fixed-step repeat behavior.
5050+- Sprites/tiles: `spr`, `map`, `font`, `paint`, `ttri` (with colorkey/flip/rotate/scale, remap callback).
5151+- Banks/persistence: `vbank`, `sync`, `pmem`; palette map/border color.
5252+- Input/system: `btn/btnp`, `key/keyp`, `mouse`, `time`, `tstamp`, `trace`, `exit`, `reset` (fixed‑step repeat timing).
5353+- Audio: `sfx`, `music` synth/mixer; capture ring for analysis.
5454+- Analysis: `fft/ffts/fftr/fftrs`, `vqt` variants; conformance carts + numeric tolerances.
5555+- Platform: WASM build path; window scaling and UX polish.
58565957**Open Questions**
6060-- Exact bit orientation for `font.inl` (confirm LSB/MSB and row order vs. current implementation).
6161-- Default `print` metrics: width advance, left/right trimming, and baseline rules in TIC-80.
6262-- Small font (`smallfont=true`) parity requirements and when to implement.
5858+- Small font (`smallfont=true`) parity: glyph source, advance, scaling, and when to land.
5959+- `print` trimming/width: exact left/right column trimming, newline advance, and width at `scale>1`.
6060+- Numeric types: confirm integer vs float acceptance/rounding for `line/tri` and similar APIs.
6161+- Palette ops: `pal/palt` semantics, palette map/border color timing, and interactions with `clip`/vbank.
6262+- Callback timing: `SCN/BDR` row timing and palette side effects integration into the pipeline.
6363+- Determinism for `time()/tstamp()`: origin/granularity guarantees for tests.
63646465**Operating Notes**
6566- Keep changes surgical and test-driven; don’t expand surface while a test is red.
+1
docs/README.md
···1111- `docs/specs/lua_api_parity.md`: API parity checklist for Lua (name, signature, side effects).
1212- `docs/specs/graphics.md`: Framebuffer, palette mapping, text/print semantics (stub to be expanded).
1313- `docs/specs/audio_fft_vqt.md`: FFT/VQT behavior and parameters (points to `CLAUDE.md`).
1414+- `docs/specs/implementation_status.md`: What’s implemented vs pending, with notes on behavior.
14151516## Architecture
1617- `docs/architecture/workspace.md`: Crate layout and module boundaries.
+35-11
docs/specs/graphics.md
···11-# Graphics Spec (Stub)
11+# Graphics Spec
2233Scope
44- Framebuffer: 240×136, 8-bit palette indices (0..15) per pixel.
55- Palette: 16 sRGB entries; index→RGBA conversion for presentation; no color-space transforms.
66-- Text: Default font (5×8 advance within 8×8 glyph box), variable-width by trimming empty columns when `fixed=false`.
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.
7888-Semantics (to expand)
99-- `cls(color=0)`: Fill the framebuffer with palette index (masked to 0..15).
1010-- `pix(x,y[,color])`: Read returns current index or nil when OOB; write masks to 0..15 and ignores OOB.
1111-- `line/rect/rectb`: Integer rasterization; inclusive endpoints for lines; clipping to framebuffer bounds.
99+Implemented Semantics
1010+- `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).
1111+- `pix(x,y[,color])`:
1212+ - Read mode: returns palette index at `(x,y)` or `nil` (Lua)/`None` (Rust) when OOB. Reads ignore `clip`.
1313+ - Write mode: writes masked color if `(x,y)` is in-bounds and inside the current `clip`; otherwise ignored.
1414+- `line(x0,y0,x1,y1,color)`: Integer Bresenham; inclusive endpoints; obeys `clip` through `set_pixel`.
1515+- `rect(x,y,w,h,color)`: Filled rect; clips to viewport and active `clip`.
1616+- `rectb(x,y,w,h,color)`: One-pixel border; inclusive edges; clipped by `clip`.
1717+- `circ(cx,cy,r,color)`: Filled circle via symmetric horizontal spans; r=0 draws a point; obeys `clip`.
1818+- `circb(cx,cy,r,color)`: One-pixel border using midpoint algorithm; r=0 draws a point; obeys `clip`.
1919+- `elli(cx,cy,a,b,color)`: Filled ellipse via horizontal spans; handles degenerate `a=0` (vertical line), `b=0` (horizontal line), and `a=b=0` (point); obeys `clip`.
2020+- `ellib(cx,cy,a,b,color)`: One-pixel border via midpoint; handles `a=b=0` as a point; obeys `clip`.
2121+- `tri(x1,y1,x2,y2,x3,y3,color)`: Filled triangle using edge functions at pixel centers with a top-left fill rule:
2222+ - Include pixels on top and left edges; exclude pixels on bottom and right edges.
2323+ - Processes CCW orientation internally for consistent edge testing; no gaps when tiling adjacent triangles.
2424+ - Obeys `clip` through `set_pixel`.
2525+- `trib(x1,y1,x2,y2,x3,y3,color)`: One-pixel border using three inclusive Bresenham lines; edges meet at vertices; obeys `clip`.
1226- `print(text, x=0, y=0, color=15, fixed=false, scale=1, small=false) -> width`:
1313- - Uses default font bitmap; top-left of first drawn column is `(x,y)`.
1414- - Returns drawn width in pixels (pre-scale), consistent with TIC-80.
2727+ - Font: default TIC-80 bitmap; bit order LSB-left; 8×8 glyph box; 6 px advance; variable-width trimming when `fixed=false`.
2828+ - Origin: top-left of first drawn column is `(x,y)`.
2929+ - Scaling: draws scaled glyphs; returned width includes scaling.
3030+ - Newlines: advances by 6 px per line (scale applied); `smallfont` currently unused.
3131+3232+Clip Behavior
3333+- `clip(x,y,w,h)`: Sets active clip rectangle; `clip()` resets to full screen.
3434+- All draw functions respect `clip` via `set_pixel`/`hspan`; `pix` reads ignore `clip`.
15351616-Open items
1717-- Document exact TIC-80 font bit packing and baseline to ensure parity.
1818-- Add rules for `clip`, `palette map`, `vbank`, `border`, and blitters.
3636+Notes
3737+- Triangle parity: top-left rule ensures bit-for-bit agreement with TIC-80 edge inclusion and eliminates gaps with adjacent primitives.
3838+- Ellipse parity: on rows coinciding with axes (e.g., center row), `ellib` perimeter overlaps `elli` endpoints; resulting color depends on draw order (expected in TIC-80 as well).
3939+- `blit_to_rgba`: Converts framebuffer indices to RGBA using the 16-color palette (no gamma adjustments).
19404141+Pending (not implemented yet)
4242+- `spr`, `map`, `ttri`, `paint`, `font` API variant, palette map, border color, `vbank`.
4343+- Blending rules, chroma tables, and remap callbacks for textured triangles and map drawing.
+58
docs/specs/implementation_status.md
···11+# Implementation Status (Rust Rewrite)
22+33+This page tracks which TIC-80 APIs are implemented in the Rust rewrite, notes per function, and what remains.
44+55+Implemented (Lua + Core)
66+- Drawing
77+ - `cls(color)`: Full-screen clear (no clip). Note: masks color to 0..15.
88+ - `pix(x,y[,color])`: Read returns color or `nil` when OOB; write obeys clip; reads ignore clip.
99+ - `line(x0,y0,x1,y1,color)`: Bresenham with inclusive endpoints; clip respected.
1010+ - `rect(x,y,w,h,color)`: Filled; respects viewport and clip.
1111+ - `rectb(x,y,w,h,color)`: One-pixel border; inclusive; clip respected.
1212+ - `circ(cx,cy,r,color)`: Filled; r=0 is a point; clip respected.
1313+ - `circb(cx,cy,r,color)`: Border; r=0 is a point; clip respected.
1414+ - `elli(cx,cy,a,b,color)`: Filled; handles degenerate axes; clip respected.
1515+ - `ellib(cx,cy,a,b,color)`: Border; clip respected.
1616+ - `tri(x1,y1,x2,y2,x3,y3,color)`: Filled triangle with top-left rule; no gaps with adjacent triangles.
1717+ - `trib(x1,y1,x2,y2,x3,y3,color)`: Border triangle via lines.
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+ - 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.
2525+2626+Implemented (Runner/CLI)
2727+- `.lua` loader: First CLI arg as a `.lua` path runs external script; fallback to bundled `assets/default.lua`.
2828+- Window title: “rustic”.
2929+3030+Behavioral Notes
3131+- Triangles: top-left inclusion; CCW orientation enforced internally; half-open bounding box prevents shared-edge double draws.
3232+- Ellipses vs circles: fill then border may overdraw endpoints; order-dependent at axis rows (parity with TIC-80).
3333+- Font: Default TIC-80 bitmap included; LSB-left bits; 6 px advance; trimming for variable width.
3434+3535+Pending APIs (not implemented yet)
3636+- Texturing/tiles
3737+ - `spr`, `map`, `ttri`, `font` (RAM font region), `paint`.
3838+ - Chroma/colorkey handling, flips/rotate/scale for sprites, map remap callbacks.
3939+- Memory/banks
4040+ - `vbank`, `sync`, `pmem`.
4141+- Input/system
4242+ - `btn/btnp`, `key/keyp`, `mouse`, `time`, `tstamp`, `trace`, `exit`, `reset`.
4343+- Audio
4444+ - `sfx`, `music`; audio mixer/synth; capture ring for analysis.
4545+- Analysis
4646+ - `fft/ffts/fftr/fftrs`, `vqt/vqts/vqtr/vqtrs` and whitening variants; behavior per CLAUDE.md.
4747+- Sprite flags
4848+ - `fget`, `fset`.
4949+5050+Test Coverage (summary)
5151+- Framebuffer unit tests cover: cls/pix/line/rect/rectb/circ/circb/elli/ellib/tri/trib, clip behavior, OOB, print width/newlines, palette blit, triangle edge rules.
5252+- Lua bridge tests cover: cls/pix/line/rect/print/rectb/clip/circ/circb/elli/ellib/tri/trib and cart file loading.
5353+- Determinism: simple VRAM hash helper asserts stable hashes over N ticks for the default cart.
5454+5555+See also
5656+- Graphics semantics: `docs/specs/graphics.md`.
5757+- API parity checklist: `docs/specs/lua_api_parity.md`.
5858+- Testing strategy and catalog: `docs/testing/strategy.md`, `docs/testing/test_catalog.md`.
+18-1
docs/testing/test_catalog.md
···1414 - `print_width_fixed_vs_variable_and_newline`: `print_text` width (fixed vs variable), newline row advance, and scale behavior.
1515 - `clip_affects_pix_write`: `pix(x,y,color)` respects the active clip.
1616 - `robust_oob_line_and_rectb`: OOB line still draws in-bounds; border rect crossing viewport produces in-bounds edges.
1717+ - `circb_cardinals_and_oob`: Circle border’s cardinal points lit; just outside remains background.
1818+ - `circ_fill_center_row_and_clip`: Filled center row span and clip restriction.
1919+ - `circ_zero_radius_draws_center`: r=0 draws a point for circ/circb.
2020+ - `ellib_cardinals_and_fill_center_row`: Ellipse border cardinals; filled center row interior.
2121+ - `tri_fill_and_border`: Basic filled triangle and border overlay.
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_degenerate_zero_area_draws_nothing`: Collinear triangles draw nothing.
17261827## Lua Bridge Tests
1928- `tic80_rust/tests/lua_api_tests.rs`
···2534 - `lua_runs_alt_cart_file`: Loads `assets/alt.lua`, checks background/marker/square.
2635 - `lua_pix_oob_read_returns_nil`: OOB `pix` read returns `nil` in Lua.
2736 - `lua_default_cart_deterministic_hash`: Default cart produces deterministic frame hashes for fixed tick counts.
3737+ - `lua_circ_and_circb`: Circle fill and border via Lua.
3838+ - `lua_elli_ellib_and_tri_trib`: Ellipse and triangle APIs via Lua.
3939+4040+## Memory Tests
4141+- `tic80_rust/tests/memory_tests.rs`
4242+ - `poke4_sets_framebuffer_pixel`: 4-bit writes update screen pixels.
4343+ - `peek4_reads_back_nibble`: 4-bit reads reflect framebuffer.
4444+ - `memcpy_and_memset_affect_vram`: VRAM writes via memcpy/memset reach the screen.
4545+ - `peek_poke_bits_general_ram`: 1/4-bit addressing in general RAM behaves correctly.
28462947Notes
3048- Tests prefer headless framebuffer inspection over image baselines.
3149- Hashing uses FNV‑1a over VRAM palette indices for portability and stability.
3232-
+146
tic80_rust/src/core/memory.rs
···11+use crate::gfx::framebuffer::Framebuffer;
22+use std::cell::RefCell;
33+use std::rc::Rc;
44+55+const RAM_TOTAL: usize = 96 * 1024; // 96KB
66+const VRAM_SIZE: usize = 16 * 1024; // first 16KB of RAM is VRAM window
77+const VRAM_SCREEN_BYTES: usize = 0x3FC0; // 16320 bytes of screen nibble pairs
88+99+pub struct Memory {
1010+ ram: Vec<u8>,
1111+ fb: Rc<RefCell<Framebuffer>>, // for VRAM screen mapping
1212+}
1313+1414+impl Memory {
1515+ pub fn new(fb: Rc<RefCell<Framebuffer>>) -> Self {
1616+ Self {
1717+ ram: vec![0; RAM_TOTAL],
1818+ fb,
1919+ }
2020+ }
2121+2222+ // 8-bit read/write with VRAM screen mapping
2323+ fn get_byte(&self, addr: usize) -> u8 {
2424+ if addr < VRAM_SCREEN_BYTES {
2525+ // pack 2 pixels from framebuffer into one byte (low nibble = even pixel)
2626+ let p = addr * 2; // pixel index
2727+ let mut fb = self.fb.borrow_mut();
2828+ let (w, h) = (Framebuffer::WIDTH as usize, Framebuffer::HEIGHT as usize);
2929+ // Framebuffer stores 1 byte per pixel index; map linear order row-major
3030+ // pixel p is (x=p%w, y=p/w)
3131+ let mut get_px = |pi: usize| -> u8 {
3232+ if pi < w * h {
3333+ fb.pix((pi % w) as i32, (pi / w) as i32, None).unwrap_or(0) & 0x0F
3434+ } else { 0 }
3535+ };
3636+ let lo = get_px(p);
3737+ let hi = get_px(p + 1);
3838+ (lo & 0x0F) | ((hi & 0x0F) << 4)
3939+ } else if addr < VRAM_SIZE {
4040+ // other VRAM bytes (palette, etc.): just return RAM view for now
4141+ self.ram[addr]
4242+ } else if addr < RAM_TOTAL {
4343+ self.ram[addr]
4444+ } else {
4545+ 0
4646+ }
4747+ }
4848+4949+ fn set_byte(&mut self, addr: usize, val: u8) {
5050+ if addr < VRAM_SCREEN_BYTES {
5151+ // unpack to 2 pixels
5252+ let p = addr * 2;
5353+ let lo = val & 0x0F;
5454+ let hi = (val >> 4) & 0x0F;
5555+ let mut fbm = self.fb.borrow_mut();
5656+ let w = Framebuffer::WIDTH as usize;
5757+ let set_px = |fb: &mut Framebuffer, pi: usize, v: u8| {
5858+ let x = (pi % w) as i32;
5959+ let y = (pi / w) as i32;
6060+ let _ = fb.pix(x, y, Some(v));
6161+ };
6262+ set_px(&mut fbm, p, lo);
6363+ set_px(&mut fbm, p + 1, hi);
6464+ // keep RAM mirror in case code inspects it directly
6565+ self.ram[addr] = val;
6666+ } else if addr < RAM_TOTAL {
6767+ self.ram[addr] = val;
6868+ // TODO: if palette region is written, we may later plumb it to framebuffer palette
6969+ }
7070+ }
7171+7272+ pub fn peek(&self, addr: usize) -> u8 { self.get_byte(addr) }
7373+ pub fn poke(&mut self, addr: usize, val: u8) { self.set_byte(addr, val); }
7474+7575+ // bit-packed peeks/pokes across entire 96KB (VRAM included)
7676+ pub fn peek_bits(&self, addr: usize, bits: u8) -> u8 {
7777+ match bits {
7878+ 8 => self.peek(addr),
7979+ 4 => {
8080+ let byte = self.peek(addr >> 1);
8181+ if (addr & 1) == 0 { byte & 0x0F } else { (byte >> 4) & 0x0F }
8282+ }
8383+ 2 => {
8484+ let byte = self.peek(addr >> 2);
8585+ let shift = (addr & 0b11) * 2;
8686+ (byte >> shift) & 0x03
8787+ }
8888+ 1 => {
8989+ let byte = self.peek(addr >> 3);
9090+ let shift = addr & 0b111;
9191+ (byte >> shift) & 0x01
9292+ }
9393+ _ => 0,
9494+ }
9595+ }
9696+9797+ pub fn poke_bits(&mut self, addr: usize, bits: u8, val: u8) {
9898+ match bits {
9999+ 8 => self.poke(addr, val),
100100+ 4 => {
101101+ let mut byte = self.peek(addr >> 1);
102102+ if (addr & 1) == 0 {
103103+ byte = (byte & 0xF0) | (val & 0x0F);
104104+ } else {
105105+ byte = (byte & 0x0F) | ((val & 0x0F) << 4);
106106+ }
107107+ self.poke(addr >> 1, byte);
108108+ }
109109+ 2 => {
110110+ let mut byte = self.peek(addr >> 2);
111111+ let shift = (addr & 0b11) * 2;
112112+ let mask = !(0x03u8 << shift);
113113+ byte = (byte & mask) | ((val & 0x03) << shift);
114114+ self.poke(addr >> 2, byte);
115115+ }
116116+ 1 => {
117117+ let mut byte = self.peek(addr >> 3);
118118+ let shift = addr & 0b111;
119119+ let mask = !(1u8 << shift);
120120+ byte = (byte & mask) | ((val & 0x01) << shift);
121121+ self.poke(addr >> 3, byte);
122122+ }
123123+ _ => {}
124124+ }
125125+ }
126126+127127+ pub fn memcpy(&mut self, dst: usize, src: usize, size: usize) {
128128+ if size == 0 { return; }
129129+ // Handle overlap with correct direction
130130+ if src < dst && src + size > dst {
131131+ for i in (0..size).rev() {
132132+ let b = self.get_byte(src + i);
133133+ self.set_byte(dst + i, b);
134134+ }
135135+ } else {
136136+ for i in 0..size {
137137+ let b = self.get_byte(src + i);
138138+ self.set_byte(dst + i, b);
139139+ }
140140+ }
141141+ }
142142+143143+ pub fn memset(&mut self, dst: usize, value: u8, size: usize) {
144144+ for i in 0..size { self.set_byte(dst + i, value); }
145145+ }
146146+}
+4-1
tic80_rust/src/lib.rs
···22 pub mod framebuffer;
33}
4455+pub mod core {
66+ pub mod memory;
77+}
88+59pub mod script {
610 pub mod lua_runner;
711}
88-
+3-1
tic80_rust/src/main.rs
···12121313use tic80_rust::gfx::framebuffer::{dimensions, Framebuffer};
1414use tic80_rust::script::lua_runner::LuaRunner;
1515+use tic80_rust::core::memory::Memory;
15161617// Simple fixed-step ticker at ~60 FPS
1718struct Ticker {
···5657 let mut pixels = Pixels::new(width, height, surface_texture)?;
57585859 let fb = Rc::new(RefCell::new(Framebuffer::new()));
6060+ let mem = Rc::new(RefCell::new(Memory::new(fb.clone())));
5961 let mut ticker = Ticker::new();
6062 // Program selection: first CLI arg as .lua script, else embedded default
6163 let args: Vec<String> = std::env::args().skip(1).collect();
···7476 } else {
7577 DEFAULT_LUA.to_string()
7678 };
7777- let lua_runner = LuaRunner::new(fb.clone(), &script).ok();
7979+ let lua_runner = LuaRunner::new(fb.clone(), mem.clone(), &script).ok();
78807981 event_loop.run(move |event, _, control_flow| {
8082 *control_flow = ControlFlow::Poll;
+41-1
tic80_rust/src/script/lua_runner.rs
···44use mlua::{Function, Lua, MultiValue, RegistryKey, Result as LuaResult, Value};
5566use crate::gfx::framebuffer::Framebuffer;
77+use crate::core::memory::Memory;
7889pub struct LuaRunner {
910 lua: Lua,
···1112}
12131314impl LuaRunner {
1414- pub fn new(fb: Rc<RefCell<Framebuffer>>, script_src: &str) -> LuaResult<Self> {
1515+ pub fn new(fb: Rc<RefCell<Framebuffer>>, mem: Rc<RefCell<Memory>>, script_src: &str) -> LuaResult<Self> {
1516 let lua = Lua::new();
1617 let tic_key = {
1718 let globals = lua.globals();
···139140 Ok(width)
140141 })?;
141142 globals.set("print", print_fn)?;
143143+144144+ // memory: peek/poke + bit variants + memcpy/memset
145145+ let mem_peek = mem.clone();
146146+ let peek_fn = lua.create_function(move |_, (addr, bits): (u32, Option<u8>)| {
147147+ let a = addr as usize;
148148+ let b = bits.unwrap_or(8);
149149+ let v = if b == 8 { mem_peek.borrow().peek(a) } else { mem_peek.borrow().peek_bits(a, b) };
150150+ Ok(v as u32)
151151+ })?;
152152+ globals.set("peek", peek_fn)?;
153153+154154+ let mem_poke = mem.clone();
155155+ let poke_fn = lua.create_function(move |_, (addr, val, bits): (u32, u32, Option<u8>)| {
156156+ let a = addr as usize;
157157+ let v = val as u8;
158158+ let b = bits.unwrap_or(8);
159159+ if b == 8 { mem_poke.borrow_mut().poke(a, v); } else { mem_poke.borrow_mut().poke_bits(a, b, v); }
160160+ Ok(())
161161+ })?;
162162+ globals.set("poke", poke_fn)?;
163163+164164+ let mem_peek1 = mem.clone();
165165+ globals.set("peek1", lua.create_function(move |_, addr: u32| Ok(mem_peek1.borrow().peek_bits(addr as usize, 1) as u32))?)?;
166166+ let mem_peek2 = mem.clone();
167167+ globals.set("peek2", lua.create_function(move |_, addr: u32| Ok(mem_peek2.borrow().peek_bits(addr as usize, 2) as u32))?)?;
168168+ let mem_peek4 = mem.clone();
169169+ globals.set("peek4", lua.create_function(move |_, addr: u32| Ok(mem_peek4.borrow().peek_bits(addr as usize, 4) as u32))?)?;
170170+171171+ let mem_poke1 = mem.clone();
172172+ globals.set("poke1", lua.create_function(move |_, (addr, val): (u32, u32)| { mem_poke1.borrow_mut().poke_bits(addr as usize, 1, val as u8); Ok(()) })?)?;
173173+ let mem_poke2 = mem.clone();
174174+ globals.set("poke2", lua.create_function(move |_, (addr, val): (u32, u32)| { mem_poke2.borrow_mut().poke_bits(addr as usize, 2, val as u8); Ok(()) })?)?;
175175+ let mem_poke4 = mem.clone();
176176+ globals.set("poke4", lua.create_function(move |_, (addr, val): (u32, u32)| { mem_poke4.borrow_mut().poke_bits(addr as usize, 4, val as u8); Ok(()) })?)?;
177177+178178+ let mem_memcpy = mem.clone();
179179+ globals.set("memcpy", lua.create_function(move |_, (dst, src, size): (u32, u32, u32)| { mem_memcpy.borrow_mut().memcpy(dst as usize, src as usize, size as usize); Ok(()) })?)?;
180180+ let mem_memset = mem.clone();
181181+ globals.set("memset", lua.create_function(move |_, (dst, val, size): (u32, u32, u32)| { mem_memset.borrow_mut().memset(dst as usize, val as u8, size as usize); Ok(()) })?)?;
142182143183 // elli(x, y, a, b, color)
144184 let fb_elli = fb.clone();
+50-2
tic80_rust/tests/gfx_framebuffer_tests.rs
···4848 fb.rect(-5, -3, 10, 8, 9);
4949 // Count colored pixels; expected area is clipped to [0,w) x [0,h)
5050 let (w, h) = dimensions();
5151- let x0 = 0i32.max(-5);
5252- let y0 = 0i32.max(-3);
5151+ let x0 = 0i32;
5252+ let y0 = 0i32;
5353 let x1 = ( -5 + 10).min(w as i32);
5454 let y1 = ( -3 + 8).min(h as i32);
5555 let expected = (x1 - x0).max(0) as usize * (y1 - y0).max(0) as usize;
···343343 assert_eq!(fb.pix(20, 10, None), Some(7));
344344 assert_eq!(fb.pix(15, 15, None), Some(7));
345345}
346346+347347+#[test]
348348+fn tri_top_left_flat_top_inclusion() {
349349+ let mut fb = Framebuffer::new();
350350+ fb.cls(0);
351351+ // Flat-top triangle: top edge y=10 should be included; rightmost x excluded
352352+ fb.tri(10, 10, 20, 10, 15, 15, 6);
353353+ // Top scanline: interior x in (10,20) filled; endpoints excluded by top-left rule
354354+ assert_eq!(fb.pix(10, 10, None), Some(0));
355355+ for x in 11..20 { assert_eq!(fb.pix(x, 10, None), Some(6)); }
356356+ assert_eq!(fb.pix(20, 10, None), Some(0));
357357+ // Bottom row excluded
358358+ for x in 10..=20 { assert_eq!(fb.pix(x, 15, None), Some(0)); }
359359+}
360360+361361+#[test]
362362+fn tri_top_left_flat_bottom_exclusion() {
363363+ let mut fb = Framebuffer::new();
364364+ fb.cls(0);
365365+ // Flat-bottom triangle: bottom edge y=20 excluded
366366+ fb.tri(10, 10, 5, 20, 15, 20, 7);
367367+ for x in 5..=15 { assert_eq!(fb.pix(x, 20, None), Some(0)); }
368368+ // No assumption on apex inclusion; key check is base exclusion
369369+}
370370+371371+#[test]
372372+fn tri_adjacent_rect_no_gaps() {
373373+ let mut fb = Framebuffer::new();
374374+ fb.cls(0);
375375+ // Two right triangles that should fully cover a 10x10 square [0,10) x [0,10)
376376+ fb.tri(0, 0, 10, 0, 0, 10, 3);
377377+ fb.tri(10, 10, 10, 0, 0, 10, 3);
378378+ let mut count = 0usize;
379379+ for y in 0..10 { for x in 0..10 { if fb.pix(x, y, None) == Some(3) { count += 1; } } }
380380+ assert_eq!(count, 100, "expected full 10x10 coverage without gaps");
381381+}
382382+383383+#[test]
384384+fn tri_degenerate_zero_area_draws_nothing() {
385385+ let mut fb = Framebuffer::new();
386386+ fb.cls(2);
387387+ // Collinear points -> zero area
388388+ fb.tri(10, 10, 10, 15, 10, 20, 9);
389389+ // No pixels should be colored with 9
390390+ let mut any = false;
391391+ for y in 10..=20 { if fb.pix(10, y, None) == Some(9) { any = true; break; } }
392392+ assert!(!any, "degenerate triangle should not draw");
393393+}
+7-6
tic80_rust/tests/lua_api_tests.rs
···5566use tic80_rust::gfx::framebuffer::{dimensions, Framebuffer};
77use tic80_rust::script::lua_runner::LuaRunner;
88+use tic80_rust::core::memory::Memory;
89910fn run_lua(script: &str, ticks: usize) -> Rc<RefCell<Framebuffer>> {
1011 let fb = Rc::new(RefCell::new(Framebuffer::new()));
1111- let runner = LuaRunner::new(fb.clone(), script).expect("lua init");
1212- for _ in 0..ticks {
1313- runner.tick();
1414- }
1212+ let mem = Rc::new(RefCell::new(Memory::new(fb.clone())));
1313+ let runner = LuaRunner::new(fb.clone(), mem, script).expect("lua init");
1414+ for _ in 0..ticks { runner.tick(); }
1515 fb
1616}
1717···207207208208 let run_hash = |ticks: usize| -> u64 {
209209 let fb = Rc::new(RefCell::new(Framebuffer::new()));
210210- let runner = LuaRunner::new(fb.clone(), &script).expect("lua init");
210210+ let mem = Rc::new(RefCell::new(Memory::new(fb.clone())));
211211+ let runner = LuaRunner::new(fb.clone(), mem, &script).expect("lua init");
211212 for _ in 0..ticks { runner.tick(); }
212213 let mut borrowed = fb.borrow_mut();
213213- fb_hash(&mut *borrowed)
214214+ fb_hash(&mut borrowed)
214215 };
215216216217 let h1 = run_hash(1);
+58
tic80_rust/tests/memory_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+77+#[test]
88+fn poke4_sets_framebuffer_pixel() {
99+ let fb = Rc::new(RefCell::new(Framebuffer::new()));
1010+ let mut mem = Memory::new(fb.clone());
1111+ // Address 0 corresponds to first two pixels (0,0) and (1,0)
1212+ mem.poke_bits(0, 4, 0xA);
1313+ assert_eq!(fb.borrow_mut().pix(0, 0, None), Some(0xA));
1414+ // High nibble is pixel 1
1515+ mem.poke_bits(1, 4, 0x3);
1616+ assert_eq!(fb.borrow_mut().pix(1, 0, None), Some(0x3));
1717+}
1818+1919+#[test]
2020+fn peek4_reads_back_nibble() {
2121+ let fb = Rc::new(RefCell::new(Framebuffer::new()));
2222+ let mem = Memory::new(fb.clone());
2323+ // Set two pixels via framebuffer then read nibbles
2424+ fb.borrow_mut().pix(0, 0, Some(7));
2525+ fb.borrow_mut().pix(1, 0, Some(12));
2626+ assert_eq!(mem.peek_bits(0, 4), 7);
2727+ assert_eq!(mem.peek_bits(1, 4), 12 & 0x0F);
2828+}
2929+3030+#[test]
3131+fn memcpy_and_memset_affect_vram() {
3232+ let fb = Rc::new(RefCell::new(Framebuffer::new()));
3333+ let mut mem = Memory::new(fb.clone());
3434+ // memset first 10 bytes of VRAM screen -> sets first 20 pixels (pairs) to (0x5,0x5)
3535+ mem.memset(0, 0x55, 10);
3636+ for x in 0..20 { assert_eq!(fb.borrow_mut().pix(x, 0, None), Some(0x5)); }
3737+3838+ // prepare source bytes with pattern 0xAB -> (0xB,0xA) on pixels
3939+ let src = 40000usize; // within RAM region beyond VRAM
4040+ for i in 0..4 { mem.poke(src + i, 0xAB); }
4141+ mem.memcpy(0, src, 4); // copy into beginning of VRAM
4242+ // First 8 pixels now map from 0xAB pairs
4343+ let mut px = vec![];
4444+ for x in 0..8 { px.push(fb.borrow_mut().pix(x, 0, None).unwrap()); }
4545+ assert_eq!(&px, &[0xB, 0xA, 0xB, 0xA, 0xB, 0xA, 0xB, 0xA]);
4646+}
4747+4848+#[test]
4949+fn peek_poke_bits_general_ram() {
5050+ let fb = Rc::new(RefCell::new(Framebuffer::new()));
5151+ let mut mem = Memory::new(fb);
5252+ let base = 90000usize; // inside 96KB
5353+ mem.memset(base, 0x00, 2);
5454+ mem.poke_bits(base * 2, 4, 0xF); // address in nibbles
5555+ assert_eq!(mem.peek(base), 0x0F);
5656+ mem.poke_bits(base * 8 + 7, 1, 1); // set MSB of first byte
5757+ assert_eq!(mem.peek(base), 0x8F);
5858+}