Repository: claudiusFX/Claudius
Pull request #38: Optimised framebuffer updates in correspondence with the requested changes
Author: login: Vanisha1606
Created: 2025-03-21T18:29:07Z | Merged: 2025-03-29T13:15:13Z

I have reviewed the requested changes and recommendations from PR #33 and then deep-dived into the codebase once more.
I have changed my approach to this issue a slight, and am adding a new commit to the PR.

This PR fixes the issue #3 

I have done a minimal patch that lets the run loop detect if the newly returned framebuffer is physically the same as the old one (i.e. == in OCaml), and then skip repainting in that case. We still get the usual frame-delay logic (so it won’t spin too fast), but if we  return the same framebuffer from oour tick function, Claudius will skip doing the texture update/draw calls.

- After each tick, we are comparing updated_buffer with prev_buffer by physical equality (!= in OCaml).
- If they are not the same object, we do the usual framebuffer_to_bigarray + render_texture calls.
- If they are the same object, we skip the texture update/draw.
- We still do Sdl.delay diff at the start of each loop iteration, so the loop will not spin faster than our target frame rate (60fps in this snippet).
- Event polling continues exactly as before, so key presses etc. still work.

IMO this is a better approach to the issue. Is this approach correct or there are other things that are needed to be taken into consideration? Thanks :))
@mdales @patricoferris 


Diff:
diff --git a/src/base.ml b/src/base.ml
index 3bae272..187c8b0 100644
--- a/src/base.ml
+++ b/src/base.ml
@@ -50,17 +50,14 @@ let run (title : string) (boot : boot_func option) (tick : tick_func) (s : Scree
 
   let s = match make_full with
   | false -> s
-  | true -> (
-    let w, h = Screen.dimensions s
-    and p = Screen.palette s in
-    match (Screen.font s) with
-    | None -> Screen.create w h 1 p
-    | Some f -> Screen.create_with_font w h 1 f p
-  )
+  | true ->
+      let w, h = Screen.dimensions s and p = Screen.palette s in
+      (match Screen.font s with
+       | None -> Screen.create w h 1 p
+       | Some f -> Screen.create_with_font w h 1 f p)
   in
 
-  let width, height = Screen.dimensions s
-  and scale = Screen.scale s in
+  let width, height = Screen.dimensions s and scale = Screen.scale s in
 
   match sdl_init (width * scale) (height * scale) title make_full with
   | Error (`Msg e) -> Sdl.log "Init error: %s" e; exit 1
@@ -79,43 +76,39 @@ let run (title : string) (boot : boot_func option) (tick : tick_func) (s : Scree
 
       let e = Sdl.Event.create () in
 
-      let rec loop (t : int) (prev_buffer : Framebuffer.t) (keys : KeyCodeSet.t) last_t = (
-
+      let rec loop (t : int) (prev_buffer : Framebuffer.t) (keys : KeyCodeSet.t) last_t =
         let now = Sdl.get_ticks () in
-        let diff = Int32.(sub (of_int(1000 / 60)) (sub now last_t)) in
-        if Int32.(compare diff zero) > 0 then (
-          Sdl.delay diff
-        );
+        let diff = Int32.(sub (of_int (1000 / 60)) (sub now last_t)) in
+        if Int32.(compare diff zero) > 0 then Sdl.delay diff;
 
         let updated_buffer = tick t s prev_buffer keys in
 
-        framebuffer_to_bigarray s updated_buffer bitmap;
+        if (updated_buffer != prev_buffer) || (Framebuffer.is_dirty updated_buffer) then (
+          framebuffer_to_bigarray s updated_buffer bitmap;
+          (match render_texture r texture s bitmap with
+           | Error (`Msg e) -> Sdl.log "Boot error: %s" e
+           | Ok () -> ());
+          Framebuffer.clear_dirty updated_buffer
+        );
 
