Skip to main content

systemless/trap/
framebuffer.rs

1//! Framebuffer drawing methods for menu bar and window chrome rendering.
2
3use std::sync::OnceLock;
4
5use crate::memory::{MacMemoryBus, MemoryBus};
6use crate::quickdraw::text::{get_font_metrics, get_glyph};
7
8/// Opt-in gate for visRgn auto-expansion. Setting
9/// `SYSTEMLESS_NO_VISRGN_AUTO_EXPAND=1` skips expanding the front window's
10/// visRgn.top when MBarHeight=0. Cached via OnceLock to keep the per-frame
11/// redraw path syscall-free.
12static NO_VISRGN_AUTO_EXPAND: OnceLock<bool> = OnceLock::new();
13fn no_visrgn_auto_expand_enabled() -> bool {
14    *NO_VISRGN_AUTO_EXPAND
15        .get_or_init(|| std::env::var_os("SYSTEMLESS_NO_VISRGN_AUTO_EXPAND").is_some())
16}
17
18impl super::TrapDispatcher {
19    /// Read screen parameters from the dispatcher's screen_mode.
20    /// Returns (screen_base, row_bytes, width, height, pixel_size).
21    pub(super) fn get_screen_params(&self) -> (u32, u32, i16, i16, u16) {
22        let (base, rb, w, h, ps) = self.screen_mode;
23        (base, rb, w as i16, h as i16, ps)
24    }
25
26    fn active_gdevice_ctab(bus: &MacMemoryBus) -> Option<u32> {
27        let gdevice_handle = {
28            let current = bus.read_long(0x0CC8); // TheGDevice
29            if current != 0 {
30                current
31            } else {
32                bus.read_long(0x08A4) // MainDevice
33            }
34        };
35        if gdevice_handle == 0 {
36            return None;
37        }
38        let gdevice = bus.read_long(gdevice_handle);
39        if gdevice == 0 {
40            return None;
41        }
42        let pixmap_handle = bus.read_long(gdevice + 22);
43        if pixmap_handle == 0 {
44            return None;
45        }
46        let pixmap = bus.read_long(pixmap_handle);
47        if pixmap == 0 {
48            return None;
49        }
50        let ctab_handle = bus.read_long(pixmap + 42);
51        if ctab_handle == 0 {
52            return None;
53        }
54        let ctab = bus.read_long(ctab_handle);
55        if ctab == 0 {
56            return None;
57        }
58        Some(ctab)
59    }
60
61    fn ctab_value_luma(bus: &MacMemoryBus, ctab: u32, wanted_value: u8) -> Option<u32> {
62        let count = u32::from(bus.read_word(ctab + 6)).min(255) + 1;
63
64        let ordinal = u32::from(wanted_value);
65        if ordinal < count {
66            let entry = ctab + 8 + ordinal * 8;
67            if bus.read_word(entry) == u16::from(wanted_value) {
68                return Some(
69                    u32::from(bus.read_word(entry + 2))
70                        + u32::from(bus.read_word(entry + 4))
71                        + u32::from(bus.read_word(entry + 6)),
72                );
73            }
74        }
75
76        for ordinal in 0..count {
77            let entry = ctab + 8 + ordinal * 8;
78            if bus.read_word(entry) != u16::from(wanted_value) {
79                continue;
80            }
81            return Some(
82                u32::from(bus.read_word(entry + 2))
83                    + u32::from(bus.read_word(entry + 4))
84                    + u32::from(bus.read_word(entry + 6)),
85            );
86        }
87        None
88    }
89
90    fn best_luma_pixel_index(bus: &MacMemoryBus, ctab: u32, brightest: bool) -> Option<u8> {
91        let count = u32::from(bus.read_word(ctab + 6)).min(255) + 1;
92        let mut best_index = 0u8;
93        let mut best_luma = 0u32;
94        let mut found = false;
95        for ordinal in 0..count {
96            let entry = ctab + 8 + ordinal * 8;
97            let value = bus.read_word(entry);
98            if value > 255 {
99                continue;
100            }
101            let luma = u32::from(bus.read_word(entry + 2))
102                + u32::from(bus.read_word(entry + 4))
103                + u32::from(bus.read_word(entry + 6));
104            let index = value as u8;
105            let better = if brightest {
106                luma > best_luma || (luma == best_luma && index < best_index)
107            } else {
108                luma < best_luma || (luma == best_luma && index < best_index)
109            };
110            if !found || better {
111                best_index = index;
112                best_luma = luma;
113                found = true;
114            }
115        }
116
117        found.then_some(best_index)
118    }
119
120    fn logical_white_pixel_index(bus: &MacMemoryBus) -> u8 {
121        let Some(ctab) = Self::active_gdevice_ctab(bus) else {
122            return 0;
123        };
124        if Self::ctab_value_luma(bus, ctab, 0) == Some(0xFFFF * 3) {
125            return 0;
126        }
127        Self::best_luma_pixel_index(bus, ctab, true).unwrap_or(0)
128    }
129
130    fn logical_black_pixel_index(bus: &MacMemoryBus) -> u8 {
131        let Some(ctab) = Self::active_gdevice_ctab(bus) else {
132            return 255;
133        };
134        if Self::ctab_value_luma(bus, ctab, 1) == Some(0) {
135            return 1;
136        }
137        if Self::ctab_value_luma(bus, ctab, 255) == Some(0) {
138            return 255;
139        }
140        Self::best_luma_pixel_index(bus, ctab, false).unwrap_or(255)
141    }
142
143    fn logical_mono_pixel_indexes(bus: &MacMemoryBus) -> (u8, u8) {
144        (
145            Self::logical_white_pixel_index(bus),
146            Self::logical_black_pixel_index(bus),
147        )
148    }
149
150    /// Set a single pixel in the framebuffer (screen coordinates).
151    /// Works for both 1bpp and 8bpp screen modes.
152    pub(crate) fn fb_set_pixel(
153        bus: &mut MacMemoryBus,
154        screen_base: u32,
155        row_bytes: u32,
156        pixel_size: u16,
157        screen_width: i16,
158        screen_height: i16,
159        x: i16,
160        y: i16,
161        black: bool,
162    ) {
163        if x < 0 || y < 0 || x >= screen_width || y >= screen_height {
164            return;
165        }
166        if pixel_size == 8 {
167            let addr = screen_base + (y as u32) * row_bytes + (x as u32);
168            bus.write_byte(
169                addr,
170                if black {
171                    255
172                } else {
173                    Self::logical_white_pixel_index(bus)
174                },
175            );
176        } else {
177            let byte_offset = (y as u32) * row_bytes + (x as u32 / 8);
178            let bit = 7 - (x as u32 % 8);
179            let addr = screen_base + byte_offset;
180            let b = bus.read_byte(addr);
181            if black {
182                bus.write_byte(addr, b | (1 << bit));
183            } else {
184                bus.write_byte(addr, b & !(1 << bit));
185            }
186        }
187    }
188
189    /// Fill a rectangle in the framebuffer
190    pub(crate) fn fb_fill_rect(
191        bus: &mut MacMemoryBus,
192        screen_base: u32,
193        row_bytes: u32,
194        pixel_size: u16,
195        screen_width: i16,
196        screen_height: i16,
197        top: i16,
198        left: i16,
199        bottom: i16,
200        right: i16,
201        black: bool,
202    ) {
203        if pixel_size == 8 {
204            let top = top.max(0).min(screen_height) as u32;
205            let left = left.max(0).min(screen_width) as u32;
206            let bottom = bottom.max(0).min(screen_height) as u32;
207            let right = right.max(0).min(screen_width) as u32;
208            if top >= bottom || left >= right {
209                return;
210            }
211            let fill = if black {
212                Self::logical_black_pixel_index(bus)
213            } else {
214                Self::logical_white_pixel_index(bus)
215            };
216            for y in top..bottom {
217                let row_addr = screen_base + y * row_bytes;
218                for x in left..right {
219                    bus.write_byte(row_addr + x, fill);
220                }
221            }
222            return;
223        }
224        for y in top..bottom {
225            for x in left..right {
226                Self::fb_set_pixel(
227                    bus,
228                    screen_base,
229                    row_bytes,
230                    pixel_size,
231                    screen_width,
232                    screen_height,
233                    x,
234                    y,
235                    black,
236                );
237            }
238        }
239    }
240
241    /// Draw a horizontal line in the framebuffer
242    pub(crate) fn fb_hline(
243        bus: &mut MacMemoryBus,
244        screen_base: u32,
245        row_bytes: u32,
246        pixel_size: u16,
247        screen_width: i16,
248        screen_height: i16,
249        y: i16,
250        x1: i16,
251        x2: i16,
252        black: bool,
253    ) {
254        if pixel_size == 8 {
255            if y < 0 || y >= screen_height {
256                return;
257            }
258            let left = x1.max(0).min(screen_width) as u32;
259            let right = x2.max(0).min(screen_width) as u32;
260            if left >= right {
261                return;
262            }
263            let fill = if black {
264                Self::logical_black_pixel_index(bus)
265            } else {
266                Self::logical_white_pixel_index(bus)
267            };
268            let row_addr = screen_base + (y as u32) * row_bytes;
269            for x in left..right {
270                bus.write_byte(row_addr + x, fill);
271            }
272            return;
273        }
274        for x in x1..x2 {
275            Self::fb_set_pixel(
276                bus,
277                screen_base,
278                row_bytes,
279                pixel_size,
280                screen_width,
281                screen_height,
282                x,
283                y,
284                black,
285            );
286        }
287    }
288
289    /// Draw a single character glyph to the framebuffer, return advance width
290    pub(crate) fn fb_draw_char(
291        bus: &mut MacMemoryBus,
292        screen_base: u32,
293        row_bytes: u32,
294        pixel_size: u16,
295        screen_width: i16,
296        screen_height: i16,
297        x: i16,
298        y: i16,
299        ch: char,
300        font_id: i16,
301        font_size: i16,
302    ) -> i16 {
303        if let Some((glyph, data)) = get_glyph(font_id, font_size, ch) {
304            let gx = x + glyph.origin_x as i16;
305            let gy = y + glyph.origin_y as i16;
306            let gw = glyph.width as usize;
307            let gh = glyph.height as usize;
308            let black_index = if pixel_size == 8 {
309                Some(Self::logical_black_pixel_index(bus))
310            } else {
311                None
312            };
313
314            // Glyph data is 8-bit coverage per pixel (row-major, one byte
315            // per pixel). Threshold at >=128 (bitmap glyphs are exclusively
316            // 0 or 255).
317            for row in 0..gh {
318                for col in 0..gw {
319                    let byte_idx = glyph.data_offset + row * gw + col;
320                    if byte_idx < data.len() && data[byte_idx] >= 128 {
321                        let px = gx + col as i16;
322                        let py = gy + row as i16;
323                        if let Some(black_index) = black_index {
324                            if px >= 0 && py >= 0 && px < screen_width && py < screen_height {
325                                let addr = screen_base + (py as u32) * row_bytes + (px as u32);
326                                bus.write_byte(addr, black_index);
327                            }
328                        } else {
329                            Self::fb_set_pixel(
330                                bus,
331                                screen_base,
332                                row_bytes,
333                                pixel_size,
334                                screen_width,
335                                screen_height,
336                                px,
337                                py,
338                                true,
339                            );
340                        }
341                    }
342                }
343            }
344            glyph.advance as i16
345        } else {
346            6 // default advance for missing glyph
347        }
348    }
349
350    /// Draw a string to the framebuffer, return total width
351    pub(crate) fn fb_draw_string(
352        bus: &mut MacMemoryBus,
353        screen_base: u32,
354        row_bytes: u32,
355        pixel_size: u16,
356        screen_width: i16,
357        screen_height: i16,
358        x: i16,
359        y: i16,
360        s: &str,
361        font_id: i16,
362        font_size: i16,
363    ) -> i16 {
364        let mut cx = x;
365        for ch in s.chars() {
366            cx += Self::fb_draw_char(
367                bus,
368                screen_base,
369                row_bytes,
370                pixel_size,
371                screen_width,
372                screen_height,
373                cx,
374                y,
375                ch,
376                font_id,
377                font_size,
378            );
379        }
380        cx - x
381    }
382
383    /// Draw the menu bar at the top of the screen.
384    /// Height is read from the MBarHeight low-memory global ($0BAA).
385    /// If MBarHeight is 0, the menu bar is hidden (full-screen mode).
386    pub(crate) fn draw_menu_bar_to_fb(&self, bus: &mut MacMemoryBus) {
387        if self.fullscreen_locked || self.menu_bar_hidden {
388            return;
389        }
390        let (screen_base, row_bytes, screen_width, screen_height, pixel_size) =
391            self.get_screen_params();
392        let menu_bar_height = bus.read_word(crate::memory::globals::addr::MBAR_HEIGHT) as i16;
393        if menu_bar_height <= 0 {
394            return;
395        }
396
397        // Fill menu bar area with white
398        Self::fb_fill_rect(
399            bus,
400            screen_base,
401            row_bytes,
402            pixel_size,
403            screen_width,
404            screen_height,
405            0,
406            0,
407            menu_bar_height,
408            screen_width,
409            false,
410        );
411
412        // Draw black bottom border line
413        Self::fb_hline(
414            bus,
415            screen_base,
416            row_bytes,
417            pixel_size,
418            screen_width,
419            screen_height,
420            menu_bar_height - 1,
421            0,
422            screen_width,
423            true,
424        );
425
426        // Chicago 12 is the system font for menus (font_id=0, size=12)
427        let font_id: i16 = 0;
428        let font_size: i16 = 12;
429        let metrics = get_font_metrics(font_id, font_size);
430        // Vertically center text: baseline = top_margin + ascent
431        let text_height = metrics.ascent + metrics.descent;
432        let text_y = (menu_bar_height - text_height) / 2 + metrics.ascent;
433
434        // Draw menu titles from the current menu list only (menus become
435        // current after InsertMenu). IM:I I-352 / I-354.
436        let mut x: i16 = 18;
437        for menu in &self.menus {
438            if !menu.in_menu_bar {
439                continue;
440            }
441            let title = &menu.title;
442            let width = Self::fb_draw_string(
443                bus,
444                screen_base,
445                row_bytes,
446                pixel_size,
447                screen_width,
448                screen_height,
449                x,
450                text_y,
451                title,
452                font_id,
453                font_size,
454            );
455            x += width + 14; // 14px gap between menu titles
456        }
457    }
458
459    /// Blit the front window's port pixels to the screen framebuffer.
460    ///
461    /// On a real Mac, the Window Manager composites window content to the
462    /// screen. In Systemless HLE, games draw to the window's GrafPort which
463    /// may use a different baseAddr than the screen framebuffer. This copies
464    /// the window content so that screen captures reflect the actual game state.
465    pub(crate) fn blit_window_to_screen(&self, bus: &mut MacMemoryBus) {
466        let (screen_base, screen_rb, screen_w, screen_h, pixel_size) = self.screen_mode;
467        let trace = std::env::var_os("SYSTEMLESS_TRACE_BLIT_WINDOW").is_some();
468        if self.front_window == 0 {
469            if trace {
470                eprintln!("[BLIT] skip: front_window=0");
471            }
472            return;
473        }
474        if pixel_size != 8 || screen_w == 0 || screen_h == 0 {
475            if trace {
476                eprintln!(
477                    "[BLIT] skip: screen mode mismatch (pixel_size={}, w={}, h={})",
478                    pixel_size, screen_w, screen_h
479                );
480            }
481            return;
482        }
483
484        // Read the window's port baseAddr.
485        // CGrafPort version flag is at offset +6 (not +0 which is `device`).
486        // Inside Macintosh Volume V, V-47
487        let port = self.front_window;
488        let port_version = bus.read_word(port + 6);
489        let port_base = if (port_version & 0xC000) == 0xC000 {
490            // CGrafPort: portPixMap handle at offset 2
491            let pm_handle = bus.read_long(port + 2);
492            if pm_handle == 0 {
493                if trace {
494                    eprintln!("[BLIT] skip: CGrafPort pm_handle=0");
495                }
496                return;
497            }
498            let pm_ptr = bus.read_long(pm_handle);
499            if pm_ptr == 0 {
500                if trace {
501                    eprintln!(
502                        "[BLIT] skip: CGrafPort pm_ptr=0 (pm_handle=${:08X})",
503                        pm_handle
504                    );
505                }
506                return;
507            }
508            bus.read_long(pm_ptr) & 0x3FFFFFFF // mask off flags
509        } else {
510            // GrafPort: portBits.baseAddr at offset 2
511            bus.read_long(port + 2)
512        };
513
514        // If the window already draws directly to the screen, no blit needed
515        if port_base == screen_base || port_base == 0 {
516            if trace {
517                eprintln!(
518                    "[BLIT] skip: port_base=${:08X} screen_base=${:08X} \
519                     (port draws directly to screen or port_base is NIL)",
520                    port_base, screen_base
521                );
522            }
523            return;
524        }
525
526        // Read the port's rowBytes (from pixMap for CGrafPort, portBits for GrafPort)
527        let port_rb = if (port_version & 0xC000) == 0xC000 {
528            let pm_handle = bus.read_long(port + 2);
529            let pm_ptr = bus.read_long(pm_handle);
530            (bus.read_word(pm_ptr + 4) & 0x3FFF) as u32
531        } else {
532            (bus.read_word(port + 6) & 0x3FFF) as u32
533        };
534
535        // Read source port pixel size. For CGrafPort, PixMap.pixelSize
536        // lives at offset +32 of the PixMap struct. Basic GrafPort is
537        // implicitly 1bpp.
538        let port_pixel_size: u32 = if (port_version & 0xC000) == 0xC000 {
539            let pm_handle = bus.read_long(port + 2);
540            let pm_ptr = bus.read_long(pm_handle);
541            bus.read_word(pm_ptr + 32) as u32
542        } else {
543            1
544        };
545
546        // Read window content bounds (portRect in GrafPort at offset +16)
547        let wr_top = bus.read_word(port + 16) as i16;
548        let wr_left = bus.read_word(port + 18) as i16;
549        let wr_bottom = bus.read_word(port + 20) as i16;
550        let wr_right = bus.read_word(port + 22) as i16;
551
552        let w = (wr_right - wr_left) as u32;
553        let h = (wr_bottom - wr_top) as u32;
554        if w == 0 || h == 0 {
555            return;
556        }
557
558        let src_y_offset = wr_top.max(0) as u32;
559        let src_x_offset = wr_left.max(0) as u32;
560        let dst_y = src_y_offset;
561        let dst_x = src_x_offset;
562
563        // 1bpp source → 8bpp screen via per-pixel bit extraction.
564        // For each source bit, resolve logical white/black through the
565        // active GDevice ColorTable. Applications can repurpose 0xFF away
566        // from black while still expecting mono source bits to scan out black.
567        if port_pixel_size == 1 && pixel_size == 8 {
568            let (white_index, black_index) = Self::logical_mono_pixel_indexes(bus);
569            // Games may set portRect MUCH larger than the actual BitMap
570            // bounds (e.g. StuntCopter: portRect=(0,0..567,791) but
571            // BitMap=(0,0..261,426)). Clamp source reads to the BitMap
572            // bounds (portBits.bounds at port + 8..15) so we don't walk
573            // past valid source data into adjacent rows. Without this,
574            // the per-row stride bug produces horizontally-doubled or
575            // tiled content.
576            let pb_top = bus.read_word(port + 8) as i16;
577            let pb_left = bus.read_word(port + 10) as i16;
578            let pb_bottom = bus.read_word(port + 12) as i16;
579            let pb_right = bus.read_word(port + 14) as i16;
580            let bitmap_w = (pb_right - pb_left).max(0) as u32;
581            let bitmap_h = (pb_bottom - pb_top).max(0) as u32;
582            let row_count = h.min((screen_h as u32).saturating_sub(dst_y)).min(bitmap_h);
583            let col_count = w.min((screen_w as u32).saturating_sub(dst_x)).min(bitmap_w);
584            for row in 0..row_count {
585                let src_row_addr = port_base + (src_y_offset + row) * port_rb;
586                let dst_row_addr = screen_base + (dst_y + row) * screen_rb + dst_x;
587                for col in 0..col_count {
588                    let src_bit_x = src_x_offset + col;
589                    let src_byte = bus.read_byte(src_row_addr + src_bit_x / 8);
590                    let bit = (src_byte >> (7 - (src_bit_x & 7))) & 1;
591                    let dst_idx = if bit == 0 { white_index } else { black_index };
592                    bus.write_byte(dst_row_addr + col, dst_idx);
593                }
594            }
595            return;
596        }
597
598        // Same-depth fast path. Anything that's neither matched-depth nor
599        // 1bpp→8bpp falls through to a no-op.
600        if port_pixel_size != pixel_size as u32 {
601            return;
602        }
603
604        // block_move per row.
605        for row in 0..h.min(screen_h as u32 - dst_y) {
606            let src_addr = port_base + (src_y_offset + row) * port_rb + src_x_offset;
607            let dst_addr = screen_base + (dst_y + row) * screen_rb + dst_x;
608            let copy_w = w.min(screen_w as u32 - dst_x);
609            bus.block_move(src_addr, dst_addr, copy_w);
610        }
611    }
612
613    /// Draw window chrome (title bar, close box, border) into the framebuffer
614    /// WIND bounds are the CONTENT RECT; title bar is drawn ABOVE it.
615    pub(crate) fn draw_window_chrome(&self, bus: &mut MacMemoryBus, active: bool) {
616        let (screen_base, row_bytes, screen_width, screen_height, pixel_size) =
617            self.get_screen_params();
618        let (wind_top, wind_left, wind_bottom, wind_right) = self.window_bounds;
619
620        // Title bar area: drawn ABOVE the content rect
621        // Clamp to menu bar height — the Window Manager never draws
622        // chrome into the menu bar area.
623        let menu_bar_height = bus.read_word(crate::memory::globals::addr::MBAR_HEIGHT) as i16;
624        let tb_top = (wind_top - 19).max(menu_bar_height);
625        let tb_bottom = wind_top - 1;
626        let tb_left = wind_left - 1;
627        let tb_right = wind_right + 1;
628
629        // Fill title bar with white (exclusive bottom)
630        Self::fb_fill_rect(
631            bus,
632            screen_base,
633            row_bytes,
634            pixel_size,
635            screen_width,
636            screen_height,
637            tb_top,
638            tb_left,
639            tb_bottom + 1,
640            tb_right,
641            false,
642        );
643
644        // Draw title bar border: top and bottom (separator) lines
645        Self::fb_hline(
646            bus,
647            screen_base,
648            row_bytes,
649            pixel_size,
650            screen_width,
651            screen_height,
652            tb_top,
653            tb_left,
654            tb_right,
655            true,
656        );
657        Self::fb_hline(
658            bus,
659            screen_base,
660            row_bytes,
661            pixel_size,
662            screen_width,
663            screen_height,
664            tb_bottom,
665            tb_left,
666            tb_right,
667            true,
668        );
669        // Left and right border of title bar
670        for y in tb_top..=tb_bottom {
671            Self::fb_set_pixel(
672                bus,
673                screen_base,
674                row_bytes,
675                pixel_size,
676                screen_width,
677                screen_height,
678                tb_left,
679                y,
680                true,
681            );
682            Self::fb_set_pixel(
683                bus,
684                screen_base,
685                row_bytes,
686                pixel_size,
687                screen_width,
688                screen_height,
689                tb_right - 1,
690                y,
691                true,
692            );
693        }
694
695        // Calculate title text area if we have a title
696        let font_id: i16 = 0; // Chicago
697        let font_size: i16 = 12;
698        let metrics = get_font_metrics(font_id, font_size);
699        let text_height = metrics.ascent + metrics.descent;
700        let tb_interior_height = tb_bottom - tb_top - 1;
701        let text_y = tb_top + 1 + (tb_interior_height - text_height) / 2 + metrics.ascent;
702
703        let (title_clear_left, title_clear_right) = if !self.window_title.is_empty() {
704            let mut title_width: i16 = 0;
705            for ch in self.window_title.chars() {
706                if let Some((glyph, _)) = get_glyph(font_id, font_size, ch) {
707                    title_width += glyph.advance as i16;
708                } else {
709                    title_width += 6;
710                }
711            }
712            let text_x = tb_left + (tb_right - tb_left - title_width) / 2;
713            (text_x - 8, text_x + title_width + 8)
714        } else {
715            (tb_right, tb_right) // No clear area
716        };
717
718        let is_movable_modal = self.window_proc_id == 5;
719        let has_go_away = active && matches!(self.window_proc_id, 0 | 4) && self.go_away_flag;
720        let _close_box_width = if has_go_away { 15i16 } else { 0 };
721
722        if is_movable_modal {
723            // movableDBoxProc: plain title bar, no stripes
724            // Just draw the title text centered
725            if !self.window_title.is_empty() {
726                let text_x = title_clear_left + 8;
727                Self::fb_draw_string(
728                    bus,
729                    screen_base,
730                    row_bytes,
731                    pixel_size,
732                    screen_width,
733                    screen_height,
734                    text_x,
735                    text_y,
736                    &self.window_title,
737                    font_id,
738                    font_size,
739                );
740            }
741        } else {
742            // documentProc/noGrowDocProc: stripes + optional close box
743
744            // Draw close box if goAwayFlag is set.
745            //
746            // Classic Mac System 7.5.3 close-box graphic per BasiliskII golden
747            // (window_goaway): NOT a clean FrameRect. The WDEF draws an 11×11
748            // bounding region split into two shapes:
749            //   * top-left  L-shape — top horizontal (11 wide) + left vertical
750            //                         (11 tall), painting the 3D-highlight edge
751            //   * bottom-right Γ-shape — right vertical (8 tall, inset 2 from
752            //                            top + 1 from bottom) + bottom
753            //                            horizontal (8 wide, inset 2 from left
754            //                            + 1 from right), painting the inner
755            //                            close-box outline
756            // The 1-pixel gap between the two shapes gives the close box its
757            // characteristic 3D-button appearance.
758            // Inside Macintosh Volume V, V-188 figure 5-3.
759            if has_go_away {
760                let cb_size: i16 = 11;
761                let interior_top = tb_top + 1;
762                let interior_height = tb_bottom - interior_top;
763                let cb_top = interior_top + (interior_height - cb_size) / 2;
764                let cb_left = tb_left + 9; // 1px border + 8px padding
765
766                // Top-left L: full 11-wide top edge + full 11-tall left edge
767                Self::fb_hline(
768                    bus,
769                    screen_base,
770                    row_bytes,
771                    pixel_size,
772                    screen_width,
773                    screen_height,
774                    cb_top,
775                    cb_left,
776                    cb_left + cb_size,
777                    true,
778                );
779                for y in cb_top..(cb_top + cb_size) {
780                    Self::fb_set_pixel(
781                        bus,
782                        screen_base,
783                        row_bytes,
784                        pixel_size,
785                        screen_width,
786                        screen_height,
787                        cb_left,
788                        y,
789                        true,
790                    );
791                }
792
793                // Bottom-right Γ: 8-tall right edge + 8-wide bottom edge,
794                // inset 2 from the top-left and 1 from the bottom-right.
795                let inner_right = cb_left + cb_size - 2; // x=cb_left+9
796                let inner_bottom = cb_top + cb_size - 2; // y=cb_top+9
797                for y in (cb_top + 2)..(cb_top + cb_size - 1) {
798                    Self::fb_set_pixel(
799                        bus,
800                        screen_base,
801                        row_bytes,
802                        pixel_size,
803                        screen_width,
804                        screen_height,
805                        inner_right,
806                        y,
807                        true,
808                    );
809                }
810                Self::fb_hline(
811                    bus,
812                    screen_base,
813                    row_bytes,
814                    pixel_size,
815                    screen_width,
816                    screen_height,
817                    inner_bottom,
818                    cb_left + 2,
819                    cb_left + cb_size - 1,
820                    true,
821                );
822            }
823
824            // Draw horizontal stripe pattern in title bar (classic Mac pinstripes)
825            // Only active windows get stripes; inactive windows have plain white title bars
826            //
827            // System 7.5.3 reserves only 6 px of clear-area on each side of
828            // the title text for stripes (the 16-px `title_clear_left/right`
829            // margin is for text-glyph hit-testing, not for stripes).
830            // Inside Macintosh Volume V, V-188 figure 5-3.
831            if active {
832                let stripe_left_edge = tb_left + 2;
833                let stripe_right_end = tb_right - 2;
834                let stripe_text_left = title_clear_left + 2;
835                let stripe_text_right = title_clear_right - 2;
836
837                // Close box region to skip (if present)
838                let (cb_gap_left, cb_gap_right) = if has_go_away {
839                    let cb_left = tb_left + 9;
840                    let cb_right = cb_left + 10; // QD exclusive right
841                    (cb_left - 1, cb_right + 2) // 1px gap left, 2px gap right
842                } else {
843                    (stripe_right_end, stripe_right_end) // no gap
844                };
845
846                for y in (tb_top + 4)..=(tb_bottom - 3) {
847                    if (y - tb_top) % 2 == 0 {
848                        // Draw stripe segments, skipping close box and title text gaps
849                        // Segment 1: left edge to close box (or title text if no close box)
850                        let seg1_end = if has_go_away {
851                            cb_gap_left
852                        } else {
853                            stripe_text_left
854                        };
855                        if stripe_left_edge < seg1_end {
856                            Self::fb_hline(
857                                bus,
858                                screen_base,
859                                row_bytes,
860                                pixel_size,
861                                screen_width,
862                                screen_height,
863                                y,
864                                stripe_left_edge,
865                                seg1_end,
866                                true,
867                            );
868                        }
869                        // Segment 2: after close box to title text (only if close box exists)
870                        if has_go_away && cb_gap_right < stripe_text_left {
871                            Self::fb_hline(
872                                bus,
873                                screen_base,
874                                row_bytes,
875                                pixel_size,
876                                screen_width,
877                                screen_height,
878                                y,
879                                cb_gap_right,
880                                stripe_text_left,
881                                true,
882                            );
883                        }
884                        // Segment 3: after title text to right edge
885                        if stripe_text_right < stripe_right_end {
886                            Self::fb_hline(
887                                bus,
888                                screen_base,
889                                row_bytes,
890                                pixel_size,
891                                screen_width,
892                                screen_height,
893                                y,
894                                stripe_text_right,
895                                stripe_right_end,
896                                true,
897                            );
898                        }
899                    }
900                }
901            }
902
903            // Draw title text centered in title bar (active windows only)
904            if active && !self.window_title.is_empty() {
905                let text_x = title_clear_left + 8;
906                Self::fb_draw_string(
907                    bus,
908                    screen_base,
909                    row_bytes,
910                    pixel_size,
911                    screen_width,
912                    screen_height,
913                    text_x,
914                    text_y,
915                    &self.window_title,
916                    font_id,
917                    font_size,
918                );
919            }
920        }
921
922        // Draw window content area border
923        for y in wind_top..wind_bottom {
924            Self::fb_set_pixel(
925                bus,
926                screen_base,
927                row_bytes,
928                pixel_size,
929                screen_width,
930                screen_height,
931                wind_left - 1,
932                y,
933                true,
934            );
935            Self::fb_set_pixel(
936                bus,
937                screen_base,
938                row_bytes,
939                pixel_size,
940                screen_width,
941                screen_height,
942                wind_right,
943                y,
944                true,
945            );
946        }
947        // Bottom border line
948        Self::fb_hline(
949            bus,
950            screen_base,
951            row_bytes,
952            pixel_size,
953            screen_width,
954            screen_height,
955            wind_bottom,
956            wind_left - 1,
957            wind_right + 1,
958            true,
959        );
960
961        // Shadow effect
962        for y in (tb_top + 1)..=(wind_bottom + 1) {
963            Self::fb_set_pixel(
964                bus,
965                screen_base,
966                row_bytes,
967                pixel_size,
968                screen_width,
969                screen_height,
970                wind_right + 1,
971                y,
972                true,
973            );
974        }
975        Self::fb_hline(
976            bus,
977            screen_base,
978            row_bytes,
979            pixel_size,
980            screen_width,
981            screen_height,
982            wind_bottom + 1,
983            tb_left + 1,
984            wind_right + 2,
985            true,
986        );
987    }
988
989    /// Draw the grow icon (size box) in the bottom-right corner of a window.
990    /// The grow icon is a 15x15 area at the intersection of scroll bars.
991    /// Inside Macintosh Volume I, I-296
992    pub(crate) fn draw_grow_icon(&self, bus: &mut MacMemoryBus, window_ptr: u32) {
993        let (screen_base, row_bytes, screen_width, screen_height, pixel_size) =
994            self.get_screen_params();
995        // Read portRect (top, left, bottom, right) from the window record
996        let port_top = bus.read_word(window_ptr + 16) as i16;
997        let port_left = bus.read_word(window_ptr + 18) as i16;
998        let port_bottom = bus.read_word(window_ptr + 20) as i16;
999        let port_right = bus.read_word(window_ptr + 22) as i16;
1000        // Read PixMap bounds to get the origin offset
1001        let port_version = bus.read_word(window_ptr + 6);
1002        let (scr_top, scr_left) = if (port_version & 0xC000) == 0xC000 {
1003            let pm_handle = bus.read_long(window_ptr + 2);
1004            if pm_handle != 0 {
1005                let pm_ptr = bus.read_long(pm_handle);
1006                let bt = bus.read_word(pm_ptr + 6) as i16;
1007                let bl = bus.read_word(pm_ptr + 8) as i16;
1008                (-bt, -bl)
1009            } else {
1010                (0, 0)
1011            }
1012        } else {
1013            let bt = bus.read_word(window_ptr + 8) as i16;
1014            let bl = bus.read_word(window_ptr + 10) as i16;
1015            (-bt, -bl)
1016        };
1017
1018        // Content area in screen coordinates
1019        let content_top = scr_top + port_top;
1020        let content_left = scr_left + port_left;
1021        let content_bottom = scr_top + port_bottom;
1022        let content_right = scr_left + port_right;
1023
1024        // DrawGrowIcon draws the scroll bar separator lines:
1025        // - Vertical line at content_right - 15 from content_top to content_bottom
1026        // - Horizontal line at content_bottom - 15 from border_left to border_right
1027        let sep_x = content_right - 15;
1028        let sep_y = content_bottom - 15;
1029        let border_left = content_left - 1;
1030        let border_right = content_right + 1;
1031
1032        // Vertical scroll separator (full content height)
1033        for y in content_top..content_bottom {
1034            Self::fb_set_pixel(
1035                bus,
1036                screen_base,
1037                row_bytes,
1038                pixel_size,
1039                screen_width,
1040                screen_height,
1041                sep_x,
1042                y,
1043                true,
1044            );
1045        }
1046        // Horizontal scroll separator (border to border)
1047        Self::fb_hline(
1048            bus,
1049            screen_base,
1050            row_bytes,
1051            pixel_size,
1052            screen_width,
1053            screen_height,
1054            sep_y,
1055            border_left,
1056            border_right + 1,
1057            true,
1058        );
1059    }
1060
1061    /// Draw a 2-pixel thick rectangle border (FrameRect with PenSize 2,2).
1062    /// Coordinates are in QuickDraw convention: (top, left) inclusive, (bottom, right) exclusive.
1063    /// On the real Mac, the pen extends DOWN and RIGHT from each point,
1064    /// giving 2 rows at top but only 1 row at bottom (clipped to rect).
1065    pub(crate) fn draw_thick_rect_border(
1066        &self,
1067        bus: &mut MacMemoryBus,
1068        top: i16,
1069        left: i16,
1070        bottom: i16,
1071        right: i16,
1072    ) {
1073        let (screen_base, row_bytes, screen_width, screen_height, pixel_size) =
1074            self.get_screen_params();
1075        // Top edge: 2 rows (pen at top extends to top+1)
1076        for dy in 0..2i16 {
1077            Self::fb_hline(
1078                bus,
1079                screen_base,
1080                row_bytes,
1081                pixel_size,
1082                screen_width,
1083                screen_height,
1084                top + dy,
1085                left,
1086                right,
1087                true,
1088            );
1089        }
1090        // Bottom edge: 1 row at bottom-1 (pen at bottom-1 would extend to bottom, clipped)
1091        Self::fb_hline(
1092            bus,
1093            screen_base,
1094            row_bytes,
1095            pixel_size,
1096            screen_width,
1097            screen_height,
1098            bottom - 1,
1099            left,
1100            right,
1101            true,
1102        );
1103        // Left edge: 2 columns (pen at left extends to left+1)
1104        for dx in 0..2i16 {
1105            for y in top..bottom {
1106                Self::fb_set_pixel(
1107                    bus,
1108                    screen_base,
1109                    row_bytes,
1110                    pixel_size,
1111                    screen_width,
1112                    screen_height,
1113                    left + dx,
1114                    y,
1115                    true,
1116                );
1117            }
1118        }
1119        // Right edge: 2 columns (right-2 and right-1, both inside the rect)
1120        for dx in 0..2i16 {
1121            for y in top..bottom {
1122                Self::fb_set_pixel(
1123                    bus,
1124                    screen_base,
1125                    row_bytes,
1126                    pixel_size,
1127                    screen_width,
1128                    screen_height,
1129                    right - 2 + dx,
1130                    y,
1131                    true,
1132                );
1133            }
1134        }
1135    }
1136
1137    /// Erase (fill white) the structure region for a window, then draw the frame.
1138    /// On a real Mac the WDEF erases the structure region before drawing borders.
1139    fn erase_structure_region(
1140        &self,
1141        bus: &mut MacMemoryBus,
1142        top: i16,
1143        left: i16,
1144        bottom: i16,
1145        right: i16,
1146    ) {
1147        let (screen_base, row_bytes, screen_width, screen_height, pixel_size) =
1148            self.get_screen_params();
1149        Self::fb_fill_rect(
1150            bus,
1151            screen_base,
1152            row_bytes,
1153            pixel_size,
1154            screen_width,
1155            screen_height,
1156            top,
1157            left,
1158            bottom,
1159            right,
1160            false,
1161        );
1162    }
1163
1164    /// Erase only the frame/shadow area around content, leaving the content
1165    /// pixels untouched. WDEFs erase their structure region before drawing
1166    /// borders, but in the HLE framebuffer the content area may already hold
1167    /// app-rendered pixels that should not be replaced with white.
1168    fn erase_structure_frame_around_content(
1169        &self,
1170        bus: &mut MacMemoryBus,
1171        structure: (i16, i16, i16, i16),
1172        content: (i16, i16, i16, i16),
1173    ) {
1174        let (screen_base, row_bytes, screen_width, screen_height, pixel_size) =
1175            self.get_screen_params();
1176        let (st, sl, sb, sr) = structure;
1177        let (ct, cl, cb, cr) = content;
1178        let mut erase = |top: i16, left: i16, bottom: i16, right: i16| {
1179            if bottom <= top || right <= left {
1180                return;
1181            }
1182            Self::fb_fill_rect(
1183                bus,
1184                screen_base,
1185                row_bytes,
1186                pixel_size,
1187                screen_width,
1188                screen_height,
1189                top,
1190                left,
1191                bottom,
1192                right,
1193                false,
1194            );
1195        };
1196
1197        erase(st, sl, ct.min(sb), sr);
1198        erase(cb.max(st), sl, sb, sr);
1199        erase(ct.max(st), sl, cb.min(sb), cl.min(sr));
1200        erase(ct.max(st), cr.max(sl), cb.min(sb), sr);
1201    }
1202
1203    /// Draw the window frame/border for a given procID.
1204    /// Called when a visible window is created to render its frame to the screen.
1205    /// This implements the standard WDEF rendering for each window type:
1206    ///   - plainDBox (2): Single 1-pixel border
1207    ///   - dBoxProc (1): Double border (outer 1px at ±8, inner 2px at ±5)
1208    ///   - altDBoxProc (3): Single border + 2-pixel drop shadow
1209    ///   - documentProc (0), noGrowDocProc (4): Title bar chrome
1210    ///   - movableDBoxProc (5): Double border + title bar chrome
1211    ///
1212    /// Draws a single window's chrome inline by deriving its screen-coord
1213    /// bounds, title, and goAway flag from the WindowRecord. The dispatcher's
1214    /// per-window state is swapped in temporarily so draw_window_chrome reads
1215    /// the right context, then restored.
1216    pub(crate) fn draw_single_window_chrome_inline(
1217        &mut self,
1218        bus: &mut MacMemoryBus,
1219        window_ptr: u32,
1220        hilited: bool,
1221    ) {
1222        if window_ptr == 0 {
1223            return;
1224        }
1225        if bus.read_byte(window_ptr + 110u32) == 0 {
1226            return; // not visible
1227        }
1228        // plainDBox (2), dBoxProc (1), altDBoxProc (3), and rDocProc (16)
1229        // windows have NO title bar — only a border — per Inside Macintosh
1230        // Volume I, I-275. Dispatch to draw_window_frame for those procIDs
1231        // rather than draw_window_chrome (which paints title-bar chrome).
1232        let proc_id = self.window_proc_ids.get(&window_ptr).copied().unwrap_or(0);
1233        let port_version = bus.read_word(window_ptr + 6);
1234        let (pmap_top, pmap_left) = if (port_version & 0xC000) == 0xC000 {
1235            let pm_handle = bus.read_long(window_ptr + 2);
1236            let pm_ptr = bus.read_long(pm_handle);
1237            (
1238                bus.read_word(pm_ptr + 6) as i16,
1239                bus.read_word(pm_ptr + 8) as i16,
1240            )
1241        } else {
1242            (
1243                bus.read_word(window_ptr + 8) as i16,
1244                bus.read_word(window_ptr + 10) as i16,
1245            )
1246        };
1247        // wrapping_neg / wrapping_add match 68k Mac OS i16 wrap-
1248        // around — guards against debug-build panics on windows
1249        // whose pixmap.bounds.topLeft is i16::MIN or whose total
1250        // width/height exceeds i16 range.
1251        let wind_top = pmap_top.wrapping_neg();
1252        let wind_left = pmap_left.wrapping_neg();
1253        let port_bottom = bus.read_word(window_ptr + 20) as i16;
1254        let port_right = bus.read_word(window_ptr + 22) as i16;
1255        let wind_bottom = wind_top.wrapping_add(port_bottom);
1256        let wind_right = wind_left.wrapping_add(port_right);
1257        if wind_bottom <= wind_top || wind_right <= wind_left {
1258            return;
1259        }
1260        let saved_bounds = self.window_bounds;
1261        let saved_title = self.window_title.clone();
1262        let saved_go_away = self.go_away_flag;
1263        let saved_proc = self.window_proc_id;
1264        self.window_bounds = (wind_top, wind_left, wind_bottom, wind_right);
1265        let title_h = bus.read_long(window_ptr + 134u32);
1266        self.window_title = if title_h != 0 {
1267            let title_p = bus.read_long(title_h);
1268            if title_p != 0 {
1269                String::from_utf8_lossy(&bus.read_pstring(title_p)).into_owned()
1270            } else {
1271                String::new()
1272            }
1273        } else {
1274            String::new()
1275        };
1276        self.go_away_flag = bus.read_byte(window_ptr + 112u32) != 0;
1277        self.window_proc_id = proc_id;
1278        if matches!(proc_id, 1..=3) {
1279            self.draw_window_frame(bus);
1280        } else {
1281            self.draw_window_chrome(bus, hilited);
1282        }
1283        self.window_bounds = saved_bounds;
1284        self.window_title = saved_title;
1285        self.go_away_flag = saved_go_away;
1286        self.window_proc_id = saved_proc;
1287    }
1288
1289    pub(crate) fn draw_window_frame(&self, bus: &mut MacMemoryBus) {
1290        let (wind_top, wind_left, wind_bottom, wind_right) = self.window_bounds;
1291        match self.window_proc_id {
1292            2 => {
1293                // plainDBox: single 1-pixel black border, no chrome.
1294                // Inside Macintosh Volume I, I-275: plainDBox windows
1295                // get their canonical 1px border from the system WDEF.
1296                // The border sits OUTSIDE the content rect so it doesn't
1297                // paint over content the application drew inside.
1298                self.draw_rect_border(
1299                    bus,
1300                    wind_top - 1,
1301                    wind_left - 1,
1302                    wind_bottom + 1,
1303                    wind_right + 1,
1304                );
1305            }
1306            1 => {
1307                // dBoxProc: double border
1308                // Structure region = content expanded by 8
1309                // WDEF erases structure, then draws:
1310                //   1. Outer 1px border at (content-8, content-8, content+3, content+8)
1311                //   2. Inner 2px border at (content-5, content-5, content+3, content+5)
1312                // Note: bottom offset is +3 (not +8), making the border asymmetric.
1313                let struc_top = wind_top - 8;
1314                let struc_left = wind_left - 8;
1315                let struc_bottom = wind_bottom + 3;
1316                let struc_right = wind_right + 8;
1317                self.erase_structure_frame_around_content(
1318                    bus,
1319                    (struc_top, struc_left, struc_bottom, struc_right),
1320                    (wind_top, wind_left, wind_bottom, wind_right),
1321                );
1322                // Outer 1px border
1323                self.draw_rect_border(bus, struc_top, struc_left, struc_bottom, struc_right);
1324                // Inner 2px border (content-5 top/left/right, content+3 bottom)
1325                self.draw_thick_rect_border(
1326                    bus,
1327                    wind_top - 5,
1328                    wind_left - 5,
1329                    struc_bottom,
1330                    wind_right + 5,
1331                );
1332            }
1333            3 => {
1334                // altDBoxProc: single border + 2px drop shadow
1335                self.erase_structure_frame_around_content(
1336                    bus,
1337                    (wind_top - 1, wind_left - 1, wind_bottom + 3, wind_right + 3),
1338                    (wind_top, wind_left, wind_bottom, wind_right),
1339                );
1340                self.draw_rect_border(
1341                    bus,
1342                    wind_top - 1,
1343                    wind_left - 1,
1344                    wind_bottom + 1,
1345                    wind_right + 1,
1346                );
1347                // Shadow starts just below/right of the border (border bottom is at wind_bottom)
1348                self.draw_shadow(
1349                    bus,
1350                    wind_top - 1,
1351                    wind_left - 1,
1352                    wind_bottom + 1,
1353                    wind_right + 1,
1354                );
1355            }
1356            5 => {
1357                // movableDBoxProc: double border wrapping title bar space + content
1358                // The outer border extends above the content to leave room for a
1359                // title bar area (18px), but no title bar chrome is drawn inside.
1360                // Outer border: symmetric ±8 except top adds 15 for title bar space
1361                let struc_top = wind_top - 23;
1362                let struc_left = wind_left - 8;
1363                let struc_bottom = wind_bottom + 8;
1364                let struc_right = wind_right + 8;
1365                self.erase_structure_frame_around_content(
1366                    bus,
1367                    (struc_top, struc_left, struc_bottom, struc_right),
1368                    (wind_top, wind_left, wind_bottom, wind_right),
1369                );
1370                // Outer 1px border
1371                self.draw_rect_border(bus, struc_top, struc_left, struc_bottom, struc_right);
1372                // Inner 2px border around content area (±5)
1373                let thick_top = wind_top - 5;
1374                let thick_left = wind_left - 5;
1375                let thick_bottom = wind_bottom + 5;
1376                let thick_right = wind_right + 5;
1377                self.draw_thick_rect_border(bus, thick_top, thick_left, thick_bottom, thick_right);
1378                // draw_thick_rect_border draws 1 row at bottom; movDBox needs 2 rows
1379                let (screen_base, row_bytes, screen_width, screen_height, pixel_size) =
1380                    self.get_screen_params();
1381                Self::fb_hline(
1382                    bus,
1383                    screen_base,
1384                    row_bytes,
1385                    pixel_size,
1386                    screen_width,
1387                    screen_height,
1388                    thick_bottom - 2,
1389                    thick_left,
1390                    thick_right,
1391                    true,
1392                );
1393            }
1394            0 | 4 => {
1395                // Document-style windows with title bars
1396                // Erase structure region (content + title bar + 1px border + shadow)
1397                let tb_top = wind_top - 19;
1398                self.erase_structure_region(
1399                    bus,
1400                    tb_top,
1401                    wind_left - 1,
1402                    wind_bottom + 2,
1403                    wind_right + 2,
1404                );
1405                self.draw_window_chrome(bus, true);
1406            }
1407            _ => {
1408                // Unknown procID: at least draw a single border
1409                self.draw_rect_border(
1410                    bus,
1411                    wind_top - 1,
1412                    wind_left - 1,
1413                    wind_bottom + 1,
1414                    wind_right + 1,
1415                );
1416            }
1417        }
1418    }
1419
1420    /// Redraw the menu bar and window chrome into the framebuffer.
1421    ///
1422    /// On a real Mac, the Window Manager maintains these UI elements and redraws
1423    /// them after any update. Our emulator draws them as raw framebuffer pixels,
1424    /// so game drawing (explosions, etc.) can overwrite them. This method restores
1425    /// the chrome and should be called after each frame of emulation.
1426    pub fn redraw_chrome(&mut self, bus: &mut MacMemoryBus) {
1427        // Blit front window's port pixels to screen framebuffer if they differ.
1428        // On real Mac OS the Window Manager composites windows to the screen.
1429        // In HLE, games draw to the window's GrafPort which may have a different
1430        // baseAddr than the screen. Copy the window content so screenshots work.
1431        //
1432        // ModalDialog's HLE first paints standard dialog content directly into
1433        // the screen framebuffer, then injects any userItem draw procs and
1434        // re-snapshots the completed result. While that snapshot is pending,
1435        // the dialog's offscreen port may still be blank; blitting it here
1436        // erases the partially rendered dialog before the draw procs can finish.
1437        let pending_dialog_snapshot = self
1438            .dialog_tracking
1439            .as_ref()
1440            .map(|tracking| !tracking.game_managed && !tracking.rendered_pixels_final)
1441            .unwrap_or(false);
1442        if !pending_dialog_snapshot {
1443            self.blit_window_to_screen(bus);
1444        }
1445
1446        let menu_bar_height = bus.read_word(crate::memory::globals::addr::MBAR_HEIGHT) as i16;
1447        let (_, _, screen_w, screen_h, _) = self.screen_mode;
1448
1449        // Detect fullscreen: the front window covers the entire screen
1450        // (top <= 0, left <= 0, bottom >= screen_h, right >= screen_w)
1451        // and MBarHeight is 0.  Once detected, lock fullscreen mode so
1452        // that the game temporarily restoring MBarHeight (e.g. on
1453        // cursor-at-top) cannot flash the menu bar.
1454        let (wt, wl, wb, wr) = self.window_bounds;
1455        if self.fullscreen_locked && menu_bar_height > 0 {
1456            self.fullscreen_locked = false;
1457        }
1458
1459        if self.front_window != 0
1460            && wt <= 0
1461            && wl <= 0
1462            && wb >= screen_h as i16
1463            && wr >= screen_w as i16
1464            && menu_bar_height <= 0
1465        {
1466            self.fullscreen_locked = true;
1467        }
1468
1469        if !self.menus.is_empty() && !self.fullscreen_locked && !self.menu_bar_hidden {
1470            self.draw_menu_bar_to_fb(bus);
1471        }
1472        // Skip chrome for borderless/dialog window types that have no title bar.
1473        // procID 1 = dBoxProc, 2 = plainDBox, 3 = altDBoxProc
1474        // All other types (documentProc, noGrowDocProc, custom WDEFs) get chrome.
1475        // Also skip chrome when MBarHeight is 0 — the game has hidden the menu bar
1476        // for full-screen mode, so window chrome should not be drawn either.
1477        // Inside Macintosh Volume I, I-299; Inside Macintosh Volume V, V-245
1478
1479        // Games set MBarHeight to 0 by writing directly to the low-memory
1480        // global ($0BAA) for full-screen mode.  Since we can't intercept
1481        // memory writes, check here whether the front window's visRgn.top
1482        // is stale and needs expanding to cover the now-hidden menu bar area.
1483        // Inside Macintosh Volume V, V-245; Tricks of the Mac Game
1484        // Programming Gurus 1995, p. 30-265
1485        // `menu_bar_hidden` (default-on for game runtimes — see
1486        // `TrapDispatcher::menu_bar_hidden`) suppresses the chrome strip
1487        // even when MBarHeight is non-zero. Treat it like fullscreen for
1488        // visRgn-expansion purposes so the band the menu bar would occupy
1489        // is owned by the front window's visRgn, not left unpainted.
1490        let effective_mbar = if self.fullscreen_locked || self.menu_bar_hidden {
1491            0
1492        } else {
1493            menu_bar_height
1494        };
1495        // Only expand visRgn when the menu bar is HIDDEN (effective_mbar == 0).
1496        // Games set MBarHeight=0 for fullscreen mode and expect their window's
1497        // visRgn to extend over the now-hidden menu bar area. Doing this
1498        // unconditionally would clobber wind_top for documentProc windows where
1499        // the menu bar is visible (degenerate title bar on every redraw).
1500        if self.front_window != 0 && !no_visrgn_auto_expand_enabled() && effective_mbar == 0 {
1501            let vis_top_expected = 0i16;
1502            let vis_rgn_handle = bus.read_long(self.front_window + 24);
1503            if vis_rgn_handle != 0 {
1504                let vis_rgn = bus.read_long(vis_rgn_handle);
1505                if vis_rgn != 0 {
1506                    let current_vis_top = bus.read_word(vis_rgn + 2) as i16;
1507                    if current_vis_top != vis_top_expected {
1508                        bus.write_word(vis_rgn + 2, vis_top_expected as u16);
1509                        // Also update clipRgn and portRect to match.
1510                        let clip_rgn_handle = bus.read_long(self.front_window + 28);
1511                        if clip_rgn_handle != 0 {
1512                            let clip_rgn = bus.read_long(clip_rgn_handle);
1513                            if clip_rgn != 0 {
1514                                let clip_top = bus.read_word(clip_rgn + 2) as i16;
1515                                if clip_top > vis_top_expected {
1516                                    bus.write_word(clip_rgn + 2, vis_top_expected as u16);
1517                                }
1518                            }
1519                        }
1520                        let port_top = bus.read_word(self.front_window + 16) as i16;
1521                        if port_top > vis_top_expected {
1522                            bus.write_word(self.front_window + 16, vis_top_expected as u16);
1523                        }
1524                        self.window_bounds.0 = vis_top_expected;
1525                    }
1526                }
1527            }
1528        }
1529
1530        let skip_chrome = matches!(self.window_proc_id, 1 | 2 | 3 | 5) || effective_mbar <= 0;
1531
1532        // Draw chrome for each visible non-front window FIRST
1533        // (back-to-front order per window_list which is front-to-back),
1534        // then the front window on top. Each back-window's chrome uses its
1535        // WindowRecord-stored state (bounds derived from portPixMap bounds,
1536        // title from titleHandle, goAway byte, hilited byte).
1537        if !skip_chrome && effective_mbar > 0 {
1538            let list_snapshot = self.window_list.clone();
1539            let saved_bounds = self.window_bounds;
1540            let saved_title = self.window_title.clone();
1541            let saved_proc = self.window_proc_id;
1542            let saved_go_away = self.go_away_flag;
1543            // Iterate back-to-front so earlier windows get overdrawn
1544            // by later ones.
1545            for &w in list_snapshot.iter().rev() {
1546                if w == self.front_window {
1547                    continue;
1548                }
1549                if bus.read_byte(w + 110u32) == 0 {
1550                    // Not visible.
1551                    continue;
1552                }
1553                // Derive per-window screen bounds from port geometry:
1554                // init_cgraf_window writes pixmap bounds as
1555                // (-wind_top, -wind_left, screen_h - wind_top,
1556                //  screen_w - wind_left) — so wind_top = -bounds_top,
1557                // wind_left = -bounds_left, and window size comes
1558                // from portRect at window_ptr+16.
1559                let port_version = bus.read_word(w + 6);
1560                let (pmap_top, pmap_left) = if (port_version & 0xC000) == 0xC000 {
1561                    let pm_handle = bus.read_long(w + 2);
1562                    let pm_ptr = bus.read_long(pm_handle);
1563                    (
1564                        bus.read_word(pm_ptr + 6) as i16,
1565                        bus.read_word(pm_ptr + 8) as i16,
1566                    )
1567                } else {
1568                    (bus.read_word(w + 8) as i16, bus.read_word(w + 10) as i16)
1569                };
1570                let wind_top = -pmap_top;
1571                let wind_left = -pmap_left;
1572                let port_bottom = bus.read_word(w + 20) as i16;
1573                let port_right = bus.read_word(w + 22) as i16;
1574                let wind_bottom = wind_top + port_bottom;
1575                let wind_right = wind_left + port_right;
1576                // Degenerate / invalid — skip.
1577                if wind_bottom <= wind_top || wind_right <= wind_left {
1578                    continue;
1579                }
1580                self.window_bounds = (wind_top, wind_left, wind_bottom, wind_right);
1581                // Read title from titleHandle at +134.
1582                let title_h = bus.read_long(w + 134u32);
1583                self.window_title = if title_h != 0 {
1584                    let title_p = bus.read_long(title_h);
1585                    if title_p != 0 {
1586                        String::from_utf8_lossy(&bus.read_pstring(title_p)).into_owned()
1587                    } else {
1588                        String::new()
1589                    }
1590                } else {
1591                    String::new()
1592                };
1593                self.go_away_flag = bus.read_byte(w + 112u32) != 0;
1594                // Use the per-window procID. Windows with no title bar
1595                // (plainDBox/dBoxProc/altDBoxProc) draw only a border.
1596                let w_proc = self.window_proc_ids.get(&w).copied().unwrap_or(0);
1597                self.window_proc_id = w_proc;
1598                let hilited = bus.read_byte(w + 111u32) != 0;
1599                if matches!(w_proc, 1..=3) {
1600                    self.draw_window_frame(bus);
1601                } else {
1602                    self.draw_window_chrome(bus, hilited);
1603                }
1604            }
1605            // Restore front-window state before drawing front chrome.
1606            self.window_bounds = saved_bounds;
1607            self.window_title = saved_title;
1608            self.window_proc_id = saved_proc;
1609            self.go_away_flag = saved_go_away;
1610        }
1611
1612        if self.front_window != 0 && !skip_chrome {
1613            // Use the front window's hilited byte rather than hard-coding
1614            // active=true so HiliteWindow(front, false) renders no stripes.
1615            let front_hilited = bus.read_byte(self.front_window + 111u32) != 0;
1616            self.draw_window_chrome(bus, front_hilited);
1617        }
1618        // If a modal dialog is active, restore the rendered snapshot and
1619        // redraw only dynamic elements (edit text, button flash) on top.
1620        // Game-managed dialogs (all userItems) handle their own rendering
1621        // via the filter proc — skip restoration to avoid overwriting their content.
1622        // While userItem draw procs are pending (rendered_pixels_final=false),
1623        // skip restoration so the draw proc output accumulates in the framebuffer.
1624        // After all draw procs complete, ModalDialog re-snapshots the final state
1625        // and sets rendered_pixels_final=true before we begin restoring.
1626        if let Some(ref tracking) = self.dialog_tracking {
1627            if !tracking.game_managed && tracking.rendered_pixels_final {
1628                // Blit the pre-rendered dialog snapshot (includes pictures)
1629                self.restore_dialog_pixels(bus, tracking.bounds, &tracking.rendered_pixels);
1630
1631                // Re-draw the edit text field on top (may have changed since snapshot)
1632                if tracking.edit_item > 0 {
1633                    let idx = (tracking.edit_item - 1) as usize;
1634                    if idx < tracking.items.len() {
1635                        let item = &tracking.items[idx];
1636                        let abs_top = tracking.bounds.0 + item.rect.0;
1637                        let abs_left = tracking.bounds.1 + item.rect.1;
1638                        let abs_bottom = tracking.bounds.0 + item.rect.2;
1639                        let abs_right = tracking.bounds.1 + item.rect.3;
1640                        self.draw_edit_text(
1641                            bus,
1642                            abs_top,
1643                            abs_left,
1644                            abs_bottom,
1645                            abs_right,
1646                            &tracking.edit_text,
1647                            !tracking.edit_text_modified,
1648                        );
1649                    }
1650                }
1651
1652                // During flash, alternate highlight on the flashing button
1653                if tracking.flash_remaining > 0 && tracking.flash_item > 0 {
1654                    let fi = tracking.flash_item;
1655                    if (fi as usize) <= tracking.items.len() {
1656                        let item = &tracking.items[(fi - 1) as usize];
1657                        let (it, il, ib, ir) = item.rect;
1658                        let abs_top = tracking.bounds.0 + it;
1659                        let abs_left = tracking.bounds.1 + il;
1660                        let abs_bottom = tracking.bounds.0 + ib;
1661                        let abs_right = tracking.bounds.1 + ir;
1662                        if tracking.flash_remaining % 2 == 0 {
1663                            self.invert_button_rect(bus, abs_top, abs_left, abs_bottom, abs_right);
1664                        }
1665                    }
1666                }
1667
1668                if let Some(ref popup) = tracking.active_popup {
1669                    self.draw_menu_dropdown(bus, popup.active_menu, popup.dropdown_rect);
1670                    if popup.highlighted_item > 0 {
1671                        self.invert_dropdown_item_rect(
1672                            bus,
1673                            popup.dropdown_rect,
1674                            popup.highlighted_item,
1675                        );
1676                    }
1677                }
1678            }
1679        }
1680
1681        // If a menu dropdown is open, redraw it on top of the menu bar
1682        // so that the menu bar redraw doesn't erase it.
1683        if let Some(ref tracking) = self.menu_tracking {
1684            self.highlight_menu_title(bus, tracking.active_menu);
1685            self.draw_menu_dropdown(bus, tracking.active_menu, tracking.dropdown_rect);
1686            // During flash, alternate highlight: even remaining = highlighted,
1687            // odd remaining = not highlighted. Outside flash, always highlight.
1688            let show_highlight = if tracking.flash_remaining > 0 {
1689                tracking.flash_remaining % 2 == 0
1690            } else {
1691                true
1692            };
1693            if tracking.highlighted_item > 0 && show_highlight {
1694                self.invert_menu_item(bus, tracking.highlighted_item);
1695            }
1696        }
1697    }
1698}
1699
1700#[cfg(test)]
1701mod redraw_chrome_tests {
1702    use super::super::test_helpers::setup_with_port;
1703    use super::super::TrapDispatcher;
1704    use crate::memory::MemoryBus;
1705
1706    // Window/port layout from `setup_with_port`:
1707    //   port_ptr      = 0x181000
1708    //   port_top      = port_ptr + 16 (word)
1709    //   visRgn handle = port_ptr + 24 (long) → 0x182100
1710    //   visRgn        = 0x182000 (rgnSize @ +0, top @ +2, ...)
1711    //   clipRgn handle= port_ptr + 28 (long) → 0x182300
1712    //   clipRgn       = 0x182200
1713    const PORT_PTR: u32 = 0x181000;
1714    const VIS_RGN: u32 = 0x182000;
1715    const CLIP_RGN: u32 = 0x182200;
1716
1717    fn install_twilight_style_black_index(
1718        disp: &mut TrapDispatcher,
1719        bus: &mut crate::memory::MacMemoryBus,
1720    ) {
1721        let gdevice_handle = disp.ensure_main_gdevice(bus);
1722        bus.write_long(0x08A4, gdevice_handle);
1723        bus.write_long(0x0CC8, gdevice_handle);
1724
1725        let gdevice = bus.read_long(gdevice_handle);
1726        let pixmap_handle = bus.read_long(gdevice + 22);
1727        let pixmap = bus.read_long(pixmap_handle);
1728        let ctab_handle = bus.read_long(pixmap + 42);
1729        let ctab = bus.read_long(ctab_handle);
1730
1731        let black_entry = ctab + 8 + 8;
1732        bus.write_word(black_entry, 1);
1733        bus.write_word(black_entry + 2, 0);
1734        bus.write_word(black_entry + 4, 0);
1735        bus.write_word(black_entry + 6, 0);
1736
1737        let repurposed_entry = ctab + 8 + 255 * 8;
1738        bus.write_word(repurposed_entry, 255);
1739        bus.write_word(repurposed_entry + 2, 0xFFFF);
1740        bus.write_word(repurposed_entry + 4, 0xFFFF);
1741        bus.write_word(repurposed_entry + 6, 0xCCCC);
1742    }
1743
1744    /// When the menu bar is visible (MBarHeight > 0), the per-frame visRgn
1745    /// auto-expansion in `redraw_chrome` MUST NOT fire for a documentProc
1746    /// window whose visRgn.top is below the menu bar.
1747    #[test]
1748    fn redraw_chrome_preserves_window_bounds_when_menu_bar_visible() {
1749        let (mut disp, _cpu, mut bus) = setup_with_port();
1750
1751        // MissileCommand-shaped layout: WIND bounds (40, 2, 339, 508),
1752        // documentProc, menu bar visible at y=0..19.
1753        bus.write_word(crate::memory::globals::addr::MBAR_HEIGHT, 20);
1754        bus.write_word(VIS_RGN + 2, 40); // visRgn.top
1755        bus.write_word(CLIP_RGN + 2, 40); // clipRgn.top
1756        bus.write_word(PORT_PTR + 16, 40); // port_top
1757        disp.front_window = PORT_PTR;
1758        disp.window_bounds = (40, 2, 339, 508);
1759        disp.window_proc_id = 0; // documentProc
1760        disp.fullscreen_locked = false;
1761        // Specifically pin "host menu bar visible" — the constructor
1762        // hides it by default for game runtimes.
1763        disp.menu_bar_hidden = false;
1764
1765        disp.redraw_chrome(&mut bus);
1766
1767        assert_eq!(
1768            disp.window_bounds.0, 40,
1769            "window_bounds.0 must not be clobbered when MBarHeight>0"
1770        );
1771        assert_eq!(
1772            bus.read_word(VIS_RGN + 2) as i16,
1773            40,
1774            "visRgn.top must not be rewritten when MBarHeight>0"
1775        );
1776        assert_eq!(
1777            bus.read_word(PORT_PTR + 16) as i16,
1778            40,
1779            "port_top must not be rewritten when MBarHeight>0"
1780        );
1781    }
1782
1783    /// When the menu bar is hidden (MBarHeight == 0), the per-frame visRgn
1784    /// auto-expansion in `redraw_chrome` MUST fire and sweep the front
1785    /// window's visRgn.top down to 0 — fullscreen games rely on this so
1786    /// they can paint over the y=0..19 region.
1787    #[test]
1788    fn redraw_chrome_expands_visrgn_when_menu_bar_hidden() {
1789        let (mut disp, _cpu, mut bus) = setup_with_port();
1790
1791        // EV-shaped layout: front window's visRgn.top is still 20
1792        // (left over from before the game wrote MBarHeight=0).
1793        bus.write_word(crate::memory::globals::addr::MBAR_HEIGHT, 0);
1794        bus.write_word(VIS_RGN + 2, 20); // stale visRgn.top
1795        bus.write_word(CLIP_RGN + 2, 20); // stale clipRgn.top
1796        bus.write_word(PORT_PTR + 16, 20); // stale port_top
1797        disp.front_window = PORT_PTR;
1798        disp.window_bounds = (20, 0, 342, 512);
1799        disp.window_proc_id = 0; // documentProc
1800        disp.fullscreen_locked = false;
1801
1802        disp.redraw_chrome(&mut bus);
1803
1804        assert_eq!(
1805            bus.read_word(VIS_RGN + 2) as i16,
1806            0,
1807            "visRgn.top must be expanded to 0 when MBarHeight=0"
1808        );
1809        assert_eq!(
1810            disp.window_bounds.0, 0,
1811            "window_bounds.0 must be updated to match expanded visRgn"
1812        );
1813        assert_eq!(
1814            bus.read_word(PORT_PTR + 16) as i16,
1815            0,
1816            "port_top must be updated to match expanded visRgn"
1817        );
1818    }
1819
1820    /// Host-side `menu_bar_hidden = true` with MBarHeight > 0 must still
1821    /// expand the front window's visRgn down to y=0. Otherwise the band
1822    /// the menu bar would have occupied is owned by nobody — the host
1823    /// suppresses its chrome paint, but the window also won't paint
1824    /// there, leaving stale or uninitialized pixels at the top of every
1825    /// fullscreen game capture.
1826    #[test]
1827    fn redraw_chrome_expands_visrgn_when_host_hides_menu_bar() {
1828        let (mut disp, _cpu, mut bus) = setup_with_port();
1829
1830        // Game has NOT zeroed MBarHeight (a polite app that just
1831        // installed menus and never went fullscreen) — but the host
1832        // suppresses chrome because we're running it as a game.
1833        bus.write_word(crate::memory::globals::addr::MBAR_HEIGHT, 20);
1834        bus.write_word(VIS_RGN + 2, 20); // visRgn.top below where chrome would be
1835        bus.write_word(CLIP_RGN + 2, 20);
1836        bus.write_word(PORT_PTR + 16, 20);
1837        disp.front_window = PORT_PTR;
1838        disp.window_bounds = (20, 0, 342, 512);
1839        disp.window_proc_id = 0; // documentProc
1840        disp.fullscreen_locked = false;
1841        disp.menu_bar_hidden = true;
1842
1843        disp.redraw_chrome(&mut bus);
1844
1845        assert_eq!(
1846            bus.read_word(VIS_RGN + 2) as i16,
1847            0,
1848            "visRgn.top must be expanded to 0 when host hides the menu bar"
1849        );
1850        assert_eq!(
1851            disp.window_bounds.0, 0,
1852            "window_bounds.0 must follow visRgn.top up to 0"
1853        );
1854        assert_eq!(
1855            bus.read_word(PORT_PTR + 16) as i16,
1856            0,
1857            "port_top must follow visRgn.top up to 0"
1858        );
1859    }
1860
1861    /// `redraw_chrome` MUST NOT paint the menu bar (the white strip at
1862    /// y=0..MBarHeight) when `menu_bar_hidden = true`, even if the
1863    /// guest has installed menus and left MBarHeight > 0. This pins
1864    /// the kiosk-mode contract: the host suppresses chrome regardless
1865    /// of game state, so fullscreen captures match the BasiliskII
1866    /// reference that has no menu bar to draw.
1867    #[test]
1868    fn redraw_chrome_does_not_paint_menu_bar_when_menu_bar_hidden() {
1869        let (mut disp, _cpu, mut bus) = setup_with_port();
1870
1871        // Allocate a real screen buffer so blit_window_to_screen has
1872        // somewhere to land (without a real screen, the test would
1873        // pass trivially).
1874        let screen_base = bus.alloc(800 * 600);
1875        disp.screen_mode = (screen_base, 800, 800, 600, 8);
1876        bus.write_long(0x0824, screen_base);
1877
1878        // Pre-fill the would-be menu bar band with a sentinel byte so
1879        // any chrome paint trampling it is detectable.
1880        for i in 0u32..(800 * 20) {
1881            bus.write_byte(screen_base + i, 0xAA);
1882        }
1883
1884        // Game has installed menus and left MBarHeight = 20, but the
1885        // host runtime is configured for kiosk mode.
1886        bus.write_word(crate::memory::globals::addr::MBAR_HEIGHT, 20);
1887        disp.menus.push(super::super::menu::Menu {
1888            id: 1,
1889            title: String::from("Apple"),
1890            items: Vec::new(),
1891            enabled: true,
1892            handle: 0,
1893            in_menu_bar: true,
1894        });
1895        // Skip blit_window_to_screen by leaving front_window=0 — the
1896        // test specifically targets draw_menu_bar_to_fb, not the window
1897        // composite path.
1898        disp.front_window = 0;
1899        disp.fullscreen_locked = false;
1900        disp.menu_bar_hidden = true;
1901
1902        disp.redraw_chrome(&mut bus);
1903
1904        // Sample a few bytes inside the menu-bar band. None should
1905        // have been overwritten with white (0xFF) — they should still
1906        // be the 0xAA sentinel we pre-filled.
1907        for &x in &[0u32, 100, 400, 799] {
1908            for &y in &[0u32, 5, 10, 19] {
1909                let off = y * 800 + x;
1910                assert_eq!(
1911                    bus.read_byte(screen_base + off),
1912                    0xAA,
1913                    "menu bar pixel at (x={}, y={}) should not be repainted \
1914                     when menu_bar_hidden=true",
1915                    x,
1916                    y
1917                );
1918            }
1919        }
1920    }
1921
1922    /// Counterpart to the kiosk-mode test above: when `menu_bar_hidden
1923    /// = false` (app-style hosting), `redraw_chrome` MUST paint the
1924    /// menu bar so menus are reachable. This pins the env-var-driven
1925    /// opt-out (SYSTEMLESS_SHOW_MENU_BAR=1 → menu_bar_hidden=false).
1926    #[test]
1927    fn redraw_chrome_paints_menu_bar_when_not_hidden() {
1928        let (mut disp, _cpu, mut bus) = setup_with_port();
1929
1930        let screen_base = bus.alloc(800 * 600);
1931        disp.screen_mode = (screen_base, 800, 800, 600, 8);
1932        bus.write_long(0x0824, screen_base);
1933
1934        // Pre-fill with sentinel so we can detect any paint.
1935        for i in 0u32..(800 * 20) {
1936            bus.write_byte(screen_base + i, 0xAA);
1937        }
1938
1939        bus.write_word(crate::memory::globals::addr::MBAR_HEIGHT, 20);
1940        disp.menus.push(super::super::menu::Menu {
1941            id: 1,
1942            title: String::from("Apple"),
1943            items: Vec::new(),
1944            enabled: true,
1945            handle: 0,
1946            in_menu_bar: true,
1947        });
1948        disp.front_window = 0;
1949        disp.fullscreen_locked = false;
1950        disp.menu_bar_hidden = false;
1951
1952        disp.redraw_chrome(&mut bus);
1953
1954        // The menu bar fills with white. In Mac 8bpp CLUT convention
1955        // index 0 = white, index 255 = black (Imaging With QuickDraw
1956        // 1994, 4-7), so painted pixels read back as 0x00, not 0xFF.
1957        // Either way, they cannot still be the 0xAA sentinel we
1958        // pre-filled — that's the load-bearing assertion.
1959        for &x in &[100u32, 400, 700] {
1960            for &y in &[5u32, 10] {
1961                let off = y * 800 + x;
1962                let pix = bus.read_byte(screen_base + off);
1963                assert_ne!(
1964                    pix, 0xAA,
1965                    "menu bar pixel at (x={}, y={}) should be painted \
1966                     when menu_bar_hidden=false (read 0x{:02X}, sentinel 0xAA)",
1967                    x, y, pix
1968                );
1969            }
1970        }
1971    }
1972
1973    /// Pin directive #3 from the task brief: "stays hidden even when
1974    /// the mouse hits the top of the screen". Hovering the mouse at
1975    /// y<MBarHeight while menu_bar_hidden=true must NOT trigger any
1976    /// chrome paint or menu-bar rendering. The previous tests cover
1977    /// the per-frame redraw_chrome guard; this test specifically
1978    /// re-runs redraw_chrome after a mouse_pos update to the top of
1979    /// the screen (the cursor-on-mbar scenario the user keeps
1980    /// flagging) and verifies the y=0..19 band still matches the
1981    /// pre-update sentinel.
1982    #[test]
1983    fn redraw_chrome_does_not_paint_on_mouse_at_top_when_hidden() {
1984        let (mut disp, _cpu, mut bus) = setup_with_port();
1985
1986        let screen_base = bus.alloc(800 * 600);
1987        disp.screen_mode = (screen_base, 800, 800, 600, 8);
1988        bus.write_long(0x0824, screen_base);
1989
1990        // Pre-fill the menu bar band with sentinel.
1991        for i in 0u32..(800 * 20) {
1992            bus.write_byte(screen_base + i, 0xAA);
1993        }
1994
1995        bus.write_word(crate::memory::globals::addr::MBAR_HEIGHT, 20);
1996        disp.menus.push(super::super::menu::Menu {
1997            id: 1,
1998            title: String::from("Apple"),
1999            items: Vec::new(),
2000            enabled: true,
2001            handle: 0,
2002            in_menu_bar: true,
2003        });
2004        disp.front_window = 0;
2005        disp.fullscreen_locked = false;
2006        disp.menu_bar_hidden = true;
2007
2008        // Initial redraw — sentinel preserved.
2009        disp.redraw_chrome(&mut bus);
2010        for x in [0u32, 100, 400, 799] {
2011            for y in [0u32, 5, 10, 19] {
2012                assert_eq!(
2013                    bus.read_byte(screen_base + y * 800 + x),
2014                    0xAA,
2015                    "initial frame: sentinel must hold at (x={}, y={})",
2016                    x,
2017                    y
2018                );
2019            }
2020        }
2021
2022        // Move mouse to the top of the screen — the very scenario
2023        // the task brief calls out. mouse_pos updates to (v=2, h=400)
2024        // which is well inside the would-be menu-bar band.
2025        disp.mouse_pos = (2, 400);
2026        bus.write_word(0x0828, 2u16); // MTemp.v
2027        bus.write_word(0x082A, 400u16); // MTemp.h
2028        bus.write_word(0x082C, 2u16); // RawMouse.v
2029        bus.write_word(0x082E, 400u16); // RawMouse.h
2030        bus.write_word(0x0830, 2u16); // Mouse.v
2031        bus.write_word(0x0832, 400u16); // Mouse.h
2032
2033        // Re-run redraw_chrome. With menu_bar_hidden=true the band
2034        // must STILL be untouched even though the cursor is now
2035        // inside it.
2036        disp.redraw_chrome(&mut bus);
2037        for x in [0u32, 100, 400, 799] {
2038            for y in [0u32, 5, 10, 19] {
2039                assert_eq!(
2040                    bus.read_byte(screen_base + y * 800 + x),
2041                    0xAA,
2042                    "after mouse-at-top: sentinel must still hold at (x={}, y={})",
2043                    x,
2044                    y
2045                );
2046            }
2047        }
2048    }
2049
2050    #[test]
2051    fn redraw_chrome_skips_window_blit_while_dialog_snapshot_pending() {
2052        let (mut disp, _cpu, mut bus) = setup_with_port();
2053
2054        let screen_base = bus.alloc(800 * 600);
2055        disp.screen_mode = (screen_base, 800, 800, 600, 8);
2056        bus.write_long(0x0824, screen_base);
2057
2058        let offscreen_base = bus.alloc(64 * 200);
2059        bus.write_long(PORT_PTR + 2, offscreen_base);
2060        bus.write_word(PORT_PTR + 6, 64);
2061        bus.write_word(PORT_PTR + 8, 0);
2062        bus.write_word(PORT_PTR + 10, 0);
2063        bus.write_word(PORT_PTR + 12, 200);
2064        bus.write_word(PORT_PTR + 14, 512);
2065
2066        let probe = screen_base + 10 * 800 + 10;
2067        bus.write_byte(probe, 0x42);
2068        bus.write_byte(offscreen_base + 10 * 64 + 1, 0x00);
2069
2070        bus.write_word(crate::memory::globals::addr::MBAR_HEIGHT, 20);
2071        disp.front_window = PORT_PTR;
2072        disp.window_bounds = (0, 0, 200, 512);
2073        disp.window_proc_id = 1;
2074        disp.dialog_tracking = Some(super::super::dispatch::DialogTrackingState {
2075            game_managed: false,
2076            rendered_pixels_final: false,
2077            ..Default::default()
2078        });
2079
2080        disp.redraw_chrome(&mut bus);
2081
2082        assert_eq!(
2083            bus.read_byte(probe),
2084            0x42,
2085            "pending ModalDialog snapshots must not be erased by blank offscreen port blits"
2086        );
2087
2088        if let Some(tracking) = disp.dialog_tracking.as_mut() {
2089            tracking.rendered_pixels_final = true;
2090        }
2091        disp.redraw_chrome(&mut bus);
2092
2093        assert_eq!(
2094            bus.read_byte(probe),
2095            0x00,
2096            "once the dialog snapshot is final, normal port blitting should resume"
2097        );
2098    }
2099
2100    #[test]
2101    fn fb_fill_rect_uses_active_ctab_brightest_entry_for_white() {
2102        let (mut disp, _cpu, mut bus) = setup_with_port();
2103
2104        let screen_base = bus.alloc(100 * 100);
2105        disp.screen_mode = (screen_base, 100, 100, 100, 8);
2106        bus.write_long(0x0824, screen_base);
2107
2108        let gdevice_handle = disp.ensure_main_gdevice(&mut bus);
2109        bus.write_long(0x08A4, gdevice_handle);
2110        bus.write_long(0x0CC8, gdevice_handle);
2111        let gdevice = bus.read_long(gdevice_handle);
2112        let pixmap_handle = bus.read_long(gdevice + 22);
2113        let pixmap = bus.read_long(pixmap_handle);
2114        let ctab_handle = bus.read_long(pixmap + 42);
2115        let ctab = bus.read_long(ctab_handle);
2116        for index in 0u32..256 {
2117            let entry = ctab + 8 + index * 8;
2118            bus.write_word(entry, index as u16);
2119            bus.write_word(entry + 2, 0);
2120            bus.write_word(entry + 4, 0);
2121            bus.write_word(entry + 6, 0);
2122        }
2123        let white_entry = ctab + 8 + 8;
2124        bus.write_word(white_entry, 1);
2125        bus.write_word(white_entry + 2, 0xFFFF);
2126        bus.write_word(white_entry + 4, 0xFFFF);
2127        bus.write_word(white_entry + 6, 0xFFFF);
2128
2129        TrapDispatcher::fb_fill_rect(
2130            &mut bus,
2131            screen_base,
2132            100,
2133            8,
2134            100,
2135            100,
2136            10,
2137            10,
2138            20,
2139            20,
2140            false,
2141        );
2142
2143        assert_eq!(
2144            bus.read_byte(screen_base + 10 * 100 + 10),
2145            1,
2146            "logical white must follow the active ColorTable, not hard-code CLUT index 0"
2147        );
2148    }
2149
2150    #[test]
2151    fn fb_fill_rect_uses_active_ctab_darkest_entry_for_black() {
2152        let (mut disp, _cpu, mut bus) = setup_with_port();
2153
2154        let screen_base = bus.alloc(100 * 100);
2155        disp.screen_mode = (screen_base, 100, 100, 100, 8);
2156        bus.write_long(0x0824, screen_base);
2157        install_twilight_style_black_index(&mut disp, &mut bus);
2158
2159        TrapDispatcher::fb_fill_rect(
2160            &mut bus,
2161            screen_base,
2162            100,
2163            8,
2164            100,
2165            100,
2166            10,
2167            10,
2168            20,
2169            20,
2170            true,
2171        );
2172
2173        assert_eq!(
2174            bus.read_byte(screen_base + 10 * 100 + 10),
2175            1,
2176            "logical black must follow the active ColorTable when index 255 is not black"
2177        );
2178    }
2179
2180    /// When a front window's port is 1bpp and the screen is 8bpp,
2181    /// `blit_window_to_screen` MUST do per-pixel bit extraction (each src
2182    /// bit → 0 or 0xFF). Falling through to the same-depth `block_move`
2183    /// path treats pixel-width as byte-width — a stride bug that produces
2184    /// a tiled-garbage band on screen.
2185    #[test]
2186    fn redraw_chrome_blit_1bpp_to_8bpp_is_bit_extracted() {
2187        let (mut disp, _cpu, mut bus) = setup_with_port();
2188
2189        // setup_with_port doesn't initialize disp.screen_mode. Allocate
2190        // a real 800×600 8bpp screen buffer in bus memory (the default
2191        // 4MB RAM doesn't reach the play-runner's $01F80000 base).
2192        let screen_base = bus.alloc(800 * 600);
2193        disp.screen_mode = (screen_base, 800, 800, 600, 8);
2194        bus.write_long(0x0824, screen_base);
2195
2196        // Allocate an offscreen 1bpp port buffer separate from the
2197        // screen. Basic GrafPort (port_version & 0xC000 != 0xC000) is
2198        // implicitly 1bpp.
2199        let offscreen_base = bus.alloc(64 * 200);
2200        bus.write_long(PORT_PTR + 2, offscreen_base); // portBits.baseAddr
2201        bus.write_word(PORT_PTR + 6, 64); // rowBytes (no flag bits = basic GrafPort)
2202
2203        // setup_with_port wrote portBits.bounds (0,0,342,512) and
2204        // portRect (0,0,342,512). Override portBits.bounds to match
2205        // our 64-byte rowBytes × 200-row allocation: (0, 0, 200, 512).
2206        bus.write_word(PORT_PTR + 8, 0); // bounds.top
2207        bus.write_word(PORT_PTR + 10, 0); // bounds.left
2208        bus.write_word(PORT_PTR + 12, 200); // bounds.bottom
2209        bus.write_word(PORT_PTR + 14, 512); // bounds.right
2210
2211        // Source pattern: row 30 byte 12 = 0b10000000 (bit 7 set) means
2212        // source pixel (row=30, col=96) = 1. Adjacent pixels (col=97..103)
2213        // are 0. After 1bpp→8bpp conversion, screen pixel (30, 96) should
2214        // become 0xFF and (30, 97) should become 0x00.
2215        bus.write_byte(offscreen_base + 30 * 64 + 12, 0b10000000);
2216
2217        // Pre-fill the test pixels with a sentinel so we can detect
2218        // both bit values being correctly written.
2219        let row30_x96 = screen_base + 30 * 800 + 96;
2220        let row30_x97 = screen_base + 30 * 800 + 97;
2221        bus.write_byte(row30_x96, 0xAB);
2222        bus.write_byte(row30_x97, 0xAB);
2223
2224        bus.write_word(crate::memory::globals::addr::MBAR_HEIGHT, 20);
2225        disp.front_window = PORT_PTR;
2226        disp.window_bounds = (0, 0, 200, 400);
2227        disp.window_proc_id = 1; // dBoxProc → skip chrome draw
2228        disp.fullscreen_locked = false;
2229
2230        disp.redraw_chrome(&mut bus);
2231
2232        assert_eq!(
2233            bus.read_byte(row30_x96),
2234            0xFF,
2235            "1bpp src bit=1 must map to 8bpp screen idx 0xFF"
2236        );
2237        assert_eq!(
2238            bus.read_byte(row30_x97),
2239            0x00,
2240            "1bpp src bit=0 must map to 8bpp screen idx 0x00 (blit MUST fire)"
2241        );
2242    }
2243
2244    #[test]
2245    fn redraw_chrome_blit_1bpp_to_8bpp_uses_active_ctab_black_entry() {
2246        let (mut disp, _cpu, mut bus) = setup_with_port();
2247
2248        let screen_base = bus.alloc(800 * 600);
2249        disp.screen_mode = (screen_base, 800, 800, 600, 8);
2250        bus.write_long(0x0824, screen_base);
2251        install_twilight_style_black_index(&mut disp, &mut bus);
2252
2253        let offscreen_base = bus.alloc(64 * 200);
2254        bus.write_long(PORT_PTR + 2, offscreen_base);
2255        bus.write_word(PORT_PTR + 6, 64);
2256        bus.write_word(PORT_PTR + 8, 0);
2257        bus.write_word(PORT_PTR + 10, 0);
2258        bus.write_word(PORT_PTR + 12, 200);
2259        bus.write_word(PORT_PTR + 14, 512);
2260
2261        bus.write_byte(offscreen_base + 30 * 64 + 12, 0b10000000);
2262
2263        let row30_x96 = screen_base + 30 * 800 + 96;
2264        let row30_x97 = screen_base + 30 * 800 + 97;
2265        bus.write_byte(row30_x96, 0xAB);
2266        bus.write_byte(row30_x97, 0xAB);
2267
2268        bus.write_word(crate::memory::globals::addr::MBAR_HEIGHT, 20);
2269        disp.front_window = PORT_PTR;
2270        disp.window_bounds = (0, 0, 200, 400);
2271        disp.window_proc_id = 1;
2272        disp.fullscreen_locked = false;
2273
2274        disp.redraw_chrome(&mut bus);
2275
2276        assert_eq!(
2277            bus.read_byte(row30_x96),
2278            1,
2279            "1bpp src bit=1 must use the active ColorTable's black index"
2280        );
2281        assert_eq!(
2282            bus.read_byte(row30_x97),
2283            0,
2284            "1bpp src bit=0 must still use the active ColorTable's white index"
2285        );
2286    }
2287}