···2828- `MEMORY_MAP.md` at repo root remains the canonical reference for layout. Specs here link to it rather than duplicating.
2929- `CLAUDE.md` remains at repo root; the spec page links to it for detailed FFT/VQT behavior.
30303131+## Run the Demo
3232+- Default cart:
3333+ - From crate dir: `cd tic80_rust && cargo run`
3434+ - From repo root: `cargo run --manifest-path tic80_rust/Cargo.toml`
3535+- Load a `.lua` file:
3636+ - `cargo run --manifest-path tic80_rust/Cargo.toml -- tic80_rust/assets/alt.lua`
3737+ - In crate dir: `cargo run -- assets/alt.lua`
3838+
+10
tic80_rust/assets/alt.lua
···11+-- Alternate cart: draws a small filled square at the origin
22+function BOOT()
33+ cls(2)
44+end
55+66+function TIC()
77+ rect(0, 0, 10, 10, 5)
88+ pix(0, 0, 7) -- top-left marker
99+end
1010+
+69
tic80_rust/assets/default.lua
···11+-- A calmer demo showcasing: cls, pix, line, rect, rectb, clip, print
22+local t = 0
33+local noise = {}
44+local seed = 123456789 -- deterministic noise
55+66+local function lcg()
77+ seed = (seed * 1664525 + 1013904223) % 4294967296
88+ return seed
99+end
1010+1111+function BOOT()
1212+ cls(1) -- deep blue background
1313+ -- Precompute a bunch of random pixel positions to avoid flicker
1414+ for i = 1, 400 do
1515+ local x = lcg() % 240
1616+ local y = lcg() % 136
1717+ noise[i] = { x, y }
1818+ end
1919+end
2020+2121+function TIC()
2222+ -- Stable background each frame to avoid trails
2323+ cls(1)
2424+2525+ -- Crosshair via pix at the center
2626+ local cx, cy = 120, 68
2727+ for dx = -10, 10 do pix(cx + dx, cy, 7) end
2828+ for dy = -10, 10 do pix(cx, cy + dy, 7) end
2929+3030+ -- Title and legend
3131+ print("TIC-80 Rust Demo", 8, 6, 15)
3232+ print("cls pix line rect rectb clip print", 8, 16, 14)
3333+3434+ -- Static card: rect + rectb
3535+ rectb(20, 28, 48, 22, 12) -- border
3636+ rect(22, 30, 44, 18, 6) -- fill
3737+3838+ -- Gentle animation: small square drifting horizontally
3939+ local ax = 90 + (t % 60) - 30
4040+ rectb(ax, 34, 14, 14, 12)
4141+ rect(ax + 1, 35, 12, 12, 3)
4242+4343+ -- Clip demo toggles every ~2.5 seconds (150 frames @60 FPS)
4444+ local clipped = (t // 150) % 2 == 0
4545+ if clipped then
4646+ clip(100, 48, 60, 36)
4747+ rect(92, 40, 80, 56, 4) -- clipped fill
4848+ clip()
4949+ rectb(100, 48, 60, 36, 14)
5050+ print("clip: ON", 102, 40, 14)
5151+ else
5252+ clip()
5353+ rect(92, 40, 80, 56, 2) -- un-clipped fill (subtle change)
5454+ rectb(100, 48, 60, 36, 14)
5555+ print("clip: OFF", 102, 40, 14)
5656+ end
5757+5858+ -- Subtle diagonal lines
5959+ line(0, 0, 239, 135, 13)
6060+ line(0, 135, 239, 0, 2)
6161+6262+ -- Sprinkle precomputed random pixels ("stars")
6363+ for i = 1, #noise do
6464+ local p = noise[i]
6565+ pix(p[1], p[2], 13)
6666+ end
6767+6868+ t = t + 1
6969+end
+70-5
tic80_rust/src/gfx/framebuffer.rs
···5353pub struct Framebuffer {
5454 // 240x136 palette indices (0..15)
5555 idx: Vec<u8>,
5656+ // Clipping rectangle in inclusive-exclusive coords [x0,x1), [y0,y1)
5757+ clip_x0: i32,
5858+ clip_y0: i32,
5959+ clip_x1: i32,
6060+ clip_y1: i32,
5661}
57625863impl Framebuffer {
···6267 pub fn new() -> Self {
6368 Self {
6469 idx: vec![0; (WIDTH * HEIGHT) as usize],
7070+ clip_x0: 0,
7171+ clip_y0: 0,
7272+ clip_x1: WIDTH as i32,
7373+ clip_y1: HEIGHT as i32,
6574 }
6675 }
6776···909991100 // Write a single pixel; returns true if in-bounds and written
92101 pub fn set_pixel(&mut self, x: i32, y: i32, color: u8) -> bool {
9393- if x < 0 || y < 0 || x as u32 >= WIDTH || y as u32 >= HEIGHT {
102102+ if x < self.clip_x0
103103+ || y < self.clip_y0
104104+ || x >= self.clip_x1
105105+ || y >= self.clip_y1
106106+ || x < 0
107107+ || y < 0
108108+ || x as u32 >= WIDTH
109109+ || y as u32 >= HEIGHT
110110+ {
94111 return false;
95112 }
96113 let i = (y as u32 * WIDTH + x as u32) as usize;
···98115 true
99116 }
100117118118+ // Set clipping rectangle to intersection with framebuffer and provided rect
119119+ pub fn clip(&mut self, x: i32, y: i32, w: i32, h: i32) {
120120+ if w <= 0 || h <= 0 {
121121+ self.clip_x0 = 0;
122122+ self.clip_y0 = 0;
123123+ self.clip_x1 = 0;
124124+ self.clip_y1 = 0;
125125+ return;
126126+ }
127127+ let x0 = x.max(0);
128128+ let y0 = y.max(0);
129129+ let x1 = (x + w).min(WIDTH as i32);
130130+ let y1 = (y + h).min(HEIGHT as i32);
131131+ self.clip_x0 = x0.max(0);
132132+ self.clip_y0 = y0.max(0);
133133+ self.clip_x1 = x1.max(self.clip_x0);
134134+ self.clip_y1 = y1.max(self.clip_y0);
135135+ }
136136+137137+ pub fn clip_reset(&mut self) {
138138+ self.clip_x0 = 0;
139139+ self.clip_y0 = 0;
140140+ self.clip_x1 = WIDTH as i32;
141141+ self.clip_y1 = HEIGHT as i32;
142142+ }
143143+101144 // Blit to RGBA buffer for pixels
102145 pub fn blit_to_rgba(&self, rgba: &mut [u8]) {
103146 for (px, idx) in rgba
···142185 return;
143186 }
144187 let c = color & 0x0F;
145145- let x0 = x.max(0);
146146- let y0 = y.max(0);
147147- let x1 = (x + w).min(WIDTH as i32);
148148- let y1 = (y + h).min(HEIGHT as i32);
188188+ let x0 = x.max(self.clip_x0).max(0);
189189+ let y0 = y.max(self.clip_y0).max(0);
190190+ let x1 = (x + w).min(self.clip_x1).min(WIDTH as i32);
191191+ let y1 = (y + h).min(self.clip_y1).min(HEIGHT as i32);
149192 if x1 <= x0 || y1 <= y0 {
150193 return;
151194 }
···155198 let start = base + x0 as usize;
156199 let end = base + x1 as usize;
157200 self.idx[start..end].fill(c);
201201+ }
202202+ }
203203+204204+ // Rectangle border (one-pixel thick), obeys clipping
205205+ pub fn rectb(&mut self, x: i32, y: i32, w: i32, h: i32, color: u8) {
206206+ if w <= 0 || h <= 0 {
207207+ return;
208208+ }
209209+ let c = color & 0x0F;
210210+ let x0 = x;
211211+ let y0 = y;
212212+ let x1 = x + w - 1;
213213+ let y1 = y + h - 1;
214214+ // Top and bottom edges
215215+ for xx in x0..=x1 {
216216+ let _ = self.set_pixel(xx, y0, c);
217217+ let _ = self.set_pixel(xx, y1, c);
218218+ }
219219+ // Left and right edges
220220+ for yy in y0..=y1 {
221221+ let _ = self.set_pixel(x0, yy, c);
222222+ let _ = self.set_pixel(x1, yy, c);
158223 }
159224 }
160225
+21-18
tic80_rust/src/main.rs
···11use std::cell::RefCell;
22+use std::fs;
33+use std::path::Path;
24use std::rc::Rc;
35use std::time::{Duration, Instant};
46···3537 }
3638}
37393838-const DEFAULT_LUA: &str = r#"
3939--- Minimal demo using cls and pix
4040-local t = 0
4141-function BOOT()
4242- cls(0)
4343-end
4444-function TIC()
4545- if t % 30 == 0 then cls(((t // 30) % 16)) end
4646- local cx, cy = 120, 68
4747- for dx = -10, 10 do pix(cx + dx, cy, 15) end
4848- for dy = -10, 10 do pix(cx, cy + dy, 15) end
4949- print("Hello", 10, 10, 15)
5050- line(0,0,239,135, 14)
5151- rect(20, 20, 40, 20, 9)
5252- t = t + 1
5353-end
5454-"#;
4040+const DEFAULT_LUA: &str = include_str!("../assets/default.lua");
55415642fn run() -> Result<(), Error> {
5743 let event_loop = EventLoop::new();
···71577258 let fb = Rc::new(RefCell::new(Framebuffer::new()));
7359 let mut ticker = Ticker::new();
7474- let lua_runner = LuaRunner::new(fb.clone(), DEFAULT_LUA).ok();
6060+ // Program selection: first CLI arg as .lua script, else embedded default
6161+ let args: Vec<String> = std::env::args().skip(1).collect();
6262+ let script = if let Some(first) = args.get(0) {
6363+ if first.ends_with(".lua") && Path::new(first).is_file() {
6464+ match fs::read_to_string(first) {
6565+ Ok(s) => s,
6666+ Err(e) => {
6767+ eprintln!("Failed to read {}: {}. Falling back to default cart.", first, e);
6868+ DEFAULT_LUA.to_string()
6969+ }
7070+ }
7171+ } else {
7272+ DEFAULT_LUA.to_string()
7373+ }
7474+ } else {
7575+ DEFAULT_LUA.to_string()
7676+ };
7777+ let lua_runner = LuaRunner::new(fb.clone(), &script).ok();
75787679 event_loop.run(move |event, _, control_flow| {
7780 *control_flow = ControlFlow::Poll;
+26
tic80_rust/src/script/lua_runner.rs
···5353 })?;
5454 globals.set("rect", rect_fn)?;
55555656+ // rectb(x,y,w,h,color)
5757+ let fb_rectb = fb.clone();
5858+ let rectb_fn = lua.create_function(
5959+ move |_, (x, y, w, h, color): (i32, i32, i32, i32, u8)| {
6060+ fb_rectb.borrow_mut().rectb(x, y, w, h, color);
6161+ Ok(())
6262+ },
6363+ )?;
6464+ globals.set("rectb", rectb_fn)?;
6565+6666+ // clip(x,y,w,h) or clip() to reset
6767+ let fb_clip = fb.clone();
6868+ let clip_fn = lua.create_function(move |_, args: MultiValue| {
6969+ if args.is_empty() {
7070+ fb_clip.borrow_mut().clip_reset();
7171+ } else {
7272+ let x = match args.get(0) { Some(Value::Integer(n)) => *n as i32, _ => 0 };
7373+ let y = match args.get(1) { Some(Value::Integer(n)) => *n as i32, _ => 0 };
7474+ let w = match args.get(2) { Some(Value::Integer(n)) => *n as i32, _ => 0 };
7575+ let h = match args.get(3) { Some(Value::Integer(n)) => *n as i32, _ => 0 };
7676+ fb_clip.borrow_mut().clip(x, y, w, h);
7777+ }
7878+ Ok(())
7979+ })?;
8080+ globals.set("clip", clip_fn)?;
8181+5682 // print(text, x=0, y=0, color=15, fixed=false, scale=1, small=false) -> width
5783 #[derive(Default)]
5884 struct PrintArgs {
+30
tic80_rust/tests/gfx_framebuffer_tests.rs
···141141 assert_eq!(&rgba[idx(1, 0)..idx(1, 0) + 4], &[0xFF, 0xA3, 0x00, 0xFF]);
142142 assert_eq!(&rgba[idx(2, 0)..idx(2, 0) + 4], &[0xFF, 0xCC, 0xAA, 0xFF]);
143143}
144144+145145+#[test]
146146+fn rectb_draws_border() {
147147+ let mut fb = Framebuffer::new();
148148+ fb.cls(0);
149149+ fb.rectb(1, 1, 3, 3, 5);
150150+ // Expected 3x3 border has 8 pixels set
151151+ assert_eq!(count_color(&mut fb, 5), 8);
152152+ // Interior remains background
153153+ assert_eq!(fb.pix(2, 2, None), Some(0));
154154+}
155155+156156+#[test]
157157+fn clip_limits_drawing_and_reset() {
158158+ let mut fb = Framebuffer::new();
159159+ fb.cls(1);
160160+ // Clip to a 1x1 at origin
161161+ fb.clip(0, 0, 1, 1);
162162+ fb.rect(0, 0, 10, 10, 7);
163163+ // Only (0,0) can be changed by rect under this clip
164164+ assert_eq!(fb.pix(0, 0, None), Some(7));
165165+ assert_eq!(fb.pix(1, 0, None), Some(1));
166166+ assert_eq!(fb.pix(0, 1, None), Some(1));
167167+168168+ // Reset clip and draw a rect filling a small area
169169+ fb.clip_reset();
170170+ fb.rect(0, 0, 2, 2, 9);
171171+ assert_eq!(fb.pix(0, 0, None), Some(9));
172172+ assert_eq!(fb.pix(1, 1, None), Some(9));
173173+}
+36
tic80_rust/tests/lua_api_tests.rs
···11use std::cell::RefCell;
22use std::rc::Rc;
33+use std::fs;
44+use std::path::PathBuf;
3546use tic80_rust::gfx::framebuffer::{dimensions, Framebuffer};
57use tic80_rust::script::lua_runner::LuaRunner;
···129131 }
130132 assert!(found, "expected a marker pixel with color 7 on row 0");
131133}
134134+135135+#[test]
136136+fn lua_runs_alt_cart_file() {
137137+ // Load the alternate cart file from assets and run one tick
138138+ let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
139139+ path.push("assets/alt.lua");
140140+ let script = fs::read_to_string(&path).expect("read alt.lua");
141141+ let fb = run_lua(&script, 1);
142142+ let mut fbm = fb.borrow_mut();
143143+ // Background set in BOOT
144144+ assert_eq!(fbm.pix(20, 20, None), Some(2));
145145+ // Marker at top-left and filled square
146146+ assert_eq!(fbm.pix(0, 0, None), Some(7));
147147+ assert_eq!(fbm.pix(9, 9, None), Some(5));
148148+}
149149+150150+#[test]
151151+fn lua_clip_and_rectb() {
152152+ let script = r#"
153153+ function BOOT()
154154+ cls(1)
155155+ clip(0, 0, 1, 1)
156156+ end
157157+ function TIC()
158158+ rectb(0, 0, 3, 3, 7)
159159+ end
160160+ "#;
161161+ let fb = run_lua(script, 1);
162162+ let mut fbm = fb.borrow_mut();
163163+ // Only origin affected due to clipping; neighbors unchanged
164164+ assert_eq!(fbm.pix(0, 0, None), Some(7));
165165+ assert_eq!(fbm.pix(1, 0, None), Some(1));
166166+ assert_eq!(fbm.pix(0, 1, None), Some(1));
167167+}