-        match render_texture r texture s bitmap with
-        | Error (`Msg e) -> Sdl.log "Boot error: %s" e
-        | Ok () -> (
-          let exit, keys =
+        let exit, keys =
           match Sdl.poll_event (Some e) with
-          | true -> (
-            match Sdl.Event.(enum (get e typ)) with
-            | `Quit -> (true, keys)
-            | `Key_down -> 
-                let key = PlatformKey.of_backend_keycode (Sdl.Event.(get e keyboard_keycode)) in
-                (false, KeyCodeSet.add key keys)
-            | `Key_up -> 
-              let key = PlatformKey.of_backend_keycode (Sdl.Event.(get e keyboard_keycode)) in
-              (false, KeyCodeSet.remove key keys)
-            | _ -> (false, keys)
-          )
-          | false -> (false, keys) in
-          match exit with
-          | true -> ()
-          | false -> loop (t + 1) updated_buffer keys now
-        )
-      ) in loop 0 initial_buffer KeyCodeSet.empty Int32.zero;
-
-
+          | true ->
+              (match Sdl.Event.(enum (get e typ)) with
+               | `Quit -> (true, keys)
+               | `Key_down ->
+                   let key = PlatformKey.of_backend_keycode (Sdl.Event.(get e keyboard_keycode)) in
+                   (false, KeyCodeSet.add key keys)
+               | `Key_up ->
+                   let key = PlatformKey.of_backend_keycode (Sdl.Event.(get e keyboard_keycode)) in
+                   (false, KeyCodeSet.remove key keys)
+               | _ -> (false, keys))
+          | false -> (false, keys)
+        in
+        if exit then () else loop (t + 1) updated_buffer keys now
+      in
+      loop 0 initial_buffer KeyCodeSet.empty Int32.zero;
       Sdl.destroy_texture texture;
       Sdl.destroy_renderer r;
       Sdl.destroy_window w;
-      Sdl.quit ()
+      Sdl.quit ()
\ No newline at end of file
diff --git a/src/framebuffer.ml b/src/framebuffer.ml
index fc99485..ec37a6c 100644
--- a/src/framebuffer.ml
+++ b/src/framebuffer.ml
@@ -1,30 +1,31 @@
-
-type t = int array array
+type t = { data : int array array; mutable dirty : bool }
 
 type shader_func = int -> int
 
 type shaderi_func = int -> int -> t -> int
 
 let to_array (buffer : t) : int array array =
-  buffer
+  buffer.data
 
 let init (dimensions : int * int) (f : int -> int -> int) : t =
   let width, height = dimensions in
   if width <= 0 then raise (Invalid_argument "Invalid width");
   if height <= 0 then raise (Invalid_argument "Invalid height");
-  Array.init height (fun y ->
+  { data = Array.init height (fun y ->
     Array.init width (fun x ->
         f x y
       )
-  )
+  );
+    dirty = true }
 
 let pixel_write (x : int) (y : int) (col : int) (buffer : t) =
-  if (x >= 0) && (x < Array.length (buffer.(0))) && (y >= 0) && (y < Array.length buffer) then
-    buffer.(y).(x) <- col
+  if (x >= 0) && (x < Array.length (buffer.data.(0))) && (y >= 0) && (y < Array.length buffer.data) then
+    buffer.data.(y).(x) <- col;
+  buffer.dirty <- true
 
 let pixel_read (x : int) (y : int) (buffer : t) : int option =
-  if (x >= 0) && (x < Array.length (buffer.(0))) && (y >= 0) && (y < Array.length buffer) then
-    Some buffer.(y).(x)
+  if (x >= 0) && (x < Array.length (buffer.data.(0))) && (y >= 0) && (y < Array.length buffer.data) then
+    Some buffer.data.(y).(x)
   else
     None
 
@@ -51,14 +52,13 @@ let draw_circle (x : int) (y : int) (r : float) (col : int) (buffer : t) =
 
 let filled_circle (x : int) (y : int) (r : float) (col : int) (buffer : t) =
   let fx = Float.of_int x and fy = Float.of_int y in
-  let my = Float.of_int ((Array.length buffer) - 1)
-  and mx = Float.of_int ((Array.length buffer.(0)) - 1) in
+  let my = Float.of_int ((Array.length buffer.data) - 1)
+  and mx = Float.of_int ((Array.length buffer.data.(0)) - 1) in
   let pminy = fy -. r
   and pmaxy = fy +. r in
   let miny = if (pminy < 0.) then 0. else pminy
   and maxy = if (pmaxy > my) then my else pmaxy in
   for yi = (Int.of_float miny) to (Int.of_float maxy) do
-    let row = buffer.(yi) in
     let a = acos ((Float.of_int (yi - y)) /. r) in
     let xw = (sin a) *. r in
     let pminx = fx -. xw
@@ -67,7 +67,7 @@ let filled_circle (x : int) (y : int) (r : float) (col : int) (buffer : t) =
     and maxx = if (pmaxx > mx) then mx else pmaxx in
     if (maxx > 0.0) && (minx < mx) then
       for xi = (Int.of_float minx) to (Int.of_float maxx) do
-        row.(xi) <- col
+        pixel_write xi yi col buffer
       done
   done
 
@@ -79,8 +79,7 @@ let draw_line (x0 : int) (y0 : int) (x1 : int) (y1 : int) (col : int) (buffer :
   let initial_error = dx + dy in
 
   let rec loop (x : int) (y : int) (error : int) =
-    if (x >= 0) && (x < Array.length (buffer.(0))) && (y >= 0) && (y < Array.length buffer) then
-      buffer.(y).(x) <- col;
+    pixel_write x y col buffer;
     match (x == x1) && (y == y1) with
     | true -> ()
     | false -> (
@@ -175,8 +174,7 @@ let interpolate_line (x0 : int) (y0 : int) (x1 : int) (y1 : int) : span array =
           | Only (a) -> (if a == nx then Only(a) else (if (a > nx) then Pair(nx, a) else (Pair(a, nx))))
           | Pair (a, b) -> (if nx <= a then Pair(nx, b) else Pair(a, nx))
         )
-      )
-      ;
+      );
       loop nx ny (error + nex + ney)
     )
   in loop x0 y0 initial_error;
@@ -222,8 +220,8 @@ let filled_triangle (x0 : int) (y0 : int) (x1 : int) (y1 : int) (x2 : int) (y2 :
   let spans = Array.map2 (fun a b -> (a, b)) long_edge other_edge in
   Array.iteri (fun i s ->
     let index = y0 + i in
-    if (index >= 0) && (index < Array.length buffer) then (
-      let row = buffer.(y0 + i) in
+    if (index >= 0) && (index < Array.length buffer.data) then (
+      let row = buffer.data.(index) in
       let stride = Array.length row in
       let p, q = s in
       let p0, p1 = if (leftmost p <= leftmost q) then p, q else q, p in
@@ -231,7 +229,9 @@ let filled_triangle (x0 : int) (y0 : int) (x1 : int) (y1 : int) (x2 : int) (y2 :
       if ((r1 > 0) && (r0 < (stride - 1))) then (
         let x0 = if (r0 < 0) then 0 else (if (r0 >= stride) then (stride - 1) else r0)
         and x1 = if (r1 < 0) then 0 else (if (r1 >= stride) then (stride - 1) else r1) in
-        Array.fill row x0 ((x1 - x0) + 1) col
+        for x = x0 to x1 do
+          pixel_write x index col buffer
+        done
       )
     )
   ) spans
@@ -375,12 +375,12 @@ let filled_polygon (points : (int * int) list) (col : int) (buffer : t) =
       ) points
     ) rendered_strands;
 
-    let height = Array.length buffer in
+    let height = Array.length buffer.data in
     Array.iteri (fun i row ->
       let index = min_y + i in
       if ((index >= 0) && (index < height)) then (
 
-        let brow = buffer.(index) in
+        let brow = buffer.data.(index) in
         let stride = Array.length brow in
         let sorted_row = List.sort (fun a b -> (leftmost a) - (leftmost b)) row in
 
@@ -397,7 +397,7 @@ let filled_polygon (points : (int * int) list) (col : int) (buffer : t) =
               let x0 = if (raw_x0 < 0) then 0 else (if (raw_x0 >= stride) then (stride - 1) else raw_x0)
               and x1 = if (raw_x1 < 0) then 0 else (if (raw_x1 >= stride) then (stride - 1) else raw_x1) in
               let dist = (x1 - x0) + 1 in
-              Array.fill brow x0 dist col;
+              if dist > 0 then Array.fill brow x0 dist col;
             )
           )
           | raw_x0 :: raw_x1 :: tl -> (
@@ -413,7 +413,8 @@ let filled_polygon (points : (int * int) list) (col : int) (buffer : t) =
           )
         in loop sorted_row
       )
-    ) map
+    ) map;
+    buffer.dirty <- true
   )
 
 (* ----- *)
@@ -458,26 +459,31 @@ let draw_string (x : int) (y : int) (f : Font.t) (s : string) (col : int) (buffe
 (* ----- *)
 
 let map (f: shader_func) (buffer : t) : t =
-  Array.map (fun row ->
-    Array.map f row
-  ) buffer
+  { data = Array.map (fun row ->
+      Array.map f row
+    ) buffer.data;
+    dirty = true }
 
-let mapi (f: shaderi_func) (buffer : t) : t =
-  Array.mapi (fun y row ->
-    Array.mapi (fun x _p -> f x y buffer) row
-  ) buffer
 
-let map_inplace (f: shader_func) (buffer : t) =
-  Array.iter (fun row ->
-    Array.iteri (fun i v -> row.(i) <- f v) row
-    (* Array.map_inplace f row*)
-  ) buffer
+let mapi (f: shaderi_func) (buffer : t) : t =
+  { data = Array.mapi (fun y row ->
+      Array.mapi (fun x _p -> f x y buffer) row
+    ) buffer.data;
+    dirty = true }
+
+    let map_inplace (f: shader_func) (buffer : t) =
+      Array.iter (fun row ->
+        Array.iteri (fun i v -> row.(i) <- f v) row
+      ) buffer.data;
+      buffer.dirty <- true
+    
 
 let mapi_inplace (f: shaderi_func) (buffer : t) =
   Array.iteri (fun y row ->
     Array.iteri (fun x _v -> row.(x) <- f x y buffer) row
     (* Array.mapi_inplace (fun x _p -> f x y buffer) row*)
-  ) buffer
+  ) buffer.data;
+  buffer.dirty <- true
 
 (* ---- *)
 
@@ -502,20 +508,34 @@ let render (buffer : t) (draw : Primitives.t list) =
 
 let map2 (f : int -> int -> int) (origin : t) (delta : t) : t =
   try
-    Array.map2 (fun o_row d_row ->
-      Array.map2 (fun o_pixel d_pixel ->
-        f o_pixel d_pixel
-      ) o_row d_row
-    ) origin delta
+    { data = Array.map2 (fun o_row d_row ->
+                Array.map2 (fun o_pixel d_pixel ->
+                  f o_pixel d_pixel
+                ) o_row d_row
+             ) origin.data delta.data;
+      dirty = true }
   with
   | Invalid_argument _ -> raise (Invalid_argument "Merging framebuffers requires both to have same dimensions")
 
-let map2_inplace (f : int -> int -> int) (origin : t) (delta : t) =
-  try
-    Array.iter2 (fun o_row d_row ->
-      Array.iteri (fun index d_pixel ->
-        o_row.(index) <- f o_row.(index) d_pixel
-      ) d_row
-    ) origin delta
-  with
-  | Invalid_argument _ -> raise (Invalid_argument "Merging framebuffers requires both to have same dimensions")
+  let map2_inplace (f : int -> int -> int) (origin : t) (delta : t) =
+    try
+      Array.iter2 (fun o_row d_row ->
+        Array.iteri (fun index d_pixel ->
+          o_row.(index) <- f o_row.(index) d_pixel
+        ) d_row
+      ) origin.data delta.data;
+      origin.dirty <- true
+    with
+    | Invalid_argument _ ->
+        raise (Invalid_argument "Merging framebuffers requires both to have same dimensions")
+  
+
+
+let is_dirty (buffer : t) : bool =
+  buffer.dirty
+
+let set_dirty (buffer : t) : unit =
+  buffer.dirty <- true
+
+let clear_dirty (buffer : t) : unit =
+  buffer.dirty <- false
\ No newline at end of file
diff --git a/src/framebuffer.mli b/src/framebuffer.mli
index c141e8d..feba3ca 100644
--- a/src/framebuffer.mli
+++ b/src/framebuffer.mli
@@ -1,9 +1,8 @@
-
 (** Provides the simulated framebuffer for Claudius.
 
 The framebuffer is logically an array of memory in which you draw using palette entries, similar to say how VGA worked on old PCs. *)
 
-type t
+type t = { data : int array array; mutable dirty : bool }
 
 (** {1 Initializations} *)
 
@@ -159,3 +158,14 @@ val pixel_read: int -> int -> t -> int option
 val to_array: t -> int array array
 (** [to_array framebuffer] converts the framebuffer into a 2D array. The top level array is an array
     of rows, and each row is an array of palette entry colours. *)
+
+(** {2 Dirty-bit support} *)
+
+val is_dirty: t -> bool
+(** [is_dirty framebuffer] returns [true] if the framebuffer has been marked as 'dirty'. *)
+
+val set_dirty: t -> unit
+(** [set_dirty framebuffer] marks the framebuffer as dirty. *)
+
+val clear_dirty: t -> unit
+(** [clear_dirty framebuffer] resets the dirty bit to false. *)
\ No newline at end of file
diff --git a/test/test_primitives.ml b/test/test_primitives.ml
index d2bc87e..ea1943a 100644
--- a/test/test_primitives.ml
+++ b/test/test_primitives.ml
@@ -1,231 +1,309 @@
 open Claudius
 open OUnit2
 
+(* Helper: prepare a framebuffer by clearing its dirty bit and asserting it's clear *)
+let prepare_fb dims init_fun =
+  let fb = Framebuffer.init dims init_fun in
+  Framebuffer.clear_dirty fb;
+  assert_equal ~msg:"dirty bit should be false after clear" false (Framebuffer.is_dirty fb);
+  fb
+
 (* Line *)
 
 let test_draw_line_direct _ =
-  let fb = Framebuffer.init (10, 10) (fun _ _ -> 0) in
-  assert_equal ~msg:"before" (Some 0) (Framebuffer.pixel_read 5 5 fb);
+  let fb = prepare_fb (10, 10) (fun _ _ -> 0) in
+  assert_equal ~msg:"before (line direct)" (Some 0) (Framebuffer.pixel_read 5 5 fb);
   Framebuffer.draw_line 3 3 7 7 1 fb;
-  assert_equal ~msg:"after" (Some 1) (Framebuffer.pixel_read 5 5 fb)
+  assert_equal ~msg:"after (line direct)" (Some 1) (Framebuffer.pixel_read 5 5 fb);
+  assert_equal ~msg:"dirty bit should be set after draw_line" true (Framebuffer.is_dirty fb)
+
 
 let test_draw_line_direct_off_framebuffer _ =
-  let fb = Framebuffer.init (10, 10) (fun _ _ -> 0) in
-  assert_equal ~msg:"before" (Some 0) (Framebuffer.pixel_read 5 5 fb);
+  let fb = prepare_fb (10, 10) (fun _ _ -> 0) in
+  assert_equal ~msg:"before (line off)" (Some 0) (Framebuffer.pixel_read 5 5 fb);
   Framebuffer.draw_line (-3) (-3) 7 7 1 fb;
-  assert_equal ~msg:"after" (Some 1) (Framebuffer.pixel_read 5 5 fb)
+  assert_equal ~msg:"after (line off)" (Some 1) (Framebuffer.pixel_read 5 5 fb);
+  assert_equal ~msg:"dirty bit should be set after draw_line (off)" true (Framebuffer.is_dirty fb)
 
 let test_draw_line_with_primitive _ =
-  let fb = Framebuffer.init (10, 10) (fun _ _ -> 0) in
-  assert_equal ~msg:"before" (Some 0) (Framebuffer.pixel_read 5 5 fb);
+  let fb = prepare_fb (10, 10) (fun _ _ -> 0) in
+  assert_equal ~msg:"before (line prim)" (Some 0) (Framebuffer.pixel_read 5 5 fb);
   let line = Primitives.Line ({x = 3; y = 3}, {x = 7 ; y = 7}, 1) in
   Framebuffer.render fb [line];
-  assert_equal ~msg:"after" (Some 1) (Framebuffer.pixel_read 5 5 fb)
+  assert_equal ~msg:"after (line prim)" (Some 1) (Framebuffer.pixel_read 5 5 fb);
+  assert_equal ~msg:"dirty bit should be set after render (line)" true (Framebuffer.is_dirty fb)
 
 (* Pixel *)
-
 let test_draw_pixel_with_primitive _ =
-  let fb = Framebuffer.init (10, 10) (fun _ _ -> 0) in
-  assert_equal ~msg:"before" (Some 0) (Framebuffer.pixel_read 5 5 fb);
+  let fb = prepare_fb (10, 10) (fun _ _ -> 0) in
+  assert_equal ~msg:"before (pixel prim)" (Some 0) (Framebuffer.pixel_read 5 5 fb);
   let prim = Primitives.Pixel ({x = 5; y = 5}, 1) in
   Framebuffer.render fb [prim];
-  assert_equal ~msg:"after" (Some 1) (Framebuffer.pixel_read 5 5 fb)
+  assert_equal ~msg:"after (pixel prim)" (Some 1) (Framebuffer.pixel_read 5 5 fb);
+  assert_equal ~msg:"dirty bit should be set after render (pixel)" true (Framebuffer.is_dirty fb)
 
 (* Circle *)
-
 let test_draw_circle_direct _ =
-  let fb = Framebuffer.init (10, 10) (fun _ _ -> 0) in
-  assert_equal ~msg:"before" (Some 0) (Framebuffer.pixel_read 5 5 fb);
+  let fb = prepare_fb (10, 10) (fun _ _ -> 0) in
+  assert_equal ~msg:"before (circle direct)" (Some 0) (Framebuffer.pixel_read 5 5 fb);
   Framebuffer.draw_circle 5 5 2.0 1 fb;
-  assert_equal ~msg:"after center" (Some 0) (Framebuffer.pixel_read 5 5 fb);
-  assert_equal ~msg:"after edge" (Some 1) (Framebuffer.pixel_read 5 7 fb)
+  assert_equal ~msg:"after center (circle direct)" (Some 0) (Framebuffer.pixel_read 5 5 fb);
+  assert_equal ~msg:"after edge (circle direct)" (Some 1) (Framebuffer.pixel_read 5 7 fb);
+  assert_equal ~msg:"dirty bit should be set after draw_circle" true (Framebuffer.is_dirty fb)
 
 let test_draw_circle_direct_off_framebuffer _ =
-  let fb = Framebuffer.init (10, 10) (fun _ _ -> 0) in
-  assert_equal ~msg:"before" (Some 0) (Framebuffer.pixel_read 5 5 fb);
+  let fb = prepare_fb (10, 10) (fun _ _ -> 0) in
+  assert_equal ~msg:"before (circle off)" (Some 0) (Framebuffer.pixel_read 5 5 fb);
   Framebuffer.draw_circle (-1) (-1) 3.0 1 fb;
-  assert_equal ~msg:"after" (Some 1) (Framebuffer.pixel_read 1 1 fb)
+  assert_equal ~msg:"after (circle off)" (Some 1) (Framebuffer.pixel_read 1 1 fb);
+  assert_equal ~msg:"dirty bit should be set after draw_circle (off)" true (Framebuffer.is_dirty fb)
 
 let test_draw_circle_with_primitive _ =
-  let fb = Framebuffer.init (10, 10) (fun _ _ -> 0) in
-  assert_equal ~msg:"before" (Some 0) (Framebuffer.pixel_read 5 5 fb);
+  let fb = prepare_fb (10, 10) (fun _ _ -> 0) in
+  assert_equal ~msg:"before (circle prim)" (Some 0) (Framebuffer.pixel_read 5 5 fb);
   let prim = Primitives.Circle ({x = 5; y = 5}, 2.0, 1) in
   Framebuffer.render fb [prim];
-  assert_equal ~msg:"after center" (Some 0) (Framebuffer.pixel_read 5 5 fb);
-  assert_equal ~msg:"after edge" (Some 1) (Framebuffer.pixel_read 5 7 fb)
+  assert_equal ~msg:"after center (circle prim)" (Some 0) (Framebuffer.pixel_read 5 5 fb);
+  assert_equal ~msg:"after edge (circle prim)" (Some 1) (Framebuffer.pixel_read 5 7 fb);
+  assert_equal ~msg:"dirty bit should be set after render (circle)" true (Framebuffer.is_dirty fb)
 
 (* Filled circle *)
 
 let test_draw_filled_circle_direct _ =
-  let fb = Framebuffer.init (10, 10) (fun _ _ -> 0) in
-  assert_equal ~msg:"before" (Some 0) (Framebuffer.pixel_read 5 5 fb);
+  let fb = prepare_fb (10, 10) (fun _ _ -> 0) in
+  assert_equal ~msg:"before (filled circle direct)" (Some 0) (Framebuffer.pixel_read 5 5 fb);
   Framebuffer.filled_circle 5 5 2.0 1 fb;
-  assert_equal ~msg:"after center" (Some 1) (Framebuffer.pixel_read 5 5 fb);
-  assert_equal ~msg:"after edge" (Some 1) (Framebuffer.pixel_read 5 7 fb)
+  assert_equal ~msg:"after center (filled circle direct)" (Some 1) (Framebuffer.pixel_read 5 5 fb);
+  assert_equal ~msg:"after edge (filled circle direct)" (Some 1) (Framebuffer.pixel_read 5 7 fb);
+  assert_equal ~msg:"dirty bit should be set after filled_circle" true (Framebuffer.is_dirty fb)
 
 let test_draw_filled_circle_direct_off_framebuffer _ =
-  let fb = Framebuffer.init (10, 10) (fun _ _ -> 0) in
-  assert_equal ~msg:"before" (Some 0) (Framebuffer.pixel_read 5 5 fb);
-  Framebuffer.draw_circle (-1) (-1) 3.0 1 fb;
-  assert_equal ~msg:"after" (Some 1) (Framebuffer.pixel_read 1 1 fb)
+  let fb = prepare_fb (10, 10) (fun _ _ -> 0) in
+  assert_equal ~msg:"before (filled circle off)" (Some 0) (Framebuffer.pixel_read 5 5 fb);
+  Framebuffer.filled_circle (-1) (-1) 3.0 1 fb;
+  assert_equal ~msg:"after (filled circle off)" (Some 1) (Framebuffer.pixel_read 1 1 fb);
+  assert_equal ~msg:"dirty bit should be set after filled_circle (off)" true (Framebuffer.is_dirty fb)
 
 let test_draw_filled_circle_with_primitive _ =
-  let fb = Framebuffer.init (10, 10) (fun _ _ -> 0) in
-  assert_equal ~msg:"before" (Some 0) (Framebuffer.pixel_read 5 5 fb);
+  let fb = prepare_fb (10, 10) (fun _ _ -> 0) in
+  assert_equal ~msg:"before (filled circle prim)" (Some 0) (Framebuffer.pixel_read 5 5 fb);
   let prim = Primitives.FilledCircle ({x = 5; y = 5}, 2.0, 1) in
   Framebuffer.render fb [prim];
-  assert_equal ~msg:"after center" (Some 1) (Framebuffer.pixel_read 5 5 fb);
-  assert_equal ~msg:"after edge" (Some 1) (Framebuffer.pixel_read 5 7 fb)
+  assert_equal ~msg:"after center (filled circle prim)" (Some 1) (Framebuffer.pixel_read 5 5 fb);
+  assert_equal ~msg:"after edge (filled circle prim)" (Some 1) (Framebuffer.pixel_read 5 7 fb);
+  assert_equal ~msg:"dirty bit should be set after render (filled circle)" true (Framebuffer.is_dirty fb)
 
 (* Rect *)
 
 let test_draw_rect_direct _ =
-  let fb = Framebuffer.init (10, 10) (fun _ _ -> 0) in
-  assert_equal ~msg:"before" (Some 0) (Framebuffer.pixel_read 5 5 fb);
-  assert_equal ~msg:"before" (Some 0) (Framebuffer.pixel_read 7 7 fb);
+  let fb = prepare_fb (10, 10) (fun _ _ -> 0) in
+  assert_equal ~msg:"before (rect direct)" (Some 0) (Framebuffer.pixel_read 5 5 fb);
+  assert_equal ~msg:"before (rect direct)" (Some 0) (Framebuffer.pixel_read 7 7 fb);
   Framebuffer.draw_rect 3 3 3 3 1 fb;
-  assert_equal ~msg:"after center" (Some 0) (Framebuffer.pixel_read 5 5 fb);
-  assert_equal ~msg:"after edge" (Some 1) (Framebuffer.pixel_read 6 6 fb)
+  assert_equal ~msg:"after center (rect direct)" (Some 0) (Framebuffer.pixel_read 5 5 fb);
+  assert_equal ~msg:"after edge (rect direct)" (Some 1) (Framebuffer.pixel_read 6 6 fb);
+  assert_equal ~msg:"dirty bit should be set after draw_rect" true (Framebuffer.is_dirty fb)
 
 let test_draw_rect_direct_off_framebuffer _ =
-  let fb = Framebuffer.init (10, 10) (fun _ _ -> 0) in
-  assert_equal ~msg:"before" (Some 0) (Framebuffer.pixel_read 5 5 fb);
+  let fb = prepare_fb (10, 10) (fun _ _ -> 0) in
+  assert_equal ~msg:"before (rect off)" (Some 0) (Framebuffer.pixel_read 5 5 fb);
   Framebuffer.draw_rect (-1) (-1) 3 3 1 fb;
-  assert_equal ~msg:"after" (Some 1) (Framebuffer.pixel_read 2 2 fb)
+  assert_equal ~msg:"after (rect off)" (Some 1) (Framebuffer.pixel_read 2 2 fb);
+  assert_equal ~msg:"dirty bit should be set after draw_rect (off)" true (Framebuffer.is_dirty fb)
 
 let test_draw_rect_with_primitive _ =
-  let fb = Framebuffer.init (10, 10) (fun _ _ -> 0) in
-  assert_equal ~msg:"before" (Some 0) (Framebuffer.pixel_read 5 5 fb);
-  assert_equal ~msg:"before" (Some 0) (Framebuffer.pixel_read 7 7 fb);
+  let fb = prepare_fb (10, 10) (fun _ _ -> 0) in
+  assert_equal ~msg:"before (rect prim)" (Some 0) (Framebuffer.pixel_read 5 5 fb);
+  assert_equal ~msg:"before (rect prim)" (Some 0) (Framebuffer.pixel_read 7 7 fb);
   let prim = Primitives.Rect ({x = 3; y = 3}, {x = 6; y = 6}, 1) in
   Framebuffer.render fb [prim];
-  assert_equal ~msg:"after center" (Some 0) (Framebuffer.pixel_read 5 5 fb);
-  assert_equal ~msg:"after edge" (Some 1) (Framebuffer.pixel_read 6 6 fb)
+  assert_equal ~msg:"after center (rect prim)" (Some 0) (Framebuffer.pixel_read 5 5 fb);
+  assert_equal ~msg:"after edge (rect prim)" (Some 1) (Framebuffer.pixel_read 6 6 fb);
+  assert_equal ~msg:"dirty bit should be set after render (rect)" true (Framebuffer.is_dirty fb)
 
 (* Filled rect *)
 
 let test_draw_filled_rect_direct _ =
-  let fb = Framebuffer.init (10, 10) (fun _ _ -> 0) in
-  assert_equal ~msg:"before" (Some 0) (Framebuffer.pixel_read 5 5 fb);
-  assert_equal ~msg:"before" (Some 0) (Framebuffer.pixel_read 7 7 fb);
+  let fb = prepare_fb (10, 10) (fun _ _ -> 0) in
+  assert_equal ~msg:"before (filled rect direct)" (Some 0) (Framebuffer.pixel_read 5 5 fb);
+  assert_equal ~msg:"before (filled rect direct)" (Some 0) (Framebuffer.pixel_read 7 7 fb);
   Framebuffer.filled_rect 3 3 3 3 1 fb;
-  assert_equal ~msg:"after center" (Some 1) (Framebuffer.pixel_read 5 5 fb);
-  assert_equal ~msg:"after edge" (Some 1) (Framebuffer.pixel_read 6 6 fb)
+  assert_equal ~msg:"after center (filled rect direct)" (Some 1) (Framebuffer.pixel_read 5 5 fb);
+  assert_equal ~msg:"after edge (filled rect direct)" (Some 1) (Framebuffer.pixel_read 6 6 fb);
+  assert_equal ~msg:"dirty bit should be set after filled_rect" true (Framebuffer.is_dirty fb)
 
 let test_draw_filled_rect_direct_off_framebuffer _ =
-  let fb = Framebuffer.init (10, 10) (fun _ _ -> 0) in
-  assert_equal ~msg:"before" (Some 0) (Framebuffer.pixel_read 5 5 fb);
+  let fb = prepare_fb (10, 10) (fun _ _ -> 0) in
+  assert_equal ~msg:"before (filled rect off)" (Some 0) (Framebuffer.pixel_read 5 5 fb);
   Framebuffer.filled_rect (-1) (-1) 3 3 1 fb;
-  assert_equal ~msg:"after" (Some 1) (Framebuffer.pixel_read 2 2 fb)
+  assert_equal ~msg:"after (filled rect off)" (Some 1) (Framebuffer.pixel_read 2 2 fb);
+  assert_equal ~msg:"dirty bit should be set after filled_rect (off)" true (Framebuffer.is_dirty fb)
 
 let test_draw_filled_rect_with_primitive _ =
-  let fb = Framebuffer.init (10, 10) (fun _ _ -> 0) in
-  assert_equal ~msg:"before" (Some 0) (Framebuffer.pixel_read 5 5 fb);
-  assert_equal ~msg:"before" (Some 0) (Framebuffer.pixel_read 7 7 fb);
+  let fb = prepare_fb (10, 10) (fun _ _ -> 0) in
+  assert_equal ~msg:"before (filled rect prim)" (Some 0) (Framebuffer.pixel_read 5 5 fb);
+  assert_equal ~msg:"before (filled rect prim)" (Some 0) (Framebuffer.pixel_read 7 7 fb);
   let prim = Primitives.FilledRect ({x = 3; y = 3}, {x = 6; y = 6}, 1) in
   Framebuffer.render fb [prim];
-  assert_equal ~msg:"after center" (Some 1) (Framebuffer.pixel_read 5 5 fb);
-  assert_equal ~msg:"after edge" (Some 1) (Framebuffer.pixel_read 6 6 fb)
+  assert_equal ~msg:"after center (filled rect prim)" (Some 1) (Framebuffer.pixel_read 5 5 fb);
+  assert_equal ~msg:"after edge (filled rect prim)" (Some 1) (Framebuffer.pixel_read 6 6 fb);
+  assert_equal ~msg:"dirty bit should be set after render (filled rect)" true (Framebuffer.is_dirty fb)
 
 (* Triangle *)
 
 let test_draw_triangle_direct _ =
-  let fb = Framebuffer.init (10, 10) (fun _ _ -> 0) in
-  assert_equal ~msg:"before" (Some 0) (Framebuffer.pixel_read 5 5 fb);
-  assert_equal ~msg:"before" (Some 0) (Framebuffer.pixel_read 7 7 fb);
+  let fb = prepare_fb (10, 10) (fun _ _ -> 0) in
+  assert_equal ~msg:"before (triangle direct)" (Some 0) (Framebuffer.pixel_read 5 5 fb);
+  assert_equal ~msg:"before (triangle direct)" (Some 0) (Framebuffer.pixel_read 7 7 fb);
   Framebuffer.draw_triangle 3 3 5 8 8 3 1 fb;
-  assert_equal ~msg:"after center" (Some 0) (Framebuffer.pixel_read 5 5 fb);
-  assert_equal ~msg:"after edge" (Some 1) (Framebuffer.pixel_read 5 3 fb)
+  assert_equal ~msg:"after (triangle direct)" (Some 0) (Framebuffer.pixel_read 5 5 fb);
+  assert_equal ~msg:"after (triangle direct)" (Some 1) (Framebuffer.pixel_read 5 3 fb);
+  assert_equal ~msg:"dirty bit should be set after draw_triangle" true (Framebuffer.is_dirty fb)
 
 let test_draw_triangle_direct_off_framebuffer _ =
-  let fb = Framebuffer.init (10, 10) (fun _ _ -> 0) in
-  assert_equal ~msg:"before" (Some 0) (Framebuffer.pixel_read 5 5 fb);
+  let fb = prepare_fb (10, 10) (fun _ _ -> 0) in
+  assert_equal ~msg:"before (triangle off)" (Some 0) (Framebuffer.pixel_read 5 5 fb);
   Framebuffer.draw_triangle (-1) (-1) 3 3 (-5) 3 1 fb;
-  assert_equal ~msg:"after" (Some 1) (Framebuffer.pixel_read 2 2 fb)
+  assert_equal ~msg:"after (triangle off)" (Some 1) (Framebuffer.pixel_read 2 2 fb);
+  assert_equal ~msg:"dirty bit should be set after draw_triangle (off)" true (Framebuffer.is_dirty fb)
 
 let test_draw_triangle_with_primitive _ =
-  let fb = Framebuffer.init (10, 10) (fun _ _ -> 0) in
-  assert_equal ~msg:"before" (Some 0) (Framebuffer.pixel_read 5 5 fb);
-  assert_equal ~msg:"before" (Some 0) (Framebuffer.pixel_read 7 7 fb);
+  let fb = prepare_fb (10, 10) (fun _ _ -> 0) in
+  assert_equal ~msg:"before (triangle prim)" (Some 0) (Framebuffer.pixel_read 5 5 fb);
+  assert_equal ~msg:"before (triangle prim)" (Some 0) (Framebuffer.pixel_read 7 7 fb);
   let prim = Primitives.Triangle ({x = 3; y = 3}, {x = 5; y = 8}, {x = 8; y = 3}, 1) in
   Framebuffer.render fb [prim];
-  assert_equal ~msg:"after center" (Some 0) (Framebuffer.pixel_read 5 5 fb);
-  assert_equal ~msg:"after edge" (Some 1) (Framebuffer.pixel_read 5 3 fb)
+  assert_equal ~msg:"after (triangle prim)" (Some 0) (Framebuffer.pixel_read 5 5 fb);
+  assert_equal ~msg:"after (triangle prim)" (Some 1) (Framebuffer.pixel_read 5 3 fb);
+  assert_equal ~msg:"dirty bit should be set after render (triangle)" true (Framebuffer.is_dirty fb)
 
 (* Filled triangle *)
 
 let test_draw_filled_triangle_direct _ =
-  let fb = Framebuffer.init (10, 10) (fun _ _ -> 0) in
-  assert_equal ~msg:"before" (Some 0) (Framebuffer.pixel_read 5 5 fb);
-  assert_equal ~msg:"before" (Some 0) (Framebuffer.pixel_read 7 7 fb);
+  let fb = prepare_fb (10, 10) (fun _ _ -> 0) in
+  assert_equal ~msg:"before (filled triangle direct)" (Some 0) (Framebuffer.pixel_read 5 5 fb);
+  assert_equal ~msg:"before (filled triangle direct)" (Some 0) (Framebuffer.pixel_read 7 7 fb);
   Framebuffer.filled_triangle 3 3 5 8 8 3 1 fb;
-  assert_equal ~msg:"after center" (Some 1) (Framebuffer.pixel_read 5 5 fb);
-  assert_equal ~msg:"after edge" (Some 1) (Framebuffer.pixel_read 5 3 fb)
+  assert_equal ~msg:"after (filled triangle direct)" (Some 1) (Framebuffer.pixel_read 5 5 fb);
+  assert_equal ~msg:"after (filled triangle direct)" (Some 1) (Framebuffer.pixel_read 5 3 fb);
+  assert_equal ~msg:"dirty bit should be set after filled_triangle" true (Framebuffer.is_dirty fb)
 
 let test_draw_filled_triangle_direct_off_framebuffer _ =
-  let fb = Framebuffer.init (10, 10) (fun _ _ -> 0) in
-  assert_equal ~msg:"before" (Some 0) (Framebuffer.pixel_read 5 5 fb);
+  let fb = prepare_fb (10, 10) (fun _ _ -> 0) in
+  assert_equal ~msg:"before (filled triangle off)" (Some 0) (Framebuffer.pixel_read 5 5 fb);
   Framebuffer.filled_triangle (-1) (-1) 3 3 (-5) 3 1 fb;
-  assert_equal ~msg:"after" (Some 1) (Framebuffer.pixel_read 2 2 fb)
+  assert_equal ~msg:"after (filled triangle off)" (Some 1) (Framebuffer.pixel_read 2 2 fb);
+  assert_equal ~msg:"dirty bit should be set after filled_triangle (off)" true (Framebuffer.is_dirty fb)
 
 let test_draw_filled_triangle_with_primitive _ =
-  let fb = Framebuffer.init (10, 10) (fun _ _ -> 0) in
-  assert_equal ~msg:"before" (Some 0) (Framebuffer.pixel_read 5 5 fb);
-  assert_equal ~msg:"before" (Some 0) (Framebuffer.pixel_read 7 7 fb);
+  let fb = prepare_fb (10, 10) (fun _ _ -> 0) in
+  assert_equal ~msg:"before (filled triangle prim)" (Some 0) (Framebuffer.pixel_read 5 5 fb);
+  assert_equal ~msg:"before (filled triangle prim)" (Some 0) (Framebuffer.pixel_read 7 7 fb);
   let prim = Primitives.FilledTriangle ({x = 3; y = 3}, {x = 5; y = 8}, {x = 8; y = 3}, 1) in
   Framebuffer.render fb [prim];
-  assert_equal ~msg:"after center" (Some 1) (Framebuffer.pixel_read 5 5 fb);
-  assert_equal ~msg:"after edge" (Some 1) (Framebuffer.pixel_read 5 3 fb)
+  assert_equal ~msg:"after (filled triangle prim)" (Some 1) (Framebuffer.pixel_read 5 5 fb);
+  assert_equal ~msg:"after (filled triangle prim)" (Some 1) (Framebuffer.pixel_read 5 3 fb);
+  assert_equal ~msg:"dirty bit should be set after render (filled triangle)" true (Framebuffer.is_dirty fb)
 
 (* Polygon *)
-
 let test_draw_polygon_direct _ =
-  let fb = Framebuffer.init (10, 10) (fun _ _ -> 0) in
-  assert_equal ~msg:"before" (Some 0) (Framebuffer.pixel_read 5 5 fb);
-  assert_equal ~msg:"before" (Some 0) (Framebuffer.pixel_read 7 7 fb);
+  let fb = prepare_fb (10, 10) (fun _ _ -> 0) in
+  assert_equal ~msg:"before (polygon direct)" (Some 0) (Framebuffer.pixel_read 5 5 fb);
+  assert_equal ~msg:"before (polygon direct)" (Some 0) (Framebuffer.pixel_read 7 7 fb);
   Framebuffer.draw_polygon [(3, 3) ; (5, 8) ; (8, 3)] 1 fb;
-  assert_equal ~msg:"after center" (Some 0) (Framebuffer.pixel_read 5 5 fb);
-  assert_equal ~msg:"after edge" (Some 1) (Framebuffer.pixel_read 5 3 fb)
+  assert_equal ~msg:"after (polygon direct)" (Some 0) (Framebuffer.pixel_read 5 5 fb);
+  assert_equal ~msg:"after (polygon direct)" (Some 1) (Framebuffer.pixel_read 5 3 fb);
+  assert_equal ~msg:"dirty bit should be set after draw_polygon" true (Framebuffer.is_dirty fb)
 
 let test_draw_polygon_direct_off_framebuffer _ =
-  let fb = Framebuffer.init (10, 10) (fun _ _ -> 0) in
-  assert_equal ~msg:"before" (Some 0) (Framebuffer.pixel_read 5 5 fb);
+  let fb = prepare_fb (10, 10) (fun _ _ -> 0) in
+  assert_equal ~msg:"before (polygon off)" (Some 0) (Framebuffer.pixel_read 5 5 fb);
   Framebuffer.draw_polygon [(-1, -1) ; (3, 3) ; (-5, 3)] 1 fb;
-  assert_equal ~msg:"after" (Some 1) (Framebuffer.pixel_read 2 2 fb)
+  assert_equal ~msg:"after (polygon off)" (Some 1) (Framebuffer.pixel_read 2 2 fb);
+  assert_equal ~msg:"dirty bit should be set after draw_polygon (off)" true (Framebuffer.is_dirty fb)
 
 let test_draw_polygon_with_primitive _ =
-  let fb = Framebuffer.init (10, 10) (fun _ _ -> 0) in
-  assert_equal ~msg:"before" (Some 0) (Framebuffer.pixel_read 5 5 fb);
-  assert_equal ~msg:"before" (Some 0) (Framebuffer.pixel_read 7 7 fb);
+  let fb = prepare_fb (10, 10) (fun _ _ -> 0) in
+  assert_equal ~msg:"before (polygon prim)" (Some 0) (Framebuffer.pixel_read 5 5 fb);
+  assert_equal ~msg:"before (polygon prim)" (Some 0) (Framebuffer.pixel_read 7 7 fb);
   let prim = Primitives.Polygon ([{x = 3; y = 3} ; {x = 5; y = 8} ; {x = 8; y = 3}], 1) in
   Framebuffer.render fb [prim];
-  assert_equal ~msg:"after center" (Some 0) (Framebuffer.pixel_read 5 5 fb);
-  assert_equal ~msg:"after edge" (Some 1) (Framebuffer.pixel_read 5 3 fb)
+  assert_equal ~msg:"after (polygon prim)" (Some 0) (Framebuffer.pixel_read 5 5 fb);
+  assert_equal ~msg:"after (polygon prim)" (Some 1) (Framebuffer.pixel_read 5 3 fb);
+  assert_equal ~msg:"dirty bit should be set after render (polygon)" true (Framebuffer.is_dirty fb)
 
 (* Filled polygon *)
 
 let test_draw_filled_polygon_direct _ =
-  let fb = Framebuffer.init (10, 10) (fun _ _ -> 0) in
-  assert_equal ~msg:"before" (Some 0) (Framebuffer.pixel_read 5 5 fb);
-  assert_equal ~msg:"before" (Some 0) (Framebuffer.pixel_read 7 7 fb);
-  Framebuffer.filled_polygon [(3, 3) ; (5, 8) ; (8, 3)] 1 fb;
-  assert_equal ~msg:"after center" (Some 1) (Framebuffer.pixel_read 5 5 fb);
-  assert_equal ~msg:"after edge" (Some 1) (Framebuffer.pixel_read 5 3 fb)
+  let fb = prepare_fb (10, 10) (fun _ _ -> 0) in
+  assert_equal ~msg:"before (filled polygon direct)" (Some 0) (Framebuffer.pixel_read 5 5 fb);
+  assert_equal ~msg:"before (filled polygon direct)" (Some 0) (Framebuffer.pixel_read 7 7 fb);
+  Framebuffer.filled_polygon [(3, 3); (5, 8); (8, 3)] 1 fb;
+  assert_equal ~msg:"after (filled polygon direct)" (Some 1) (Framebuffer.pixel_read 5 5 fb);
+  assert_equal ~msg:"after (filled polygon direct)" (Some 1) (Framebuffer.pixel_read 5 3 fb);
+  assert_equal ~msg:"dirty bit should be set after filled_polygon" true (Framebuffer.is_dirty fb)
 
 let test_draw_filled_polygon_direct_off_framebuffer _ =
-  let fb = Framebuffer.init (10, 10) (fun _ _ -> 0) in
-  assert_equal ~msg:"before" (Some 0) (Framebuffer.pixel_read 5 5 fb);
+  let fb = prepare_fb (10, 10) (fun _ _ -> 0) in
+  assert_equal ~msg:"before (filled polygon off)" (Some 0) (Framebuffer.pixel_read 5 5 fb);
   Framebuffer.filled_polygon [(-1, -1) ; (3, 3) ; (-5, 3)] 1 fb;
-  assert_equal ~msg:"after" (Some 1) (Framebuffer.pixel_read 2 2 fb)
+  assert_equal ~msg:"after (filled polygon off)" (Some 1) (Framebuffer.pixel_read 2 2 fb);
+  assert_equal ~msg:"dirty bit should be set after filled_polygon (off)" true (Framebuffer.is_dirty fb)
 
 let test_draw_filled_polygon_with_primitive _ =
-  let fb = Framebuffer.init (10, 10) (fun _ _ -> 0) in
-  assert_equal ~msg:"before" (Some 0) (Framebuffer.pixel_read 5 5 fb);
-  assert_equal ~msg:"before" (Some 0) (Framebuffer.pixel_read 7 7 fb);
+  let fb = prepare_fb (10, 10) (fun _ _ -> 0) in
+  assert_equal ~msg:"before (filled polygon prim)" (Some 0) (Framebuffer.pixel_read 5 5 fb);
+  assert_equal ~msg:"before (filled polygon prim)" (Some 0) (Framebuffer.pixel_read 7 7 fb);
   let prim = Primitives.FilledPolygon ([{x = 3; y = 3} ; {x = 5; y = 8} ; {x = 8; y = 3}], 1) in
   Framebuffer.render fb [prim];
-  assert_equal ~msg:"after center" (Some 1) (Framebuffer.pixel_read 5 5 fb);
-  assert_equal ~msg:"after edge" (Some 1) (Framebuffer.pixel_read 5 3 fb)
+  assert_equal ~msg:"after (filled polygon prim)" (Some 1) (Framebuffer.pixel_read 5 5 fb);
+  assert_equal ~msg:"after (filled polygon prim)" (Some 1) (Framebuffer.pixel_read 5 3 fb);
+  assert_equal ~msg:"dirty bit should be set after render (filled polygon)" true (Framebuffer.is_dirty fb)
+
+(* ----- Dirty Bit Test For All Primitives ----- *)
+let test_dirty_bit_render_primitive _ =
+  let primitives = [
+    Primitives.Pixel ({x = 5; y = 5}, 1);
+    Primitives.Line ({x = 2; y = 2}, {x = 5; y = 5}, 1);
+    Primitives.Circle ({x = 5; y = 5}, 4.0, 1);
+    Primitives.FilledCircle ({x = 5; y = 5}, 4.0, 1);
+    Primitives.Rect ({x = 3; y = 3}, {x = 6; y = 6}, 1);
+    Primitives.FilledRect ({x = 3; y = 3}, {x = 6; y = 6}, 1);
+    Primitives.Triangle ({x = 3; y = 3}, {x = 5; y = 8}, {x = 8; y = 3}, 1);
+    Primitives.FilledTriangle ({x = 3; y = 3}, {x = 5; y = 8}, {x = 8; y = 3}, 1);
+    Primitives.Polygon ([{x = 3; y = 3}; {x = 5; y = 8}; {x = 8; y = 3}], 1);
+    Primitives.FilledPolygon ([{x = 3; y = 3}; {x = 5; y = 8}; {x = 8; y = 3}], 1)
+  ] in
+  List.iter (fun prim ->
+    let fb = prepare_fb (10, 10) (fun _ _ -> 0) in
+    Framebuffer.render fb [prim];
+    assert_equal ~msg:"dirty bit should be set after render (all prim)" true (Framebuffer.is_dirty fb)
+  ) primitives
+
+(* ----- Map Functions Tests ----- *)
+let test_map _ =
+  let fb = prepare_fb (10, 10) (fun _ _ -> 1) in
+  let fb2 = Framebuffer.map (fun x -> x + 1) fb in
+  assert_equal ~msg:"map should increase pixel value" (Some 2) (Framebuffer.pixel_read 5 5 fb2);
+  assert_equal ~msg:"dirty bit should be set in new framebuffer from map" true (Framebuffer.is_dirty fb2)
+
+let test_mapi _ =
+  let fb = prepare_fb (10, 10) (fun x y -> x + y) in
+  let fb2 = Framebuffer.mapi (fun x y _ -> x * y) fb in
+  assert_equal ~msg:"mapi should compute product" (Some 12) (Framebuffer.pixel_read 3 4 fb2);
+  assert_equal ~msg:"dirty bit should be set in new framebuffer from mapi" true (Framebuffer.is_dirty fb2)
+
+let test_map_inplace _ =
+  let fb = prepare_fb (10, 10) (fun _ _ -> 1) in
+  Framebuffer.map_inplace (fun x -> x * 2) fb;
+  assert_equal ~msg:"map_inplace should double pixel" (Some 2) (Framebuffer.pixel_read 5 5 fb);
+  assert_equal ~msg:"dirty bit should be set after map_inplace" true (Framebuffer.is_dirty fb)
+
+let test_mapi_inplace _ =
+  let fb = prepare_fb (10, 10) (fun x y -> x + y) in
+  Framebuffer.mapi_inplace (fun x y _ -> x * y) fb;
+  assert_equal ~msg:"mapi_inplace should compute product" (Some 12) (Framebuffer.pixel_read 3 4 fb);
+  assert_equal ~msg:"dirty bit should be set after mapi_inplace" true (Framebuffer.is_dirty fb)
 
 let suite =
   "Primitives tests" >::: [
@@ -257,7 +335,12 @@ let suite =
     "Test filled polygon direct" >:: test_draw_filled_polygon_direct ;
     "Test filled polygon direct off framebuffer" >:: test_draw_filled_polygon_direct_off_framebuffer ;
     "Test filled polygon with primative" >:: test_draw_filled_polygon_with_primitive ;
+    "Test dirty bit render primitive (all prim)" >:: test_dirty_bit_render_primitive;
+    "Test map" >:: test_map ;
+    "Test mapi" >:: test_mapi ;
+    "Test map_inplace" >:: test_map_inplace ;
+    "Test mapi_inplace" >:: test_mapi_inplace ;
   ]
 
 let () =
-  run_test_tt_main suite
+  run_test_tt_main suite
\ No newline at end of file


Reviews:
user: login: mdales
body: Well done, you've taken what is quite a challenging ticket and got the method down to what I think is the right approach. I've made a lot of comments, but please don't despair! Most of them are the same, around where I think the dirty bit is set a bit eagerly.

That does make me think that perhaps the problem is that we should *always* use `pixel_write` to update the buffer, as that would have made your job a lot easier here. If I'd done that originally, you'd only need to set the dirty_bit there (and in the map functions), but not in every circle/line/char function. That would be one approach to fix things, but I think just rationalising what you have is also fine as per the comments. 

The other missing thing are test cases - we definitely need to have some test cases around ensuring the bit is set and cleared using the accessor functions you added, and we should modify the existing drawing tests also to check the bit has been set.

Code reviews can be frustrating, but I want to be clear I'm very pleased with how this is going: you've taken on what was probably the most challenging of the beginner tickets and come out on the other side with a working approach! That's super good - we now just need to do the tidying part and we can get this in!
state: CHANGES_REQUESTED
author_association: COLLABORATOR
submitted_at: 2025-03-24T08:50:57Z
user: login: mdales
body: Hi there. Apologies, but it took me half an hour to read through the first half of the file, just checking each line didn't have an accidental change on it so can you please simplify this PR just to have the bits you changed in it? In particular automated checks failed due to some of the changes.

I'm keen to get your work in, as the core change here is a good one, but it's causing me a lot of work having to read format changes, and I've not yet got to reviewing the logic of the change, just all these extra lines changed. Again, you had the best intent, but in big software projects that causes a lot of work.

It's also worth noting that this PR will conflict with #42 - and the formatting changes will make the job of updating that PR harder also.

Very sorry to have to push this back, but given how many PRs there are, I need to do so. 
state: CHANGES_REQUESTED
author_association: COLLABORATOR
submitted_at: 2025-03-25T14:59:22Z
user: login: mdales
body: Okay, so the unrelated code changes have gone, thank you. 🙏

But I think some of the good bits of you PR also got lost: you'd moved to pixel_write in most places, and we're back to direct pixel writes - I highlighted a couple of places. The strategy you had was correct:

* Where we set a pixel using `buffer.(y).(x) <- value` we should move to `pixel_write` so we don't need to add a dirty bit set
* Where it's more complicated (like you've done in `map`) we need to set the dirty bit 
* We need to check every method that draws does one or the above (first is better, but otherwise use second)

For example, `filled_polygon` is still missing that flag.

For the tests, I realise that both `test_primatives.ml` might be a better place to test this flag is set.

I'm sorry this PR must feel like it's dragging on, but you have it all there, we just need to push through! I really hope you can, as I'm keen to get this fix in!
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-26T19:13:57Z
user: login: mdales
body: Thank you! I appreciate this was a tough ticket, but thanks for sticking with it. Having good test coverage is super important as we have more people working on the project. And this is a great change to have in, so thank you very much!
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-03-29T13:14:57Z

Review comments:
diff_hunk: @@ -102,6 +106,7 @@ let draw_line (x0 : int) (y0 : int) (x1 : int) (y1 : int) (col : int) (buffer :
   in loop x0 y0 initial_error
 
 let draw_polygon (points : (int * int) list) (col : int) (buffer : t) =
+  buffer.dirty <- true;
path: src/framebuffer.ml
user: login: mdales
body: This is too soon to decide in `draw_polygon`, as if the point list is empty we don't modify the buffer. In this instance this function doesn't modify the buffer directly, but calls draw_line, so I'd let that make the buffer as dirty.

Or put another way, only mark the buffer dirty when you know you differently change the contents: even when we call draw_line, draw_line might also decide not to do any work.
created_at: 2025-03-24T08:24:22Z
author_association: COLLABORATOR
diff_hunk: @@ -197,6 +201,7 @@ let rightmost span =
   | Pair (_, x1) -> x1
 
 let filled_triangle (x0 : int) (y0 : int) (x1 : int) (y1 : int) (x2 : int) (y2 : int) (col : int) (buffer : t) =
+  buffer.dirty <- true;
path: src/framebuffer.ml
user: login: mdales
body: Again, this feels too early: What if the triangle was too small to draw? Currently looking at the code I suspect we always draw one pixel if the triangle is implossibly small, but if we ever fixed that, then I imagine we'd get into trouble. I'd feel much better if this change was next to the lines that actually update the buffer.
created_at: 2025-03-24T08:27:46Z
author_association: COLLABORATOR
diff_hunk: @@ -354,6 +359,7 @@ let interpolate_strand (strand : ((int * int) * (int * int)) list) : (int * span
   )
 
 let filled_polygon (points : (int * int) list) (col : int) (buffer : t) =
+  buffer.dirty <- true;
path: src/framebuffer.ml
user: login: mdales
body: If there are no points we don't update the buffer.
created_at: 2025-03-24T08:28:16Z
author_association: COLLABORATOR
diff_hunk: @@ -419,6 +425,7 @@ let filled_polygon (points : (int * int) list) (col : int) (buffer : t) =
 (* ----- *)
 
 let draw_char (x : int) (y : int) (f : Font.t) (c : char) (col : int) (buffer : t) : int =
+  buffer.dirty <- true;
path: src/framebuffer.ml
user: login: mdales
body: Not necessary here I think, as this calls `pixel_write` and that should set the buffer.
created_at: 2025-03-24T08:29:00Z
author_association: COLLABORATOR
diff_hunk: @@ -445,6 +452,7 @@ let draw_char (x : int) (y : int) (f : Font.t) (c : char) (col : int) (buffer :
   )
 
 let draw_string (x : int) (y : int) (f : Font.t) (s : string) (col : int) (buffer : t) =
+  buffer.dirty <- true;
path: src/framebuffer.ml
user: login: mdales
body: This doesn't modify the buffer, but calls something that does, so let that do the work.
created_at: 2025-03-24T08:30:01Z
author_association: COLLABORATOR
diff_hunk: @@ -159,3 +158,9 @@ val pixel_read: int -> int -> t -> int option
 val to_array: t -> int array array
 (** [to_array framebuffer] converts the framebuffer into a 2D array. The top level array is an array
     of rows, and each row is an array of palette entry colours. *)
+
+(** {2 Dirty-bit support} *)
+
+val is_dirty: t -> bool
+val set_dirty: t -> unit
+val clear_dirty: t -> unit
path: src/framebuffer.mli
user: login: mdales
body: Can you add odoc comments for each of these please?
created_at: 2025-03-24T08:30:54Z
author_association: COLLABORATOR
diff_hunk: @@ -1,34 +1,36 @@
-
-type t = int array array
+type t = { data : int array array; mutable dirty : bool }
 
 type shader_func = int -> int
 
 type shaderi_func = int -> int -> t -> int
 
 let to_array (buffer : t) : int array array =
-  buffer
+  buffer.data
 
 let init (dimensions : int * int) (f : int -> int -> int) : t =
   let width, height = dimensions in
   if width <= 0 then raise (Invalid_argument "Invalid width");
   if height <= 0 then raise (Invalid_argument "Invalid height");
-  Array.init height (fun y ->
+  { data = Array.init height (fun y ->
     Array.init width (fun x ->
         f x y
       )
-  )
+  );
+    dirty = true }
 
 let pixel_write (x : int) (y : int) (col : int) (buffer : t) =
-  if (x >= 0) && (x < Array.length (buffer.(0))) && (y >= 0) && (y < Array.length buffer) then
-    buffer.(y).(x) <- col
+  if (x >= 0) && (x < Array.length (buffer.data.(0))) && (y >= 0) && (y < Array.length buffer.data) then
+    buffer.data.(y).(x) <- col;
+  buffer.dirty <- true
 
 let pixel_read (x : int) (y : int) (buffer : t) : int option =
-  if (x >= 0) && (x < Array.length (buffer.(0))) && (y >= 0) && (y < Array.length buffer) then
-    Some buffer.(y).(x)
+  if (x >= 0) && (x < Array.length (buffer.data.(0))) && (y >= 0) && (y < Array.length buffer.data) then
+    Some buffer.data.(y).(x)
   else
     None
 
 let draw_circle (x : int) (y : int) (r : float) (col : int) (buffer : t) =
+  buffer.dirty <- true;
path: src/framebuffer.ml
user: login: mdales
body: This calls `pixel_write`, which will set the dirty bit, so doesn't need to explicitly call it.
created_at: 2025-03-24T08:32:30Z
author_association: COLLABORATOR
diff_hunk: @@ -50,15 +52,16 @@ let draw_circle (x : int) (y : int) (r : float) (col : int) (buffer : t) =
   done
 
 let filled_circle (x : int) (y : int) (r : float) (col : int) (buffer : t) =
+  buffer.dirty <- true;
path: src/framebuffer.ml
user: login: mdales
body: Are there any scenarios where I can call filled_circle and not change the buffer? What if I draw a circle that's off the screen? A circle that is 0 size? In those cases I'd not expect the dirty bit to be set, but this means it always will be.
created_at: 2025-03-24T08:34:22Z
author_association: COLLABORATOR
diff_hunk: @@ -72,15 +75,16 @@ let filled_circle (x : int) (y : int) (r : float) (col : int) (buffer : t) =
   done
 
 let draw_line (x0 : int) (y0 : int) (x1 : int) (y1 : int) (col : int) (buffer : t) =
+  buffer.dirty <- true;
path: src/framebuffer.ml
user: login: mdales
body: Similar for filled circle :)
created_at: 2025-03-24T08:34:53Z
author_association: COLLABORATOR
diff_hunk: @@ -159,3 +158,9 @@ val pixel_read: int -> int -> t -> int option
 val to_array: t -> int array array
 (** [to_array framebuffer] converts the framebuffer into a 2D array. The top level array is an array
     of rows, and each row is an array of palette entry colours. *)
+
+(** {2 Dirty-bit support} *)
+
+val is_dirty: t -> bool
path: src/framebuffer.mli
user: login: mdales
body: I don't see any implementation for this - did you forget to commit it?
created_at: 2025-03-24T08:35:50Z
author_association: COLLABORATOR
diff_hunk: @@ -89,33 +89,40 @@ let run (title : string) (boot : boot_func option) (tick : tick_func) (s : Scree
 
         let updated_buffer = tick t s prev_buffer keys in
 
-        framebuffer_to_bigarray s updated_buffer bitmap;
-
-        match render_texture r texture s bitmap with
-        | Error (`Msg e) -> Sdl.log "Boot error: %s" e
-        | Ok () -> (
-          let exit, keys =
-          match Sdl.poll_event (Some e) with
-          | true -> (
-            match Sdl.Event.(enum (get e typ)) with
-            | `Quit -> (true, keys)
-            | `Key_down -> 
-                let key = PlatformKey.of_backend_keycode (Sdl.Event.(get e keyboard_keycode)) in
-                (false, KeyCodeSet.add key keys)
-            | `Key_up -> 
+        if updated_buffer != prev_buffer then (
+          framebuffer_to_bigarray s updated_buffer bitmap;
+          match render_texture r texture s bitmap with
+          | Error (`Msg e) -> Sdl.log "Boot error: %s" e
+          | Ok () -> ()
+        ) else if Framebuffer.is_dirty updated_buffer then (
+          framebuffer_to_bigarray s updated_buffer bitmap;
path: src/base.ml
user: login: mdales
body: This technically works, but you end up duplicating an important bit of code here in your two branches, and that means there's a risk that we update one and miss the other if we ever change `framebuffer_to_bigarray` - in software engineering this is referred to as "DRY" or "Don't Repeat Yourself".

There is actually, I think, a bug here also potentially: Surely we need to call `clear_dirty` in both cases? As even the "fresh" buffer returned from tick will still be dirty?
created_at: 2025-03-24T08:41:25Z
author_association: COLLABORATOR
diff_hunk: @@ -77,53 +78,46 @@ let draw_line (x0 : int) (y0 : int) (x1 : int) (y1 : int) (col : int) (buffer :
   and dy = (abs (y1 - y0)) * -1
   and sy = if y0 < y1 then 1 else -1 in
   let initial_error = dx + dy in
-
   let rec loop (x : int) (y : int) (error : int) =
-    if (x >= 0) && (x < Array.length (buffer.(0))) && (y >= 0) && (y < Array.length buffer) then
-      buffer.(y).(x) <- col;
-    match (x == x1) && (y == y1) with
-    | true -> ()
-    | false -> (
+    pixel_write x y col buffer;
+    if (x = x1) && (y = y1) then ()
path: src/framebuffer.ml
user: login: mdales
body: You changed the type of equality check here. Can you explain why? 

(I'm not saying it's wrong, but if you change code you should be able to to say what was wrong before, and why you picked the new version)
created_at: 2025-03-25T14:20:21Z
author_association: COLLABORATOR
diff_hunk: @@ -79,8 +80,8 @@ let draw_line (x0 : int) (y0 : int) (x1 : int) (y1 : int) (col : int) (buffer :
   let initial_error = dx + dy in
 
   let rec loop (x : int) (y : int) (error : int) =
-    if (x >= 0) && (x < Array.length (buffer.(0))) && (y >= 0) && (y < Array.length buffer) then
-      buffer.(y).(x) <- col;
+    if (x >= 0) && (x < Array.length (buffer.data.(0))) && (y >= 0) && (y < Array.length buffer.data) then
+      buffer.data.(y).(x) <- col;
path: src/framebuffer.ml
user: login: mdales
body: This should be a pixel_write, I think you accidentally undid too much when undoing all the additional changes.
created_at: 2025-03-26T18:54:52Z
author_association: COLLABORATOR
diff_hunk: @@ -222,8 +222,8 @@ let filled_triangle (x0 : int) (y0 : int) (x1 : int) (y1 : int) (x2 : int) (y2 :
   let spans = Array.map2 (fun a b -> (a, b)) long_edge other_edge in
   Array.iteri (fun i s ->
     let index = y0 + i in
-    if (index >= 0) && (index < Array.length buffer) then (
-      let row = buffer.(y0 + i) in
+    if (index >= 0) && (index < Array.length buffer.data) then (
+      let row = buffer.data.(y0 + i) in
path: src/framebuffer.ml
user: login: mdales
body: Again, can we restore the pixel_write here
created_at: 2025-03-26T18:56:15Z
author_association: COLLABORATOR
diff_hunk: @@ -51,14 +52,14 @@ let draw_circle (x : int) (y : int) (r : float) (col : int) (buffer : t) =
 
 let filled_circle (x : int) (y : int) (r : float) (col : int) (buffer : t) =
   let fx = Float.of_int x and fy = Float.of_int y in
-  let my = Float.of_int ((Array.length buffer) - 1)
-  and mx = Float.of_int ((Array.length buffer.(0)) - 1) in
+  let my = Float.of_int ((Array.length buffer.data) - 1)
+  and mx = Float.of_int ((Array.length buffer.data.(0)) - 1) in
   let pminy = fy -. r
   and pmaxy = fy +. r in
   let miny = if (pminy < 0.) then 0. else pminy
   and maxy = if (pmaxy > my) then my else pmaxy in
   for yi = (Int.of_float miny) to (Int.of_float maxy) do
-    let row = buffer.(yi) in
+    let row = buffer.data.(yi) in
path: src/framebuffer.ml
user: login: mdales
body: You lost the improvements to this function that was in your earlier PR, where you removed this line, and changed the loop below to use `pixel_write`

```
    for xi = (Int.of_float minx) to (Int.of_float maxx) do
       pixel_write xi yi col buffer
     done
```
created_at: 2025-03-26T19:06:06Z
author_association: COLLABORATOR
diff_hunk: @@ -159,3 +158,9 @@ val pixel_read: int -> int -> t -> int option
 val to_array: t -> int array array
 (** [to_array framebuffer] converts the framebuffer into a 2D array. The top level array is an array
     of rows, and each row is an array of palette entry colours. *)
+
+(** {2 Dirty-bit support} *)
+
+val is_dirty: t -> bool
path: src/framebuffer.mli
user: login: mdales
body: These have got lost again
created_at: 2025-03-26T19:06:58Z
author_association: COLLABORATOR

PR comments:
user: login: Vanisha1606
created_at: 2025-03-22T16:59:21Z
body: @mdales Are there any changes you would like me to do?
author_association: CONTRIBUTOR
user: login: mdales
created_at: 2025-03-22T17:33:57Z
body: > @mdales Are there any changes you would like me to do?

Hey there! Unfortunately I'm travelling today, I'll try get to look at it tomorrow!
author_association: COLLABORATOR
user: login: Vanisha1606
created_at: 2025-03-22T17:36:18Z
body: Okay @mdales Thank you!

author_association: CONTRIBUTOR
user: login: mdales
created_at: 2025-03-23T12:20:48Z
body: This is great, but you need to do both! This is because of how arrays work in OCaml (and some other languages). 

When you update the array in OCaml, you change the contents, but you don't change anything about the reference to that data, which is what is stored in the array variable. We can play with `utop` to see this in action:

```ocaml
utop # let a = Array.init 10 (fun i -> i);;
val a : int array = [|0; 1; 2; 3; 4; 5; 6; 7; 8; 9|]

utop # let b = a;;
val b : int array = [|0; 1; 2; 3; 4; 5; 6; 7; 8; 9|]

utop # b == a;;
- : bool = true

utop # a.(0) <- 42;;
- : unit = ()

utop # a;;
- : int array = [|42; 1; 2; 3; 4; 5; 6; 7; 8; 9|]

utop # a == b;;
- : bool = true
```

What you can see here is that at the start `a` and `b` are the same array, and then I modify the contents of the array via `a`, but when I check, `a` and `b` are still the same, which is because in OCaml `a` and `b` are just references to the array, rather than the array themselves.

The same is true in Python also for instance:

``` python
>>> a = list(range(10))
>>> a
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
>>> b = a
>>> b
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
>>> a[0] = 42
>>> a
[42, 1, 2, 3, 4, 5, 6, 7, 8, 9]
>>> b
[42, 1, 2, 3, 4, 5, 6, 7, 8, 9]
```

This is why you need the dirty bit from your first appoach also, as there's two ways a tick function can update the screen:

1. It can create a new framebuffer and return that (which is caught by this PR)
2. It can modify the passed in framebuffer and return that (which was caught by your dirty bit approach)

Both are required :)

But the good news is that you now understand the right run loop to modify, which you've done here, and you know how to do the dirty bit approach, which you did originally. You need to just merge those two, and add:

* when you check are teh arrays equal, if they are you also have to check the dirty bit
* the original PR needed to set the dirty bit in more places

I hope that all makes sense! You're so close, and it's great to see in this PR you've dived into the run loop and figured out what its doing, which is awesome, as it's one of the tougher parts of Claudius, where it interacts with SDL.



author_association: COLLABORATOR
user: login: Vanisha1606
created_at: 2025-03-23T18:19:49Z
body: Hello @mdales I have made modifications in base.ml, framebuffer.ml, and framebuffer.mli, and have merged both the approaches. Now the run loop in base.ml will repaint when either a new framebuffer is returned (different reference) or when the same framebuffer is returned but its dirty flag indicates that in‐place updates occurred. In framebuffer.ml, the type is changed from a plain 2D array to a record that holds the pixel data in a field (named “data”) along with a mutable dirty flag, and all functions that modify the buffer now set that flag. Finally, framebuffer.mli exposes these changes without any extra formatting changes. This set of changes fully addresses the issue as described.
Is this implementation now correct or do I need to make any more changes or take other things into consideration?
Thank Youu :))
author_association: CONTRIBUTOR
user: login: Vanisha1606
created_at: 2025-03-24T17:33:59Z
body: Hello @patricoferris @mdales I have resolving these conflicts and have pushed a new commit into the PR
In the newer commit - 
- We only set dirty when pixels are actually changed.
- Higher-level drawing functions (like draw_polygon, draw_circle, etc.) no longer unconditionally set dirty themselves. Instead, they rely on pixel_write (or equivalent per-pixel loops) to do so if anything is actually in range and modified.
- All direct array writes (like row.(xi) <- col) have been replaced by calls to pixel_write, or by loops that call pixel_write, so out-of-bounds writes do nothing (and do not set the dirty bit).
-  Only pixel_write marks the buffer as dirty when an in‑bounds pixel is actually modified.
- In the base.ml file, the two separate branches that both called framebuffer_to_bigarray and render_texture are now merged into one single branch. If either the buffer reference changes (i.e. a new buffer is returned) or the buffer is marked dirty, we update the texture and then call Framebuffer.clear_dirty updated_buffer. This ensures we clear the dirty flag in all cases after rendering.
- The run loop now uses a single branch that checks whether the buffer changed (i.e. updated_buffer ≠ prev_buffer) or if it’s marked dirty. In that case, it calls framebuffer_to_bigarray and render_texture and then clears the dirty flag. This eliminates the duplicated code and ensures clear_dirty is always called when needed.
- Also added necessary comments in the framebuffer.mli file as asked per reviews.

@mdales are the changes in accordance to the reviews? Also I have made some formatting changes and other necessary changes for the passing of the failing test cases.  

The test cases are now running properly :)
bash
eval $(opam env)
dune build
dune run


 I have attached the screenshot for the same as well.  Thanks a lot for the reviews!!
![WhatsApp Image 2025-03-24 at 10 58 56 PM](https://github.com/user-attachments/assets/5e66ca4b-c6c6-4936-a6d3-062d7e87ef6c)


author_association: CONTRIBUTOR
user: login: Vanisha1606
created_at: 2025-03-24T17:40:43Z
body: @mdales I have made the changes based on your feedback. I wanted to ask if I can work on another issue alongside this one, while you review this one?? Would that be a good idea? Thank you
author_association: CONTRIBUTOR
user: login: mdales
created_at: 2025-03-25T14:05:16Z
body: Just an observertion @Vanisha1606 - there's a bunch of changes in this pull request that feel like formatting changes or style changes. In theory, a great ambition, but in practice it now means I have a lot more code to read through and review, which means more work I have to do before I can approve this PR.

In general on a large project we should run a tool like dune auto format so that the code is consistent in format, I'll make a ticket for me to do that at some point, but in a multi-collaborator project you can't just rewrite code because you feel it is better a certain way, as if everyone does that we end up with all the code being written.

I appreciate you did this with the best intentions, but in big projects it both causes people to get upset because you changed their code for no technical reason, and it just means the reviewer has to check every single line changed for mistakes, slowing everything down.

This time I'll go through it and flag if I disagree, but in future please don't to tidying up work like this. If you think something needs tidying make a ticket for it and do the work under that ticket.

Again, this is on me, I should have moved to the ocaml auto-formatting before we started Outreachy, but it's also something everyone need to learn that "just tidying" has a bigger cost than anticipated :)
author_association: COLLABORATOR
user: login: Vanisha1606
created_at: 2025-03-25T15:15:54Z
body: @mdales 
I will revert back the changes and make sure that now there are only minimal changes , Sorry for making you go through the same code again because of the formatting issue , I wanted to ask if I can simultaneously work on some other simpler issue so that my moral is boosted.
Thank you so much for reviews , I really appreciate it!
author_association: CONTRIBUTOR
user: login: mdales
created_at: 2025-03-25T15:23:11Z
body: I appreciate the need to work on something else so you can feel progress is being made, but let's just get this one in, and we'll get you onto something else. I super keen to get this one in, as it's such an important change in terms of stopping Claudius wasting power, and you have all the parts there. Let's get it over the line, we're so close.

For what it's worth, the PR is otherwise looking strong now - I like the move to using pixel_write in all instances, which simplifies the code a lot, and let you remove some bounds checks I saw.

Do you have an idea for what you'd like to work on next thematically? 
author_association: COLLABORATOR
user: login: Vanisha1606
created_at: 2025-03-25T15:30:59Z
body: @mdales Should I revert back to the previous commit or should I open a new PR all together? I am very sorry that you have to go through all code again because of this formatting issue from my side , I will keep this in my mind that I don't do formatting untill required.
I will send you the issue which I would like to work on next .
author_association: CONTRIBUTOR
user: login: Vanisha1606
created_at: 2025-03-25T15:34:51Z
body: @mdales Also another thing i see the ci tests are failing but they were successful on my local setup. Could you please tell if there are other things to be taken into consideration as well?
author_association: CONTRIBUTOR
user: login: mdales
created_at: 2025-03-25T16:34:24Z
body: Whatever is easier for you in terms of how to undo the changes. But looking at your branch here, what I'd do is:

1. First create a backup! 

```shell
$ git checkout -b backup-my-work Vanisha1606-dev-fix/efficient-frame-skip
$ git checkout Vanisha1606-dev-fix/efficient-frame-skip
```

You should now have a back should anything go wrong!

2. All the extra code changes are just in that last commit, so we can "upstage" that commit by doing:

```shell
$ git reset HEAD~1
```

You should now find that your branch is back to the second command of your PR, but all the changes from that last change are still there, as if you'd not yet committed them.

3. Use `git add -p` to select which changes you want. Use this to add all the changes that impact the topic for this ticket, and don't commit the other bits for now. If there's a line where you've done some changes that are both necessary and unnecessary, just add that, but don't add areas where there are many lines of unnecessary changes. If `git add -p` mixes them, rather than pressing 'y' or 'n' you can use 's' to split that block up into smaller parts.

4. Now do `git commit` and add just those parts you specified with `git add -p` earlier. Do NOT do `git commit -a`

5. Now force update the branch on GitHub: `git push --force origin Vanisha1606-dev-fix/efficient-frame-skip`

You will still have a bunch of uncommitted changes on your branch, for now I'd just ask git to stash those away: `git stash` - that means git has put them on a scratch pad somewhere should you need them, but they're gone from your working directory.

Does any of that make sense?
author_association: COLLABORATOR
user: login: mdales
created_at: 2025-03-25T16:36:40Z
body: > @mdales Also another thing i see the ci tests are failing but they were successful on my local setup. Could you please tell if there are other things to be taken into consideration as well?

This is in part why I stopped, as this confused me given you'd built things locally. I wonder if the CI is using a different version of OCaml that doesn't like the syntax versus what you have. Which version are you running? CI is running OCaml 5.2 currently.

(note that this change is on a line that I think was tidied but not related to the ticket, so I Assume won't be a problem in general next time)
author_association: COLLABORATOR
user: login: Vanisha1606
created_at: 2025-03-25T16:45:55Z
body: @mdales I am using the version 5.3 but as you said i don’t think that would be a problem for the next time as it was on a tidied up line
author_association: CONTRIBUTOR
user: login: Vanisha1606
created_at: 2025-03-25T16:48:15Z
body: @mdales Thankyou for guiding me through the changes and yes it all makes sense to me .
Also I would like to work on "track control changes "issue #13 .
author_association: CONTRIBUTOR
user: login: Vanisha1606
created_at: 2025-03-26T10:28:39Z
body: Hey @mdales I have now strictly followed the conversations that were to be resolved. I see that base.ml and framebuffer.mli reviews are already resolved by you :)) .  I have updated the framebuffer.ml file strictly according to your reviews now as well, most of it contained removing the buffer which I had already set right? However I was thinking for covering the test cases around ensuring the bit is set and cleared using the accessor functions I added and for moving to pixel_write in all instances which simplifies the code a lot and let us remove the bound check cases I can open another ticket and work on it and raise a PR for those as well if it’s all right?
author_association: CONTRIBUTOR
user: login: Vanisha1606
created_at: 2025-03-27T18:35:21Z
body: Hey @mdales I have gone all the reviews fixed my code accordingly. Thanks a lot :)) I also made some minute changes in the code to fix build errors of this sort : -

```bash
File "src/framebuffer.ml", line 485, characters 4-10:
485 |   ) buffer;
          ^^^^^^
Error: This expression has type t but an expression was expected of type
         int array array


File "src/framebuffer.ml", line 1:    
Error: The implementation src/framebuffer.ml
       does not match the interface src/framebuffer.ml: 
       The value is_dirty is required but not provided
       File "src/framebuffer.mli", line 165, characters 0-23:
         Expected declaration
       The value set_dirty is required but not provided
       File "src/framebuffer.mli", line 168, characters 0-24:
         Expected declaration
       The value clear_dirty is required but not provided
       File "src/framebuffer.mli", line 171, characters 0-26:
         Expected declaration

```
![WhatsApp Image 2025-03-27 at 10 10 01 PM](https://github.com/user-attachments/assets/15395f3d-1662-4b96-a6ab-448e1ec18a1b)

Now the build errors are all gone :) and tests are also running successfully, also reviewed and made corrections you requested.
author_association: CONTRIBUTOR
user: login: mdales
created_at: 2025-03-27T18:42:06Z
body: Well done @Vanisha1606!

But there is one bit I asked for that you missed :) Can you update `test_primatives.ml` to assert the dirty bit has been set after something has been drawn (you'll need to remember to clear it beforehand as it starts set). That way the tests actually not just pass, but also do check that all the work you've done doesn't get accidentally broken by someone in the future.
author_association: COLLABORATOR
user: login: Vanisha1606
created_at: 2025-03-28T10:56:28Z
body: @mdales I have added the test case in the test_primitivies.ml file as you have asked, and ran tests once more !! Is it okaay now? Thanks for the suggestion
![WhatsApp Image 2025-03-28 at 3 40 41 PM](https://github.com/user-attachments/assets/2b0348f8-6cad-4cb7-a715-38b05626e23c)

author_association: CONTRIBUTOR
user: login: mdales
created_at: 2025-03-28T11:33:19Z
body: This is great, but it doesn't check every path you've changed. 

* Can you add a case for map/mapi/map_inplace?
* Can you change the primitive test case to repeat the test for all the primitives?

Why do we add tests? For me, there's two reasons:

* We get confidence that what we've added does what we think it does
* It stops someone in the future accidentally breaking the code.

And it's the second reason that is the most liberating the more you write software. If frees you up to move quickly: if I don't have good test coverage every modification I make I need to check so much more manually, but if I have good test coverage I can make changes in confidence that if I accidentally break something tests will tell me (or whoever it is doing the changes). That's why I'm so keen on everyone adding tests.

Is it tedious? Is it less fun than making new things? Yes. But it's a lot less tedious than when you next make a change and people start asking why things have broken :) It's a small amount of effort now that saves a lot of effort in the future.

There some things you can do to speed up the process here: rather than add a new primitive test, you can add the dirty bit check to all the existing tests in test_primative.ml, rather than create new tests.

I appreciate you want to move on to another ticket, and you want a chance to work on new things, and I really want to merge this code, but if I do that then I'll just end up writing all these tests myself later when I go to do any changes in this area, as I won't be comfortable without those tests being there. Please stick with it!
author_association: COLLABORATOR
user: login: mdales
created_at: 2025-03-28T11:38:12Z
body: Another way to make it easier to write the tests without writing a new test case for each primative:

```ocaml
  let test_dirty_bit_render_primitive _ =
    let primatives = [
      Primitives.Pixel ({x = 5; y = 5}, 1);
      Primitives.Line ({x = 2; y = 2}, {x = 5; y = 5}, 1);
      Primitives.Circle ({x = 5; y = 5}, 4.0, 1);
    ] in
    List.iter (fun prim ->
      let fb = Framebuffer.init (10, 10) (fun _ _ -> 0) in
      Framebuffer.clear_dirty fb;
      assert_equal ~msg:"dirty bit should be false after clear" false (Framebuffer.is_dirty fb);
      Framebuffer.render fb [prim];
      assert_equal ~msg:"dirty bit should be set after render" true (Framebuffer.is_dirty fb)
    ) primatives
 ```
 
 You could build up a list like this (just making sure it has one of every primative in it) - this is just an extension to the test you already wrote to make it have more coverage! Your form is correct, we just need to get every primitive in there!
author_association: COLLABORATOR
user: login: mdales
created_at: 2025-03-28T14:12:59Z
body: As motivation, I took your branch for a spin, along with the mouse code PR, and it was great to see it working! (See video on #15) When I'm drawing I can see higher CPU load than when I'm not, which means your code is doing what it should!
author_association: COLLABORATOR
user: login: Vanisha1606
created_at: 2025-03-29T12:24:50Z
body: @mdales I have made changes in the test file accordingly, also needed to make a lil extra changes for the tests to run successfully.
![WhatsApp Image 2025-03-29 at 5 45 03 PM](https://github.com/user-attachments/assets/ea9ae04d-3d89-449e-8294-b7e04a94f637)

author_association: CONTRIBUTOR
user: login: Vanisha1606
created_at: 2025-03-29T13:27:46Z
body: @mdales Thankyou so much for guiding me throughout the process and helping me out with each and every problem I encountered.
It was great working on this ticket and finally seeing this getting merged!
author_association: CONTRIBUTOR