···15151616**Build Hygiene (always do this)**
1717- Fix all compiler warnings before landing changes (treat warnings as errors).
1818-- Run clippy on the crate and keep zero warnings: `cd tic80_rust && cargo clippy --all-targets --all-features -D warnings`.
1818+- Run clippy and keep zero warnings: `cd tic80_rust && cargo clippy -- -D warnings`.
1919- Validate with tests: `cd tic80_rust && cargo test` (and run the specific failing test during fixes).
2020- Keep changes minimal and focused; don’t expand scope while tests are red.
21212222**Testing**
2323- Strategy: see `docs/testing/strategy.md` for layers (framebuffer, Lua bridge, deterministic hashes) and future plans (audio, conformance, fuzzing).
2424- Catalog: see `docs/testing/test_catalog.md` for a concise list of existing tests and their intent.
2525-- Run: `cd tic80_rust && cargo test` for unit + Lua tests; `cargo clippy --all-targets --all-features -D warnings` for linting.
2525+- Run: `cd tic80_rust && cargo test` for unit + Lua tests; `cargo clippy -- -D warnings` for linting.
2626- Determinism: VRAM hashes use FNV-1a over 240×136 palette indices (see strategy doc for rationale and helper snippet).
27272828**Decisions (Locked for prototype)**
···223223 }
224224 }
225225226226+ // Circle border using 8-way symmetry (integer midpoint algorithm)
227227+ pub fn circb(&mut self, cx: i32, cy: i32, r: i32, color: u8) {
228228+ if r < 0 { return; }
229229+ let c = color & 0x0F;
230230+ if r == 0 {
231231+ let _ = self.set_pixel(cx, cy, c);
232232+ return;
233233+ }
234234+ let mut x = r;
235235+ let mut y = 0;
236236+ let mut err = 1 - r;
237237+ while x >= y {
238238+ // 8 symmetric points
239239+ let _ = self.set_pixel(cx + x, cy + y, c);
240240+ let _ = self.set_pixel(cx + y, cy + x, c);
241241+ let _ = self.set_pixel(cx - y, cy + x, c);
242242+ let _ = self.set_pixel(cx - x, cy + y, c);
243243+ let _ = self.set_pixel(cx - x, cy - y, c);
244244+ let _ = self.set_pixel(cx - y, cy - x, c);
245245+ let _ = self.set_pixel(cx + y, cy - x, c);
246246+ let _ = self.set_pixel(cx + x, cy - y, c);
247247+248248+ y += 1;
249249+ if err < 0 {
250250+ err += 2 * y + 1;
251251+ } else {
252252+ x -= 1;
253253+ err += 2 * (y - x) + 1;
254254+ }
255255+ }
256256+ }
257257+258258+ // Filled circle via horizontal spans using symmetry
259259+ pub fn circ(&mut self, cx: i32, cy: i32, r: i32, color: u8) {
260260+ if r < 0 { return; }
261261+ let c = color & 0x0F;
262262+ if r == 0 {
263263+ let _ = self.set_pixel(cx, cy, c);
264264+ return;
265265+ }
266266+ let mut x = r;
267267+ let mut y = 0;
268268+ let mut err = 1 - r;
269269+ while x >= y {
270270+ // Draw horizontal spans for the current y and x offsets
271271+ self.hspan(cx - x, cx + x, cy + y, c);
272272+ self.hspan(cx - x, cx + x, cy - y, c);
273273+ self.hspan(cx - y, cx + y, cy + x, c);
274274+ self.hspan(cx - y, cx + y, cy - x, c);
275275+276276+ y += 1;
277277+ if err < 0 {
278278+ err += 2 * y + 1;
279279+ } else {
280280+ x -= 1;
281281+ err += 2 * (y - x) + 1;
282282+ }
283283+ }
284284+ }
285285+286286+ fn hspan(&mut self, x0: i32, x1: i32, y: i32, color: u8) {
287287+ if y < 0 || y as u32 >= HEIGHT { return; }
288288+ let start = x0.min(x1);
289289+ let end = x0.max(x1);
290290+ for x in start..=end {
291291+ let _ = self.set_pixel(x, y, color);
292292+ }
293293+ }
294294+226295 // Print text using TIC-80 default font (5x8 glyphs, 1px spacing)
227296 #[allow(clippy::too_many_arguments)]
228297 pub fn print_text(
···328397pub fn dimensions() -> (u32, u32) {
329398 (Framebuffer::WIDTH, Framebuffer::HEIGHT)
330399}
400400+401401+impl Default for Framebuffer {
402402+ fn default() -> Self {
403403+ Self::new()
404404+ }
405405+}
+1-1
tic80_rust/src/main.rs
···5959 let mut ticker = Ticker::new();
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) {
6262+ let script = if let Some(first) = args.first() {
6363 if first.ends_with(".lua") && Path::new(first).is_file() {
6464 match fs::read_to_string(first) {
6565 Ok(s) => s,
+16
tic80_rust/src/script/lua_runner.rs
···6363 )?;
6464 globals.set("rectb", rectb_fn)?;
65656666+ // circ(x, y, r, color)
6767+ let fb_circ = fb.clone();
6868+ let circ_fn = lua.create_function(move |_, (x, y, r, color): (i32, i32, i32, u8)| {
6969+ fb_circ.borrow_mut().circ(x, y, r, color);
7070+ Ok(())
7171+ })?;
7272+ globals.set("circ", circ_fn)?;
7373+7474+ // circb(x, y, r, color)
7575+ let fb_circb = fb.clone();
7676+ let circb_fn = lua.create_function(move |_, (x, y, r, color): (i32, i32, i32, u8)| {
7777+ fb_circb.borrow_mut().circb(x, y, r, color);
7878+ Ok(())
7979+ })?;
8080+ globals.set("circb", circb_fn)?;
8181+6682 // clip(x,y,w,h) or clip() to reset
6783 let fb_clip = fb.clone();
6884 let clip_fn = lua.create_function(move |_, args: MultiValue| {
+54
tic80_rust/tests/gfx_framebuffer_tests.rs
···248248 assert_eq!(fb.pix(6, 0, None), Some(4)); // right edge
249249 assert_eq!(fb.pix(0, 6, None), Some(4)); // bottom edge
250250}
251251+252252+#[test]
253253+fn circb_cardinals_and_oob() {
254254+ let mut fb = Framebuffer::new();
255255+ fb.cls(0);
256256+ let cx = 20;
257257+ let cy = 20;
258258+ let r = 5;
259259+ fb.circb(cx, cy, r, 7);
260260+ // cardinal points
261261+ assert_eq!(fb.pix(cx + r, cy, None), Some(7));
262262+ assert_eq!(fb.pix(cx - r, cy, None), Some(7));
263263+ assert_eq!(fb.pix(cx, cy + r, None), Some(7));
264264+ assert_eq!(fb.pix(cx, cy - r, None), Some(7));
265265+ // just outside should remain background
266266+ assert_eq!(fb.pix(cx + r + 1, cy, None), Some(0));
267267+}
268268+269269+#[test]
270270+fn circ_fill_center_row_and_clip() {
271271+ let mut fb = Framebuffer::new();
272272+ fb.cls(0);
273273+ let cx = 30;
274274+ let cy = 30;
275275+ let r = 4;
276276+ fb.circ(cx, cy, r, 5);
277277+ // center row should be filled from cx-r .. cx+r
278278+ for x in (cx - r)..=(cx + r) {
279279+ assert_eq!(fb.pix(x, cy, None), Some(5));
280280+ }
281281+ assert_eq!(fb.pix(cx - r - 1, cy, None), Some(0));
282282+ assert_eq!(fb.pix(cx + r + 1, cy, None), Some(0));
283283+284284+ // clipping restricts drawing to 1x1
285285+ let mut fb2 = Framebuffer::new();
286286+ fb2.cls(2);
287287+ fb2.clip(0, 0, 1, 1);
288288+ fb2.circ(0, 0, 5, 9);
289289+ assert_eq!(fb2.pix(0, 0, None), Some(9));
290290+ assert_eq!(fb2.pix(1, 0, None), Some(2));
291291+ assert_eq!(fb2.pix(0, 1, None), Some(2));
292292+}
293293+294294+#[test]
295295+fn circ_zero_radius_draws_center() {
296296+ let mut fb = Framebuffer::new();
297297+ fb.cls(0);
298298+ fb.circ(10, 10, 0, 3);
299299+ assert_eq!(fb.pix(10, 10, None), Some(3));
300300+ let mut fb2 = Framebuffer::new();
301301+ fb2.cls(0);
302302+ fb2.circb(10, 10, 0, 4);
303303+ assert_eq!(fb2.pix(10, 10, None), Some(4));
304304+}
+20
tic80_rust/tests/lua_api_tests.rs
···224224 // Different frame counts should usually yield different hashes for this cart
225225 assert_ne!(h1, h2, "different ticks should yield different frame hashes");
226226}
227227+228228+#[test]
229229+fn lua_circ_and_circb() {
230230+ let script = r#"
231231+ function BOOT() cls(0) end
232232+ function TIC()
233233+ circ(20, 20, 4, 6)
234234+ circb(30, 20, 3, 9)
235235+ end
236236+ "#;
237237+ let fb = run_lua(script, 1);
238238+ let mut fbm = fb.borrow_mut();
239239+ // Filled circle: center row span for r=4
240240+ for x in 16..=24 { assert_eq!(fbm.pix(x, 20, None), Some(6)); }
241241+ // Border circle: cardinal points for r=3
242242+ assert_eq!(fbm.pix(33, 20, None), Some(9));
243243+ assert_eq!(fbm.pix(27, 20, None), Some(9));
244244+ assert_eq!(fbm.pix(30, 23, None), Some(9));
245245+ assert_eq!(fbm.pix(30, 17, None), Some(9));
246246+}