diff --git a/src/memory/bus.rs b/src/memory/bus.rs index 83e740d4..54de4682 100644 --- a/src/memory/bus.rs +++ b/src/memory/bus.rs @@ -13,12 +13,18 @@ use super::globals::LowMemGlobals; const LEGACY_SOUND_BUFFER_WORDS: u32 = 370; const LEGACY_SOUND_BUFFER_BYTES: u32 = LEGACY_SOUND_BUFFER_WORDS * 2; const SYNTHETIC_RESERVE_BYTES: u32 = 64 * 1024; +// Keep the boot-ROM shadow deliberately narrow: bytes outside these witnessed +// or synthetic HLE words retain the bus's existing unmapped-zero behavior. // System 7.5.3 on the Quadra 650 leaves exception vector 0 pointing to -// $40810000. A BasiliskII oracle capture of that ROM establishes the word at -// offset 6 as $0372. Keep the shadow deliberately narrow: bytes outside this -// witnessed range retain the bus's existing unmapped-zero behavior. -const BOOT_ROM_SHADOW_BASE: u32 = 0x4081_0006; -const BOOT_ROM_SHADOW: [u8; 2] = 0x0372u16.to_be_bytes(); +// $40810000; a BasiliskII oracle capture establishes the word at offset 6 as +// $0372. The two RTE words let the runner retain ROM-shaped exception-vector +// values while safely resuming after the post-instruction exceptions it +// handles (Zero Divide, CHK, and TRAPV). +const BOOT_ROM_WORD_SHADOWS: &[(u32, u16)] = &[ + (0x4080_26F8, 0x4E73), + (0x4080_26FA, 0x4E73), + (0x4081_0006, 0x0372), +]; // Release-mode tracer for writes to a guest address range. Use to // localize the source of unexpected pixel writes in the framebuffer // or to any other narrow guest memory range. Format: @@ -768,8 +774,10 @@ impl RamStorage { impl MacMemoryBus { #[inline] fn boot_rom_shadow_byte(address: u32) -> Option { - let offset = address.checked_sub(BOOT_ROM_SHADOW_BASE)? as usize; - BOOT_ROM_SHADOW.get(offset).copied() + BOOT_ROM_WORD_SHADOWS.iter().find_map(|&(base, word)| { + let offset = address.checked_sub(base)?; + (offset < 2).then(|| word.to_be_bytes()[offset as usize]) + }) } pub(crate) fn allocation_bucket_size(size: u32) -> u32 { @@ -953,8 +961,7 @@ impl MacMemoryBus { pub(crate) fn configure_screen_depth(&mut self, depth: u16) { debug_assert!(matches!(depth, 1 | 4 | 8)); let profile = crate::machine_profile::reference_machine_profile(); - let visible_row_bytes = - (u32::from(profile.screen_width) * u32::from(depth)).div_ceil(8); + let visible_row_bytes = (u32::from(profile.screen_width) * u32::from(depth)).div_ceil(8); let row_bytes = (visible_row_bytes / 16 + 1) * 16; self.write_word(super::globals::addr::SCREEN_ROW, row_bytes as u16); self.write_word(super::globals::addr::SCREEN_BITS + 4, row_bytes as u16); @@ -1977,6 +1984,16 @@ mod tests { assert_eq!(bus.read_byte(0x4081_0008), 0); } + #[test] + fn boot_rom_shadow_exposes_post_instruction_rte_handlers() { + let bus = MacMemoryBus::new(1024); + + assert_eq!(bus.read_word(0x4080_26F8), 0x4E73); + assert_eq!(bus.read_word(0x4080_26FA), 0x4E73); + assert_eq!(bus.read_byte(0x4080_26F7), 0); + assert_eq!(bus.read_byte(0x4080_26FC), 0); + } + #[test] fn boot_rom_shadow_ignores_writes() { let mut bus = MacMemoryBus::new(1024); diff --git a/src/memory/globals.rs b/src/memory/globals.rs index 926c3942..9a3eddf9 100644 --- a/src/memory/globals.rs +++ b/src/memory/globals.rs @@ -77,10 +77,9 @@ pub mod addr { /// returned zero, to surface "which disabled item did the user click?" /// for help/explanation UI. Per IM:V V-248 + MTb 1992 3-118 (the /// canonical EQU at IM:V V-571 line 8689: `MenuDisable EQU $0B54`). - /// Systemless's HLE reads this lowmem word directly in MenuChoice; it - /// still does not synthesize the MDEF cursor-tracking writes that - /// classic ROMs receive, so tests seed the value explicitly when they - /// need a deterministic result. + /// Systemless's HLE reads this lowmem word directly in MenuChoice. + /// Popup-control MDEF tracking updates it as the cursor changes items; + /// direct MenuSelect/MenuKey tests may seed it for deterministic results. /// Inside Macintosh Volume V, V-248 (MenuChoice routine description) /// and V-571 (assembly globals table); Macintosh Toolbox Essentials /// 1992, 3-118..3-119 (MenuChoice canonical chapter). diff --git a/src/runner.rs b/src/runner.rs index 35362949..6836e415 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -102,6 +102,11 @@ const APPLICATION_RESOURCE_REFNUM: u16 = 2; const HFS_FCB_SIZE: u16 = 94; const HFS_FCB_BUFFER_SIZE: u16 = 2 + HFS_FCB_SIZE; const HFS_VCB_SIZE: u32 = 178; +// Synthetic RTE bodies shadowed in the boot-ROM address space. Keeping these +// vectors ROM-shaped matters independently of taking an exception: classic +// software can inspect the vector table as ordinary low memory. +const BOOT_ROM_RTE_HANDLER: u32 = 0x4080_26F8; +const BOOT_ROM_TRAPV_RTE_HANDLER: u32 = 0x4080_26FA; // Sound 1994, pp. 2-146–2-148: a doubleback routine refills an exhausted // buffer before returning. Keep a bounded safety limit, but allow callbacks // that do substantially more work than a normal foreground interpreter batch. @@ -1519,6 +1524,7 @@ enum ActiveInterruptCallbackSource { SoundFileCompletion, SoundDoubleBack, FileCompletion, + NotificationResponse, DialogDrawProc, DialogFilterProc, MenuHook, @@ -1842,6 +1848,9 @@ pub struct FixtureRunner { /// Guest-memory trampoline used to invoke File Manager asynchronous /// completion procedures. file_completion_trampoline: u32, + /// Guest-memory trampoline used to invoke Notification Manager response + /// procedures. + notification_response_trampoline: u32, /// Guest-memory address of the dialog userItem draw proc trampoline (26 bytes). /// Allocated once on first use and reused for all subsequent draw proc calls. dialog_draw_trampoline: u32, @@ -2008,6 +2017,7 @@ impl FixtureRunner { sound_callback_trampoline: 0, sound_file_completion_trampoline: 0, file_completion_trampoline: 0, + notification_response_trampoline: 0, dialog_draw_trampoline: 0, dialog_filter_trampoline: 0, menu_hook_trampoline: 0, @@ -5020,6 +5030,7 @@ impl FixtureRunner { // completed request before the foreground application can inspect // or reuse its parameter block. if !sound_work_only && self.active_interrupt_callback.is_none() { + self.fire_notification_response_callback(); self.fire_file_completion_callback(); self.fire_adb_callback(); } @@ -9078,6 +9089,50 @@ impl FixtureRunner { true } + /// Deliver one Pascal Notification Manager response procedure with its + /// NMRecPtr argument on the stack. The response procedure removes that + /// argument and the Notification Manager does not set up A5 before calling. + /// Inside Macintosh Volume VI, VI-24-8 to VI-24-10. + fn fire_notification_response_callback(&mut self) -> bool { + if self.active_interrupt_callback.is_some() { + return false; + } + + let Some(response) = self + .dispatcher + .pending_notification_responses + .front() + .copied() + else { + return false; + }; + if self.dispatcher.tick_count < response.ready_tick { + return false; + } + self.dispatcher.pending_notification_responses.pop_front(); + if !self + .dispatcher + .installed_notifications + .contains(&response.notification_record) + { + return false; + } + + if self.notification_response_trampoline == 0 { + let tramp = self.bus.alloc_synthetic(14); + self.bus.write_word(tramp, 0x2F3C); // MOVE.L #NMRecPtr,-(SP) + self.bus.write_word(tramp + 6, 0x4EB9); // JSR abs.L + self.bus.write_word(tramp + 12, 0x4E75); // RTS + self.notification_response_trampoline = tramp; + } + + let tramp = self.notification_response_trampoline; + self.bus.write_long(tramp + 2, response.notification_record); + self.bus.write_long(tramp + 8, response.response_addr); + self.inject_interrupt_callback(ActiveInterruptCallbackSource::NotificationResponse, tramp); + true + } + /// Deliver one pending ADB Talk-register-0 packet to the service routine /// installed through SetADBInfo. /// @@ -10408,7 +10463,7 @@ fn load_app_generic( bus.write_long((vector as u32) * 4, handler); } - // Install default RTE stubs for the "post-instruction" exception + // Install default RTE handlers for the "post-instruction" exception // vectors that real Mac OS would route to SysError. Because these // exceptions all stack the PC of the *next* instruction (per // M68000PRM, "Group 2 — internal" — vectors 5/6/7 advance PC past @@ -10425,10 +10480,13 @@ fn load_app_generic( // instruction), so RTE-ing would re-execute it and loop forever. // Properly handling those requires a skip-the-instruction stub // which is a separate undertaking. - bus.write_word(0x00FE, 0x4E73); // RTE - bus.write_long(0x0014, 0x0000_00FE); // ZeroDivide vector - bus.write_long(0x0018, 0x0000_00FE); // CHK vector - bus.write_long(0x001C, 0x0000_00FE); // TRAPV vector + // The bus supplies synthetic RTE words at these otherwise-unmapped ROM + // addresses. Do not point the vectors at a low-memory stub: applications + // are allowed to inspect the table, and a low address changes the bytes + // they observe from the ROM-shaped values present on a booted Mac. + bus.write_long(0x0014, BOOT_ROM_RTE_HANDLER); // ZeroDivide vector + bus.write_long(0x0018, BOOT_ROM_RTE_HANDLER); // CHK vector + bus.write_long(0x001C, BOOT_ROM_TRAPV_RTE_HANDLER); // TRAPV vector // Load DATA 0 into A5 world (initialized globals) // DATA goes below A5 at address (A5 - below_a5) = load_address @@ -17152,6 +17210,57 @@ mod tests { assert!(!runner.is_halted()); } + #[test] + fn notification_response_receives_record_and_can_remove_itself() { + let mut runner = FixtureRunner::new(8 * 1024 * 1024, FixtureRunnerConfig::default()); + let interrupted_pc = 0x0001_0000; + let interrupted_sp = 0x007F_FFC0; + let nm_rec = runner.bus.alloc(36); + let response_addr = runner.bus.alloc(14); + + runner.bus.write_word(nm_rec + 4, 8); + runner.bus.write_long(nm_rec + 28, response_addr); + runner.bus.write_word(response_addr, 0x206F); // MOVEA.L 4(SP),A0 + runner.bus.write_word(response_addr + 2, 4); + runner.bus.write_word(response_addr + 4, 0xA05F); // NMRemove + runner.bus.write_word(response_addr + 6, 0x225F); // MOVEA.L (SP)+,A1 + runner.bus.write_word(response_addr + 8, 0x588F); // ADDQ.L #4,SP + runner.bus.write_word(response_addr + 10, 0x4ED1); // JMP (A1) + for offset in (0..20).step_by(2) { + runner.bus.write_word(interrupted_pc + offset, 0x4E71); // NOP + } + runner.cpu.write_reg(Register::PC, interrupted_pc); + runner.cpu.write_reg(Register::A7, interrupted_sp); + runner.cpu.write_reg(Register::A0, nm_rec); + runner + .dispatcher + .dispatch_memory(false, 0x5E, &mut runner.cpu, &mut runner.bus) + .unwrap() + .unwrap(); + runner.dispatcher.tick_count = 1; + + assert!(runner.fire_notification_response_callback()); + assert!(matches!( + runner.active_interrupt_callback.map(|active| active.source), + Some(ActiveInterruptCallbackSource::NotificationResponse) + )); + assert!( + runner + .bus + .get_alloc_size(runner.notification_response_trampoline) + .is_none(), + "Systemless-owned response trampoline must stay outside the guest heap" + ); + + let (_, running) = runner.run_steps(12, None); + + assert!(running); + assert!(runner.active_interrupt_callback.is_none()); + assert!(!runner.dispatcher.installed_notifications.contains(&nm_rec)); + assert_eq!(runner.cpu.read_reg(Register::A7), interrupted_sp); + assert!(!runner.is_halted()); + } + #[test] fn adb_mouse_callback_uses_documented_registers_and_restores_foreground() { let mut runner = FixtureRunner::new(8 * 1024 * 1024, FixtureRunnerConfig::default()); @@ -21503,8 +21612,8 @@ mod tests { } /// Running a `DIVU.W D0,D1` with `D0 = 0` must not halt the - /// runner. The `load_app_generic` loader installs an RTE stub at - /// `$00FE` and points vector 5 (`$14`) at it; the m68k crate's + /// runner. The `load_app_generic` loader points vector 5 (`$14`) at a + /// synthetic RTE word in the boot-ROM shadow; the m68k crate's /// zero-divide trap stacks the *next* PC and jumps to that vector, /// so RTE-ing returns past the DIVU and execution continues. /// Inside Macintosh Volume I, I-103 (Exception Vector Table); @@ -21514,9 +21623,8 @@ mod tests { fn zero_divide_rte_handler_resumes_after_divu_by_zero() { let mut runner = FixtureRunner::new(8 * 1024 * 1024, FixtureRunnerConfig::default()); - // Mirror what load_app_generic installs: RTE stub + vector. - runner.bus.write_word(0x00FE, 0x4E73); // RTE - runner.bus.write_long(0x0014, 0x0000_00FE); + // Mirror what load_app_generic installs. + runner.bus.write_long(0x0014, BOOT_ROM_RTE_HANDLER); let prog = 0x0010_0000u32; runner.bus.write_word(prog, 0x82C0); // DIVU.W D0, D1 @@ -21528,8 +21636,8 @@ mod tests { runner.cpu.write_reg(Register::D0, 0); runner.cpu.write_reg(Register::D1, 100); - // 1 step: DIVU.W traps, vectors to $00FE. - // 2nd step: RTE at $00FE pops SR/PC, returns past DIVU. + // 1 step: DIVU.W traps, vectors to the ROM shadow. + // 2nd step: RTE pops SR/PC, returns past DIVU. // 3rd step: NOP at prog+2. let (steps, running) = runner.run_steps(3, None); @@ -21547,7 +21655,7 @@ mod tests { ); } - /// CHK exception (vector 6) shares the same `$00FE` RTE stub as + /// CHK exception (vector 6) shares the same boot-ROM RTE word as /// the zero-divide handler. A `CHK.W #5, D0` with `D0 = 100` /// exceeds the bound and triggers the trap; on a real Mac the /// handler calls SysError, on Systemless we silently RTE so D0 is @@ -21557,8 +21665,7 @@ mod tests { fn chk_rte_handler_resumes_after_bounds_violation() { let mut runner = FixtureRunner::new(8 * 1024 * 1024, FixtureRunnerConfig::default()); - runner.bus.write_word(0x00FE, 0x4E73); // RTE - runner.bus.write_long(0x0018, 0x0000_00FE); // CHK vector + runner.bus.write_long(0x0018, BOOT_ROM_RTE_HANDLER); // CHK vector let prog = 0x0010_0000u32; runner.bus.write_word(prog, 0x41BC); // CHK.W #imm, D0 @@ -21569,7 +21676,7 @@ mod tests { runner.cpu.write_reg(Register::A7, 0x007F_FFC0); runner.cpu.write_reg(Register::D0, 100); - // 1 step: CHK fires (100 > 5), vectors to $00FE. + // 1 step: CHK fires, vectors to the ROM shadow. // 2nd step: RTE pops SR/PC, returns past CHK. // 3rd step: NOP executes. let (steps, running) = runner.run_steps(3, None); @@ -21584,7 +21691,7 @@ mod tests { assert_eq!(runner.cpu.read_reg(Register::D0), 100); } - /// TRAPV (vector 7) shares the `$00FE` RTE stub. Pre-set the V + /// TRAPV (vector 7) uses the adjacent boot-ROM RTE word. Pre-set the V /// flag in CCR via the m68k API and execute TRAPV; the trap fires /// because V is set, vectors to the RTE stub, and resumes at the /// next instruction. Inside Macintosh Volume I, I-103. @@ -21592,8 +21699,7 @@ mod tests { fn trapv_rte_handler_resumes_when_v_flag_is_set() { let mut runner = FixtureRunner::new(8 * 1024 * 1024, FixtureRunnerConfig::default()); - runner.bus.write_word(0x00FE, 0x4E73); // RTE - runner.bus.write_long(0x001C, 0x0000_00FE); // TRAPV vector + runner.bus.write_long(0x001C, BOOT_ROM_TRAPV_RTE_HANDLER); // TRAPV vector let prog = 0x0010_0000u32; runner.bus.write_word(prog, 0x4E76); // TRAPV diff --git a/src/trap/control.rs b/src/trap/control.rs index 44484b69..9cd43ae5 100644 --- a/src/trap/control.rs +++ b/src/trap/control.rs @@ -256,11 +256,16 @@ impl super::TrapDispatcher { } fn control_def_proc_handle(&mut self, bus: &mut MacMemoryBus, proc_id: i16) -> u32 { - if proc_id <= 0 { + if proc_id == 0 { return 0; } - let cdef_id = proc_id >> 4; + // The encoded procedure ID is a 16-bit bit field: the upper 12 bits + // select the CDEF resource and the low 4 bits select its variant. + // Extracting the resource ID must therefore use a logical shift; + // valid IDs 2048...4095 set the sign bit of an INTEGER procID. + // Inside Macintosh Volume I (1985), p. I-323. + let cdef_id = ((proc_id as u16) >> 4) as i16; self.find_or_load_resource_any(bus, *b"CDEF", cdef_id) .map(|(_, ptr)| ptr) .map(|ptr| self.get_or_create_resource_handle(bus, *b"CDEF", cdef_id, ptr)) @@ -840,6 +845,20 @@ impl super::TrapDispatcher { return; } + let menu_choice = self + .control_tracking + .as_ref() + .and_then(|tracking| self.menus.get(tracking.active_menu)) + .map(|menu| { + if item > 0 { + (u32::from(menu.id as u16) << 16) | u32::from(item as u16) + } else { + 0 + } + }) + .unwrap_or(0); + bus.write_long(addr::MENU_DISABLE, menu_choice); + if self.ui_theme_id() == crate::ui_theme::UiThemeId::ClassicSystem7 { if old_item > 0 { self.invert_control_tracking_item(bus, old_item); @@ -874,10 +893,17 @@ impl super::TrapDispatcher { self.restore_dropdown_pixels(bus, tracking.dropdown_rect, &tracking.saved_pixels); let part = if selected_item > 0 { + let menu_choice = self + .menus + .get(tracking.active_menu) + .map(|menu| (u32::from(menu.id as u16) << 16) | u32::from(selected_item as u16)) + .unwrap_or(0); + bus.write_long(addr::MENU_DISABLE, menu_choice); self.write_control_value(bus, tracking.ctrl_handle, selected_item); self.draw_control(cpu, bus, tracking.ctrl_ptr); 10u16 } else { + bus.write_long(addr::MENU_DISABLE, 0); 0u16 }; self.record_trackcontrol_input_trace( @@ -920,26 +946,58 @@ impl super::TrapDispatcher { else { return; }; - bus.write_byte(ctrl_ptr + 17, if highlighted { 1 } else { saved_hilite }); - self.draw_control(cpu, bus, ctrl_ptr); + let ctrl_handle = self + .control_tracking + .as_ref() + .map(|tracking| tracking.ctrl_handle) + .unwrap_or(0); + let application_cdef = self.control_uses_application_def_proc(bus, ctrl_ptr); + let part = self + .control_tracking + .as_ref() + .map(|tracking| tracking.simple_part) + .unwrap_or(0); + bus.write_byte( + ctrl_ptr + 17, + if highlighted { + if application_cdef { + part as u8 + } else { + 1 + } + } else { + saved_hilite + }, + ); + if application_cdef { + let callback_return_pc = cpu.read_reg(Register::PC).wrapping_sub(2); + cpu.write_reg(Register::PC, callback_return_pc); + if self.arm_control_def_messages( + cpu, + bus, + ctrl_handle, + &[(Self::CDEF_DRAW_CNTL_MSG, u32::from(part), None)], + ) { + if let Some(tracking) = self.control_tracking.as_mut() { + tracking.cdef_draw_callback_pending = true; + } + } else { + cpu.write_reg(Register::PC, callback_return_pc.wrapping_add(2)); + self.draw_control(cpu, bus, ctrl_ptr); + } + } else { + self.draw_control(cpu, bus, ctrl_ptr); + } if let Some(tracking) = self.control_tracking.as_mut() { tracking.simple_highlighted = highlighted; } } - fn finish_simple_control_tracking( - &mut self, - cpu: &mut C, - bus: &mut MacMemoryBus, - inside: bool, - ) { + fn complete_simple_control_tracking(&mut self, cpu: &mut C, bus: &mut MacMemoryBus) { let Some(tracking) = self.control_tracking.take() else { return; }; - bus.write_byte(tracking.ctrl_ptr + 17, tracking.saved_hilite); - self.draw_control(cpu, bus, tracking.ctrl_ptr); - - let part = if inside { tracking.simple_part } else { 0 }; + let part = tracking.cdef_finish_part.unwrap_or(0); self.record_trackcontrol_input_trace( bus, "tracking_finish", @@ -948,16 +1006,60 @@ impl super::TrapDispatcher { 0, Some(part), None, - if inside { - "simple_part_selected" - } else { + if part == 0 { "simple_no_selection" + } else { + "simple_part_selected" }, ); bus.write_word(tracking.stack_ptr + 12, part); cpu.write_reg(Register::A7, tracking.stack_ptr + 12); } + fn finish_simple_control_tracking( + &mut self, + cpu: &mut C, + bus: &mut MacMemoryBus, + inside: bool, + ) { + let Some((ctrl_handle, ctrl_ptr, saved_hilite, simple_part)) = + self.control_tracking.as_ref().map(|tracking| { + ( + tracking.ctrl_handle, + tracking.ctrl_ptr, + tracking.saved_hilite, + tracking.simple_part, + ) + }) + else { + return; + }; + let part = if inside { simple_part } else { 0 }; + bus.write_byte(ctrl_ptr + 17, saved_hilite); + if let Some(tracking) = self.control_tracking.as_mut() { + tracking.cdef_finish_part = Some(part); + } + if self.control_uses_application_def_proc(bus, ctrl_ptr) { + let callback_return_pc = cpu.read_reg(Register::PC).wrapping_sub(2); + cpu.write_reg(Register::PC, callback_return_pc); + if self.arm_control_def_messages( + cpu, + bus, + ctrl_handle, + &[(Self::CDEF_DRAW_CNTL_MSG, u32::from(simple_part), None)], + ) { + if let Some(tracking) = self.control_tracking.as_mut() { + tracking.cdef_draw_callback_pending = true; + } + return; + } + cpu.write_reg(Register::PC, callback_return_pc.wrapping_add(2)); + } else { + self.draw_control(cpu, bus, ctrl_ptr); + } + self.complete_simple_control_tracking(cpu, bus); + } + fn standard_scrollbar_testcontrol_part_code( &self, bus: &MacMemoryBus, @@ -1813,12 +1915,16 @@ impl super::TrapDispatcher { ) { let (screen_base, row_bytes, screen_width, screen_height, pixel_size) = self.get_screen_params(); - if inactive && pixel_size == 8 { + if pixel_size == 8 { // Control drawing writes directly to the screen framebuffer, so // resolve against the same live device CLUT used by screenshots // instead of TheGDevice, which games may leave on an offscreen // palette while redrawing dialogs. - let ink = self.inactive_control_title_index(); + let ink = if inactive { + self.inactive_control_title_index() + } else { + super::pict::closest_clut_index(0, 0, 0, &self.device_clut) + }; Self::fb_draw_string_styled_index( bus, screen_base, @@ -3265,6 +3371,49 @@ impl super::TrapDispatcher { // simple push/checkbox/radio controls across refires; preserves // the old immediate hit-test path when the mouse is already up. (true, 0x168) => { + if self + .control_tracking + .as_ref() + .is_some_and(|tracking| tracking.cdef_test_callback_pending) + { + let (stack_ptr, ctrl_ptr) = self + .control_tracking + .as_ref() + .map(|tracking| (tracking.stack_ptr, tracking.ctrl_ptr)) + .unwrap_or((0, 0)); + let part = bus.read_word(stack_ptr + 12); + if let Some(tracking) = self.control_tracking.as_mut() { + tracking.cdef_test_callback_pending = false; + tracking.simple_part = part; + } + if part == 0 { + if let Some(tracking) = self.control_tracking.as_mut() { + tracking.cdef_finish_part = Some(0); + } + self.complete_simple_control_tracking(cpu, bus); + } else { + debug_assert_ne!(ctrl_ptr, 0); + self.redraw_simple_control_tracking_state(cpu, bus, true); + } + return Some(Ok(())); + } + if self + .control_tracking + .as_ref() + .is_some_and(|tracking| tracking.cdef_draw_callback_pending) + { + let finish_pending = self + .control_tracking + .as_ref() + .is_some_and(|tracking| tracking.cdef_finish_part.is_some()); + if let Some(tracking) = self.control_tracking.as_mut() { + tracking.cdef_draw_callback_pending = false; + } + if finish_pending { + self.complete_simple_control_tracking(cpu, bus); + } + return Some(Ok(())); + } if self .control_tracking .as_ref() @@ -3512,6 +3661,9 @@ impl super::TrapDispatcher { scrollbar_last_action_tick: self.tick_count, scrollbar_idle_refires: 0, scrollbar_callback_pending: true, + cdef_test_callback_pending: false, + cdef_draw_callback_pending: false, + cdef_finish_part: None, }); return Some(Ok(())); } @@ -3550,6 +3702,9 @@ impl super::TrapDispatcher { scrollbar_last_action_tick: 0, scrollbar_idle_refires: 0, scrollbar_callback_pending: false, + cdef_test_callback_pending: false, + cdef_draw_callback_pending: false, + cdef_finish_part: None, }); self.record_trackcontrol_input_trace( bus, @@ -3571,11 +3726,17 @@ impl super::TrapDispatcher { // Preserve the old immediate path when the // mouse is already up; scripted callers that // model a real mouse-down take the refire path. + let application_cdef = + self.control_uses_application_def_proc(bus, ctrl_ptr); if self.mouse_button && action_proc == 0 - && matches!(proc_id, 0 | 1 | 2) + && (matches!(proc_id, 0 | 1 | 2) || application_cdef) { - let part = self.standard_testcontrol_part_code(ctrl_ptr); + let part = if application_cdef { + 0 + } else { + self.standard_testcontrol_part_code(ctrl_ptr) + }; let window_ptr = bus.read_long(ctrl_ptr + 4); let (scr_top, scr_left, _, _) = Self::dialog_screen_bounds(bus, window_ptr); @@ -3603,8 +3764,40 @@ impl super::TrapDispatcher { scrollbar_last_action_tick: 0, scrollbar_idle_refires: 0, scrollbar_callback_pending: false, + cdef_test_callback_pending: false, + cdef_draw_callback_pending: false, + cdef_finish_part: None, }); - self.redraw_simple_control_tracking_state(cpu, bus, true); + if application_cdef { + let point = + ((pt_v as u16 as u32) << 16) | pt_h as u16 as u32; + let callback_return_pc = + cpu.read_reg(Register::PC).wrapping_sub(2); + cpu.write_reg(Register::PC, callback_return_pc); + if self.arm_control_def_messages( + cpu, + bus, + ctrl_handle, + &[(Self::CDEF_TEST_CNTL_MSG, point, Some(sp + 12))], + ) { + if let Some(tracking) = self.control_tracking.as_mut() { + tracking.cdef_test_callback_pending = true; + } + } else { + cpu.write_reg( + Register::PC, + callback_return_pc.wrapping_add(2), + ); + if let Some(tracking) = self.control_tracking.as_mut() { + tracking.simple_part = 10; + } + self.redraw_simple_control_tracking_state( + cpu, bus, true, + ); + } + } else { + self.redraw_simple_control_tracking_state(cpu, bus, true); + } self.record_trackcontrol_input_trace( bus, "start", @@ -3719,6 +3912,14 @@ impl super::TrapDispatcher { self.draw_control(cpu, bus, ctrl_ptr); } } + + if self.dialog_items.contains_key(&window_ptr) { + // DrawControls is an application-owned dialog + // composition. ModalDialog handles events; it must not + // replace these pixels with a fresh synthesized shell + // on first entry. Inside Macintosh Volume I, p. I-415. + self.dialogs_drawn_by_app.insert(window_ptr); + } } cpu.write_reg(Register::A7, sp + 4); @@ -3808,6 +4009,7 @@ impl super::TrapDispatcher { let mut found_handle: u32 = 0; let mut found_part: u16 = 0; + let mut cdef_test_handle: u32 = 0; // Walk the control list starting at window+140 (wControlList) // Macintosh TB Essentials 1992, 4-67 @@ -3834,11 +4036,11 @@ impl super::TrapDispatcher { if pt_v >= r_top && pt_v < r_bottom && pt_h >= r_left && pt_h < r_right { found_handle = ctrl_handle; - // Return part code 10 (kControlButtonPart) for - // simple button controls. The proper approach - // would call the CDEF's testCntl, but for HLE - // we return a generic hit indicator. - found_part = 10; + if self.control_uses_application_def_proc(bus, ctrl_ptr) { + cdef_test_handle = ctrl_handle; + } else { + found_part = 10; + } break; } } @@ -3854,6 +4056,16 @@ impl super::TrapDispatcher { bus.write_word(sp + 12, found_part); cpu.write_reg(Register::A7, sp + 12); + if cdef_test_handle != 0 { + let point = ((pt_v as u16 as u32) << 16) | pt_h as u16 as u32; + self.arm_control_def_messages( + cpu, + bus, + cdef_test_handle, + &[(Self::CDEF_TEST_CNTL_MSG, point, Some(sp + 12))], + ); + } + Ok(()) } @@ -4703,15 +4915,20 @@ mod tests { } #[test] - fn newcontrol_custom_cdef_arms_init_then_draw_pascal_callbacks() { + fn newcontrol_resolves_high_bit_cdef_id_and_arms_pascal_callbacks() { let (mut disp, mut cpu, mut bus) = setup(); let sp = 0x300000u32; let return_pc = 0x1234_5678; let window_ptr = bus.alloc(200); let bounds_ptr = bus.alloc(8); let title_ptr = bus.alloc(16); - let proc_id = (160i16 << 4) | 5; - let cdef_proc = disp.install_test_resource(&mut bus, *b"CDEF", 160, &[0x4E, 0x56, 0, 0]); + // Resource ID 3006 sets the sign bit when encoded into a 16-bit + // procID. The Control Manager still treats the upper 12 bits as the + // unsigned CDEF resource ID. Inside Macintosh Volume I, p. I-323. + let cdef_id = 3006; + let proc_id = (((cdef_id as u16) << 4) | 5) as i16; + let cdef_proc = + disp.install_test_resource(&mut bus, *b"CDEF", cdef_id, &[0x4E, 0x56, 0, 0]); for (offset, value) in [10i16, 20, 40, 100].into_iter().enumerate() { bus.write_word(bounds_ptr + offset as u32 * 2, value as u16); @@ -5554,6 +5771,32 @@ mod tests { "inactive radio title must not leave black glyph pixels" ); + // Active labels use the same framebuffer path. Keep the GDevice's + // notion of black deliberately stale and verify that label ink still + // follows the live device CLUT used by screen export. + let stale_black_entry = ctab + 8 + 1 * 8; + bus.write_word(stale_black_entry + 2, 0); + bus.write_word(stale_black_entry + 4, 0); + bus.write_word(stale_black_entry + 6, 0); + bus.write_word(black_entry + 2, 0x2020); + bus.write_word(black_entry + 4, 0x4040); + bus.write_word(black_entry + 6, 0x6060); + for offset in 0..(row_bytes * 128) { + bus.write_byte(screen_base + offset, 0); + } + bus.write_byte(control_ptrs[0] + 17, 0); + disp.draw_control(&mut cpu, &mut bus, control_ptrs[0]); + assert!( + count_pixel_index(&bus, screen_base, row_bytes, 20, 35, 40, 118, 255) > 12, + "active checkbox title should draw with the live device black index" + ); + assert_eq!( + count_pixel_index(&bus, screen_base, row_bytes, 20, 35, 40, 118, 1), + 0, + "active checkbox title must not use stale GDevice black" + ); + bus.write_byte(control_ptrs[0] + 17, 255); + // A custom palette with only neutral white/black endpoints still // maps grayishTextOr's blend to a visible tinted intermediate entry. disp.device_clut = [[0x2020, 0x4040, 0x6060]; 256]; @@ -5923,6 +6166,115 @@ mod tests { assert!(trace.contains("part=10 highlighted_item=none outcome=simple_part_selected")); } + #[test] + fn track_control_application_cdef_retains_trap_through_press_and_release_draws() { + let (mut disp, mut cpu, mut bus) = setup_with_port(); + let sp = 0x300000u32; + let trap_pc = 0x0012_3454; + let window = disp.current_port; + let cdef_id = 160; + let proc_id = (cdef_id << 4) | 5; + let cdef_proc = + disp.install_test_resource(&mut bus, *b"CDEF", cdef_id, &[0x4E, 0x56, 0, 0]); + let ctrl_ptr = bus.alloc(296); + let ctrl_handle = bus.alloc(4); + bus.write_long(ctrl_handle, ctrl_ptr); + disp.initialize_control_record( + &mut bus, + ctrl_ptr, + window, + (20, 20, 40, 80), + b"", + true, + 0, + 0, + 1, + proc_id, + 0, + ); + + disp.mouse_button = true; + disp.mouse_pos = (30, 30); + cpu.write_reg(Register::PC, trap_pc + 2); + cpu.write_reg(Register::A7, sp); + bus.write_long(sp, 0); + bus.write_word(sp + 4, 30); + bus.write_word(sp + 6, 30); + bus.write_long(sp + 8, ctrl_handle); + bus.write_word(sp + 12, 0xBEEF); + + disp.dispatch_control(true, 0x168, &mut cpu, &mut bus) + .unwrap() + .unwrap(); + + let trampoline = disp.control_def_trampoline; + assert_ne!(trampoline, 0); + assert_eq!(cpu.read_reg(Register::PC), trampoline); + assert_eq!(cpu.read_reg(Register::A7), sp - 4); + assert_eq!(bus.read_long(sp - 4), trap_pc); + assert_eq!(bus.read_long(trampoline + 16), ctrl_handle); + assert_eq!(bus.read_word(trampoline + 22), 1); + assert_eq!(bus.read_long(trampoline + 26), (30 << 16) | 30); + assert_eq!(bus.read_long(trampoline + 32), cdef_proc); + assert_eq!(bus.read_byte(ctrl_ptr + 17), 0); + assert!( + disp.control_tracking + .as_ref() + .unwrap() + .cdef_test_callback_pending + ); + assert!(disp.is_control_action_callback_pending()); + + // The CDEF-selected part starts a pressed-state draw, also returning + // to the retained trap rather than to its caller. + bus.write_word(sp + 12, 10); + cpu.write_reg(Register::PC, trap_pc + 2); + cpu.write_reg(Register::A7, sp); + disp.dispatch_control(true, 0x168, &mut cpu, &mut bus) + .unwrap() + .unwrap(); + assert!(disp.control_tracking.is_some()); + assert!(disp.is_control_action_callback_pending()); + assert_eq!(cpu.read_reg(Register::PC), trampoline); + assert_eq!(bus.read_word(trampoline + 22), 0); + assert_eq!(bus.read_long(trampoline + 26), 10); + assert_eq!(bus.read_byte(ctrl_ptr + 17), 10); + + cpu.write_reg(Register::PC, trap_pc + 2); + cpu.write_reg(Register::A7, sp); + disp.dispatch_control(true, 0x168, &mut cpu, &mut bus) + .unwrap() + .unwrap(); + assert!(disp.control_tracking.is_some()); + assert!(!disp.is_control_action_callback_pending()); + assert_eq!(cpu.read_reg(Register::A7), sp); + + // Mouse-up restores the hilite through a second guest CDEF draw, then + // completes the Pascal result only after that callback has returned. + disp.mouse_button = false; + cpu.write_reg(Register::PC, trap_pc + 2); + disp.dispatch_control(true, 0x168, &mut cpu, &mut bus) + .unwrap() + .unwrap(); + assert_eq!(cpu.read_reg(Register::PC), trampoline); + assert_eq!(bus.read_long(sp - 4), trap_pc); + assert_eq!(bus.read_byte(ctrl_ptr + 17), 0); + assert_eq!(bus.read_word(sp + 12), 10); + assert_eq!( + disp.control_tracking.as_ref().unwrap().cdef_finish_part, + Some(10) + ); + + cpu.write_reg(Register::PC, trap_pc + 2); + cpu.write_reg(Register::A7, sp); + disp.dispatch_control(true, 0x168, &mut cpu, &mut bus) + .unwrap() + .unwrap(); + assert!(disp.control_tracking.is_none()); + assert_eq!(bus.read_word(sp + 12), 10); + assert_eq!(cpu.read_reg(Register::A7), sp + 12); + } + fn trackcontrol_button_release_result_for_theme( theme_id: UiThemeId, release_inside: bool, @@ -6189,6 +6541,11 @@ mod tests { .map(|tracking| tracking.highlighted_item), Some(2) ); + assert_eq!( + bus.read_long(crate::memory::globals::addr::MENU_DISABLE), + (900u32 << 16) | 2, + "popup MDEF tracking should publish MenuChoice state" + ); disp.mouse_button = false; disp.dispatch_control(true, 0x168, &mut cpu, &mut bus) @@ -6198,6 +6555,10 @@ mod tests { assert_eq!(cpu.read_reg(Register::A7), sp + 12); assert_eq!(bus.read_word(sp + 12), 10); assert_eq!(bus.read_word(ctrl_ptr + 18) as i16, 2); + assert_eq!( + bus.read_long(crate::memory::globals::addr::MENU_DISABLE), + (900u32 << 16) | 2 + ); assert!(disp.control_tracking.is_none()); let trace = disp.input_trace_text(); assert!(trace.contains("A968 action=start start=(15,25)")); @@ -6414,6 +6775,24 @@ mod tests { assert_eq!(bus.read_long(first_ptr), 0); } + #[test] + fn drawcontrols_marks_dialog_composition_for_modal_preservation() { + let (mut disp, mut cpu, mut bus) = setup_with_port(); + let sp = 0x300000u32; + let a5 = cpu.read_reg(Register::A5); + let qd_globals = bus.read_long(a5); + let dialog_ptr = bus.read_long(qd_globals); + disp.dialog_items.insert(dialog_ptr, Vec::new()); + + cpu.write_reg(Register::A7, sp); + bus.write_long(sp, dialog_ptr); + disp.dispatch_control(true, 0x169, &mut cpu, &mut bus) + .unwrap() + .unwrap(); + + assert!(disp.dialogs_drawn_by_app.contains(&dialog_ptr)); + } + #[test] fn drawcontrols_dispatches_visible_application_cdefs_in_control_list_draw_order() { let (mut disp, mut cpu, mut bus) = setup_with_port(); @@ -6518,6 +6897,59 @@ mod tests { assert_eq!(cpu.read_reg(Register::A7), sp + 12); } + #[test] + fn findcontrol_application_cdef_arms_test_message_for_native_part_code() { + let (mut disp, mut cpu, mut bus) = setup(); + let sp = 0x300000u32; + let return_pc = 0x1234_5678; + let window_ptr = bus.alloc(200); + let which_ctrl_out = bus.alloc(4); + let cdef_id = 160; + let proc_id = (cdef_id << 4) | 5; + let cdef_proc = + disp.install_test_resource(&mut bus, *b"CDEF", cdef_id, &[0x4E, 0x56, 0, 0]); + let ctrl_ptr = bus.alloc(296); + let ctrl_handle = bus.alloc(4); + bus.write_long(ctrl_handle, ctrl_ptr); + disp.initialize_control_record( + &mut bus, + ctrl_ptr, + 0, + (10, 20, 30, 60), + b"", + true, + 0, + 0, + 1, + proc_id, + 0, + ); + bus.write_long(window_ptr + 140, ctrl_handle); + + cpu.write_reg(Register::PC, return_pc); + cpu.write_reg(Register::A7, sp); + bus.write_long(sp, which_ctrl_out); + bus.write_long(sp + 4, window_ptr); + bus.write_word(sp + 8, 15); + bus.write_word(sp + 10, 25); + bus.write_word(sp + 12, 0xBEEF); + + disp.dispatch_control(true, 0x16C, &mut cpu, &mut bus) + .unwrap() + .unwrap(); + + let trampoline = disp.control_def_trampoline; + assert_eq!(bus.read_long(which_ctrl_out), ctrl_handle); + assert_eq!(bus.read_word(sp + 12), 0); + assert_eq!(cpu.read_reg(Register::PC), trampoline); + assert_eq!(cpu.read_reg(Register::A7), sp + 8); + assert_eq!(bus.read_long(sp + 8), return_pc); + assert_eq!(bus.read_word(trampoline + 22), 1); + assert_eq!(bus.read_long(trampoline + 26), (15 << 16) | 25); + assert_eq!(bus.read_long(trampoline + 32), cdef_proc); + assert_eq!(bus.read_long(trampoline + 40), sp + 12); + } + #[test] fn findcontrol_inactive_invisible_or_miss_returns_zero_and_nil() { let (mut disp, mut cpu, mut bus) = setup(); diff --git a/src/trap/dispatch.rs b/src/trap/dispatch.rs index b06b6763..519a6fb1 100644 --- a/src/trap/dispatch.rs +++ b/src/trap/dispatch.rs @@ -59,6 +59,13 @@ pub(crate) struct PendingFileCompletion { pub(crate) result: i16, } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct PendingNotificationResponse { + pub(crate) notification_record: u32, + pub(crate) response_addr: u32, + pub(crate) ready_tick: u32, +} + // Env-var lookups are cached via OnceLock. Tests/diagnostics that want // to toggle these at runtime cannot — values are read ONCE at first call. use std::sync::atomic::{AtomicU64, Ordering}; @@ -611,6 +618,9 @@ pub(crate) struct ControlTrackingState { pub scrollbar_last_action_tick: u32, pub scrollbar_idle_refires: u8, pub scrollbar_callback_pending: bool, + pub cdef_test_callback_pending: bool, + pub cdef_draw_callback_pending: bool, + pub cdef_finish_part: Option, } /// Retained state for DragWindow while the mouse button remains down. @@ -1395,6 +1405,12 @@ pub struct TrapDispatcher { /// so these records are synthesized on demand. Inside Macintosh Volume I /// (1985), pp. I-495..I-505. pub(crate) system_intl_cache: HashMap, + /// Cache of the standard System-file `'PAT#'` ID 0 resource. QuickDraw + /// and application definition procedures use this list for the classic + /// 8-by-8 monochrome fill patterns. Systemless does not mount a System + /// file, so the list is synthesized on demand. Imaging With QuickDraw + /// (1994), pp. 3-127..3-128 and 3-141. + pub(crate) system_pattern_list_cache: HashMap, /// Cache of synthesized built-in system cursor blocks for /// GetCursor ($A9B9). On real Mac the standard cursor IDs (1 /// iBeamCursor, 2 crossCursor, 3 plusCursor, 4 watchCursor per @@ -1485,6 +1501,10 @@ pub struct TrapDispatcher { /// Completed asynchronous File Manager requests awaiting `ioResult` /// publication and optional completion-procedure delivery. pub(crate) pending_file_completions: VecDeque, + /// Installed Notification Manager requests and response procedures waiting + /// to run after the foreground trap returns. + pub(crate) installed_notifications: HashSet, + pub(crate) pending_notification_responses: VecDeque, /// Set of VFS keys whose `ioFlAttrib` lock bit is set. /// Maintained by SetFilLock/HSetFLock ($A041/$A241) and /// RstFilLock/HRstFLock ($A042/$A242); read by @@ -3115,6 +3135,7 @@ impl TrapDispatcher { fired_oapp_handler: false, system_str_cache: HashMap::new(), system_intl_cache: HashMap::new(), + system_pattern_list_cache: HashMap::new(), system_cursor_cache: HashMap::new(), system_clut_cache: HashMap::new(), system_wctb_cache: HashMap::new(), @@ -3140,6 +3161,8 @@ impl TrapDispatcher { file_positions: HashMap::new(), recent_file_read: None, pending_file_completions: VecDeque::new(), + installed_notifications: HashSet::new(), + pending_notification_responses: VecDeque::new(), locked_files: HashSet::new(), next_refnum: 100, mmu_mode: 1, // true32b — 32-bit addressing by default @@ -3438,13 +3461,15 @@ impl TrapDispatcher { self.region_tracking.is_some() } - /// Whether TrackControl has redirected execution into a guest scrollbar - /// action procedure. The runner must let that callback return to the - /// retained A968 trap instead of immediately rewinding over it. + /// Whether TrackControl has redirected execution into a guest callback. + /// The runner must let that callback return to the retained A968 trap + /// instead of immediately rewinding over it. pub(crate) fn is_control_action_callback_pending(&self) -> bool { - self.control_tracking - .as_ref() - .is_some_and(|tracking| tracking.scrollbar_callback_pending) + self.control_tracking.as_ref().is_some_and(|tracking| { + tracking.scrollbar_callback_pending + || tracking.cdef_test_callback_pending + || tracking.cdef_draw_callback_pending + }) } /// Shared check used by both dispatch.rs (auto-pop push-back) and @@ -5851,6 +5876,72 @@ impl TrapDispatcher { Some(ptr) } + /// Allocate and cache the standard monochrome pattern list. A `PAT#` + /// resource starts with a big-endian count followed by packed eight-byte + /// `Pattern` records. ID 0 is the system pattern palette used by classic + /// Control Definition Functions and by `GetIndPattern`. + pub(crate) fn synthesize_system_pattern_list( + &mut self, + bus: &mut MacMemoryBus, + res_id: i16, + ) -> Option { + if res_id != 0 { + return None; + } + if let Some(&ptr) = self.system_pattern_list_cache.get(&res_id) { + return Some(ptr); + } + + const PATTERNS: [[u8; 8]; 38] = [ + [0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF], + [0xDD, 0xFF, 0x77, 0xFF, 0xDD, 0xFF, 0x77, 0xFF], + [0xDD, 0x77, 0xDD, 0x77, 0xDD, 0x77, 0xDD, 0x77], + [0xAA, 0x55, 0xAA, 0x55, 0xAA, 0x55, 0xAA, 0x55], + [0x55, 0xFF, 0x55, 0xFF, 0x55, 0xFF, 0x55, 0xFF], + [0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA], + [0xEE, 0xDD, 0xBB, 0x77, 0xEE, 0xDD, 0xBB, 0x77], + [0x88, 0x88, 0x88, 0x88, 0x88, 0x88, 0x88, 0x88], + [0xB1, 0x30, 0x03, 0x1B, 0xD8, 0xC0, 0x0C, 0x8D], + [0x80, 0x10, 0x02, 0x20, 0x01, 0x08, 0x40, 0x04], + [0xFF, 0x88, 0x88, 0x88, 0xFF, 0x88, 0x88, 0x88], + [0xFF, 0x80, 0x80, 0x80, 0xFF, 0x08, 0x08, 0x08], + [0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00], + [0x80, 0x40, 0x20, 0x00, 0x02, 0x04, 0x08, 0x00], + [0x82, 0x44, 0x39, 0x44, 0x82, 0x01, 0x01, 0x01], + [0xF8, 0x74, 0x22, 0x47, 0x8F, 0x17, 0x22, 0x71], + [0x55, 0xA0, 0x40, 0x40, 0x55, 0x0A, 0x04, 0x04], + [0x20, 0x50, 0x88, 0x88, 0x88, 0x88, 0x05, 0x02], + [0xBF, 0x00, 0xBF, 0xBF, 0xB0, 0xB0, 0xB0, 0xB0], + [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00], + [0x80, 0x00, 0x08, 0x00, 0x80, 0x00, 0x08, 0x00], + [0x88, 0x00, 0x22, 0x00, 0x88, 0x00, 0x22, 0x00], + [0x88, 0x22, 0x88, 0x22, 0x88, 0x22, 0x88, 0x22], + [0xAA, 0x00, 0xAA, 0x00, 0xAA, 0x00, 0xAA, 0x00], + [0xFF, 0x00, 0xFF, 0x00, 0xFF, 0x00, 0xFF, 0x00], + [0x11, 0x22, 0x44, 0x88, 0x11, 0x22, 0x44, 0x88], + [0xFF, 0x00, 0x00, 0x00, 0xFF, 0x00, 0x00, 0x00], + [0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80], + [0xAA, 0x00, 0x80, 0x00, 0x88, 0x00, 0x80, 0x00], + [0xFF, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80], + [0x08, 0x1C, 0x22, 0xC1, 0x80, 0x01, 0x02, 0x04], + [0x88, 0x14, 0x22, 0x41, 0x88, 0x00, 0xAA, 0x00], + [0x40, 0xA0, 0x00, 0x00, 0x04, 0x0A, 0x00, 0x00], + [0x03, 0x84, 0x48, 0x30, 0x0C, 0x02, 0x01, 0x01], + [0x80, 0x80, 0x41, 0x3E, 0x08, 0x08, 0x14, 0xE3], + [0x10, 0x20, 0x54, 0xAA, 0xFF, 0x02, 0x04, 0x08], + [0x77, 0x89, 0x8F, 0x8F, 0x77, 0x98, 0xF8, 0xF8], + [0x00, 0x08, 0x14, 0x2A, 0x55, 0x2A, 0x14, 0x08], + ]; + + let ptr = bus.alloc(2 + PATTERNS.len() as u32 * 8); + bus.write_word(ptr, PATTERNS.len() as u16); + for (index, pattern) in PATTERNS.iter().enumerate() { + bus.write_bytes(ptr + 2 + index as u32 * 8, pattern); + } + self.system_pattern_list_cache.insert(res_id, ptr); + Some(ptr) + } + /// Allocate (and cache) a synthetic System-file `'clut'` resource for /// the standard indexed color-table IDs. The resource body is a /// ColorTable record, matching what `GetCTable(depth)` exposes through @@ -7285,6 +7376,9 @@ mod tests { scrollbar_last_action_tick: 0, scrollbar_idle_refires: 0, scrollbar_callback_pending: false, + cdef_test_callback_pending: false, + cdef_draw_callback_pending: false, + cdef_finish_part: None, }); } diff --git a/src/trap/memory.rs b/src/trap/memory.rs index 78950229..f2439c2b 100644 --- a/src/trap/memory.rs +++ b/src/trap/memory.rs @@ -3423,31 +3423,56 @@ impl super::TrapDispatcher { // NMInstall ($A05E) // Installs a notification request. // FUNCTION NMInstall(nmReqPtr: NMRecPtr): OSErr; - // Inside Macintosh Volume VI (1991), p. 24-10 - // - // A0 = NMRecPtr. Returns noErr — notifications are not displayed - // in the emulator, but we accept the request silently. - // - // Regression coverage: - // src/trap/memory.rs::tests::nminstall_returns_noerr_for_nominal_notification_request - // src/trap/memory.rs::tests::nminstall_uses_a0_nmrecptr_register_calling_convention - // NMInstall ($A05E): Returns noErr on nominal requests; per IM:VI 24-10 + // Inside Macintosh Volume VI, VI-24-10 (false, 0x5E) => { - cpu.write_reg(Register::D0, 0); // noErr + const NM_TYPE: u16 = 8; + const NM_TYP_ERR: i16 = -299; + + let nm_rec = cpu.read_reg(Register::A0); + if bus.read_word(nm_rec + 4) != NM_TYPE { + cpu.write_reg(Register::D0, NM_TYP_ERR as i32 as u32); + return Some(Ok(())); + } + + if self.installed_notifications.insert(nm_rec) { + let response_addr = bus.read_long(nm_rec + 28); + if response_addr == u32::MAX { + self.installed_notifications.remove(&nm_rec); + } else if response_addr != 0 { + self.pending_notification_responses.push_back( + super::dispatch::PendingNotificationResponse { + notification_record: nm_rec, + response_addr, + ready_tick: self.tick_count.wrapping_add(1), + }, + ); + } + } + cpu.write_reg(Register::D0, 0); Ok(()) } // NMRemove ($A05F) // Removes a notification request. // FUNCTION NMRemove(nmReqPtr: NMRecPtr): OSErr; - // Inside Macintosh Volume VI (1991), p. 24-11 - // - // Regression coverage: - // src/trap/memory.rs::tests::nmremove_returns_noerr_for_nominal_notification_request - // src/trap/memory.rs::tests::nmremove_uses_a0_nmrecptr_register_calling_convention - // NMRemove ($A05F): Returns noErr on nominal requests; per IM:VI 24-11 + // Inside Macintosh Volume VI, VI-24-11 (false, 0x5F) => { - cpu.write_reg(Register::D0, 0); // noErr + const NM_TYPE: u16 = 8; + const NM_TYP_ERR: i16 = -299; + const Q_ERR: i16 = -1; + + let nm_rec = cpu.read_reg(Register::A0); + let result = if bus.read_word(nm_rec + 4) != NM_TYPE { + NM_TYP_ERR + } else if self.installed_notifications.remove(&nm_rec) { + self.pending_notification_responses + .retain(|pending| pending.notification_record != nm_rec); + bus.write_long(nm_rec, 0); + 0 + } else { + Q_ERR + }; + cpu.write_reg(Register::D0, result as i32 as u32); Ok(()) } @@ -8364,6 +8389,11 @@ mod tests { bus.write_word(nm_rec + 4, 8); // qType = ORD(nmType) cpu.write_reg(Register::A0, nm_rec); + dispatcher + .dispatch_memory(false, 0x5E, &mut cpu, &mut bus) + .unwrap() + .unwrap(); + let result = dispatcher.dispatch_memory(false, 0x5F, &mut cpu, &mut bus); assert!(result.is_some(), "NMRemove should be handled"); assert!(result.unwrap().is_ok(), "NMRemove should return cleanly"); @@ -8385,6 +8415,11 @@ mod tests { bus.write_long(sp_before, 0xC0DE_CAFE); cpu.write_reg(Register::A0, nm_rec); + dispatcher + .dispatch_memory(false, 0x5E, &mut cpu, &mut bus) + .unwrap() + .unwrap(); + let result = dispatcher.dispatch_memory(false, 0x5F, &mut cpu, &mut bus); assert!(result.is_some(), "NMRemove should be handled"); assert!(result.unwrap().is_ok(), "NMRemove should return cleanly"); @@ -8405,6 +8440,73 @@ mod tests { ); } + #[test] + fn notification_manager_validates_type_and_queue_membership() { + let (mut dispatcher, mut cpu, mut bus) = setup(); + let nm_rec = bus.alloc(32); + cpu.write_reg(Register::A0, nm_rec); + + dispatcher + .dispatch_memory(false, 0x5E, &mut cpu, &mut bus) + .unwrap() + .unwrap(); + assert_eq!(cpu.read_reg(Register::D0) as i32, -299); + + dispatcher + .dispatch_memory(false, 0x5F, &mut cpu, &mut bus) + .unwrap() + .unwrap(); + assert_eq!(cpu.read_reg(Register::D0) as i32, -299); + + bus.write_word(nm_rec + 4, 8); + dispatcher + .dispatch_memory(false, 0x5F, &mut cpu, &mut bus) + .unwrap() + .unwrap(); + assert_eq!(cpu.read_reg(Register::D0) as i32, -1); + } + + #[test] + fn nminstall_queues_response_and_nmremove_cancels_it() { + let (mut dispatcher, mut cpu, mut bus) = setup(); + let nm_rec = bus.alloc(32); + bus.write_word(nm_rec + 4, 8); + bus.write_long(nm_rec + 28, 0x0012_3456); + cpu.write_reg(Register::A0, nm_rec); + + dispatcher + .dispatch_memory(false, 0x5E, &mut cpu, &mut bus) + .unwrap() + .unwrap(); + assert!(dispatcher.installed_notifications.contains(&nm_rec)); + assert_eq!(dispatcher.pending_notification_responses.len(), 1); + + dispatcher + .dispatch_memory(false, 0x5F, &mut cpu, &mut bus) + .unwrap() + .unwrap(); + assert!(!dispatcher.installed_notifications.contains(&nm_rec)); + assert!(dispatcher.pending_notification_responses.is_empty()); + } + + #[test] + fn nminstall_auto_removes_minus_one_response() { + let (mut dispatcher, mut cpu, mut bus) = setup(); + let nm_rec = bus.alloc(32); + bus.write_word(nm_rec + 4, 8); + bus.write_long(nm_rec + 28, u32::MAX); + cpu.write_reg(Register::A0, nm_rec); + + dispatcher + .dispatch_memory(false, 0x5E, &mut cpu, &mut bus) + .unwrap() + .unwrap(); + + assert_eq!(cpu.read_reg(Register::D0), 0); + assert!(!dispatcher.installed_notifications.contains(&nm_rec)); + assert!(dispatcher.pending_notification_responses.is_empty()); + } + #[test] fn lowertext_variant_converts_ascii_and_macroman_uppercase_to_lowercase() { // Inside Macintosh Volume VI (1991), p. 14-62: LowerText localizes diff --git a/src/trap/menu.rs b/src/trap/menu.rs index cd841f60..ca61506e 100644 --- a/src/trap/menu.rs +++ b/src/trap/menu.rs @@ -1730,15 +1730,18 @@ impl super::TrapDispatcher { continue; }; - let menu_ptr = bus.alloc(256); + // A MENU resource is variable-sized. The fixed header + // is followed by the title and every item record, so a + // perfectly ordinary menu can exceed 256 bytes. Copy + // the complete record and its terminating zero byte; + // truncating it lets CountMItems walk into the next + // heap allocation. + let res_size = menu_resource_size(bus, menu_res_ptr) as u32; + let menu_ptr = bus.alloc(res_size); let menu_handle = bus.alloc(4); bus.write_long(menu_handle, menu_ptr); - let res_size = menu_resource_size(bus, menu_res_ptr); - for j in 0..res_size.min(256) { - bus.write_byte( - menu_ptr + j as u32, - bus.read_byte(menu_res_ptr + j as u32), - ); + for j in 0..res_size { + bus.write_byte(menu_ptr + j, bus.read_byte(menu_res_ptr + j)); } let mut parsed = parse_menu_resource(bus, menu_ptr, menu_handle); @@ -3750,9 +3753,9 @@ impl super::TrapDispatcher { // high-order word is the menu ID and the low-order word is // the item number. The Menu Manager stores that packed result // in lowmem global MenuDisable ($0B54); the trap simply reads - // the current longword and returns it. Systemless's HLE does not - // synthesize the MDEF cursor-tracking writes, so tests seed the - // lowmem global directly to exercise the read path. + // the current longword and returns it. Popup-control tracking + // publishes its MDEF choice there; direct MenuSelect/MenuKey + // tests may also seed the global to exercise this read path. // // Tool-bit Pascal FUNCTION calling convention: A7 unchanged // across the C-level call sequence (caller pre-push of 4-byte @@ -7821,6 +7824,71 @@ mod tests { ); } + #[test] + fn getnewmbar_preserves_menu_records_larger_than_256_bytes() { + let (mut disp, mut cpu, mut bus) = setup(); + let menu_id = 640i16; + let title = b"League"; + let item = b"Twenty character item"; + let item_count = 12u16; + let menu_size = 15 + title.len() + item_count as usize * (1 + item.len() + 4) + 1; + assert!(menu_size > 256); + + let menu_ptr = bus.alloc(menu_size as u32); + bus.write_word(menu_ptr, menu_id as u16); + bus.write_long(menu_ptr + 6, 0); + bus.write_long(menu_ptr + 10, 0xFFFF_FFFF); + bus.write_byte(menu_ptr + 14, title.len() as u8); + bus.write_bytes(menu_ptr + 15, title); + let mut offset = 15 + title.len() as u32; + for _ in 0..item_count { + bus.write_byte(menu_ptr + offset, item.len() as u8); + bus.write_bytes(menu_ptr + offset + 1, item); + offset += 1 + item.len() as u32; + bus.fill_zeros(menu_ptr + offset, 4); + offset += 4; + } + bus.write_byte(menu_ptr + offset, 0); + + let mbar_ptr = seed_mbar_resource(&mut bus, &[menu_id]); + disp.resources = Some(crate::trap::dispatch::LoadedResources { + files: std::collections::HashMap::from([( + 0, + crate::trap::dispatch::ResourceFileMap { + loaded: std::collections::HashMap::from([ + ((*b"MBAR", 902), mbar_ptr), + ((*b"MENU", menu_id), menu_ptr), + ]), + named: std::collections::HashMap::new(), + names_by_id: std::collections::HashMap::new(), + attrs: std::collections::HashMap::new(), + map_attrs: 0, + }, + )]), + names: std::collections::HashMap::new(), + search_order: vec![0], + current_file: 0, + }); + + cpu.write_reg(Register::A7, TEST_SP); + bus.write_word(TEST_SP, 902); + disp.dispatch_menu(true, 0x1C0, &mut cpu, &mut bus) + .unwrap() + .unwrap(); + + let menu_list_handle = bus.read_long(TEST_SP + 2); + let menu_list_ptr = bus.read_long(menu_list_handle); + let copied_menu_handle = bus.read_long(menu_list_ptr + 2); + let copied_menu_ptr = bus.read_long(copied_menu_handle); + assert_eq!(bus.get_alloc_size(copied_menu_ptr), Some(menu_size as u32)); + assert_eq!(bus.read_byte(copied_menu_ptr + menu_size as u32 - 1), 0); + assert_eq!( + count_menu_items_from_memory(&bus, copied_menu_handle), + item_count, + "CountMItems must stop at the copied MENU terminator" + ); + } + // IM:V 1986 p. V-244: GetNewMBar clears the current menu color // information table, loads the requested menus, and leaves the new // MenuCInfo state in place even though the previous MenuList is restored. diff --git a/src/trap/resource.rs b/src/trap/resource.rs index ec9e7bb6..b1f9ffea 100644 --- a/src/trap/resource.rs +++ b/src/trap/resource.rs @@ -1763,6 +1763,19 @@ impl super::TrapDispatcher { } } + if res_type == *b"PAT#" { + if let Some(ptr) = self.synthesize_system_pattern_list(bus, res_id) { + let handle = self + .get_or_create_resource_handle_in_file(bus, res_type, res_id, ptr, 0); + cpu.write_reg(Register::A0, handle); + cpu.write_reg(Register::D0, 0); + bus.write_word(0x0A60, 0); // ResErr = noErr + bus.write_long(sp + 6, handle); + cpu.write_reg(Register::A7, sp + 6); + return Some(Ok(())); + } + } + if res_type == *b"clut" { if let Some(ptr) = self.synthesize_system_clut(bus, res_id) { if trace_getresource_enabled() { @@ -8475,6 +8488,44 @@ impl super::TrapDispatcher { filename: &str, fdir_index: i16, ) -> Option { + // ioNamePtr accepts full and partial HFS pathnames, not only a leaf + // name. Resolve those pathnames before the directory-local lookup so + // callers can obtain the catalog ID for (for example) + // "MacintoshHD:Game:Data:". Files 1992, 2-27 to 2-30 and 2-190. + if fdir_index == 0 && filename.contains(':') { + let target = self.vfs_key_for_fsspec(vref, dir_id, filename)?; + + let mut directory_paths: Vec = self.vfs_directories.keys().cloned().collect(); + directory_paths.sort_unstable(); + if let Some(path) = directory_paths + .into_iter() + .find(|path| path.eq_ignore_ascii_case(&target)) + { + let directory = self.vfs_directories.get(&path)?; + return Some(super::dispatch::VfsCatalogEntry { + name: super::TrapDispatcher::hfs_name_from_vfs_component(&directory.name), + path, + is_directory: true, + }); + } + + let path = self + .vfs + .keys() + .chain(self.vfs_rsrc.keys()) + .filter(|path| path.eq_ignore_ascii_case(&target)) + .min() + .cloned()?; + self.vfs_file_metadata(&path)?; + return Some(super::dispatch::VfsCatalogEntry { + name: super::TrapDispatcher::hfs_name_from_vfs_component( + super::TrapDispatcher::vfs_basename(&path), + ), + path, + is_directory: false, + }); + } + for candidate_dir_id in self.hfs_lookup_directory_ids(vref, dir_id) { if let Some(entry) = self.lookup_catalog_entry(candidate_dir_id, filename, fdir_index) { return Some(entry); @@ -8514,7 +8565,6 @@ impl super::TrapDispatcher { None } - fn find_vfs_directory_for_hfs_lookup( &mut self, vref: i16, @@ -9142,6 +9192,37 @@ mod tests { assert_eq!(bus.read_word(black + 6), 0, "entry 255 blue"); } + #[test] + fn get_resource_synthesizes_standard_system_pattern_list() { + // Imaging With QuickDraw (1994), pp. 3-127..3-128 and 3-141: + // PAT# stores a count followed by one-based eight-byte patterns. + let (mut disp, mut cpu, mut bus) = setup(); + + bus.write_word(TEST_SP, 0); + bus.write_long(TEST_SP + 2, u32::from_be_bytes(*b"PAT#")); + call(&mut disp, true, 0x1A0, &mut cpu, &mut bus).unwrap(); + + let handle = bus.read_long(TEST_SP + 6); + assert_ne!(handle, 0); + assert_eq!(disp.resource_handle_files.get(&handle), Some(&0)); + assert_eq!(bus.read_word(0x0A60), 0); + + let patterns = bus.read_long(handle); + assert_eq!(bus.get_alloc_size(patterns), Some(2 + 38 * 8)); + assert_eq!(bus.read_word(patterns), 38); + assert_eq!(bus.read_bytes(patterns + 2, 8), &[0xFF; 8]); + assert_eq!( + bus.read_bytes(patterns + 2 + 37 * 8, 8), + &[0x00, 0x08, 0x14, 0x2A, 0x55, 0x2A, 0x14, 0x08] + ); + + cpu.write_reg(Register::A7, TEST_SP); + bus.write_word(TEST_SP, 0); + bus.write_long(TEST_SP + 2, u32::from_be_bytes(*b"PAT#")); + call(&mut disp, true, 0x1A0, &mut cpu, &mut bus).unwrap(); + assert_eq!(bus.read_long(TEST_SP + 6), handle); + } + #[test] fn get_resource_synthesizes_us_system_intl_zero() { // IM:I pp. I-495..I-499: INTL ID 0 contains the active numeric, @@ -15807,6 +15888,36 @@ mod tests { assert_eq!(bus.read_long(pb + 100), app_dir_id); } + #[test] + fn fsdispatch_pbgetcatinfo_resolves_full_hfs_directory_pathname() { + // Files 1992, 2-27 to 2-30: ioNamePtr may contain a full pathname + // beginning with the volume name. PBGetCatInfo must resolve every + // component and return the leaf directory's catalog information. + let (mut disp, mut cpu, mut bus) = setup(); + + let game_dir_id = disp.ensure_vfs_directory("Baseball"); + let data_dir_id = disp.ensure_vfs_directory("Baseball/Data"); + disp.vfs + .insert("Baseball/Data/FILENEW.BMP".to_string(), vec![0x42]); + + let pb = 0x300000u32; + let name_ptr = setup_param_block(&mut bus, &mut cpu, pb, b"MacintoshHD:Baseball:Data:"); + bus.write_word(pb + 16, 0x3FFF); + bus.write_word(pb + 22, super::super::dispatch::BOOT_VOLUME_REF_NUM as u16); + bus.write_word(pb + 28, 0); + bus.write_long(pb + 48, 2); + cpu.write_reg(Register::D0, 9); + + call(&mut disp, false, 0x60, &mut cpu, &mut bus).unwrap(); + + assert_eq!(cpu.read_reg(Register::D0) as i32, 0); + assert_eq!(bus.read_word(pb + 16) as i16, 0); + assert_eq!(bus.read_pstring(name_ptr), b"Data".to_vec()); + assert_eq!(bus.read_byte(pb + 30), 0x10); + assert_eq!(bus.read_long(pb + 48), data_dir_id); + assert_eq!(bus.read_long(pb + 100), game_dir_id); + } + #[test] fn fsdispatch_pbgetcatinfo_empty_name_returns_directory_itself() { // Some legacy apps probe the current directory with ioFDirIndex = 0 diff --git a/src/trap/window.rs b/src/trap/window.rs index 03ec4560..48e6f07d 100644 --- a/src/trap/window.rs +++ b/src/trap/window.rs @@ -2522,10 +2522,15 @@ impl super::TrapDispatcher { if the_window == old_front { // BringToFront can place another window visually ahead without // changing activation. Selecting the already-active window still - // has to restore it to the head of WindowList. - self.track_window_front(bus, the_window); - if let Some(content) = self.window_content_rect(bus, the_window) { - self.invalidate_window_rect(bus, the_window, content); + // has to restore it to the head of WindowList. When it is already + // there, SelectWindow is a true no-op: manufacturing a full update + // event makes applications that select their event window on each + // pass repaint continuously. Inside Macintosh Volume I, p. I-286. + if self.window_list.first().copied() != Some(the_window) { + self.track_window_front(bus, the_window); + if let Some(content) = self.window_content_rect(bus, the_window) { + self.invalidate_window_rect(bus, the_window, content); + } } return; } @@ -8416,6 +8421,8 @@ mod tests { disp.front_window = win_a; bus.write_byte(win_a + 110u32, 0xFF); bus.write_byte(win_a + 111u32, 0xFF); + let (_, update_rgn_data) = + setup_full_window_with_regions(&mut bus, win_a, 10, 20, 110, 220); let sp = TEST_SP - 4; cpu.write_reg(Register::A7, sp); @@ -8429,6 +8436,14 @@ mod tests { queue_len_before, "SelectWindow on already-front window must not queue any events per IM:I I-286" ); + assert_eq!( + bus.read_word(update_rgn_data + 2), + 0, + "SelectWindow on the already-frontmost window must not dirty its content" + ); + assert_eq!(bus.read_word(update_rgn_data + 4), 0); + assert_eq!(bus.read_word(update_rgn_data + 6), 0); + assert_eq!(bus.read_word(update_rgn_data + 8), 0); } #[test]