Skip to main content

systemless/
runner.rs

1//! Fixture Runner - Loading and execution infrastructure
2
3use crate::cpu::{M68kCpu, Register, StepResult};
4use crate::debug_overlay::{DebugOverlayFrameStats, DebugOverlaySnapshot};
5use crate::loader::{
6    ApplicationSizeResource, Code0Header, CodeSegmentHeader, JumpTableEntry, LoadedApp,
7};
8use crate::managers::resource::ResourceFork;
9use crate::memory::{MacMemoryBus, MemoryBus};
10use crate::trap::TrapDispatcher;
11use crate::ui_theme::{ThemeMetricsMode, UiTheme, UiThemeId};
12use crate::{Error, Result};
13use std::collections::{BTreeSet, HashMap};
14use std::time::{Instant, SystemTime, UNIX_EPOCH};
15
16// Cache env-var lookups (per-call syscall otherwise).
17use std::sync::OnceLock;
18static TRACE_DIALOG_FILTER: OnceLock<bool> = OnceLock::new();
19static TRACE_TIMER: OnceLock<bool> = OnceLock::new();
20static TRACE_VBL: OnceLock<bool> = OnceLock::new();
21static TRACE_SOUND_RUNNER: OnceLock<bool> = OnceLock::new();
22static TRACE_DIALOG_PROCS: OnceLock<bool> = OnceLock::new();
23
24const APP_HEAP_FLOOR: u32 = 0x0020_0000;
25const APP_ZONE_HEADER_SIZE: u32 = 64;
26const APP_STACK_SAFETY_MARGIN: u32 = 0x2000;
27
28fn trace_dialog_filter_enabled() -> bool {
29    *TRACE_DIALOG_FILTER
30        .get_or_init(|| std::env::var_os("SYSTEMLESS_TRACE_DIALOG_FILTER").is_some())
31}
32
33fn trace_timer_enabled() -> bool {
34    *TRACE_TIMER.get_or_init(|| std::env::var_os("SYSTEMLESS_TRACE_TIMER").is_some())
35}
36
37fn trace_vbl_enabled() -> bool {
38    *TRACE_VBL.get_or_init(|| std::env::var_os("SYSTEMLESS_TRACE_VBL").is_some())
39}
40
41fn trace_sound_runner_enabled() -> bool {
42    *TRACE_SOUND_RUNNER.get_or_init(|| std::env::var_os("SYSTEMLESS_TRACE_SOUND").is_some())
43}
44
45fn trace_dialog_procs_enabled() -> bool {
46    *TRACE_DIALOG_PROCS.get_or_init(|| std::env::var_os("SYSTEMLESS_TRACE_DIALOG_PROCS").is_some())
47}
48
49// Gate the per-instruction trace_buffer behind an env var. The buffer
50// is populated on EVERY instruction fetch and only used from
51// `dump_trace()` on halt/crash. Default-disabled saves per-instruction
52// `VecDeque` pop_front + push_back + an extra `bus.read_word` + 6
53// register reads. Enable with `SYSTEMLESS_TRACE_BUFFER=1` when diagnosing
54// a crash.
55#[cfg(not(target_arch = "wasm32"))]
56static TRACE_BUFFER_ENABLED: OnceLock<bool> = OnceLock::new();
57fn trace_buffer_enabled() -> bool {
58    #[cfg(target_arch = "wasm32")]
59    {
60        return false;
61    }
62    #[cfg(not(target_arch = "wasm32"))]
63    *TRACE_BUFFER_ENABLED.get_or_init(|| std::env::var_os("SYSTEMLESS_TRACE_BUFFER").is_some())
64}
65
66#[cfg(not(target_arch = "wasm32"))]
67static TRACE_PC_RANGE: OnceLock<Option<(u32, u32)>> = OnceLock::new();
68#[cfg(not(target_arch = "wasm32"))]
69static TRACE_PC_RANGE_TICKS: OnceLock<Option<(Option<u32>, Option<u32>)>> = OnceLock::new();
70#[cfg(not(target_arch = "wasm32"))]
71fn trace_pc_range() -> Option<(u32, u32)> {
72    *TRACE_PC_RANGE.get_or_init(|| {
73        let value = std::env::var("SYSTEMLESS_TRACE_PC_RANGE").ok()?;
74        let mut parts = value.split(':');
75        let start = parts.next()?.trim();
76        let end = parts.next()?.trim();
77        let parse = |s: &str| u32::from_str_radix(s.trim_start_matches("0x"), 16).ok();
78        Some((parse(start)?, parse(end)?))
79    })
80}
81
82#[cfg(not(target_arch = "wasm32"))]
83fn trace_pc_range_ticks() -> Option<(Option<u32>, Option<u32>)> {
84    *TRACE_PC_RANGE_TICKS.get_or_init(|| {
85        let min = std::env::var("SYSTEMLESS_TRACE_PC_RANGE_TICK_MIN")
86            .ok()
87            .and_then(|v| v.parse::<u32>().ok());
88        let max = std::env::var("SYSTEMLESS_TRACE_PC_RANGE_TICK_MAX")
89            .ok()
90            .and_then(|v| v.parse::<u32>().ok());
91        (min.is_some() || max.is_some()).then_some((min, max))
92    })
93}
94
95fn trace_pc_range_contains(pc: u32, tick: u32) -> bool {
96    #[cfg(target_arch = "wasm32")]
97    {
98        let _ = (pc, tick);
99        return false;
100    }
101    #[cfg(not(target_arch = "wasm32"))]
102    {
103        let in_range = trace_pc_range()
104            .map(|(start, end)| pc >= start && pc <= end)
105            .unwrap_or(false);
106        if !in_range {
107            return false;
108        }
109        trace_pc_range_ticks()
110            .map(|(min, max)| {
111                min.map(|min| tick >= min).unwrap_or(true)
112                    && max.map(|max| tick <= max).unwrap_or(true)
113            })
114            .unwrap_or(true)
115    }
116}
117
118// Gate the most-prominent startup/load chatter behind an env var.
119// Library consumers shouldn't see arbitrary debug stderr output by
120// default — these prints are useful when bring-up debugging a new game
121// but pure noise once the loader works. Enable with
122// `SYSTEMLESS_TRACE_LOAD=1` when diagnosing a load/halt.
123static TRACE_LOAD_ENABLED: OnceLock<bool> = OnceLock::new();
124pub(crate) fn trace_load_enabled() -> bool {
125    *TRACE_LOAD_ENABLED.get_or_init(|| std::env::var_os("SYSTEMLESS_TRACE_LOAD").is_some())
126}
127
128/// If `pc` matches a `GetTrapAddress` fake-pointer pattern, return a
129/// human-readable hint identifying the trap that the game most
130/// likely tried to JMP/JSR through. Else `None`.
131///
132/// Background: `GetTrapAddress` (and friends) on Systemless return a
133/// unique-per-trap fake address so apps can compare against
134/// `_Unimplemented` without hitting cache aliasing. Apps that ONLY
135/// compare the address (the documented use) are fine. Apps that
136/// actually `JMP (A0)` / `JSR (A0)` through the fake pointer land in
137/// unmapped or garbage-filled memory and trip an IllegalInstruction
138/// 30-100 instructions later. Surfacing the trap word at halt time
139/// lets future investigators identify the missing trampoline at a
140/// glance instead of having to disassemble around the halted PC.
141///
142/// Fake-pointer ranges (matching trap/memory.rs ranges):
143///   OS-style:    `$00F00000 | (trap_word as u32)` — range
144///                `$00F00000-$00F0FFFF`.
145///   Tool-style:  `$CAFE0000 + (trap_word & 0x3FF)` — range
146///                `$CAFE0000-$CAFE03FF`.
147pub fn decode_fakeptr_pc(pc: u32) -> Option<String> {
148    if (0x00F00000..=0x00F0FFFF).contains(&pc) {
149        let trap_word = (pc & 0xFFFF) as u16;
150        Some(format!(
151            "PC matches GetTrapAddress fake-ptr ($A046/$A346/$A746) for trap ${:04X} — \
152             game likely JMP/JSR'd through the unique-address placeholder. Implementing \
153             a re-trap trampoline at the fake-ptr address would unblock this path.",
154            trap_word
155        ))
156    } else if (0xCAFE0000..=0xCAFE03FF).contains(&pc) {
157        let trap_num = (pc - 0xCAFE0000) as u16;
158        let trap_word = 0xA800 | trap_num;
159        Some(format!(
160            "PC matches GetToolTrapAddress fake-ptr for trap ${:04X} (tool num=${:03X}) — \
161             same trampoline gap as the OS-style fake-ptr range.",
162            trap_word, trap_num
163        ))
164    } else {
165        None
166    }
167}
168
169// Per-opcode M68K histogram, opt-in via
170// `SYSTEMLESS_TRACE_OPCODE_COUNTS=1`. Complements the trap histogram
171// (which only sees A-line traps). Populated in `run_steps_internal`
172// after each step succeeds. Use to prioritize decode-table / super-
173// instruction-fusion work — the instruction mix is the input to that
174// kind of optimization.
175#[cfg(not(target_arch = "wasm32"))]
176static TRACE_OPCODE_COUNTS: OnceLock<bool> = OnceLock::new();
177fn trace_opcode_counts_enabled() -> bool {
178    #[cfg(target_arch = "wasm32")]
179    {
180        return false;
181    }
182    #[cfg(not(target_arch = "wasm32"))]
183    *TRACE_OPCODE_COUNTS
184        .get_or_init(|| std::env::var_os("SYSTEMLESS_TRACE_OPCODE_COUNTS").is_some())
185}
186
187// Sampled PC histogram, opt-in via `SYSTEMLESS_TRACE_HOT_PC=1`. Every
188// 1000th step's PC increments a `HashMap` entry. Use to locate the
189// code address of a hot game loop. 1/1000 sampling keeps `HashMap`
190// overhead negligible while still giving high-confidence attribution
191// for any loop that takes more than ~0.1% of runtime.
192const PC_SAMPLE_INTERVAL: u64 = 1000;
193#[cfg(not(target_arch = "wasm32"))]
194static TRACE_HOT_PC: OnceLock<bool> = OnceLock::new();
195fn trace_hot_pc_enabled() -> bool {
196    #[cfg(target_arch = "wasm32")]
197    {
198        return false;
199    }
200    #[cfg(not(target_arch = "wasm32"))]
201    *TRACE_HOT_PC.get_or_init(|| std::env::var_os("SYSTEMLESS_TRACE_HOT_PC").is_some())
202}
203
204// Generic TickCount spin-wait fast-forward. Tri-state default:
205// headless callers (scripted harnesses, no real-time pacing) get the fast-
206// forward by default — they gain the spin-elimination win. GUI
207// callers (`yield_for_ui = true`) get the fast-forward OFF by default
208// because tick-driven animations look wrong when ticks advance in
209// batches. Two env vars override:
210//   SYSTEMLESS_SPIN_WAIT_FASTFWD=1     force on (any mode)
211//   SYSTEMLESS_DISABLE_SPIN_FASTFWD=1  force off (any mode)
212static SPIN_WAIT_FASTFWD_FORCE_ON: OnceLock<bool> = OnceLock::new();
213static SPIN_WAIT_FASTFWD_FORCE_OFF: OnceLock<bool> = OnceLock::new();
214
215fn spin_wait_fastfwd_force_on() -> bool {
216    *SPIN_WAIT_FASTFWD_FORCE_ON
217        .get_or_init(|| std::env::var_os("SYSTEMLESS_SPIN_WAIT_FASTFWD").is_some())
218}
219fn spin_wait_fastfwd_force_off() -> bool {
220    *SPIN_WAIT_FASTFWD_FORCE_OFF
221        .get_or_init(|| std::env::var_os("SYSTEMLESS_DISABLE_SPIN_FASTFWD").is_some())
222}
223
224/// Resolve the fast-forward gate for the current run-loop call.
225/// Tri-state precedence:
226///   1. force-off env wins.
227///   2. force-on env wins next.
228///   3. default = headless (yield_for_ui = false) gets it on,
229///      GUI (yield_for_ui = true) gets it off.
230fn spin_wait_fastfwd_enabled_for(yield_for_ui: bool) -> bool {
231    spin_wait_fastfwd_gate(
232        spin_wait_fastfwd_force_on(),
233        spin_wait_fastfwd_force_off(),
234        yield_for_ui,
235    )
236}
237
238/// Pure decision function for the tri-state gate. Split out from
239/// `spin_wait_fastfwd_enabled_for` so the env-var reads can be mocked
240/// in unit tests (the `OnceLock`-based env caches initialise once per
241/// process and would prevent testing all three modes in one test
242/// run).
243fn spin_wait_fastfwd_gate(force_on: bool, force_off: bool, yield_for_ui: bool) -> bool {
244    if force_off {
245        return false;
246    }
247    if force_on {
248        return true;
249    }
250    !yield_for_ui
251}
252
253/// Pure decision function for the ModalDialog noop-refire skip. ALL
254/// of these must be true for the skip to fire:
255///   - mode is headless (`yield_for_ui = false`)
256///   - dialog tracking is active (`has_tracking = true`)
257///   - no filter proc callback is due (`filter_allows_noop`)
258///   - no button flash animating (`flash_remaining_zero`)
259///   - initial draw procs all completed (`draw_procs_done`)
260///   - dialog pixels already captured (`rendered_pixels_final`)
261///   - event queue is empty (no input pending)
262fn modaldialog_refire_is_noop(
263    yield_for_ui: bool,
264    has_tracking: bool,
265    filter_allows_noop: bool,
266    flash_remaining_zero: bool,
267    draw_procs_done: bool,
268    rendered_pixels_final: bool,
269    event_queue_empty: bool,
270) -> bool {
271    !yield_for_ui
272        && has_tracking
273        && filter_allows_noop
274        && flash_remaining_zero
275        && draw_procs_done
276        && rendered_pixels_final
277        && event_queue_empty
278}
279
280/// Some tracking traps should block the application's foreground event
281/// loop without advancing the app-visible tick clock in GUI mode. ModalDialog
282/// is different: the dialog manager is itself the active event loop, and
283/// Sound/VBL/Time Manager work must keep advancing while it tracks input.
284fn tracking_refire_should_freeze_ticks(opcode: u16) -> bool {
285    let trap_no_autopop = opcode & !0x0400;
286    trap_no_autopop == 0xA93D // MenuSelect
287        || trap_no_autopop == 0xA80B // PopUpMenuSelect / MenuKey tracking
288        || trap_no_autopop == 0xA968 // TrackControl
289}
290
291fn canonical_trap_number(opcode: u16) -> (bool, u16) {
292    let is_tool = (opcode & 0x0800) != 0;
293    let trap_num = if is_tool {
294        opcode & 0x03FF
295    } else {
296        opcode & 0x00FF
297    };
298    (is_tool, trap_num)
299}
300
301fn hle_trap_extra_tick_cost(opcode: u16) -> i32 {
302    let (is_tool, trap_num) = canonical_trap_number(opcode);
303    match (is_tool, trap_num) {
304        // Event/time polling rates vary with host speed; charging these
305        // creates false game-time drift instead of modelling ROM work.
306        (true, 0x0170) // GetNextEvent
307        | (true, 0x0060) // WaitNextEvent
308        | (true, 0x0171) // EventAvail
309        | (true, 0x0175) // TickCount
310        | (true, 0x0062) // Button
311        | (false, 0x0031) // GetOSEvent
312        | (_, 0x003B) => 0, // Delay already advances requested ticks explicitly
313
314        // SANE Pack4/Pack5 calls can be hot inner-loop arithmetic in games.
315        // Treat them as guest computation, not manager work that should yield.
316        (true, 0x006C) | (true, 0x006E) | (true, 0x01EB) | (true, 0x01EC) => 0,
317
318        // QuickDraw blits and PICT draws do substantial HLE-side pixel work.
319        (true, 0x00EC) | (true, 0x00F6) => 96,
320
321        // Resource loads move and parse data that real ROM/file-system code
322        // would not complete in one 68k instruction.
323        (true, 0x01A0) | (true, 0x01A1) | (true, 0x01A2) | (true, 0x01BC) => 96,
324
325        // Resource metadata/release calls are cheaper than loads but still
326        // non-trivial manager work.
327        (true, 0x019D..=0x01CF) => 24,
328
329        // Other Toolbox/OS HLE traps should cost more than a single guest
330        // opcode without making simple math/geometry helpers dominate timing.
331        (true, _) => 4,
332        (false, _) => 2,
333    }
334}
335
336fn event_manager_yield_trap(opcode: u16) -> bool {
337    matches!(
338        canonical_trap_number(opcode),
339        (true, 0x0170) // GetNextEvent
340            | (true, 0x0060) // WaitNextEvent
341            | (true, 0x0171) // EventAvail
342    )
343}
344
345// Cap how many ticks the fast-forward will advance in one shot,
346// to protect against pathological target values (e.g. overflowed
347// unsigned register values being misinterpreted as huge-future
348// ticks). If the cap trips, we fall back to normal spin — still
349// correct, just not fast.
350const SPIN_FASTFWD_MAX_TICKS: u32 = 1_000_000;
351
352/// Outcome of `advance_until_tick`. Used to distinguish the "we
353/// advanced, please synthesise the exit state" happy path from
354/// the abort paths: tick_cap reached (caller must break the
355/// outer run loop), pathological target difference (caller must
356/// NOT synthesise — let the guest spin normally), and interrupt
357/// callback injection (caller must leave the CPU at the callback
358/// trampoline).
359enum AdvanceResult {
360    Advanced,
361    CapHit,
362    Interrupted,
363    TooFar,
364}
365
366// Layout for dialog callback scratch region.
367const DIALOG_DRAW_TRAMPOLINE_OFFSET: u32 = 0x00;
368const DIALOG_FILTER_TRAMPOLINE_OFFSET: u32 = 0x40;
369const DIALOG_FILTER_EVENT_OFFSET: u32 = 0x80;
370// 2-byte scratch where the filter trampoline writes its Boolean return value.
371const DIALOG_FILTER_RESULT_OFFSET: u32 = 0x96;
372const MENU_HOOK_TRAMPOLINE_OFFSET: u32 = 0xA0;
373const DIALOG_CALLBACK_SCRATCH_FALLBACK: u32 = 0x0000_1200;
374/// Compact Mac video hardware refreshes at approximately 60.15 Hz.
375pub const DEFAULT_VBL_HZ: f64 = 60.15;
376
377#[derive(Clone, Debug, Eq, PartialEq)]
378pub struct VfsFileSummary {
379    pub path: String,
380    pub data_len: usize,
381    pub resource_len: usize,
382    pub data_hash: u64,
383    pub resource_hash: u64,
384    pub file_type: u32,
385    pub creator: u32,
386    pub finder_flags: u16,
387    pub created_date: u32,
388    pub modified_date: u32,
389}
390
391#[derive(Clone, Debug, Eq, PartialEq)]
392pub struct VfsFileStat {
393    pub path: String,
394    pub data_len: usize,
395    pub resource_len: usize,
396    pub file_type: u32,
397    pub creator: u32,
398    pub finder_flags: u16,
399    pub created_date: u32,
400    pub modified_date: u32,
401}
402
403#[derive(Clone, Debug, Eq, PartialEq)]
404pub struct VfsFileSnapshot {
405    pub path: String,
406    pub data_fork: Vec<u8>,
407    pub resource_fork: Vec<u8>,
408    pub file_type: u32,
409    pub creator: u32,
410    pub finder_flags: u16,
411    pub created_date: u32,
412    pub modified_date: u32,
413}
414
415fn vfs_fork_hash(bytes: &[u8]) -> u64 {
416    let mut hash = 0xcbf2_9ce4_8422_2325u64;
417    for &byte in bytes {
418        hash ^= u64::from(byte);
419        hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
420    }
421    hash
422}
423/// Default emulated CPU speed for realtime frontends (Mac IIci-class 68030).
424pub const DEFAULT_REALTIME_CPU_MHZ: f64 =
425    crate::machine_profile::ORACLE_MACHINE_PROFILE.realtime_cpu_mhz;
426/// Shared default realtime CPU budget used by the GUI runner and scripted
427/// realtime mode so both frontends expose the same machine profile.
428pub const DEFAULT_REALTIME_INSTRUCTIONS_PER_SECOND: f64 = DEFAULT_REALTIME_CPU_MHZ * 1_000_000.0;
429// Default instructions per VBL tick for non-realtime execution (scripted harnesses, tests).
430// Realtime frontends override this via set_instructions_per_tick() to match the
431// shared default machine profile defined above.
432// This lower value lets scripted harnesses run quickly without being wall-clock-paced.
433const INSTRUCTIONS_PER_TICK: u32 = 12_000;
434const MAC_EPOCH_OFFSET_FROM_UNIX: u64 = 2_082_844_800;
435const CURSOR_TASK_NOOP_ADDR: u32 = 0x0000_0060;
436
437fn current_mac_epoch_seconds() -> u32 {
438    let unix_now = SystemTime::now()
439        .duration_since(UNIX_EPOCH)
440        .unwrap_or_default()
441        .as_secs();
442    unix_now
443        .saturating_add(MAC_EPOCH_OFFSET_FROM_UNIX)
444        .min(u32::MAX as u64) as u32
445}
446
447#[derive(Clone, Copy, Debug)]
448enum ActiveInterruptCallbackSource {
449    Timer,
450    Vbl,
451    CursorTask,
452    SoundCallback,
453    SoundFileCompletion,
454    SoundDoubleBack,
455    DialogDrawProc,
456    DialogFilterProc,
457    MenuHook,
458}
459
460#[derive(Clone, Copy, Debug)]
461struct ActiveInterruptCallback {
462    source: ActiveInterruptCallbackSource,
463    resume_pc: u32,
464    resume_sp: u32,
465    d_regs: [u32; 8],
466    a_regs: [u32; 8],
467    ccr: u8,
468    restore_port: Option<(u32, u32)>,
469}
470
471fn is_sound_interrupt_source(source: ActiveInterruptCallbackSource) -> bool {
472    matches!(
473        source,
474        ActiveInterruptCallbackSource::SoundCallback
475            | ActiveInterruptCallbackSource::SoundFileCompletion
476            | ActiveInterruptCallbackSource::SoundDoubleBack
477    )
478}
479
480fn align4(value: u32) -> u32 {
481    value.saturating_add(3) & !3
482}
483
484fn app_heap_start_for_loaded_app(app: &LoadedApp) -> u32 {
485    APP_HEAP_FLOOR.max(align4(app.loaded_image_end))
486}
487
488/// Configuration knobs for [`FixtureRunner`]. Use
489/// [`FixtureRunnerConfig::default`] for the canonical defaults
490/// (10M-instruction budget, 0x10000 load base, arrow-keys NOT
491/// remapped to numpad) — only override fields you actually need.
492pub struct FixtureRunnerConfig {
493    /// Hard cap on instructions executed by the simpler unbounded
494    /// [`FixtureRunner::run`] entry point. Not consulted by
495    /// [`FixtureRunner::run_steps`], which uses its own per-call
496    /// `max_steps` argument. Default: 10,000,000.
497    pub max_instructions: usize,
498    /// Base address where 68k CODE segments are loaded into guest
499    /// RAM. Default: 0x10000 (64 KiB above the low-mem globals).
500    /// Most games tolerate the default; a few with hardcoded
501    /// expectations about A5 placement may need a higher value.
502    pub load_address: u32,
503    /// When true, arrow key virtual key codes are remapped to their numpad equivalents.
504    /// Useful on keyboards without a numeric keypad, since many classic Mac games use
505    /// the numpad for movement. Inside Macintosh Volume V, V-191.
506    pub arrows_as_numpad: bool,
507    /// Selected UI rendering provider. The default `classic-system7` provider
508    /// represents the existing renderer; non-classic themes are explicit and
509    /// must not alter guest-visible Toolbox behavior in classic metrics mode.
510    pub ui_theme: UiThemeId,
511    /// Declares whether theme rendering preserves classic guest metrics or opts
512    /// into future themed hit/measurement behavior.
513    pub theme_metrics_mode: ThemeMetricsMode,
514}
515
516impl Default for FixtureRunnerConfig {
517    fn default() -> Self {
518        Self {
519            max_instructions: 10_000_000,
520            load_address: 0x10000,
521            arrows_as_numpad: false,
522            ui_theme: UiThemeId::ClassicSystem7,
523            theme_metrics_mode: ThemeMetricsMode::ClassicGuestMetrics,
524        }
525    }
526}
527
528/// Canonical entry point of the systemless library.
529///
530/// `FixtureRunner` owns the three pieces of guest state — the [`M68kCpu`]
531/// interpreter, the [`MacMemoryBus`], and the [`TrapDispatcher`] (Toolbox
532/// + OS trap handlers) — and exposes the load / step / halt-inspect
533/// surface that drives them.
534///
535/// **Lifecycle:**
536/// 1. [`FixtureRunner::new`] — allocate guest RAM + dispatcher.
537/// 2. [`crate::game::load_game`] — auto-detect StuffIt / MacBinary,
538///    populate guest memory, seed CPU state.
539/// 3. [`run_steps`](Self::run_steps) (preferred) or [`run`](Self::run)
540///    — drive the CPU. `run_steps` returns `(steps_executed,
541///    still_running)`; `run` runs until halt or
542///    [`FixtureRunnerConfig::max_instructions`].
543/// 4. After halt: [`halted_pc`](Self::halted_pc) /
544///    [`halted_trap`](Self::halted_trap) /
545///    [`halted_sp`](Self::halted_sp) / [`halted_d0`](Self::halted_d0)
546///    expose per-halt detail, and
547///    [`halted_by_exit_to_shell`](Self::halted_by_exit_to_shell) classifies
548///    the common clean application-exit path.
549///
550/// **Defaults:** kiosk mode (Mac menu bar suppressed regardless of the
551/// guest's `MBarHeight`); arrow keys NOT remapped to numpad. Override
552/// each via [`set_menu_bar_visible`](Self::set_menu_bar_visible) /
553/// [`set_arrows_as_numpad`](Self::set_arrows_as_numpad) or the
554/// `SYSTEMLESS_SHOW_MENU_BAR` env var.
555///
556/// See `examples/run_headless.rs` for a runnable end-to-end example.
557pub struct FixtureRunner {
558    cpu: M68kCpu,
559    bus: MacMemoryBus,
560    dispatcher: TrapDispatcher,
561    config: FixtureRunnerConfig,
562    trace_buffer: std::collections::VecDeque<(u32, u16, u32, u32, u32, u32)>, // (PC, Op, A0, SP, A6, A5)
563    /// Set to true when the application calls ExitToShell
564    halted: bool,
565    /// Trap opcode that caused the halt, if known.
566    halted_trap: Option<u16>,
567    /// Program counter at the point of halt.
568    halted_pc: Option<u32>,
569    /// Stack pointer at the point of halt.
570    halted_sp: Option<u32>,
571    /// D0 register at the point of halt.
572    halted_d0: Option<u32>,
573    /// Total guest instructions retired by the interpreter.
574    total_instructions: u64,
575    /// Number of interpreted guest instructions per `Ticks` increment.
576    instructions_per_tick: u32,
577    /// Optional cap on per-WaitNextEvent-call sleep tick advance in headless
578    /// mode (when `run_steps` is called without a `tick_override`). `None`
579    /// keeps the legacy drain-all behavior. `Some(n)` advances at most `n`
580    /// ticks per WNE call, mirroring GUI mode's 1-tick cap. Used for
581    /// scripted tick alignment with Basilisk.
582    wait_sleep_cap_in_headless: Option<u32>,
583    /// Remaining instruction budget for the current tick. Both 68k instructions
584    /// and HLE trap costs are deducted. When this reaches zero or below, the
585    /// tick advances and the budget is refilled from `instructions_per_tick`.
586    tick_budget: i32,
587    /// Tick value saved when menu tracking starts.  While set, run_steps caps
588    /// its tick_override to this value so the game clock is frozen — matching
589    /// the real Mac where MenuSelect blocks the application event loop.
590    frozen_ticks: Option<u32>,
591    /// Guest-memory address of the Time Manager interrupt trampoline code.
592    /// Allocated once on first use and reused for all subsequent timer fires.
593    timer_trampoline: u32,
594    /// Guest-memory address of the Vertical Retrace Manager trampoline code.
595    /// Allocated once on first use and reused for all VBL callbacks.
596    vbl_trampoline: u32,
597    /// Guest-memory address of the low-memory `JCrsrTask` callback trampoline.
598    /// Allocated once on first use and reused for cursor task callbacks.
599    cursor_task_trampoline: u32,
600    /// Currently executing Time Manager callback, if any.
601    ///
602    /// Real timer delivery happens from interrupt context, so the same timer source
603    /// must not be re-entered by our synthetic tick advancement while the callback
604    /// is still unwinding back to interrupted guest code.
605    active_interrupt_callback: Option<ActiveInterruptCallback>,
606    /// Audio output backend (None = no audio output).
607    audio: Option<Box<dyn crate::audio::AudioBackend>>,
608    /// Accumulated audio samples for external consumers (e.g. WASM).
609    /// Unsigned 8-bit mono PCM at OUTPUT_RATE Hz (silence = 0x80).
610    audio_buffer: Vec<u8>,
611    /// Guest-memory address of the SndPlayDoubleBuffer doubleback trampoline.
612    /// Allocated once on first use and reused for all double-buffer callbacks.
613    sound_doubleback_trampoline: u32,
614    /// Guest-memory address of the SndNewChannel callback trampoline.
615    /// Allocated once on first use and reused for all callback procedures.
616    sound_callback_trampoline: u32,
617    /// Guest-memory address of the SndStartFilePlay completion trampoline.
618    /// Allocated once on first use and reused for all file completion routines.
619    sound_file_completion_trampoline: u32,
620    /// Guest-memory address of the dialog userItem draw proc trampoline (26 bytes).
621    /// Allocated once on first use and reused for all subsequent draw proc calls.
622    dialog_draw_trampoline: u32,
623    /// Guest-memory address of the ModalDialog filter proc trampoline.
624    /// Allocated once on first use and reused for all callback invocations.
625    dialog_filter_trampoline: u32,
626    /// Guest-memory address of the MenuSelect MenuHook trampoline.
627    /// Allocated once on first use and reused for all callback invocations.
628    menu_hook_trampoline: u32,
629    /// Guest-memory address of a scratch EventRecord passed to ModalDialog filters.
630    dialog_filter_event: u32,
631    /// Last dialog/tick pair that received a synthetic ModalDialog null event.
632    /// Real queued events bypass this; it only paces the no-input idle callback.
633    dialog_filter_last_null_event_tick: Option<(u32, u32)>,
634    /// Last dialog/update-window/tick triple that received a synthetic
635    /// ModalDialog update event from the Window Manager invalid-region state.
636    /// Queued mouse/key/update events bypass this; it only prevents the same
637    /// still-invalid dialog from starving later input in one guest tick.
638    dialog_filter_last_update_event_tick: Option<(u32, u32, u32)>,
639    /// Override for the application's startup time in Mac-epoch seconds.
640    /// Used by scripted frontends to keep guest-visible time deterministic.
641    app_start_time: Option<u32>,
642    /// Optional Finder-style application partition size override in bytes.
643    /// Scripted frontends use this to model a user raising the preferred
644    /// memory size before launch without mutating the application's resources.
645    application_partition_size: Option<u32>,
646    /// Per-opcode histogram for M68K instructions. Indexed by the
647    /// full 16-bit opcode word (`cpu.core.ir` after step). Always
648    /// allocated (512 KB); populated only when
649    /// `SYSTEMLESS_TRACE_OPCODE_COUNTS=1` is set at startup. Zero cost
650    /// on the hot path when disabled (cached bool compare, branch
651    /// short-circuited). Complements the trap histogram, which only
652    /// sees A-line opcodes; this captures MOVE/ADD/Bcc/etc. too,
653    /// which is what decode-table or super-instruction-fusion work
654    /// needs to prioritize.
655    opcode_histogram: Box<[u64; 65536]>,
656    /// Sampled PC histogram. When `SYSTEMLESS_TRACE_HOT_PC=1` is set,
657    /// every 1000th step's PC increments a `HashMap` bucket. Answers
658    /// "which CODE ADDRESS is hot", useful for locating game-side
659    /// hot loops by routine. Sampling (1/1000) keeps `HashMap`
660    /// overhead low; a million hot samples still fits in tens of
661    /// unique addresses.
662    pc_histogram: HashMap<u32, u64>,
663}
664
665impl FixtureRunner {
666    /// Construct a fresh runner with `ram_size` bytes of guest RAM and
667    /// the given [`FixtureRunnerConfig`]. The CPU starts halted at
668    /// PC = 0; the framebuffer is whatever bytes the host allocator
669    /// hands us. Call [`load_app`](Self::load_app) (or the higher-level
670    /// `systemless::game::load_game`) to populate guest memory and seed the
671    /// run state, then drive the guest with [`run_steps`](Self::run_steps).
672    ///
673    /// `ram_size` is typically 4 MiB to 16 MiB — most games never push
674    /// past 8 MiB. The runner allocates a single contiguous host
675    /// region of this size; bumping it costs only the upfront alloc.
676    ///
677    /// The dispatcher defaults to **kiosk mode** (Mac menu bar
678    /// suppressed, regardless of the guest's `MBarHeight`). Call
679    /// [`set_menu_bar_visible`](Self::set_menu_bar_visible) to opt back
680    /// in to the original Mac menu-bar behaviour.
681    pub fn new(ram_size: usize, config: FixtureRunnerConfig) -> Self {
682        let mut dispatcher = TrapDispatcher::new();
683        dispatcher.set_ui_theme_id(config.ui_theme);
684        Self {
685            cpu: M68kCpu::new(),
686            bus: MacMemoryBus::new(ram_size),
687            dispatcher,
688            config,
689            trace_buffer: std::collections::VecDeque::with_capacity(2000),
690            halted: false,
691            halted_trap: None,
692            halted_pc: None,
693            halted_sp: None,
694            halted_d0: None,
695            total_instructions: 0,
696            instructions_per_tick: INSTRUCTIONS_PER_TICK,
697            wait_sleep_cap_in_headless: None,
698            tick_budget: INSTRUCTIONS_PER_TICK as i32,
699            frozen_ticks: None,
700            timer_trampoline: 0,
701            vbl_trampoline: 0,
702            cursor_task_trampoline: 0,
703            active_interrupt_callback: None,
704            audio: None,
705            audio_buffer: Vec::new(),
706            sound_doubleback_trampoline: 0,
707            sound_callback_trampoline: 0,
708            sound_file_completion_trampoline: 0,
709            dialog_draw_trampoline: 0,
710            dialog_filter_trampoline: 0,
711            menu_hook_trampoline: 0,
712            dialog_filter_event: 0,
713            dialog_filter_last_null_event_tick: None,
714            dialog_filter_last_update_event_tick: None,
715            app_start_time: None,
716            application_partition_size: None,
717            opcode_histogram: Box::new([0u64; 65536]),
718            pc_histogram: HashMap::new(),
719        }
720    }
721
722    /// Returns true once guest execution has stopped.
723    pub fn is_halted(&self) -> bool {
724        self.halted
725    }
726
727    /// Returns true when the halt was the documented clean application
728    /// termination path, `_ExitToShell` (`$A9F4`).
729    ///
730    /// This lets runners and tests distinguish apps that intentionally quit
731    /// from halts caused by faults, invalid PCs, or other fatal errors.
732    pub fn halted_by_exit_to_shell(&self) -> bool {
733        self.halted && self.halted_trap == Some(0xA9F4)
734    }
735
736    pub fn guest_tick(&self) -> u32 {
737        self.bus.read_long(0x016A)
738    }
739
740    pub fn host_now(&self) -> Instant {
741        Instant::now()
742    }
743
744    pub fn halted_trap(&self) -> Option<u16> {
745        self.halted_trap
746    }
747
748    pub fn halted_pc(&self) -> Option<u32> {
749        self.halted_pc
750    }
751
752    pub fn halted_sp(&self) -> Option<u32> {
753        self.halted_sp
754    }
755
756    pub fn halted_stack_word0(&self) -> Option<u16> {
757        self.halted_sp.map(|sp| self.bus.read_word(sp))
758    }
759
760    pub fn halted_stack_word(&self, word_index: u32) -> Option<u16> {
761        self.halted_sp
762            .map(|sp| self.bus.read_word(sp + word_index.saturating_mul(2)))
763    }
764
765    pub fn halted_d0(&self) -> Option<u32> {
766        self.halted_d0
767    }
768
769    pub fn total_instructions(&self) -> u64 {
770        self.total_instructions
771    }
772
773    pub fn debug_overlay_snapshot(
774        &self,
775        frame_stats: DebugOverlayFrameStats,
776    ) -> DebugOverlaySnapshot {
777        use crate::memory::globals::addr;
778
779        let dispatcher = &self.dispatcher;
780        let (_, _, screen_width, screen_height, pixel_size) = dispatcher.screen_mode;
781        let cursor_image = dispatcher.cursor_data();
782        let cursor_mask_nonzero_bytes = cursor_image
783            .as_ref()
784            .map(|(_, mask, _, _)| mask.iter().filter(|&&byte| byte != 0).count());
785        let cursor_hotspot = cursor_image
786            .as_ref()
787            .map(|(_, _, hot_v, hot_h)| (*hot_v, *hot_h));
788
789        DebugOverlaySnapshot {
790            frame_stats,
791            guest_tick: self.guest_tick(),
792            total_instructions: self.total_instructions,
793            trap_count: dispatcher.trap_count,
794            game_trap_count: dispatcher.game_trap_count,
795            cursor_visible: dispatcher.cursor_visible(),
796            cursor_level: dispatcher.cursor_level(),
797            cursor_data_present: dispatcher.cursor_data_present(),
798            cursor_mask_nonzero_bytes,
799            cursor_hotspot,
800            cursor_position: dispatcher.mouse_position(),
801            mouse_button: dispatcher.mouse_button,
802            fullscreen_locked: dispatcher.fullscreen_locked,
803            mbar_height: self.bus.read_word(addr::MBAR_HEIGHT),
804            screen_width,
805            screen_height,
806            pixel_size,
807            front_window: dispatcher.front_window(),
808            window_bounds: dispatcher.window_bounds(),
809            window_count: dispatcher.window_count(),
810            menu_count: dispatcher.menu_count(),
811            halted: self.halted,
812            halted_trap: self.halted_trap,
813            halted_pc: self.halted_pc,
814        }
815    }
816
817    pub fn cpu(&self) -> &M68kCpu {
818        &self.cpu
819    }
820
821    pub fn cpu_mut(&mut self) -> &mut M68kCpu {
822        &mut self.cpu
823    }
824
825    pub fn bus(&self) -> &MacMemoryBus {
826        &self.bus
827    }
828
829    pub fn bus_mut(&mut self) -> &mut MacMemoryBus {
830        &mut self.bus
831    }
832
833    pub fn dispatcher(&self) -> &crate::trap::dispatch::TrapDispatcher {
834        &self.dispatcher
835    }
836
837    pub fn dispatcher_mut(&mut self) -> &mut crate::trap::dispatch::TrapDispatcher {
838        &mut self.dispatcher
839    }
840
841    /// Returns the selected UI theme provider. `classic-system7` is the
842    /// default and maps to the existing renderer path; non-classic providers
843    /// are explicit Systemless-owned rendering contracts.
844    pub fn ui_theme(&self) -> &'static dyn UiTheme {
845        self.config.ui_theme.provider()
846    }
847
848    pub fn ui_theme_id(&self) -> UiThemeId {
849        self.config.ui_theme
850    }
851
852    pub fn theme_metrics_mode(&self) -> ThemeMetricsMode {
853        self.config.theme_metrics_mode
854    }
855
856    pub fn uses_classic_guest_metrics(&self) -> bool {
857        self.config
858            .theme_metrics_mode
859            .preserves_classic_guest_metrics()
860    }
861
862    /// Show or hide the Mac menu bar.
863    ///
864    /// systemless runs in **kiosk mode** by default — the Mac menu bar is
865    /// suppressed regardless of the guest's `MBarHeight` ($0BAA) value
866    /// and `DrawMenuBar` is a no-op. This matches the typical embedding
867    /// case (running a single classic Mac game inside a fullscreen
868    /// host window) where the host owns the chrome and the guest's
869    /// menu bar would just diverge from the original-machine
870    /// appearance whenever the cursor entered `y < 20`.
871    ///
872    /// Pass `true` to opt back in to original Mac behavior — for
873    /// example, when running a Mac *application* that relies on the
874    /// menu bar as its primary user surface.
875    ///
876    /// The same toggle is also accessible via the `SYSTEMLESS_SHOW_MENU_BAR`
877    /// environment variable (set to any value to show) and via
878    /// `systemless --show-menu-bar`. This library method is the
879    /// preferred entry point for library embedders that don't want
880    /// to depend on environment-variable plumbing.
881    ///
882    /// Inside Macintosh Volume I, I-354 (DrawMenuBar);
883    /// Inside Macintosh Volume V, V-245 (MBarHeight global).
884    pub fn set_menu_bar_visible(&mut self, visible: bool) {
885        self.dispatcher.menu_bar_hidden = !visible;
886    }
887
888    /// Returns true when the Mac menu bar is currently being rendered.
889    /// In the default kiosk configuration this returns `false`.
890    pub fn menu_bar_visible(&self) -> bool {
891        !self.dispatcher.menu_bar_hidden
892    }
893
894    /// Disassemble M68K instructions starting at `pc` for `count`
895    /// instruction words. Returns one entry per word: `(pc, mnemonic,
896    /// size_in_bytes)`. The size includes any operand words consumed
897    /// by the instruction; advance `pc` by `size` to reach the next.
898    ///
899    /// Unknown opcodes (including A-line traps and other reserved
900    /// patterns) come back as `DC.W $XXXX` with size 2 — the same
901    /// convention the underlying [`m68k::dasm::disassemble`] uses.
902    /// Reads past the end of guest RAM yield `(addr, "<unmapped>", 2)`
903    /// rather than panicking.
904    ///
905    /// Diagnostic helper for pixel-divergence and trap-misroute
906    /// investigations: pair with the framebuffer-write tracer
907    /// (`SYSTEMLESS_TRACE_FB_WRITE_RANGE`) to see what the guest is
908    /// actually executing at a suspect PC.
909    pub fn disassemble_at(&self, pc: u32, count: usize) -> Vec<(u32, String, u32)> {
910        use crate::memory::MemoryBus;
911        let mut out = Vec::with_capacity(count);
912        let mut cur = pc;
913        for _ in 0..count {
914            // bus.read_word returns 0 for OOB rather than panicking,
915            // so wrap-around safety is a property of the underlying
916            // bus impl. Tag explicitly when the read landed at an
917            // address we know is past the framebuffer.
918            let opcode = self.bus.read_word(cur);
919            let unmapped = (cur as u64) >= (8 * 1024 * 1024);
920            let (mnemonic, size) = if unmapped {
921                ("<unmapped>".to_string(), 2)
922            } else {
923                m68k::dasm::disassemble(cur, opcode, m68k::CpuType::M68000)
924            };
925            // m68k's disassemble returns the instruction's TOTAL size
926            // including operand words; cap at a reasonable max so a
927            // malformed opcode doesn't run away.
928            let size = size.clamp(2, 10);
929            out.push((cur, mnemonic, size));
930            cur = cur.wrapping_add(size);
931        }
932        out
933    }
934
935    /// Dump the top-N M68K opcodes by execution count. No-op when
936    /// `SYSTEMLESS_TRACE_OPCODE_COUNTS` wasn't set at startup. Format:
937    ///   [OPCODE-HIST]   43210123  $3F3C  MOVE.W #imm,-(SP)
938    /// Unknown opcodes fall back to showing just the hex word.
939    pub fn print_opcode_histogram(&self, top_n: usize) {
940        if !trace_opcode_counts_enabled() {
941            return;
942        }
943        let mut entries: Vec<(u16, u64)> = self
944            .opcode_histogram
945            .iter()
946            .enumerate()
947            .filter_map(|(i, &c)| if c > 0 { Some((i as u16, c)) } else { None })
948            .collect();
949        entries.sort_by_key(|e| std::cmp::Reverse(e.1));
950        let total: u64 = entries.iter().map(|(_, c)| c).sum();
951        eprintln!(
952            "[OPCODE-HIST] top {} of {} distinct opcodes ({} total non-Aline instructions)",
953            top_n.min(entries.len()),
954            entries.len(),
955            total
956        );
957        for (opcode, count) in entries.iter().take(top_n) {
958            let group = (opcode >> 12) & 0xF;
959            let group_name = match group {
960                0x0 => "bit-op/MOVEP/immediate",
961                0x1 => "MOVE.B",
962                0x2 => "MOVE.L",
963                0x3 => "MOVE.W",
964                0x4 => "misc (LEA/JSR/etc.)",
965                0x5 => "ADDQ/SUBQ/Scc/DBcc",
966                0x6 => "Bcc/BSR",
967                0x7 => "MOVEQ",
968                0x8 => "OR/DIV/SBCD",
969                0x9 => "SUB/SUBX",
970                0xA => "A-line (should be in trap-hist)",
971                0xB => "CMP/EOR",
972                0xC => "AND/MUL/ABCD/EXG",
973                0xD => "ADD/ADDX",
974                0xE => "shift/rotate",
975                0xF => "F-line (FPU/coproc)",
976                _ => "?",
977            };
978            eprintln!(
979                "[OPCODE-HIST]   {:>10}  ${:04X}  group {:X}: {}",
980                count, opcode, group, group_name
981            );
982        }
983    }
984
985    /// Dump the top-N hottest PCs by sampled hit count. No-op when
986    /// `SYSTEMLESS_TRACE_HOT_PC` is unset. Each count represents one
987    /// `PC_SAMPLE_INTERVAL` (=1000) M68K instructions; multiply by
988    /// 1000 for an approximate instruction count attributed to that
989    /// PC.
990    pub fn print_pc_histogram(&self, top_n: usize) {
991        if !trace_hot_pc_enabled() {
992            return;
993        }
994        let mut entries: Vec<(u32, u64)> =
995            self.pc_histogram.iter().map(|(&a, &c)| (a, c)).collect();
996        entries.sort_by_key(|e| std::cmp::Reverse(e.1));
997        let total: u64 = entries.iter().map(|(_, c)| c).sum();
998        eprintln!(
999            "[PC-HIST] top {} of {} distinct PCs ({} samples × {} = ~{} instructions)",
1000            top_n.min(entries.len()),
1001            entries.len(),
1002            total,
1003            PC_SAMPLE_INTERVAL,
1004            total * PC_SAMPLE_INTERVAL
1005        );
1006        for (pc, count) in entries.iter().take(top_n) {
1007            // Classify by address region: the common Mac-app
1008            // convention for loaded segments is roughly $00010000-
1009            // $00600000 (game code) and $01000000+ (ROM).
1010            let region = match *pc {
1011                0x0000_0000..=0x0000_FFFF => "low-mem",
1012                0x0001_0000..=0x005F_FFFF => "app code",
1013                0x0060_0000..=0x00FF_FFFF => "heap/data",
1014                0x0100_0000..=0x01FF_FFFF => "ROM",
1015                _ => "other",
1016            };
1017            eprintln!("[PC-HIST]   {:>8}  PC=${:08X}  ({})", count, pc, region);
1018        }
1019    }
1020
1021    pub fn install_application_clut(&mut self, clut: [[u16; 3]; 256]) {
1022        self.dispatcher
1023            .install_application_clut(&mut self.bus, clut);
1024    }
1025
1026    pub fn set_app_start_time(&mut self, secs: u32) {
1027        self.app_start_time = Some(secs);
1028    }
1029
1030    pub fn set_application_partition_size(&mut self, bytes: Option<u32>) {
1031        self.application_partition_size = bytes.filter(|&bytes| bytes >= 128 * 1024);
1032    }
1033
1034    /// Getter for the pinned Mac epoch seconds. Returns `None` when no
1035    /// pin has been applied — without a pin, `init_app` falls back to
1036    /// `current_mac_epoch_seconds()` (host wall-clock), which leaks
1037    /// into the guest's `Time` global (`$020C`) and breaks
1038    /// reproducibility.
1039    pub fn app_start_time(&self) -> Option<u32> {
1040        self.app_start_time
1041    }
1042
1043    pub fn application_partition_size(&self) -> Option<u32> {
1044        self.application_partition_size
1045    }
1046
1047    pub fn enable_oracle_recording(
1048        &mut self,
1049        output_dir: impl AsRef<std::path::Path>,
1050        source: crate::oracle::OracleSource,
1051    ) -> Result<()> {
1052        self.dispatcher.enable_oracle_recording(output_dir, source)
1053    }
1054
1055    /// Composite chrome/dialog overlays onto the framebuffer.
1056    /// Call before reading raw pixels for screenshots.
1057    pub fn composite_frame(&mut self) {
1058        self.dispatcher.redraw_chrome(&mut self.bus);
1059    }
1060
1061    /// Enable or disable arrow-key-to-numpad remapping.
1062    pub fn set_arrows_as_numpad(&mut self, enabled: bool) {
1063        self.config.arrows_as_numpad = enabled;
1064    }
1065
1066    /// Returns true when arrow keys are remapped to numpad key codes.
1067    pub fn arrows_as_numpad(&self) -> bool {
1068        self.config.arrows_as_numpad
1069    }
1070
1071    /// Move the mouse without changing the button state. Updates the
1072    /// dispatcher's tracked position and the six mouse-position
1073    /// low-memory globals (MTemp / RawMouse / Mouse) so guest code that
1074    /// reads them directly sees the new coordinates immediately. Leaves
1075    /// MBState ($0172) untouched. Inside Macintosh Volume II, II-371.
1076    pub fn set_mouse_position(&mut self, v: i16, h: i16) {
1077        self.dispatcher.set_mouse_position(v, h);
1078        self.sync_mouse_position_lowmem();
1079        if !self.wake_pending_wait_next_event_if_input_available() {
1080            self.wake_pending_wait_next_event_with_null_event_for_polling_input();
1081        }
1082        self.wake_foreground_after_input();
1083    }
1084
1085    /// Inject a mouse-down event and sync low-memory globals.
1086    ///
1087    /// On real hardware the VBL interrupt handler updates MBState ($0172)
1088    /// and the mouse-position globals whenever the button state changes.
1089    /// Since our HLE has no interrupt-driven mouse driver, we sync these
1090    /// globals here so that code polling the low-memory locations directly
1091    /// (instead of calling Button or GetNextEvent) sees the correct state.
1092    pub fn push_mouse_down(&mut self, v: i16, h: i16) {
1093        self.dispatcher.push_mouse_down(v, h);
1094        self.sync_mouse_lowmem();
1095        self.wake_pending_wait_next_event_if_input_available();
1096        self.wake_foreground_after_input();
1097    }
1098
1099    /// Inject a mouse-up event.
1100    ///
1101    /// Sync MBState ($0172) immediately so code that polls the low-memory
1102    /// byte directly (rather than calling Button or GetNextEvent) sees the
1103    /// release without waiting for the next tick advance.
1104    ///
1105    /// On real hardware the ADB manager polls the mouse at ~200 Hz, updating
1106    /// MBState within a few milliseconds of the physical release. Deferring
1107    /// the update to the next advance_guest_tick left MBState stale for an
1108    /// entire tick (~16 ms), which is longer than real hardware and caused
1109    /// frame-rate-dependent games to read the wrong button state for too
1110    /// many loop iterations after a click-up.
1111    /// Inside Macintosh Volume II, II-371
1112    pub fn push_mouse_up(&mut self, v: i16, h: i16) {
1113        self.dispatcher.push_mouse_up(v, h);
1114        self.sync_mouse_lowmem();
1115        self.wake_pending_wait_next_event_if_input_available();
1116        self.wake_foreground_after_input();
1117    }
1118
1119    /// Write the three mouse-position low-memory globals (MTemp $0828,
1120    /// RawMouse $082C, Mouse $0830) from `self.dispatcher.mouse_pos`.
1121    /// Inside Macintosh Volume I, I-258.
1122    fn sync_mouse_position_lowmem(&mut self) {
1123        let (v, h) = self.dispatcher.mouse_pos;
1124        self.bus.write_word(0x0828, v as u16);
1125        self.bus.write_word(0x082A, h as u16);
1126        self.bus.write_word(0x082C, v as u16);
1127        self.bus.write_word(0x082E, h as u16);
1128        self.bus.write_word(0x0830, v as u16);
1129        self.bus.write_word(0x0832, h as u16);
1130    }
1131
1132    /// Sync mouse button + position low-memory globals from internal state.
1133    ///
1134    /// MBState ($0172): 0x00 = button down, 0x80 = button up
1135    /// MTemp ($0828), RawMouse ($082C), Mouse ($0830): current position
1136    /// Inside Macintosh Volume I, I-258; Inside Macintosh Volume II, II-371
1137    fn sync_mouse_lowmem(&mut self) {
1138        let mb_state: u8 = if self.dispatcher.mouse_button {
1139            0x00
1140        } else {
1141            0x80
1142        };
1143        self.bus.write_byte(0x0172, mb_state);
1144        self.sync_mouse_position_lowmem();
1145    }
1146
1147    /// Sync the 16-byte KeyMapLM low-memory bitmap from the dispatcher's
1148    /// current key state. Inside Macintosh Volume I, I-260 documents the
1149    /// KeyMap returned by GetKeys; MPW SysEqu.h exposes the ROM-maintained
1150    /// low-memory mirror at $0174 for code that polls it directly.
1151    fn sync_key_map_lowmem(&mut self) {
1152        use crate::memory::globals::addr;
1153
1154        self.bus
1155            .write_bytes(addr::KEY_MAP_LM, self.dispatcher.key_map_bytes());
1156    }
1157
1158    /// Inject a key-down event, applying arrow→numpad remapping if configured.
1159    pub fn push_key_down(&mut self, mac_key: u8, char_code: u8) {
1160        let (key, char_code) = self.remap_key(mac_key, char_code);
1161        self.dispatcher.push_key_down(key, char_code);
1162        self.sync_key_map_lowmem();
1163        self.wake_pending_wait_next_event_if_input_available();
1164        self.wake_foreground_after_input();
1165    }
1166
1167    /// Inject a key-up event, applying arrow→numpad remapping if configured.
1168    pub fn push_key_up(&mut self, mac_key: u8, char_code: u8) {
1169        let (key, char_code) = self.remap_key(mac_key, char_code);
1170        self.dispatcher.push_key_up(key, char_code);
1171        self.sync_key_map_lowmem();
1172        self.wake_pending_wait_next_event_if_input_available();
1173        self.wake_foreground_after_input();
1174    }
1175
1176    /// Remap arrow key virtual key codes to numpad equivalents when enabled.
1177    /// Arrow keys: Left=0x7B, Right=0x7C, Down=0x7D, Up=0x7E
1178    /// Numpad dirs: 4(left)=0x56, 6(right)=0x58, 5(down)=0x57, 8(up)=0x5B
1179    /// Inside Macintosh Volume V, V-191
1180    fn remap_key(&self, mac_key: u8, char_code: u8) -> (u8, u8) {
1181        if self.config.arrows_as_numpad {
1182            match mac_key {
1183                0x7B => (0x56, b'4'), // Left  -> Numpad4
1184                0x7C => (0x58, b'6'), // Right -> Numpad6
1185                0x7D => (0x57, b'5'), // Down  -> Numpad5
1186                0x7E => (0x5B, b'8'), // Up    -> Numpad8
1187                _ => (mac_key, char_code),
1188            }
1189        } else {
1190            (mac_key, char_code)
1191        }
1192    }
1193
1194    /// Set the audio output backend. If not set, no audio is produced.
1195    pub fn set_audio(&mut self, audio: Box<dyn crate::audio::AudioBackend>) {
1196        self.audio = Some(audio);
1197    }
1198
1199    pub fn set_instructions_per_tick(&mut self, instructions_per_tick: u32) {
1200        let old = self.instructions_per_tick.max(1);
1201        let new = instructions_per_tick.max(1);
1202        // Scale the remaining budget proportionally so a mid-run change
1203        // doesn't cause an immediate tick advance or an artificially long tick.
1204        self.tick_budget = ((self.tick_budget as i64 * new as i64) / old as i64) as i32;
1205        self.instructions_per_tick = new;
1206    }
1207
1208    pub fn instructions_per_tick(&self) -> u32 {
1209        self.instructions_per_tick
1210    }
1211
1212    /// Cap the per-WaitNextEvent-call sleep tick advance in headless mode.
1213    /// None (default) preserves the legacy drain-all behavior. Some(n) caps
1214    /// each WNE sleep to at most n tick advances, mirroring GUI mode.
1215    pub fn set_wait_sleep_cap_in_headless(&mut self, cap: Option<u32>) {
1216        self.wait_sleep_cap_in_headless = cap;
1217    }
1218
1219    pub fn wait_sleep_cap_in_headless(&self) -> Option<u32> {
1220        self.wait_sleep_cap_in_headless
1221    }
1222
1223    /// Drain accumulated audio samples for external consumers (e.g. WASM).
1224    /// Returns unsigned 8-bit mono PCM at 22050 Hz (silence = 0x80).
1225    pub fn drain_audio(&mut self) -> Vec<u8> {
1226        std::mem::take(&mut self.audio_buffer)
1227    }
1228
1229    /// Drain accumulated audio samples into a caller-owned buffer.
1230    ///
1231    /// This avoids transferring the runner's `Vec` allocation out on every
1232    /// browser frame, so the next audio mix can reuse its existing capacity.
1233    pub fn drain_audio_into(&mut self, out: &mut Vec<u8>) {
1234        out.clear();
1235        out.extend_from_slice(&self.audio_buffer);
1236        self.audio_buffer.clear();
1237    }
1238
1239    /// Current number of buffered audio samples (for diagnostics).
1240    pub fn audio_buffer_len(&self) -> usize {
1241        self.audio_buffer.len()
1242    }
1243
1244    pub fn has_pending_sound_work(&self) -> bool {
1245        self.active_interrupt_callback
1246            .map(|callback| {
1247                matches!(
1248                    callback.source,
1249                    ActiveInterruptCallbackSource::SoundCallback
1250                        | ActiveInterruptCallbackSource::SoundFileCompletion
1251                        | ActiveInterruptCallbackSource::SoundDoubleBack
1252                )
1253            })
1254            .unwrap_or(false)
1255            || !self
1256                .dispatcher
1257                .sound_manager
1258                .pending_sound_callbacks
1259                .is_empty()
1260            || !self.dispatcher.sound_manager.pending_callbacks.is_empty()
1261    }
1262
1263    pub fn is_ui_tracking_active(&self) -> bool {
1264        self.frozen_ticks.is_some()
1265            || self.dispatcher.is_menu_tracking()
1266            || self.dispatcher.is_dialog_tracking()
1267            || self.dispatcher.is_control_tracking()
1268    }
1269
1270    /// Advance the guest tick counter by one, firing VBL and timer tasks.
1271    /// Used by the GUI runner to force-advance ticks when the CPU can't
1272    /// keep up with wall-clock time (e.g. during expensive PICT draws).
1273    pub fn force_advance_guest_tick(&mut self) {
1274        self.advance_guest_tick();
1275    }
1276
1277    pub fn set_output_path(&mut self, path: std::path::PathBuf) {
1278        // The path points to a specific file (e.g. temp/foo/fixture_dump.bin).
1279        // Use its parent directory as the VFS output directory.
1280        if let Some(dir) = path.parent() {
1281            self.dispatcher.output_dir = Some(dir.to_path_buf());
1282        }
1283    }
1284
1285    /// Get the contents of a file from the virtual filesystem.
1286    pub fn vfs_read(&self, filename: &str) -> Option<&[u8]> {
1287        self.dispatcher.vfs.get(filename).map(|v| v.as_slice())
1288    }
1289
1290    pub fn vfs_file_summaries(&mut self) -> Vec<VfsFileSummary> {
1291        self.vfs_file_summaries_where(|_| true)
1292    }
1293
1294    pub fn vfs_file_summaries_where<F>(&mut self, mut include: F) -> Vec<VfsFileSummary>
1295    where
1296        F: FnMut(&str) -> bool,
1297    {
1298        self.vfs_file_paths()
1299            .into_iter()
1300            .filter(|path| include(path))
1301            .filter_map(|path| self.vfs_file_summary_for_path(&path))
1302            .collect()
1303    }
1304
1305    pub fn vfs_file_stats_where<F>(&mut self, mut include: F) -> Vec<VfsFileStat>
1306    where
1307        F: FnMut(&str) -> bool,
1308    {
1309        self.vfs_file_paths()
1310            .into_iter()
1311            .filter(|path| include(path))
1312            .filter_map(|path| self.vfs_file_stat_for_path(&path))
1313            .collect()
1314    }
1315
1316    pub fn vfs_file_summary(&mut self, path: &str) -> Option<VfsFileSummary> {
1317        let normalized = TrapDispatcher::normalize_vfs_path(path);
1318        if normalized.is_empty() || normalized.starts_with("__rsrc__") {
1319            return None;
1320        }
1321        self.vfs_file_summary_for_path(&normalized)
1322    }
1323
1324    pub fn vfs_file_snapshot(&mut self, path: &str) -> Option<VfsFileSnapshot> {
1325        let normalized = TrapDispatcher::normalize_vfs_path(path);
1326        if normalized.is_empty() || normalized.starts_with("__rsrc__") {
1327            return None;
1328        }
1329        if !self.dispatcher.vfs.contains_key(&normalized)
1330            && !self.dispatcher.vfs_rsrc.contains_key(&normalized)
1331        {
1332            return None;
1333        }
1334        let metadata = self.dispatcher.vfs_file_metadata(&normalized)?;
1335        Some(VfsFileSnapshot {
1336            path: normalized.clone(),
1337            data_fork: self
1338                .dispatcher
1339                .vfs
1340                .get(&normalized)
1341                .cloned()
1342                .unwrap_or_default(),
1343            resource_fork: self
1344                .dispatcher
1345                .vfs_rsrc
1346                .get(&normalized)
1347                .cloned()
1348                .unwrap_or_default(),
1349            file_type: metadata.file_type,
1350            creator: metadata.creator,
1351            finder_flags: metadata.finder_flags,
1352            created_date: metadata.created_date,
1353            modified_date: metadata.modified_date,
1354        })
1355    }
1356
1357    pub fn import_vfs_file(&mut self, file: &VfsFileSnapshot) {
1358        let normalized = TrapDispatcher::normalize_vfs_path(&file.path);
1359        if normalized.is_empty() || normalized.starts_with("__rsrc__") {
1360            return;
1361        }
1362
1363        self.dispatcher
1364            .vfs
1365            .insert(normalized.clone(), file.data_fork.clone());
1366        self.dispatcher
1367            .vfs_rsrc
1368            .insert(normalized.clone(), file.resource_fork.clone());
1369        self.dispatcher.ensure_vfs_file_metadata(&normalized);
1370        if let Some(metadata) = self.dispatcher.vfs_metadata.get_mut(&normalized) {
1371            metadata.file_type = file.file_type;
1372            metadata.creator = file.creator;
1373            metadata.finder_flags = file.finder_flags;
1374            if file.created_date != 0 {
1375                metadata.created_date = file.created_date;
1376            }
1377            if file.modified_date != 0 {
1378                metadata.modified_date = file.modified_date;
1379            }
1380        }
1381    }
1382
1383    pub fn import_vfs_file_relative_to_launched_app(
1384        &mut self,
1385        relative_dir: &str,
1386        file: &VfsFileSnapshot,
1387    ) -> std::result::Result<(), String> {
1388        let app_path = self
1389            .dispatcher
1390            .launched_app_path
1391            .clone()
1392            .ok_or_else(|| "launched app path is not available".to_string())?;
1393        let app_parent = TrapDispatcher::vfs_parent_path(&app_path);
1394        if app_parent.is_empty() {
1395            return Err("launched app has no parent folder".to_string());
1396        }
1397
1398        let relative_dir = TrapDispatcher::normalize_vfs_path(relative_dir);
1399        if relative_dir.is_empty() || relative_dir.starts_with('/') || relative_dir.contains("..") {
1400            return Err(format!("invalid relative VFS directory {relative_dir:?}"));
1401        }
1402        let file_path = TrapDispatcher::normalize_vfs_path(&file.path);
1403        let filename = TrapDispatcher::vfs_basename(&file_path);
1404        if filename.is_empty() {
1405            return Err("plugin file has no filename".to_string());
1406        }
1407
1408        let mut mounted = file.clone();
1409        mounted.path = format!("{app_parent}/{relative_dir}/{filename}");
1410        self.import_vfs_file(&mounted);
1411        Ok(())
1412    }
1413
1414    pub fn remove_vfs_file(&mut self, path: &str) -> bool {
1415        self.dispatcher.remove_vfs_path(path)
1416    }
1417
1418    fn vfs_file_paths(&mut self) -> Vec<String> {
1419        self.dispatcher.ensure_vfs_catalog();
1420        let mut paths = BTreeSet::new();
1421        for path in self.dispatcher.vfs.keys() {
1422            if !path.starts_with("__rsrc__") {
1423                paths.insert(TrapDispatcher::normalize_vfs_path(path));
1424            }
1425        }
1426        for path in self.dispatcher.vfs_rsrc.keys() {
1427            if !path.starts_with("__rsrc__") {
1428                paths.insert(TrapDispatcher::normalize_vfs_path(path));
1429            }
1430        }
1431        paths.into_iter().filter(|path| !path.is_empty()).collect()
1432    }
1433
1434    fn vfs_file_summary_for_path(&mut self, path: &str) -> Option<VfsFileSummary> {
1435        let stat = self.vfs_file_stat_for_path(path)?;
1436        let data_fork = self
1437            .dispatcher
1438            .vfs
1439            .get(path)
1440            .map(Vec::as_slice)
1441            .unwrap_or(&[]);
1442        let resource_fork = self
1443            .dispatcher
1444            .vfs_rsrc
1445            .get(path)
1446            .map(Vec::as_slice)
1447            .unwrap_or(&[]);
1448        Some(VfsFileSummary {
1449            path: stat.path,
1450            data_len: stat.data_len,
1451            resource_len: stat.resource_len,
1452            data_hash: vfs_fork_hash(data_fork),
1453            resource_hash: vfs_fork_hash(resource_fork),
1454            file_type: stat.file_type,
1455            creator: stat.creator,
1456            finder_flags: stat.finder_flags,
1457            created_date: stat.created_date,
1458            modified_date: stat.modified_date,
1459        })
1460    }
1461
1462    fn vfs_file_stat_for_path(&mut self, path: &str) -> Option<VfsFileStat> {
1463        let metadata = self.dispatcher.vfs_file_metadata(path)?;
1464        let data_len = self.dispatcher.vfs.get(path).map(Vec::len).unwrap_or(0);
1465        let resource_len = self
1466            .dispatcher
1467            .vfs_rsrc
1468            .get(path)
1469            .map(Vec::len)
1470            .unwrap_or(0);
1471        Some(VfsFileStat {
1472            path: path.to_string(),
1473            data_len,
1474            resource_len,
1475            file_type: metadata.file_type,
1476            creator: metadata.creator,
1477            finder_flags: metadata.finder_flags,
1478            created_date: metadata.created_date,
1479            modified_date: metadata.modified_date,
1480        })
1481    }
1482
1483    /// Execute exactly one 68k instruction. Returns the per-step result
1484    /// (continue / halted / unimplemented opcode). Most embedders
1485    /// should call [`run_steps`](Self::run_steps) instead — it amortises
1486    /// the per-step bookkeeping (tick advancement, halt detection,
1487    /// trace ring filling) across the whole budget.
1488    pub fn step(&mut self) -> StepResult {
1489        self.cpu.step(&mut self.bus)
1490    }
1491
1492    /// Load a parsed Mac resource fork into guest memory: registers
1493    /// every resource with the Resource Manager, links the application
1494    /// CODE segments through the trap dispatcher's segment table, and
1495    /// returns a [`LoadedApp`] describing the entry-point base address
1496    /// and per-segment offsets.
1497    ///
1498    /// Lower-level than [`systemless::game::load_game`](crate::game::load_game) —
1499    /// that helper auto-detects StuffIt / MacBinary / raw-resource-fork
1500    /// containers and calls this method internally. Use `load_app`
1501    /// directly only when you've already parsed the resource fork
1502    /// yourself (e.g. building a custom test fixture).
1503    pub fn load_app(&mut self, fork: &ResourceFork) -> Option<LoadedApp> {
1504        let app = load_app_generic(fork, &mut self.bus, self.config.load_address)?;
1505        let heap_start = app_heap_start_for_loaded_app(&app);
1506
1507        // Resource data is allocated after direct CODE/global loading. Reserve
1508        // the app-zone header at the actual heap start so early app resources
1509        // cannot land in loader-owned memory or under `bkLim`/`hFstFree`.
1510        // Inside Macintosh Volume II, II-22.
1511        self.bus
1512            .reserve_heap_until(heap_start.saturating_add(APP_ZONE_HEADER_SIZE));
1513        self.dispatcher.load_resources(fork, &mut self.bus);
1514
1515        let segments: HashMap<i16, u32> = app.segment_bases.iter().map(|(&k, &v)| (k, v)).collect();
1516        self.dispatcher.register_segments(segments);
1517
1518        Some(app)
1519    }
1520
1521    fn clear_startup_framebuffer(&mut self) {
1522        if self.menu_bar_visible() {
1523            let (scrn_base, row_bytes, screen_width, screen_height, pixel_size) =
1524                self.dispatcher.screen_mode;
1525            TrapDispatcher::fb_fill_pattern_rect(
1526                &mut self.bus,
1527                scrn_base,
1528                row_bytes,
1529                pixel_size,
1530                screen_width as i16,
1531                screen_height as i16,
1532                0,
1533                0,
1534                screen_height as i16,
1535                screen_width as i16,
1536                [0xAA, 0x55, 0xAA, 0x55, 0xAA, 0x55, 0xAA, 0x55],
1537            );
1538            return;
1539        }
1540
1541        let (scrn_base, row_bytes, _, scrn_height, _) = self.dispatcher.screen_mode;
1542        self.bus
1543            .fill_bytes(scrn_base, row_bytes * scrn_height as u32, 0xFF);
1544    }
1545
1546    fn switch_to_launched_application(
1547        &mut self,
1548        app_path: &str,
1549    ) -> std::result::Result<(), String> {
1550        use crate::memory::globals::addr;
1551
1552        let normalized = TrapDispatcher::normalize_vfs_path(app_path);
1553        let rsrc_key = self
1554            .dispatcher
1555            .find_vfs_rsrc_file(&normalized)
1556            .ok_or_else(|| format!("no resource fork for launched application {normalized:?}"))?;
1557        let rsrc_bytes = self
1558            .dispatcher
1559            .vfs_rsrc
1560            .get(&rsrc_key)
1561            .cloned()
1562            .ok_or_else(|| format!("resource fork {rsrc_key:?} disappeared before launch"))?;
1563        let fork = ResourceFork::parse(&rsrc_bytes)
1564            .ok_or_else(|| format!("failed to parse launched application {normalized:?}"))?;
1565
1566        let ram_size = self.bus.ram_size() as usize;
1567        let config = FixtureRunnerConfig {
1568            max_instructions: self.config.max_instructions,
1569            load_address: self.config.load_address,
1570            arrows_as_numpad: self.config.arrows_as_numpad,
1571            ui_theme: self.config.ui_theme,
1572            theme_metrics_mode: self.config.theme_metrics_mode,
1573        };
1574        let menu_bar_visible = self.menu_bar_visible();
1575        let instructions_per_tick = self.instructions_per_tick;
1576        let wait_sleep_cap_in_headless = self.wait_sleep_cap_in_headless;
1577        let app_start_time = self.app_start_time;
1578        let application_partition_size = self.application_partition_size;
1579        let total_instructions = self.total_instructions;
1580        let launch_tick = self.guest_tick();
1581        let launch_time = self.bus.read_long(addr::TIME);
1582        let mouse_pos = self.dispatcher.mouse_pos;
1583        let mouse_button = self.dispatcher.mouse_button;
1584        let output_dir = self.dispatcher.output_dir.clone();
1585        let vfs = self.dispatcher.vfs.clone();
1586        let vfs_rsrc = self.dispatcher.vfs_rsrc.clone();
1587        let vfs_metadata = self.dispatcher.vfs_metadata.clone();
1588        let vfs_directories = self.dispatcher.vfs_directories.clone();
1589        let vfs_directory_paths = self.dispatcher.vfs_directory_paths.clone();
1590        let locked_files = self.dispatcher.locked_files.clone();
1591        let next_vfs_dir_id = self.dispatcher.next_vfs_dir_id;
1592        let next_vfs_file_id = self.dispatcher.next_vfs_file_id;
1593        let next_vfs_timestamp = self.dispatcher.next_vfs_timestamp;
1594        let next_working_dir_refnum = self.dispatcher.next_working_dir_refnum;
1595
1596        let mut replacement = FixtureRunner::new(ram_size, config);
1597        replacement.set_menu_bar_visible(menu_bar_visible);
1598        replacement.instructions_per_tick = instructions_per_tick;
1599        replacement.tick_budget = instructions_per_tick as i32;
1600        replacement.wait_sleep_cap_in_headless = wait_sleep_cap_in_headless;
1601        replacement.app_start_time = app_start_time;
1602        replacement.application_partition_size = application_partition_size;
1603        replacement.total_instructions = total_instructions;
1604
1605        replacement.dispatcher.output_dir = output_dir;
1606        replacement.dispatcher.vfs = vfs;
1607        replacement.dispatcher.vfs_rsrc = vfs_rsrc;
1608        replacement.dispatcher.vfs_metadata = vfs_metadata;
1609        replacement.dispatcher.vfs_directories = vfs_directories;
1610        replacement.dispatcher.vfs_directory_paths = vfs_directory_paths;
1611        replacement.dispatcher.locked_files = locked_files;
1612        replacement.dispatcher.next_vfs_dir_id = next_vfs_dir_id;
1613        replacement.dispatcher.next_vfs_file_id = next_vfs_file_id;
1614        replacement.dispatcher.next_vfs_timestamp = next_vfs_timestamp;
1615        replacement.dispatcher.next_working_dir_refnum = next_working_dir_refnum;
1616        replacement.dispatcher.set_launched_app_path(&normalized);
1617
1618        let app = replacement
1619            .load_app(&fork)
1620            .ok_or_else(|| format!("failed to load launched application {normalized:?}"))?;
1621        replacement.init_app(&app);
1622        replacement.bus.write_long(addr::TICKS, launch_tick);
1623        replacement.bus.write_long(addr::TIME, launch_time);
1624        replacement.dispatcher.tick_count = launch_tick;
1625        replacement.dispatcher.mouse_pos = mouse_pos;
1626        replacement.dispatcher.mouse_button = mouse_button;
1627        replacement
1628            .bus
1629            .write_byte(addr::MB_STATE, if mouse_button { 0x00 } else { 0x80 });
1630        replacement.clear_startup_framebuffer();
1631
1632        replacement.audio = self.audio.take();
1633        replacement.audio_buffer = std::mem::take(&mut self.audio_buffer);
1634
1635        eprintln!("[LAUNCH] Switched foreground application to {normalized}");
1636        *self = replacement;
1637        Ok(())
1638    }
1639
1640    fn service_pending_launch_application(&mut self, event_yield_reached: bool) -> bool {
1641        let Some(path) = self
1642            .dispatcher
1643            .take_pending_launch_application(event_yield_reached)
1644        else {
1645            return false;
1646        };
1647
1648        if let Err(err) = self.switch_to_launched_application(&path) {
1649            eprintln!("[LAUNCH] Failed to switch to queued application {path:?}: {err}");
1650            self.halted = true;
1651            self.halted_pc = Some(self.cpu.read_reg(Register::PC));
1652            self.halted_sp = Some(self.cpu.read_reg(Register::A7));
1653            self.halted_d0 = Some((-43i32) as u32);
1654        }
1655        true
1656    }
1657
1658    pub(crate) fn merge_resources_into_application(&mut self, fork: &ResourceFork) -> usize {
1659        self.dispatcher
1660            .merge_resources_into_existing_file(fork, &mut self.bus, 0)
1661    }
1662
1663    fn alloc_handle_with_bytes(&mut self, bytes: &[u8]) -> u32 {
1664        let data_ptr = if bytes.is_empty() {
1665            0
1666        } else {
1667            let data_ptr = self.bus.alloc(bytes.len() as u32);
1668            if data_ptr == 0 {
1669                return 0;
1670            }
1671            self.bus.write_bytes(data_ptr, bytes);
1672            data_ptr
1673        };
1674
1675        let handle = self.bus.alloc(4);
1676        if handle == 0 {
1677            if data_ptr != 0 {
1678                self.bus.free(data_ptr);
1679            }
1680            return 0;
1681        }
1682
1683        self.bus.write_long(handle, data_ptr);
1684        if data_ptr != 0 {
1685            self.dispatcher.ptr_to_handle.insert(data_ptr, handle);
1686        }
1687        handle
1688    }
1689
1690    /// Seed the Mac-canonical low-memory globals (`MemTop`,
1691    /// `CurStackBase`, `ApplLimit`, `Lo3Bytes`, `Ticks`, etc.) and
1692    /// the A5 World start so `run_steps` lands the guest in a
1693    /// runnable state. Must be called after [`load_app`](Self::load_app)
1694    /// and before the first call to [`run_steps`](Self::run_steps);
1695    /// the higher-level `systemless::game::load_game` helper invokes it
1696    /// automatically.
1697    ///
1698    /// Without `init_app`, A5-relative startup code (CodeWarrior /
1699    /// Think C runtimes, e.g. Koji / Munchies) sees `CurStackBase` =
1700    /// 0 and spins forever in the globals-decompression loop.
1701    pub fn init_app(&mut self, app: &LoadedApp) {
1702        use crate::memory::globals::addr;
1703        let ram_size = self.bus.ram_size();
1704
1705        // Initialize low-memory globals
1706        self.bus.write_long(addr::MEM_TOP, ram_size);
1707        // CurStackBase ($0908): "Address of base of stack; start of
1708        // application global variables." Per Inside Macintosh: Memory
1709        // 1992, p. 2-104, this points at the boundary where the
1710        // application's stack region meets the A5 World — equivalently,
1711        // the address of the *first* below-A5 global. CodeWarrior /
1712        // Think C runtime startup code (e.g. Koji the Frog, Munchies)
1713        // reads $0908 as a destination pointer when decompressing
1714        // initial values into the A5 globals area; pointing it at the
1715        // stack top instead leaves that decompression loop spinning
1716        // forever because its termination condition compares the
1717        // walked-forward A1 against (A5)+. Use `a5_base - below_a5`
1718        // here — that's the Mac-canonical "start of application
1719        // globals" regardless of where the stack lives in our
1720        // (inverted) host memory map.
1721        let app_globals_start = app.a5_base.saturating_sub(app.code0_header.below_a5);
1722        self.bus.write_long(addr::CUR_STACK_BASE, app_globals_start);
1723        self.bus.write_long(addr::CURRENT_A5, app.a5_base);
1724        self.bus.write_word(addr::ROM85, 0x0000);
1725        // MMU32Bit ($0CB2): TRUE when 32-bit addressing mode is in effect.
1726        // Inside Macintosh: Memory 1992, p. 4-25 says applications can test
1727        // this low-memory byte directly; Systemless's TrapDispatcher already
1728        // defaults SwapMMUMode to true32b and Gestalt('addr') bit 0 to set.
1729        self.bus.write_byte(addr::MMU32_BIT, 1);
1730        // Initialize Ticks ($016A) to a realistic post-boot value.
1731        // On a real Mac, hundreds of ticks elapse during the boot ROM,
1732        // system extensions, and Finder startup before the application
1733        // launches. Games that read Ticks early (e.g. to seed a PRNG)
1734        // expect a non-zero value; starting at 0 produces degenerate
1735        // random sequences (e.g., ship heading always zero in EV).
1736        // 600 ticks ≈ 10 seconds of post-boot time, a conservative
1737        // estimate for a minimal System 7 configuration.
1738        self.bus.write_long(addr::TICKS, 0);
1739        let time = self
1740            .app_start_time
1741            .unwrap_or_else(current_mac_epoch_seconds);
1742        self.bus.write_long(addr::TIME, time);
1743        // RndSeed ($0156): system random seed initialized during boot.
1744        // On a real Mac, the boot code seeds this from the real-time clock
1745        // so that programs that read it directly (without calling Random)
1746        // get non-deterministic entropy. Use the startup time so play
1747        // scripts produce repeatable but non-trivial random sequences.
1748        // Inside Macintosh Volume II, II-387
1749        self.bus.write_long(addr::RND_SEED, time);
1750        // MBState: $80 = button UP (Mac convention: 0 = down, $80 = up).
1751        // RAM is zero-initialized which would mean "button down" — must set explicitly.
1752        self.bus.write_byte(addr::MB_STATE, 0x80);
1753        // KeyMapLM ($0174): ROM-maintained current-key bitmap mirrored by
1754        // GetKeys. Keep it explicitly clear at launch for direct pollers.
1755        // Inside Macintosh Volume I, I-260; MPW SysEqu.h `KeyMapLM`.
1756        self.sync_key_map_lowmem();
1757        // JCrsrTask ($08EE): address of the cursor VBL task routine.
1758        // MPW Interfaces/AIncludes/LowMemEqu.a lists `JCrsrTask EQU $8EE`.
1759        // Classic applications can wrap this low-memory vector and then wait
1760        // for their wrapper to run at interrupt time, so the default must be
1761        // both non-NIL and callable. `$0060` is one of Systemless's low-memory
1762        // RTS stubs for direct-call compatibility.
1763        self.bus.write_word(CURSOR_TASK_NOOP_ADDR, 0x4E75);
1764        self.bus
1765            .write_long(addr::J_CRSR_TASK, CURSOR_TASK_NOOP_ADDR);
1766        // MBarHeight: 20 pixels (standard Roman system script value).
1767        // Games may set this to 0 to hide the menu bar for full-screen mode.
1768        // Inside Macintosh Volume V, V-245
1769        self.bus.write_word(addr::MBAR_HEIGHT, 20);
1770        // SdVolume ($0260): current speaker volume, low three bits only.
1771        // Inside Macintosh Volume III, III-425. Some classic apps also read
1772        // this byte directly as a "Sound Driver present" sentinel — most
1773        // notably Marathon 1, whose sound module (CODE 5 +$0003F2:
1774        // `MOVE.B (mem $260).W, (A0)`) short-circuits its audio submission
1775        // path when this byte is zero. Initialize it to the minimum nonzero
1776        // compatibility value; higher legacy volume values can change old
1777        // Sound Driver clients' control flow.
1778        self.bus.write_byte(addr::SD_VOLUME, 1);
1779
1780        // Memory Manager zone globals
1781        // Inside Macintosh Volume II, II-19 and II-29..II-30.
1782        // The heap normally starts at 0x200000, but large applications can
1783        // place A5 globals or CODE segments above that floor. In that case,
1784        // move the application zone above the direct-loaded image so Toolbox
1785        // heap allocations cannot overwrite executable/app-global memory.
1786        let heap_start = app_heap_start_for_loaded_app(app);
1787        let zone_header_size: u32 = APP_ZONE_HEADER_SIZE;
1788        let initial_heap_end = heap_start + zone_header_size;
1789        let stack_base = app.initial_sp;
1790        let default_appl_limit = stack_base - APP_STACK_SAFETY_MARGIN;
1791        let requested_partition_size = self.application_partition_size.or_else(|| {
1792            app.size_resource
1793                .and_then(|size| size.preferred_partition_size())
1794        });
1795        let appl_limit = requested_partition_size
1796            .and_then(|partition_size| {
1797                // Processes 1994, pp. 1-3 and 2-18: the Process Manager
1798                // allocates the application partition from the app's 'SIZE'
1799                // resource preferred size when available. A scripted override
1800                // represents the same Finder-style preferred-memory setting
1801                // applied to a temporary launch. Systemless keeps the physical
1802                // stack at the top of guest RAM, but narrows the observable
1803                // heap limit so FreeMem/MaxMem/process info see the same
1804                // partition pressure.
1805                partition_size
1806                    .checked_sub(APP_STACK_SAFETY_MARGIN)
1807                    .and_then(|heap_span| heap_start.checked_add(heap_span))
1808            })
1809            .filter(|&limit| limit > initial_heap_end && limit < default_appl_limit)
1810            .unwrap_or(default_appl_limit.max(initial_heap_end));
1811        let buf_ptr = appl_limit; // Buffer area at the limit
1812        self.bus.write_long(addr::SYS_ZONE, heap_start);
1813        self.bus.write_long(addr::APP_L_ZONE, heap_start);
1814        self.bus.write_long(addr::HEAP_END, initial_heap_end);
1815        self.bus.write_long(addr::APPL_LIMIT, appl_limit);
1816        self.bus.write_long(addr::BUF_PTR, buf_ptr);
1817        self.bus.write_long(addr::THE_ZONE, heap_start);
1818
1819        // CurApRefNum: resource file reference number of the application (word).
1820        // CurApName: application name as Pascal string (Str31).
1821        // AppParmHandle is allocated below, after the zone header is reserved.
1822        // Inside Macintosh Volume II, II-57 to II-58
1823        self.bus.write_word(addr::CUR_APREF_NUM, 0); // app resources use refnum 0
1824        if let Some(app_path) = &self.dispatcher.launched_app_path {
1825            let app_name = crate::trap::dispatch::TrapDispatcher::vfs_basename(app_path);
1826            let name_bytes = app_name.as_bytes();
1827            let len = name_bytes.len().min(31);
1828            self.bus.write_byte(addr::CUR_APNAME, len as u8);
1829            for (i, &b) in name_bytes.iter().take(len).enumerate() {
1830                self.bus.write_byte(addr::CUR_APNAME + 1 + i as u32, b);
1831            }
1832        }
1833
1834        // Set the current directory to the application's parent folder so that
1835        // file-relative lookups (e.g. Marathon opening "Music") resolve correctly.
1836        // CurDirStore: directory ID of directory last opened (long)
1837        // SFSaveDisk: negative of volume reference number (word)
1838        // Inside Macintosh Volume IV, IV-72
1839        let app_dir_id = self.dispatcher.default_dir_id;
1840        self.bus.write_long(addr::CUR_DIR_STORE, app_dir_id);
1841        self.bus.write_word(
1842            addr::SF_SAVE_DISK,
1843            (-crate::trap::dispatch::BOOT_VOLUME_REF_NUM) as u16,
1844        );
1845        eprintln!(
1846            "[INIT] CurDirStore={} SFSaveDisk={}",
1847            app_dir_id,
1848            (-crate::trap::dispatch::BOOT_VOLUME_REF_NUM) as u16
1849        );
1850
1851        // The application stack is ordinary RAM used for stack frames and
1852        // local variables; classic Mac code does not get it pre-cleared.
1853        // Memory 1992, 1-9 and 1-39 describe the stack as the region where
1854        // stack frames live and grow downward from high memory. Seed the
1855        // unused top-of-stack window with a deterministic nonzero pattern so
1856        // partially initialized stack records behave like real hardware
1857        // instead of inheriting zeroed RAM.
1858        let stack_seed_start = stack_base.saturating_sub(0x8000);
1859        self.bus
1860            .fill_bytes(stack_seed_start, stack_base - stack_seed_start, 0xA5);
1861
1862        // Zone header at heap_start (Inside Macintosh Volume II, II-22)
1863        // Apps and the Memory Manager read the zone header to determine
1864        // available memory. zcbFree (offset +12) must reflect free bytes.
1865        // Reserve heap space so alloc() doesn't overwrite the zone header.
1866        self.bus.reserve_heap_until(initial_heap_end);
1867        let zone_size = appl_limit.saturating_sub(heap_start);
1868        let free_bytes = zone_size.saturating_sub(zone_header_size);
1869        self.bus.write_long(heap_start, appl_limit); // bkLim: end of zone
1870        self.bus
1871            .write_long(heap_start + 8, heap_start + zone_header_size); // hFstFree
1872        self.bus.write_long(heap_start + 12, free_bytes); // zcbFree: total free
1873        self.bus
1874            .write_long(heap_start + 56, heap_start + zone_header_size); // allocPtr
1875        eprintln!(
1876            "[INIT] Zone header: start=${:08X} bkLim=${:08X} zcbFree={} ({:.1}MB)",
1877            heap_start,
1878            appl_limit,
1879            free_bytes,
1880            free_bytes as f64 / (1024.0 * 1024.0)
1881        );
1882
1883        // AppParmHandle: handle to Finder information about files selected
1884        // when launching the application. A normal Finder application launch
1885        // with no documents still provides the message/count header:
1886        // appOpen (0), count 0. Assembly code may read this global directly.
1887        // Inside Macintosh Volume II, II-57; Files 1992, 1-58.
1888        let app_param_handle = self.alloc_handle_with_bytes(&[0, 0, 0, 0]);
1889        self.bus.write_long(addr::APP_PARM_HANDLE, app_param_handle);
1890
1891        // Write an ExitToShell trap at a known low-memory address so that when
1892        // main() returns via RTS, the CPU executes ExitToShell and halts cleanly.
1893        // We use address 0x100 (safe, unused low memory) to hold the A-line instruction.
1894        let exit_trampoline = 0x100u32;
1895        self.bus.write_word(exit_trampoline, 0xA9F4); // ExitToShell
1896
1897        // Pre-allocate the main GDevice with 800x600 8bpp settings
1898        // and set the low-memory globals that games read directly.
1899        // screenBits is already initialized to 800x600 8bpp by the bus.
1900        let gdh = self.dispatcher.ensure_main_gdevice(&mut self.bus);
1901        let gd_ptr = self.bus.read_long(gdh);
1902        self.bus.write_long(0x8A4, gdh); // MainDevice
1903        self.bus.write_long(0xCC8, gdh); // TheGDevice
1904        self.bus.write_long(0x8A8, gdh); // DeviceList
1905        eprintln!(
1906            "[INIT] Set MainDevice=${:08X}, TheGDevice=${:08X} (ptr=${:08X})",
1907            gdh, gdh, gd_ptr
1908        );
1909
1910        // Initialize screen_mode from the main GDevice PixMap.
1911        let pmap_h = self.bus.read_long(gd_ptr + 22);
1912        let pmap = self.bus.read_long(pmap_h);
1913        let scrn_base = self.bus.read_long(pmap);
1914        let rb = (self.bus.read_word(pmap + 4) & 0x3FFF) as u32;
1915        let top = self.bus.read_word(pmap + 6) as i16;
1916        let left = self.bus.read_word(pmap + 8) as i16;
1917        let bottom = self.bus.read_word(pmap + 10) as i16;
1918        let right = self.bus.read_word(pmap + 12) as i16;
1919        let pixel_size = self.bus.read_word(pmap + 32);
1920        let width = (right - left).max(1) as u16;
1921        let height = (bottom - top).max(1) as u16;
1922        self.dispatcher.screen_mode = (scrn_base, rb, width, height, pixel_size);
1923
1924        // Debug: dump the GDevice chain to verify correctness
1925        {
1926            let main_dev = self.bus.read_long(0x8A4);
1927            let gd = self.bus.read_long(main_dev);
1928            let pmap_h = self.bus.read_long(gd + 22);
1929            let pmap = self.bus.read_long(pmap_h);
1930            let rb = self.bus.read_word(pmap + 4);
1931            let top = self.bus.read_word(pmap + 6) as i16;
1932            let left = self.bus.read_word(pmap + 8) as i16;
1933            let bottom = self.bus.read_word(pmap + 10) as i16;
1934            let right = self.bus.read_word(pmap + 12) as i16;
1935            let ps = self.bus.read_word(pmap + 32);
1936            let gd_top = self.bus.read_word(gd + 34) as i16;
1937            let gd_left = self.bus.read_word(gd + 36) as i16;
1938            let gd_bottom = self.bus.read_word(gd + 38) as i16;
1939            let gd_right = self.bus.read_word(gd + 40) as i16;
1940            let gd_flags = self.bus.read_word(gd + 20);
1941            eprintln!(
1942                "[INIT] GDevice chain: $8A4→${:08X}→${:08X} gdPMap→${:08X}→${:08X}",
1943                main_dev, gd, pmap_h, pmap
1944            );
1945            eprintln!(
1946                "[INIT]   PixMap: rowBytes=${:04X} bounds=({},{},{},{}) pixelSize={}",
1947                rb, top, left, bottom, right, ps
1948            );
1949            eprintln!(
1950                "[INIT]   GDevice: gdRect=({},{},{},{}) gdFlags=${:04X}",
1951                gd_top, gd_left, gd_bottom, gd_right, gd_flags
1952            );
1953        }
1954
1955        // Set CPU state
1956        self.cpu.write_reg(Register::A5, app.a5_base);
1957        // Push the exit trampoline as the return address on the stack
1958        let sp = app.initial_sp.wrapping_sub(4);
1959        self.bus.write_long(sp, exit_trampoline);
1960        self.cpu.write_reg(Register::A7, sp);
1961        // Initialize A6 (frame pointer) to the stack pointer.
1962        // On a real Mac, the Process Manager sets up A6 before launching
1963        // the application. The CRT startup code (e.g. Think C's __start)
1964        // expects A6 to be a valid stack address for its initial LINK frame.
1965        self.cpu.write_reg(Register::A6, sp);
1966        self.cpu
1967            .write_reg(Register::PC, app.entry_point(app.a5_base));
1968    }
1969
1970    /// Mix and queue audio samples without full frame finalization.
1971    /// Used to keep the audio buffer fed during long CPU frames.
1972    pub fn mix_audio(&mut self, num_samples: usize) {
1973        self.mix_host_audio(num_samples);
1974    }
1975
1976    fn queue_mixed_audio(&mut self, stereo_samples: &[u8]) {
1977        if stereo_samples.is_empty() {
1978            return;
1979        }
1980        if let Some(ref mut audio) = self.audio {
1981            audio.queue_stereo_samples(stereo_samples);
1982        }
1983        for frame in stereo_samples.chunks_exact(2) {
1984            let left = frame[0] as i32 - 0x80;
1985            let right = frame[1] as i32 - 0x80;
1986            self.audio_buffer
1987                .push(((left + right) / 2 + 0x80).clamp(0, 255) as u8);
1988        }
1989    }
1990
1991    fn queue_host_silence_audio(&mut self, num_samples: usize) {
1992        if num_samples == 0 {
1993            return;
1994        }
1995        if let Some(ref mut audio) = self.audio {
1996            audio.queue_stereo_samples(&vec![0x80; num_samples * 2]);
1997        }
1998    }
1999
2000    fn mix_host_audio(&mut self, mut remaining_samples: usize) {
2001        while remaining_samples > 0 {
2002            self.try_load_pending_double_buffers();
2003            self.dispatcher.service_guest_sound_queues(&mut self.bus);
2004
2005            let chunk = self
2006                .dispatcher
2007                .sound_manager
2008                .samples_until_next_exhaustion()
2009                .map(|samples| samples.max(1).min(remaining_samples))
2010                .unwrap_or(remaining_samples);
2011
2012            let samples = self.dispatcher.sound_manager.mix_frame_stereo(chunk);
2013            if samples.is_empty() {
2014                self.queue_host_silence_audio(remaining_samples);
2015                self.dispatcher
2016                    .release_finished_internal_sound_channels(&mut self.bus);
2017                break;
2018            }
2019            self.queue_mixed_audio(&samples);
2020            self.dispatcher
2021                .release_finished_internal_sound_channels(&mut self.bus);
2022            remaining_samples -= chunk;
2023        }
2024    }
2025
2026    fn finish_host_frame(&mut self, audio_samples: usize, sound_interrupt_dispatched: bool) {
2027        // Redraw menu bar and window chrome after each frame.
2028        // On a real Mac the Window Manager maintains these as
2029        // separate layers; here they are raw framebuffer pixels
2030        // that game drawing (explosions, etc.) can overwrite.
2031        self.dispatcher.redraw_chrome(&mut self.bus);
2032        self.finish_audio_frame(audio_samples, sound_interrupt_dispatched);
2033    }
2034
2035    fn finish_audio_frame(&mut self, audio_samples: usize, sound_interrupt_dispatched: bool) {
2036        // Try to load any double-buffer data that callbacks have refilled.
2037        self.try_load_pending_double_buffers();
2038
2039        // Sound channels expose an in-memory queue that some games update
2040        // directly instead of routing every command through SndDoCommand.
2041        self.dispatcher.service_guest_sound_queues(&mut self.bus);
2042
2043        // Mix and output audio for this frame.
2044        if audio_samples > 0 {
2045            self.mix_host_audio(audio_samples);
2046        }
2047
2048        self.dispatcher
2049            .sync_guest_sound_channel_state(&mut self.bus);
2050
2051        if !sound_interrupt_dispatched {
2052            let fired_sound_callback = self.fire_sound_callbacks();
2053            if !fired_sound_callback {
2054                // Fire pending double-buffer callbacks (SndPlayDoubleBuffer).
2055                self.fire_sound_doubleback_callbacks();
2056            }
2057        }
2058    }
2059
2060    /// Mix a GUI-only audio slice without running foreground guest code or
2061    /// redrawing the frame. Used by realtime frontends to let Sound Manager
2062    /// doubleback callbacks run between small audio chunks when TickCount is
2063    /// already caught up to the wall clock.
2064    pub fn mix_gui_audio_slice(&mut self, audio_samples: usize) {
2065        self.finish_audio_frame(audio_samples, false);
2066    }
2067
2068    /// Try to fast-forward past a TickCount spin-wait loop. Returns
2069    /// true iff the `tick_cap` was hit during advancement (caller
2070    /// should break the outer run loop); returns false if no match,
2071    /// if the advance succeeded, or if the max-cap
2072    /// (`SPIN_FASTFWD_MAX_TICKS`) protected us from a runaway target.
2073    /// All bytes are read from guest memory — the function never runs
2074    /// game code.
2075    fn try_tickcount_spin_fastfwd(
2076        &mut self,
2077        pc_after_trap: u32,
2078        tick_cap: Option<u32>,
2079        count: &mut usize,
2080    ) -> bool {
2081        // Step 1: MOVE.L (A7)+, Dn (shared by all templates).
2082        let w0 = self.bus.read_word(pc_after_trap);
2083        if (w0 & 0xF1FF) != 0x201F {
2084            return false;
2085        }
2086        let dn = ((w0 >> 9) & 7) as usize;
2087
2088        let w1 = self.bus.read_word(pc_after_trap.wrapping_add(2));
2089
2090        // Template A: SUBQ.L #imm, Dn; CMP.L Dn, Dm; BHI.S <back-to-SUBQ-#4,A7>
2091        if (w1 & 0xF1F8) == 0x5180 && (w1 & 0x0007) as usize == dn {
2092            return self.try_spin_template_a(pc_after_trap, dn, w1, tick_cap, count);
2093        }
2094
2095        // Template B: CMP.L (d16, An), Dn; BLS.S <back>
2096        if (w1 & 0xF1F8) == 0xB0A8 && ((w1 >> 9) & 7) as usize == dn {
2097            return self.try_spin_template_b(pc_after_trap, dn, w1, tick_cap, count);
2098        }
2099
2100        // Template C: CMP.L (xxx).L, Dn; BCS.S <back>
2101        if (w1 & 0xF1FF) == 0xB0B9 && ((w1 >> 9) & 7) as usize == dn {
2102            return self.try_spin_template_c(pc_after_trap, dn, tick_cap, count);
2103        }
2104
2105        false
2106    }
2107
2108    /// Template A: classic pre-System-7 SUBQ-compare spin.
2109    ///   MOVE.L (A7)+, Dn
2110    ///   SUBQ.L #imm, Dn
2111    ///   CMP.L  Dn, Dm
2112    ///   BHI.S  <SUBQ.W #4, A7 before the _TickCount>
2113    fn try_spin_template_a(
2114        &mut self,
2115        pc_after_trap: u32,
2116        dn: usize,
2117        w1: u16,
2118        tick_cap: Option<u32>,
2119        count: &mut usize,
2120    ) -> bool {
2121        let imm_bits = ((w1 >> 9) & 7) as u32;
2122        let imm = if imm_bits == 0 { 8 } else { imm_bits };
2123
2124        let w2 = self.bus.read_word(pc_after_trap.wrapping_add(4));
2125        let w3 = self.bus.read_word(pc_after_trap.wrapping_add(6));
2126
2127        // CMP.L Dn, Dm (0xB_80 family, src-mode 000 = data reg direct).
2128        if (w2 & 0xF1F8) != 0xB080 || (w2 & 0x0007) as usize != dn {
2129            return false;
2130        }
2131        let dm = ((w2 >> 9) & 7) as usize;
2132
2133        // BHI.S
2134        if (w3 & 0xFF00) != 0x6200 {
2135            return false;
2136        }
2137        let disp8 = (w3 & 0xFF) as i8 as i32;
2138        if disp8 == 0 {
2139            return false;
2140        }
2141        let branch_src = pc_after_trap.wrapping_add(6);
2142        let target = (branch_src.wrapping_add(2) as i32).wrapping_add(disp8) as u32;
2143        if target != pc_after_trap.wrapping_sub(4) {
2144            return false;
2145        }
2146
2147        let dm_val = self.cpu.core.d(dm);
2148        let target_tick = dm_val.wrapping_add(imm);
2149        match self.advance_until_tick(target_tick, tick_cap) {
2150            AdvanceResult::CapHit => return true,
2151            AdvanceResult::Interrupted | AdvanceResult::TooFar => return false,
2152            AdvanceResult::Advanced => {}
2153        }
2154
2155        // Synthesise exit: Dn = final_tick - imm = Dm (by definition of
2156        // the fall-through condition), A7 += 4, PC past BHI.S.
2157        let final_tick = self.dispatcher.tick_count;
2158        let sp = self.cpu.core.a(7);
2159        self.cpu.core.set_a(7, sp.wrapping_add(4));
2160        self.cpu.core.set_d(dn, final_tick.wrapping_sub(imm));
2161        self.cpu.core.pc = pc_after_trap.wrapping_add(8);
2162
2163        *count += 4;
2164        self.total_instructions = self.total_instructions.wrapping_add(4);
2165        false
2166    }
2167
2168    /// Template B: memory-target variant.
2169    ///   MOVE.L (A7)+, Dn
2170    ///   CMP.L  (d16, An), Dn    ; 4 bytes (opcode word + d16)
2171    ///   BLS.S  <backward, into body that rewrites (An+d16)>
2172    ///
2173    /// Exit when `TickCount() > *(An+d16)`, i.e. `target_tick =
2174    /// *(An+d16) + 1`.
2175    fn try_spin_template_b(
2176        &mut self,
2177        pc_after_trap: u32,
2178        dn: usize,
2179        w1: u16,
2180        tick_cap: Option<u32>,
2181        count: &mut usize,
2182    ) -> bool {
2183        let an = (w1 & 7) as usize;
2184        let d16 = self.bus.read_word(pc_after_trap.wrapping_add(4)) as i16 as i32;
2185        let w_brk = self.bus.read_word(pc_after_trap.wrapping_add(6));
2186
2187        // BLS.S disp8
2188        if (w_brk & 0xFF00) != 0x6300 {
2189            return false;
2190        }
2191        let disp8 = (w_brk & 0xFF) as i8 as i32;
2192        if disp8 == 0 {
2193            return false;
2194        }
2195        // Branch target must be a short backward branch. We don't
2196        // insist on an exact target since template B's body runs
2197        // BEFORE the _TickCount trap (not just the SUBQ #4, A7).
2198        let branch_src = pc_after_trap.wrapping_add(6);
2199        let target = (branch_src.wrapping_add(2) as i32).wrapping_add(disp8) as u32;
2200        if target >= pc_after_trap || pc_after_trap.wrapping_sub(target) > 128 {
2201            return false;
2202        }
2203
2204        let an_val = self.cpu.core.a(an);
2205        let mem_addr = (an_val as i32).wrapping_add(d16) as u32;
2206        let mem_target = self.bus.read_long(mem_addr);
2207        let target_tick = mem_target.wrapping_add(1);
2208
2209        match self.advance_until_tick(target_tick, tick_cap) {
2210            AdvanceResult::CapHit => return true,
2211            AdvanceResult::Interrupted | AdvanceResult::TooFar => return false,
2212            AdvanceResult::Advanced => {}
2213        }
2214
2215        // Synthesise exit: Dn = final_tick, A7 += 4, PC past BLS.S.
2216        // body_size: MOVE.L (2) + CMP.L w/d16 (4) + BLS.S (2) = 8 bytes.
2217        let final_tick = self.dispatcher.tick_count;
2218        let sp = self.cpu.core.a(7);
2219        self.cpu.core.set_a(7, sp.wrapping_add(4));
2220        self.cpu.core.set_d(dn, final_tick);
2221        self.cpu.core.pc = pc_after_trap.wrapping_add(8);
2222
2223        *count += 3;
2224        self.total_instructions = self.total_instructions.wrapping_add(3);
2225        false
2226    }
2227
2228    /// Template C: absolute-long target variant.
2229    ///   MOVE.L (A7)+, Dn
2230    ///   CMP.L  (xxx).L, Dn
2231    ///   BCS.S  <back-to-SUBQ.W #4,A7 before the _TickCount>
2232    ///
2233    /// Exit when `TickCount() >= *(xxx).L`.
2234    fn try_spin_template_c(
2235        &mut self,
2236        pc_after_trap: u32,
2237        dn: usize,
2238        tick_cap: Option<u32>,
2239        count: &mut usize,
2240    ) -> bool {
2241        let target_addr = self.bus.read_long(pc_after_trap.wrapping_add(4));
2242        let w_brk = self.bus.read_word(pc_after_trap.wrapping_add(8));
2243
2244        // BCS.S/BLO.S disp8. The loop repeats while Dn < *(xxx).L.
2245        if (w_brk & 0xFF00) != 0x6500 {
2246            return false;
2247        }
2248        let disp8 = (w_brk & 0xFF) as i8 as i32;
2249        if disp8 == 0 {
2250            return false;
2251        }
2252        let branch_src = pc_after_trap.wrapping_add(8);
2253        let target = (branch_src.wrapping_add(2) as i32).wrapping_add(disp8) as u32;
2254        if target != pc_after_trap.wrapping_sub(4) {
2255            return false;
2256        }
2257
2258        let target_tick = self.bus.read_long(target_addr);
2259        match self.advance_until_tick(target_tick, tick_cap) {
2260            AdvanceResult::CapHit => return true,
2261            AdvanceResult::Interrupted | AdvanceResult::TooFar => return false,
2262            AdvanceResult::Advanced => {}
2263        }
2264
2265        let final_tick = self.dispatcher.tick_count;
2266        let sp = self.cpu.core.a(7);
2267        self.cpu.core.set_a(7, sp.wrapping_add(4));
2268        self.cpu.core.set_d(dn, final_tick);
2269        self.cpu.core.pc = pc_after_trap.wrapping_add(10);
2270
2271        *count += 3;
2272        self.total_instructions = self.total_instructions.wrapping_add(3);
2273        false
2274    }
2275
2276    /// Shared helper: advance guest ticks until `target_tick` is
2277    /// reached.
2278    fn advance_until_tick(&mut self, target_tick: u32, tick_cap: Option<u32>) -> AdvanceResult {
2279        let current_tick = self.dispatcher.tick_count;
2280        let ticks_to_advance = target_tick.wrapping_sub(current_tick);
2281        if ticks_to_advance > SPIN_FASTFWD_MAX_TICKS {
2282            return AdvanceResult::TooFar;
2283        }
2284        for _ in 0..ticks_to_advance {
2285            if let Some(cap) = tick_cap {
2286                if self.bus.read_long(0x016A) >= cap {
2287                    return AdvanceResult::CapHit;
2288                }
2289            }
2290            self.advance_guest_tick();
2291            if self.active_interrupt_callback.is_some() {
2292                return AdvanceResult::Interrupted;
2293            }
2294        }
2295        AdvanceResult::Advanced
2296    }
2297
2298    fn run_steps_internal(
2299        &mut self,
2300        max_steps: usize,
2301        tick_cap: Option<u32>,
2302        audio_samples: usize,
2303        yield_for_ui: bool,
2304        sound_work_only: bool,
2305        finish_frame: bool,
2306    ) -> (usize, bool) {
2307        // Freeze ticks while menu/control tracking is active. ModalDialog
2308        // refires still return to the GUI for intermediate rendering, but
2309        // they must not freeze ticks: EV's pilot dialogs keep Sound/VBL/Time
2310        // Manager work alive through the dialog manager's event loop.
2311        // On entry, cap tick_cap to the frozen value; when tracking ends
2312        // mid-frame, snap $016A to wall-clock time so there's no gap to catch
2313        // up on.
2314        let real_tick_cap = tick_cap;
2315        let tick_cap = match self.frozen_ticks {
2316            Some(frozen) => tick_cap.map(|_| frozen),
2317            None => tick_cap,
2318        };
2319
2320        self.dispatcher.instruction_count = self.total_instructions;
2321        let mut count = 0;
2322        let mut tick_cap_reached = false;
2323        let mut sound_interrupt_dispatched = self
2324            .active_interrupt_callback
2325            .map(|callback| is_sound_interrupt_source(callback.source))
2326            .unwrap_or(false);
2327
2328        while count < max_steps && !self.halted && !tick_cap_reached {
2329            if sound_work_only
2330                && self.active_interrupt_callback.is_none()
2331                && (sound_interrupt_dispatched || !self.has_pending_sound_work())
2332            {
2333                break;
2334            }
2335
2336            // Sound callbacks are interrupt work. If a previous slice queued
2337            // one, dispatch it before running more foreground guest code. Do
2338            // not drain the whole queue in one CPU slice: double-buffer
2339            // callbacks are paced by audio-buffer completion, and firing
2340            // several back-to-back at the same guest PC/tick makes games that
2341            // run their own mixer refill with click-sized fragments.
2342            if self.active_interrupt_callback.is_none() && !sound_interrupt_dispatched {
2343                sound_interrupt_dispatched = self.fire_sound_callbacks();
2344                if !sound_interrupt_dispatched {
2345                    sound_interrupt_dispatched = self.fire_sound_doubleback_callbacks();
2346                }
2347                if sound_work_only && !sound_interrupt_dispatched {
2348                    continue;
2349                }
2350            }
2351
2352            if sound_work_only && self.active_interrupt_callback.is_none() {
2353                break;
2354            }
2355
2356            // Service blocking traps (Delay, WaitNextEvent sleep).
2357            if !sound_work_only {
2358                if self.service_wait_sleep_ticks(tick_cap) {
2359                    break;
2360                }
2361                if self.service_delay_ticks(tick_cap) {
2362                    break;
2363                }
2364            }
2365            if self.cpu.is_stopped() {
2366                self.halted = true;
2367                self.halted_pc = Some(self.cpu.read_reg(Register::PC));
2368                self.halted_sp = Some(self.cpu.read_reg(Register::A7));
2369                self.halted_d0 = Some(self.cpu.read_reg(Register::D0));
2370                self.dump_trace();
2371                return (count, false);
2372            }
2373
2374            let pc = self.cpu.read_reg(Register::PC);
2375            // Defer reading SP until needed. sp is only used by the
2376            // interrupt-callback match (rare), the env-gated
2377            // trace_buffer path, and the PC-bounds error branch.
2378            //
2379            // Opcode + trace_buffer reads are gated behind
2380            // `SYSTEMLESS_TRACE_BUFFER`. Without the gate the `read_word`
2381            // and `VecDeque` pop/push run on every instruction fetch
2382            // just so `dump_trace()` can show recent instructions on a
2383            // halt. Default off; enable for crash diagnostics.
2384            if trace_buffer_enabled() {
2385                let opcode = self.bus.read_word(pc);
2386                let a0 = self.cpu.read_reg(Register::A0);
2387                let a6 = self.cpu.read_reg(Register::A6);
2388                let a5 = self.cpu.read_reg(Register::A5);
2389                let sp = self.cpu.read_reg(Register::A7);
2390                if self.trace_buffer.len() >= 200 {
2391                    self.trace_buffer.pop_front();
2392                }
2393                self.trace_buffer.push_back((pc, opcode, a0, sp, a6, a5));
2394            }
2395
2396            if let Some(active_interrupt_callback) = self.active_interrupt_callback {
2397                let sp = self.cpu.read_reg(Register::A7);
2398                if pc == active_interrupt_callback.resume_pc
2399                    && sp == active_interrupt_callback.resume_sp
2400                {
2401                    if trace_timer_enabled() {
2402                        eprintln!(
2403                            "[TIMER] resume {:?} pc=${:08X} sp=${:08X} restore_ccr=${:02X}",
2404                            active_interrupt_callback.source, pc, sp, active_interrupt_callback.ccr
2405                        );
2406                    }
2407                    if trace_sound_runner_enabled()
2408                        && is_sound_interrupt_source(active_interrupt_callback.source)
2409                    {
2410                        eprintln!(
2411                            "[SOUND-CB] resume {:?} pc=${:08X} sp=${:08X} restore_ccr=${:02X}",
2412                            active_interrupt_callback.source, pc, sp, active_interrupt_callback.ccr
2413                        );
2414                    }
2415                    for (index, value) in
2416                        active_interrupt_callback.d_regs.iter().copied().enumerate()
2417                    {
2418                        self.cpu.write_reg(
2419                            match index {
2420                                0 => Register::D0,
2421                                1 => Register::D1,
2422                                2 => Register::D2,
2423                                3 => Register::D3,
2424                                4 => Register::D4,
2425                                5 => Register::D5,
2426                                6 => Register::D6,
2427                                _ => Register::D7,
2428                            },
2429                            value,
2430                        );
2431                    }
2432                    for (index, value) in
2433                        active_interrupt_callback.a_regs.iter().copied().enumerate()
2434                    {
2435                        self.cpu.write_reg(
2436                            match index {
2437                                0 => Register::A0,
2438                                1 => Register::A1,
2439                                2 => Register::A2,
2440                                3 => Register::A3,
2441                                4 => Register::A4,
2442                                5 => Register::A5,
2443                                6 => Register::A6,
2444                                _ => Register::A7,
2445                            },
2446                            value,
2447                        );
2448                    }
2449                    self.cpu.core.set_ccr(active_interrupt_callback.ccr);
2450                    if let Some((port, gdevice)) = active_interrupt_callback.restore_port {
2451                        self.dispatcher.set_current_port_state(
2452                            &mut self.bus,
2453                            &mut self.cpu,
2454                            port,
2455                            Some(gdevice),
2456                        );
2457                    }
2458                    let completed_dialog_draw_proc = matches!(
2459                        active_interrupt_callback.source,
2460                        ActiveInterruptCallbackSource::DialogDrawProc
2461                    );
2462                    let completed_modeless_dialog_draw_proc = completed_dialog_draw_proc
2463                        && self.dispatcher.active_modeless_dialog_draw_proc.is_some();
2464                    if completed_dialog_draw_proc {
2465                        self.dispatcher
2466                            .finalize_dialog_draw_procs_if_idle(&mut self.bus);
2467                    }
2468                    self.active_interrupt_callback = None;
2469                    self.refill_foreground_budget_after_async_return();
2470                    if completed_modeless_dialog_draw_proc && self.fire_modeless_dialog_draw_proc()
2471                    {
2472                        continue;
2473                    }
2474                    if sound_work_only {
2475                        break;
2476                    }
2477                } else if trace_timer_enabled() {
2478                    eprintln!(
2479                        "[TIMER] pending {:?} pc=${:08X} sp=${:08X} waiting_for pc=${:08X} sp=${:08X}",
2480                        active_interrupt_callback.source,
2481                        pc,
2482                        sp,
2483                        active_interrupt_callback.resume_pc,
2484                        active_interrupt_callback.resume_sp
2485                    );
2486                }
2487            }
2488
2489            if pc == 0 {
2490                // App's RTS chain reached PC=0 — treat as clean exit.
2491                // Some apps (e.g. Centaurian 1.2.1) zero out our
2492                // exit-trampoline at \$100 during their CRT init then
2493                // pop past the saved A6 chain and JMP through a
2494                // popped-from-out-of-RAM zero. Real Mac OS would have
2495                // a launcher-provided return-to-Finder address; on
2496                // the HLE we just halt gracefully.
2497                if trace_load_enabled() {
2498                    eprintln!(
2499                        "[RUN_STEPS] App reached PC=0 (clean exit via deep RTS chain) at count={}",
2500                        count
2501                    );
2502                }
2503                self.dump_trace();
2504                self.halted = true;
2505                self.halted_pc = Some(0);
2506                self.halted_sp = Some(self.cpu.read_reg(Register::A7));
2507                self.halted_d0 = Some(self.cpu.read_reg(Register::D0));
2508                return (count, false);
2509            }
2510
2511            if pc >= self.bus.ram_size() || pc < 0x60 {
2512                // Read opcode + sp on-demand in the error branch.
2513                let opcode = self.bus.read_word(pc);
2514                let sp = self.cpu.read_reg(Register::A7);
2515                eprintln!(
2516                    "[RUN_STEPS] Invalid PC ${:08X} at count={} sp=${:08X} op=${:04X}",
2517                    pc, count, sp, opcode
2518                );
2519                self.dump_invalid_pc_state();
2520                if let Some(hint) = decode_fakeptr_pc(pc) {
2521                    eprintln!("[RUN_STEPS]   {}", hint);
2522                } else if let Some((entry_pc, hint)) = self.trace_find_fakeptr_entry() {
2523                    eprintln!(
2524                        "[RUN_STEPS]   PC drifted ${:X} bytes from a fake-ptr entry at \
2525                         ${:08X}. {}",
2526                        pc.wrapping_sub(entry_pc),
2527                        entry_pc,
2528                        hint
2529                    );
2530                }
2531                self.halted = true;
2532                self.halted_pc = Some(pc);
2533                self.halted_sp = Some(sp);
2534                self.halted_d0 = Some(self.cpu.read_reg(Register::D0));
2535                self.dump_trace();
2536                return (count, false);
2537            }
2538
2539            // Tick advancement: deduct 1 instruction; when the budget hits
2540            // zero, advance $016A and refill from instructions_per_tick.
2541            if !sound_work_only {
2542                tick_cap_reached = self.charge_tick_budget(1, tick_cap);
2543                if tick_cap_reached {
2544                    break;
2545                }
2546            }
2547
2548            // Update debug counters for watchpoint tracking (debug builds only)
2549            #[cfg(debug_assertions)]
2550            if crate::memory::bus::watchpoint_armed() {
2551                crate::memory::bus::increment_step();
2552                crate::memory::bus::set_current_pc(pc);
2553                crate::memory::bus::set_watch_registers(
2554                    self.cpu.read_reg(Register::A0),
2555                    self.cpu.read_reg(Register::A1),
2556                    self.cpu.read_reg(Register::A6),
2557                    self.cpu.read_reg(Register::A7),
2558                );
2559            }
2560
2561            // Mirror PC for release-mode memory/framebuffer traces;
2562            // watchpoint context above is debug-only.
2563            if crate::memory::bus::fb_write_trace_active()
2564                || crate::memory::bus::mem_read_trace_active()
2565            {
2566                crate::memory::bus::set_current_pc(pc);
2567            }
2568
2569            let trace_pc_range_hit = trace_pc_range_contains(pc, self.dispatcher.tick_count);
2570            if trace_pc_range_hit {
2571                let sp = self.cpu.read_reg(Register::A7);
2572                let a6 = self.cpu.read_reg(Register::A6);
2573                let stack0 = self.bus.read_long(sp);
2574                let stack4 = self.bus.read_long(sp.wrapping_add(4));
2575                let stack8 = self.bus.read_word(sp.wrapping_add(8));
2576                let frame_ret = self.bus.read_long(a6.wrapping_add(4));
2577                let frame_arg = self.bus.read_word(a6.wrapping_add(8));
2578                eprintln!(
2579                    "[TRACE-PC-RANGE] pc=${:08X} op=${:04X} ccr=${:02X} d0=${:08X} d1=${:08X} d2=${:08X} d3=${:08X} d4=${:08X} d5=${:08X} d6=${:08X} d7=${:08X} a0=${:08X} a1=${:08X} a2=${:08X} a3=${:08X} a4=${:08X} a5=${:08X} a6=${:08X} sp=${:08X} stack0=${:08X} stack4=${:08X} stack8=${:04X} frame_ret=${:08X} frame_arg=${:04X}",
2580                    pc,
2581                    self.bus.read_word(pc),
2582                    self.cpu.core.get_ccr(),
2583                    self.cpu.read_reg(Register::D0),
2584                    self.cpu.read_reg(Register::D1),
2585                    self.cpu.read_reg(Register::D2),
2586                    self.cpu.read_reg(Register::D3),
2587                    self.cpu.read_reg(Register::D4),
2588                    self.cpu.read_reg(Register::D5),
2589                    self.cpu.read_reg(Register::D6),
2590                    self.cpu.read_reg(Register::D7),
2591                    self.cpu.read_reg(Register::A0),
2592                    self.cpu.read_reg(Register::A1),
2593                    self.cpu.read_reg(Register::A2),
2594                    self.cpu.read_reg(Register::A3),
2595                    self.cpu.read_reg(Register::A4),
2596                    self.cpu.read_reg(Register::A5),
2597                    a6,
2598                    sp,
2599                    stack0,
2600                    stack4,
2601                    stack8,
2602                    frame_ret,
2603                    frame_arg,
2604                );
2605            }
2606            // Execute one instruction.
2607            match self.cpu.step(&mut self.bus) {
2608                StepResult::Ok => {
2609                    count += 1;
2610                    self.total_instructions = self.total_instructions.wrapping_add(1);
2611                    // Populate opcode histogram if enabled. cpu.core.ir
2612                    // holds the last-fetched opcode (set by step's
2613                    // read_imm_16). Only counts successful non-trap
2614                    // steps here; A-line opcodes go through the trap
2615                    // histogram instead. Zero cost when disabled
2616                    // (cached bool).
2617                    if trace_opcode_counts_enabled() {
2618                        let opcode = self.cpu.core.ir as u16 as usize;
2619                        self.opcode_histogram[opcode] =
2620                            self.opcode_histogram[opcode].saturating_add(1);
2621                    }
2622                    // Sampled PC histogram. 1/1000 sampling keeps the
2623                    // HashMap cost bounded.
2624                    if trace_hot_pc_enabled()
2625                        && self.total_instructions.is_multiple_of(PC_SAMPLE_INTERVAL)
2626                    {
2627                        *self.pc_histogram.entry(pc).or_insert(0) += 1;
2628                    }
2629                }
2630                StepResult::Stopped => {
2631                    let halted_pc = self.cpu.read_reg(Register::PC);
2632                    eprintln!(
2633                        "[RUN_STEPS] CPU stopped at count={} pc=${:08X} op=${:04X}",
2634                        count,
2635                        halted_pc,
2636                        self.bus.read_word(halted_pc)
2637                    );
2638                    if let Some(hint) = decode_fakeptr_pc(halted_pc) {
2639                        eprintln!("[RUN_STEPS]   {}", hint);
2640                    } else if let Some((entry_pc, hint)) = self.trace_find_fakeptr_entry() {
2641                        eprintln!(
2642                            "[RUN_STEPS]   PC drifted ${:X} bytes from a fake-ptr entry at \
2643                             ${:08X}. {}",
2644                            halted_pc.wrapping_sub(entry_pc),
2645                            entry_pc,
2646                            hint
2647                        );
2648                    }
2649                    self.halted = true;
2650                    self.halted_pc = Some(halted_pc);
2651                    self.halted_sp = Some(self.cpu.read_reg(Register::A7));
2652                    self.halted_d0 = Some(self.cpu.read_reg(Register::D0));
2653                    self.dump_trace();
2654                    return (count, false);
2655                }
2656                StepResult::Aline(opcode) => {
2657                    count += 1;
2658                    self.total_instructions = self.total_instructions.wrapping_add(1);
2659
2660                    // --- Runner-inline fast paths for hot traps ---
2661                    //
2662                    // Rule of thumb: only inline a trap's body here if
2663                    // BOTH hold:
2664                    //   (a) per-call saving > ~100ns (handler does
2665                    //       non-trivial work relative to dispatch
2666                    //       overhead), AND
2667                    //   (b) call count > ~5M per reference workload.
2668                    // Below either threshold, the dispatch-cost
2669                    // saving is swamped by I-cache pressure from
2670                    // adding more code to this hot loop.
2671                    //
2672                    // Current inlines:
2673                    //   $A975 TickCount
2674                    //   $A991 ModalDialog no-op
2675                    // plus:
2676                    //   $A975 spin-wait fast-fwd — detects TickCount
2677                    //       compare-and-branch templates and advances
2678                    //       guest ticks.
2679                    // --- end guidance ---
2680
2681                    // Pre-dispatch fast path for TickCount ($A975).
2682                    // Handler body is `read self.tick_count` (cached)
2683                    // + write to SP. Skip the full dispatch →
2684                    // `dispatch_toolbox` → match chain; inline the
2685                    // 3-line body directly. Counters still update so
2686                    // logs and the trap histogram reflect real
2687                    // dispatch count.
2688                    if opcode == 0xA975 {
2689                        let sp = self.cpu.core.a(7);
2690                        let tick = self.dispatcher.tick_count;
2691                        self.bus.write_long(sp, tick);
2692                        self.dispatcher.trap_count += 1;
2693                        self.dispatcher.current_trap_word = opcode;
2694                        let idx = (opcode & 0xFFF) as usize;
2695                        self.dispatcher.trap_histogram[idx] =
2696                            self.dispatcher.trap_histogram[idx].saturating_add(1);
2697                        // Count this entry as inline-skipped — the
2698                        // fast path bypassed dispatch().
2699                        self.dispatcher.inline_skipped[idx] =
2700                            self.dispatcher.inline_skipped[idx].saturating_add(1);
2701
2702                        // Generic TickCount spin-wait fast-forward (opt-in).
2703                        // Check post-trap bytes against the spin-wait
2704                        // template; if matched, skip straight past the
2705                        // loop. m68k's step() advances PC past the A-trap
2706                        // (read_imm_16 does pc += 2), so the post-trap
2707                        // PC is already at pc + 2.
2708                        if spin_wait_fastfwd_enabled_for(yield_for_ui) {
2709                            let post_trap_pc = pc.wrapping_add(2);
2710                            let hit_cap =
2711                                self.try_tickcount_spin_fastfwd(post_trap_pc, tick_cap, &mut count);
2712                            if hit_cap {
2713                                break;
2714                            }
2715                        }
2716                        continue;
2717                    }
2718
2719                    // Pre-dispatch fast skip for no-op ModalDialog
2720                    // refires. When dialog tracking is active and no
2721                    // state change is possible this step (draw procs
2722                    // done, pixels already captured, no filter, no
2723                    // flash animation, no queued events), skip the
2724                    // full dispatch → handler → post-dispatch rewind
2725                    // loop entirely. Rewind PC directly and continue.
2726                    // Counters still update so logs and the trap
2727                    // histogram reflect real dispatch count.
2728                    if opcode == 0xA991 {
2729                        // Extracted into `modaldialog_refire_is_noop` so
2730                        // the gate logic is unit-tested. See its
2731                        // doc-comment for the full list of conditions.
2732                        let (
2733                            has_tracking,
2734                            filter_allows_noop,
2735                            flash_remaining_zero,
2736                            draw_procs_done,
2737                            rendered_pixels_final,
2738                        ) = self
2739                            .dispatcher
2740                            .dialog_tracking
2741                            .as_ref()
2742                            .map(|t| {
2743                                let idle_dialog_mouse =
2744                                    self.dispatcher.mouse_down_over_dialog_button()
2745                                        || self.dispatcher.mouse_down_over_dialog_plain_user_item()
2746                                        || (self.dispatcher.event_queue.is_empty()
2747                                            && self
2748                                                .dispatcher
2749                                                .pending_dialog_plain_user_item_mouse_down());
2750                                let paced_filter_idle = t.filter_proc != 0
2751                                    && t.last_filter_event.is_none()
2752                                    && !idle_dialog_mouse
2753                                    && self.dialog_filter_null_event_already_sent_this_tick(
2754                                        t.dialog_ptr,
2755                                    )
2756                                    && !self.dialog_filter_has_real_event_pending(t.dialog_ptr);
2757                                (
2758                                    true,
2759                                    t.filter_proc == 0 || paced_filter_idle,
2760                                    t.flash_remaining == 0,
2761                                    t.draw_procs_done,
2762                                    t.rendered_pixels_final,
2763                                )
2764                            })
2765                            .unwrap_or((false, false, false, false, false));
2766                        let noop_refire = modaldialog_refire_is_noop(
2767                            yield_for_ui,
2768                            has_tracking,
2769                            filter_allows_noop,
2770                            flash_remaining_zero,
2771                            draw_procs_done,
2772                            rendered_pixels_final,
2773                            self.dispatcher.event_queue.is_empty(),
2774                        );
2775                        if noop_refire {
2776                            // Batch additional virtual no-op refires
2777                            // without re-entering `cpu.step`. Each saved
2778                            // step avoids the PC save + register snapshot
2779                            // + opcode fetch + `dispatch_group_a` branch
2780                            // path.
2781                            let idx = (opcode & 0xFFF) as usize;
2782                            self.dispatcher.trap_count += 1;
2783                            self.dispatcher.current_trap_word = opcode;
2784                            self.dispatcher.trap_histogram[idx] =
2785                                self.dispatcher.trap_histogram[idx].saturating_add(1);
2786                            // Count inline-skipped entries separately
2787                            // from real dispatches so the trap-timing
2788                            // histogram can show per-real-dispatch ns.
2789                            self.dispatcher.inline_skipped[idx] =
2790                                self.dispatcher.inline_skipped[idx].saturating_add(1);
2791                            const BATCH: u32 = 64;
2792                            let mut budget = BATCH - 1;
2793                            while budget > 0 && count < max_steps && !tick_cap_reached {
2794                                tick_cap_reached = self.charge_tick_budget(1, tick_cap);
2795                                if tick_cap_reached {
2796                                    break;
2797                                }
2798                                count += 1;
2799                                self.total_instructions = self.total_instructions.wrapping_add(1);
2800                                self.dispatcher.trap_count += 1;
2801                                self.dispatcher.trap_histogram[idx] =
2802                                    self.dispatcher.trap_histogram[idx].saturating_add(1);
2803                                self.dispatcher.inline_skipped[idx] =
2804                                    self.dispatcher.inline_skipped[idx].saturating_add(1);
2805                                budget -= 1;
2806                            }
2807                            self.cpu.write_reg(Register::PC, pc);
2808                            continue;
2809                        }
2810                    }
2811
2812                    // Pre-dispatch fast path for PtInRect ($A8AD).
2813                    // EV calls this millions of times while walking dialog
2814                    // controls. The handler is pure stack/Rect arithmetic, so
2815                    // inline the exact Pascal ABI and keep accounting aligned
2816                    // with TrapDispatcher::dispatch.
2817                    if opcode == 0xA8AD {
2818                        let sp = self.cpu.core.a(7);
2819                        let rect_ptr = self.bus.read_long(sp);
2820                        let pt_v = self.bus.read_word(sp + 4) as i16;
2821                        let pt_h = self.bus.read_word(sp + 6) as i16;
2822                        let top = self.bus.read_word(rect_ptr) as i16;
2823                        let left = self.bus.read_word(rect_ptr + 2) as i16;
2824                        let bottom = self.bus.read_word(rect_ptr + 4) as i16;
2825                        let right = self.bus.read_word(rect_ptr + 6) as i16;
2826                        let in_rect = pt_v >= top && pt_v < bottom && pt_h >= left && pt_h < right;
2827                        self.bus
2828                            .write_word(sp + 8, if in_rect { 0x0100 } else { 0 });
2829                        self.cpu.write_reg(Register::A7, sp + 8);
2830
2831                        self.dispatcher.trap_count += 1;
2832                        self.dispatcher.current_trap_word = opcode;
2833                        if pc < 0x0080_0000
2834                            && !self.dispatcher.is_menu_tracking()
2835                            && !self.dispatcher.is_dialog_tracking()
2836                            && !self.dispatcher.is_control_tracking()
2837                        {
2838                            self.dispatcher.game_trap_count += 1;
2839                        }
2840                        let idx = (opcode & 0xFFF) as usize;
2841                        self.dispatcher.trap_histogram[idx] =
2842                            self.dispatcher.trap_histogram[idx].saturating_add(1);
2843                        self.dispatcher.inline_skipped[idx] =
2844                            self.dispatcher.inline_skipped[idx].saturating_add(1);
2845                        if self.service_pending_launch_application(true) {
2846                            if self.halted {
2847                                return (count, false);
2848                            }
2849                            continue;
2850                        }
2851                        continue;
2852                    }
2853
2854                    // Pre-dispatch fast path for EventAvail ($A971).
2855                    // Marathon polls this heavily while waiting at terminal
2856                    // panels. Reuse the dispatcher helpers so event filtering
2857                    // and EventRecord layout stay centralized.
2858                    if opcode == 0xA971 {
2859                        let sp = self.cpu.core.a(7);
2860                        let event_ptr = self.bus.read_long(sp);
2861                        let event_mask = self.bus.read_word(sp + 4);
2862
2863                        if let Some(ev) = self.dispatcher.peek_toolbox_event(&self.bus, event_mask)
2864                        {
2865                            self.dispatcher.write_event_record(
2866                                &mut self.bus,
2867                                event_ptr,
2868                                ev.what,
2869                                ev.message,
2870                                ev.where_v,
2871                                ev.where_h,
2872                                ev.modifiers,
2873                            );
2874                            self.bus.write_word(sp + 6, 0xFFFF);
2875                        } else {
2876                            self.dispatcher.write_event_record(
2877                                &mut self.bus,
2878                                event_ptr,
2879                                0,
2880                                0,
2881                                self.dispatcher.mouse_pos.0,
2882                                self.dispatcher.mouse_pos.1,
2883                                self.dispatcher.current_event_modifiers(),
2884                            );
2885                            self.bus.write_word(sp + 6, 0);
2886                        }
2887                        self.cpu.write_reg(Register::A7, sp + 6);
2888
2889                        self.dispatcher.trap_count += 1;
2890                        self.dispatcher.current_trap_word = opcode;
2891                        let idx = (opcode & 0xFFF) as usize;
2892                        self.dispatcher.trap_histogram[idx] =
2893                            self.dispatcher.trap_histogram[idx].saturating_add(1);
2894                        self.dispatcher.inline_skipped[idx] =
2895                            self.dispatcher.inline_skipped[idx].saturating_add(1);
2896                        continue;
2897                    }
2898
2899                    self.dispatcher.yield_for_ui = yield_for_ui;
2900                    match self
2901                        .dispatcher
2902                        .dispatch(opcode, &mut self.cpu, &mut self.bus)
2903                    {
2904                        Ok(()) => {
2905                            let extra_tick_cost = hle_trap_extra_tick_cost(opcode)
2906                                .saturating_add(self.dispatcher.take_hle_tick_cost());
2907                            if extra_tick_cost > 0
2908                                && self.charge_tick_budget(extra_tick_cost, tick_cap)
2909                            {
2910                                tick_cap_reached = true;
2911                            }
2912                            // The m68k CPU already advanced PC past the A-line
2913                            // instruction during fetch (read_imm_16 does pc += 2).
2914                            //
2915                            // When menu or dialog tracking is active, REWIND PC
2916                            // back to the A-line instruction so it re-fires on
2917                            // the next frame.
2918                            //
2919                            // Shared check with `dispatch.rs`'s auto-pop
2920                            // push-back logic — both call
2921                            // `TrapDispatcher::is_tracking_refire` so
2922                            // they can never diverge. Strips auto-pop
2923                            // bit so `$AD3D` / `$AC0B` / `$AD91`
2924                            // match too.
2925                            let is_tracking_refire = self.dispatcher.is_tracking_refire(opcode);
2926                            if is_tracking_refire {
2927                                // In GUI mode, freeze ticks so the game clock doesn't
2928                                // advance while the host renders intermediate frames.
2929                                // In headless mode (scripted harnesses), let the budget
2930                                // advance ticks naturally — frozen_ticks would snap
2931                                // $016A on each re-fire, which consumes ticks at a
2932                                // different rate than real hardware where ModalDialog's
2933                                // WNE loop paces against the VBL.
2934                                if yield_for_ui
2935                                    && self.frozen_ticks.is_none()
2936                                    && tracking_refire_should_freeze_ticks(opcode)
2937                                {
2938                                    self.frozen_ticks = Some(self.bus.read_long(0x016A));
2939                                }
2940                                if yield_for_ui
2941                                    && self.frozen_ticks.is_some()
2942                                    && !tracking_refire_should_freeze_ticks(opcode)
2943                                {
2944                                    self.unfreeze_ticks_to(real_tick_cap);
2945                                }
2946                                self.cpu.write_reg(Register::PC, pc);
2947
2948                                // Fire MenuSelect's documented MenuHook while
2949                                // the dropdown is still live on screen. The
2950                                // hook is guest code, so inject it before the
2951                                // next A93D re-fire instead of approximating it
2952                                // inside the HLE trap body.
2953                                let fired_menu_hook = self.fire_menu_hook_proc(opcode);
2954
2955                                // Fire pending dialog userItem draw procs.
2956                                // The trampoline redirects PC to execute the
2957                                // 68K draw proc; when it RTS's, PC returns to
2958                                // the ModalDialog A-line for the next re-fire.
2959                                let fired_draw_proc = if fired_menu_hook {
2960                                    false
2961                                } else {
2962                                    self.fire_dialog_draw_procs()
2963                                };
2964                                let mut fired_filter_proc = false;
2965                                if !fired_menu_hook && !fired_draw_proc {
2966                                    // Fire the filter proc for any dialog that has one,
2967                                    // once draw procs are complete. On a real Mac,
2968                                    // ModalDialog calls the filter for every event
2969                                    // (including null events) regardless of item types.
2970                                    // Inside Macintosh Volume I, I-415
2971                                    if self.should_fire_dialog_filter_proc() {
2972                                        self.fire_dialog_filter_proc();
2973                                        fired_filter_proc = true;
2974                                    }
2975                                }
2976
2977                                // In realtime frontends, yield only once the
2978                                // tracking trap is idle. A just-scheduled
2979                                // dialog draw/filter proc has not run yet, so
2980                                // presenting here shows half-painted screens.
2981                                // Headless mode keeps executing as before.
2982                                if yield_for_ui
2983                                    && !fired_menu_hook
2984                                    && !fired_draw_proc
2985                                    && !fired_filter_proc
2986                                {
2987                                    if (opcode & !0x0400) == 0xA991
2988                                        && !self.service_gui_modal_dialog_idle_tick(tick_cap)
2989                                    {
2990                                        continue;
2991                                    }
2992                                    if finish_frame {
2993                                        self.finish_host_frame(
2994                                            audio_samples,
2995                                            sound_interrupt_dispatched,
2996                                        );
2997                                    }
2998                                    return (count, true);
2999                                }
3000                            }
3001                            // If tracking just ended this trap (MenuSelect or
3002                            // ModalDialog completed), unfreeze ticks and snap
3003                            // $016A to wall-clock time so the game doesn't
3004                            // fast-forward through the pause gap.
3005                            if self.frozen_ticks.is_some() {
3006                                self.unfreeze_ticks_to(real_tick_cap);
3007                            }
3008
3009                            if !is_tracking_refire && self.fire_modeless_dialog_draw_proc() {
3010                                continue;
3011                            }
3012
3013                            // Service any pending Delay ticks immediately after
3014                            // the trap dispatch, before the next instruction.
3015                            self.service_delay_ticks(tick_cap);
3016                            if self.service_pending_launch_application(event_manager_yield_trap(
3017                                opcode,
3018                            )) {
3019                                if self.halted {
3020                                    return (count, false);
3021                                }
3022                                continue;
3023                            }
3024                        }
3025                        Err(Error::Halted) => {
3026                            // Surface the auto-pop caller PC if the
3027                            // halted trap was called via JSR through a
3028                            // trampoline. Without this, the halt log
3029                            // only shows the trampoline PC; the actual
3030                            // game-side caller is what investigators
3031                            // want to disassemble.
3032                            let caller_str = self
3033                                .dispatcher
3034                                .current_trap_caller
3035                                .map(|c| format!(" caller=${:08X}", c))
3036                                .unwrap_or_default();
3037                            eprintln!(
3038                                "[RUN_STEPS] Application halted at count={} pc=${:08X} trap=${:04X}{}",
3039                                count,
3040                                pc,
3041                                opcode,
3042                                caller_str,
3043                            );
3044                            self.halted = true;
3045                            self.halted_pc = Some(pc);
3046                            self.halted_trap = Some(opcode);
3047                            self.halted_sp = Some(self.cpu.read_reg(Register::A7));
3048                            self.halted_d0 = Some(self.cpu.read_reg(Register::D0));
3049                            self.dump_trace();
3050                            return (count, false);
3051                        }
3052                        Err(Error::UnimplementedTrap(t)) => {
3053                            eprintln!("[RUN_STEPS] Unimplemented trap ${:04X} — skipping", t);
3054                            self.cpu.write_reg(Register::PC, pc + 2);
3055                        }
3056                        Err(e) => {
3057                            eprintln!(
3058                                "[RUN_STEPS] Error {:?} at PC=${:08X} trap=${:04X} count={}",
3059                                e, pc, opcode, count
3060                            );
3061                            self.halted = true;
3062                            self.halted_pc = Some(pc);
3063                            self.halted_sp = Some(self.cpu.read_reg(Register::A7));
3064                            self.halted_d0 = Some(self.cpu.read_reg(Register::D0));
3065                            self.dump_trace();
3066                            return (count, false);
3067                        }
3068                    }
3069                }
3070            }
3071
3072            self.dispatcher.instruction_count = self.total_instructions;
3073        }
3074
3075        if finish_frame {
3076            self.finish_host_frame(audio_samples, sound_interrupt_dispatched || sound_work_only);
3077        }
3078
3079        (count, !self.halted)
3080    }
3081
3082    /// Run for a specific number of steps and mix the supplied amount of host audio.
3083    /// Returns the number of instructions executed and whether the CPU is still running.
3084    ///
3085    /// `tick_override`: If `Some(ticks)`, `Ticks` is capped to the supplied external
3086    /// wall-clock target. If `None`, `Ticks` advances from the runner's configured
3087    /// instruction cadence.
3088    pub fn run_steps_with_audio(
3089        &mut self,
3090        max_steps: usize,
3091        tick_override: Option<u32>,
3092        audio_samples: usize,
3093    ) -> (usize, bool) {
3094        self.run_steps_internal(
3095            max_steps,
3096            tick_override,
3097            audio_samples,
3098            tick_override.is_some(),
3099            false,
3100            true,
3101        )
3102    }
3103
3104    /// Run a realtime GUI/WASM slice using the runner's internal tick cadence.
3105    /// The caller is responsible for converting wall-clock time into `max_steps`
3106    /// and `audio_samples`.
3107    pub fn run_realtime_steps_with_audio(
3108        &mut self,
3109        max_steps: usize,
3110        audio_samples: usize,
3111    ) -> (usize, bool) {
3112        self.run_steps_internal(max_steps, None, audio_samples, true, false, true)
3113    }
3114
3115    /// Run a GUI frame slice paced by wall-clock time.
3116    ///
3117    /// Wall-clock GUI pacing works differently from the reference-runtime oracle:
3118    /// in the oracle, ticks are driven purely by the instruction budget
3119    /// (deterministic, host-speed-independent). In the GUI, the user expects
3120    /// the game to run at real time regardless of how fast the emulator can
3121    /// execute instructions, so the caller computes a `deadline_tick` from
3122    /// host wall-clock time and we cap `$016A` advancement there. The CPU
3123    /// runs flat out (up to `max_steps`) until either the tick cap is hit
3124    /// or the instruction budget is exhausted, at which point the caller
3125    /// yields to the UI thread for rendering.
3126    pub fn run_gui_slice_with_audio(
3127        &mut self,
3128        max_steps: usize,
3129        deadline_tick: u32,
3130        audio_samples: usize,
3131    ) -> (usize, bool) {
3132        self.run_steps_internal(
3133            max_steps,
3134            Some(deadline_tick),
3135            audio_samples,
3136            true,
3137            false,
3138            true,
3139        )
3140    }
3141
3142    /// Run a GUI CPU slice paced by wall-clock time without finalizing a host
3143    /// frame. Browser frontends use this to execute several small CPU batches
3144    /// and then redraw chrome / mix queued audio once for the outer frame.
3145    pub fn run_gui_cpu_slice(&mut self, max_steps: usize, deadline_tick: u32) -> (usize, bool) {
3146        self.run_steps_internal(max_steps, Some(deadline_tick), 0, true, false, false)
3147    }
3148
3149    /// Run pending Sound Manager interrupt work without advancing TickCount
3150    /// or continuing into foreground guest code after the callback returns.
3151    pub fn run_pending_sound_work(&mut self, max_steps: usize) -> (usize, bool) {
3152        self.run_steps_internal(max_steps, None, 0, true, true, true)
3153    }
3154
3155    /// Run for a specific number of steps (for GUI/headless callers that don't
3156    /// provide a real wall-clock audio budget).
3157    ///
3158    /// Returns `(steps_executed, still_running)` — note that the bool is
3159    /// **`still_running`**, not `halted`. `false` means the CPU halted
3160    /// (via `ExitToShell`, an unimplemented opcode, or a memory fault).
3161    /// The per-halt detail (trap word, PC, SP, D0) is exposed via the
3162    /// [`halted_trap`](Self::halted_trap), [`halted_pc`](Self::halted_pc),
3163    /// [`halted_sp`](Self::halted_sp), [`halted_d0`](Self::halted_d0)
3164    /// accessors after this call returns.
3165    pub fn run_steps(&mut self, max_steps: usize, tick_override: Option<u32>) -> (usize, bool) {
3166        self.run_steps_internal(max_steps, tick_override, 0, false, false, true)
3167    }
3168
3169    fn charge_tick_budget(&mut self, units: i32, tick_cap: Option<u32>) -> bool {
3170        if units <= 0 {
3171            return false;
3172        }
3173        if self.active_interrupt_callback.is_some() {
3174            return false;
3175        }
3176
3177        self.tick_budget -= units;
3178        while self.tick_budget <= 0 && self.frozen_ticks.is_none() {
3179            if let Some(cap) = tick_cap {
3180                if self.bus.read_long(0x016A) >= cap {
3181                    return true;
3182                }
3183            }
3184            self.advance_guest_tick();
3185            self.tick_budget += self.instructions_per_tick as i32;
3186            if self.active_interrupt_callback.is_some() {
3187                return false;
3188            }
3189            if let Some(cap) = tick_cap {
3190                if self.bus.read_long(0x016A) >= cap {
3191                    return true;
3192                }
3193            }
3194        }
3195        false
3196    }
3197
3198    fn advance_guest_tick(&mut self) -> u32 {
3199        let new_tick = self.bus.read_long(0x016A).wrapping_add(1);
3200        self.bus.write_long(0x016A, new_tick);
3201        self.dispatcher.tick_count = new_tick;
3202
3203        // Sync MBState ($0172) from the internal button state.
3204        // On real hardware the VBL interrupt handler reads the ADB mouse
3205        // state and writes $0172 at each retrace. In our HLE, the button
3206        // state and the event queue are updated together by push_mouse_down;
3207        // keep $0172 at "pressed" while either the button is physically
3208        // held OR an unconsumed mouseDown is still pending in the queue
3209        // WITHOUT a later mouseUp pairing it off. This ensures code that
3210        // polls $0172 directly (rather than calling GetNextEvent) can
3211        // detect clicks injected before polling started, while a
3212        // mouse_up queued behind the mouse_down still flips MBState back
3213        // to 0x80 even when no GetNextEvent ever drains the queue —
3214        // critical for polling-only games (Bonkheads-Deluxe class titles)
3215        // that would otherwise see the button as "held forever".
3216        let has_pending_unmatched_down = self.dispatcher.has_unmatched_queued_mouse_down();
3217        let pressed = self.dispatcher.mouse_button || has_pending_unmatched_down;
3218        let mb_state: u8 = if pressed { 0x00 } else { 0x80 };
3219        self.bus.write_byte(0x0172, mb_state);
3220
3221        // Advance the real-time clock ($020C) once per second.
3222        // On a real Mac the IOP or VIA increments Time every second;
3223        // we approximate this by incrementing every 60 ticks (~1 s at
3224        // 60.15 Hz VBL). Games that read $020C directly (e.g. for
3225        // PRNG seeding or save-file timestamps) need a changing value.
3226        // Inside Macintosh Volume II, II-378
3227        if new_tick.is_multiple_of(60) {
3228            let time = self.bus.read_long(0x020C);
3229            self.bus.write_long(0x020C, time.wrapping_add(1));
3230        }
3231
3232        // Fire the cursor task and vertical-retrace tasks before Time Manager
3233        // tasks. Games commonly drive screen/audio housekeeping from VBL, so
3234        // letting those callbacks run first avoids starving them behind
3235        // unrelated timer traffic.
3236        self.fire_cursor_task();
3237        self.fire_vbl_tasks();
3238        self.fire_timer_tasks(new_tick);
3239        new_tick
3240    }
3241
3242    fn deliver_pending_wait_next_event_if_available(&mut self) -> bool {
3243        let Some(pending) = self.dispatcher.pending_wait_next_event_return.take() else {
3244            if !self.dispatcher.event_queue.is_empty() {
3245                self.dispatcher.pending_wait_sleep_ticks = 0;
3246                return true;
3247            }
3248            return false;
3249        };
3250
3251        if let (Some(resume_pc), Some(resume_sp)) = (pending.resume_pc, pending.resume_sp) {
3252            let current_pc = self.cpu.read_reg(Register::PC);
3253            let current_sp = self.cpu.read_reg(Register::A7);
3254            if current_pc != resume_pc || current_sp != resume_sp {
3255                self.dispatcher.pending_wait_sleep_ticks = 0;
3256                if crate::trap::dispatch::trace_input_enabled() {
3257                    eprintln!(
3258                        "[INPUT] dropping stale WaitNextEvent sleep return parked pc=${:08X} sp=${:08X}; current pc=${:08X} sp=${:08X}",
3259                        resume_pc, resume_sp, current_pc, current_sp
3260                    );
3261                }
3262                return false;
3263            }
3264        }
3265
3266        let (mut what, mut message, mut where_v, mut where_h, mut modifiers, mut has_event) = self
3267            .dispatcher
3268            .dequeue_toolbox_event(&mut self.cpu, &mut self.bus, pending.event_mask);
3269        if !has_event {
3270            if let Some(event) = self.dispatcher.mouse_moved_event_for_region(
3271                &self.bus,
3272                pending.event_mask,
3273                pending.mouse_rgn,
3274            ) {
3275                what = event.what;
3276                message = event.message;
3277                where_v = event.where_v;
3278                where_h = event.where_h;
3279                modifiers = event.modifiers;
3280                has_event = true;
3281                self.dispatcher.debug_mouse_moved_event_count = self
3282                    .dispatcher
3283                    .debug_mouse_moved_event_count
3284                    .saturating_add(1);
3285            }
3286        }
3287        if !has_event {
3288            self.dispatcher.pending_wait_next_event_return = Some(pending);
3289            return false;
3290        }
3291
3292        self.dispatcher.write_event_record(
3293            &mut self.bus,
3294            pending.event_ptr,
3295            what,
3296            message,
3297            where_v,
3298            where_h,
3299            modifiers,
3300        );
3301        self.bus.write_word(pending.result_ptr, 0xFFFF);
3302        self.dispatcher.pending_wait_sleep_ticks = 0;
3303        if crate::trap::dispatch::trace_input_enabled() {
3304            eprintln!(
3305                "[INPUT] WaitNextEvent sleep woke with input event what={} message=${:08X}",
3306                what, message
3307            );
3308        }
3309        true
3310    }
3311
3312    fn wake_pending_wait_next_event_if_input_available(&mut self) -> bool {
3313        if self.active_interrupt_callback.is_some() {
3314            return false;
3315        }
3316        if self.dispatcher.pending_wait_sleep_ticks == 0
3317            || self.dispatcher.pending_wait_next_event_return.is_none()
3318        {
3319            return false;
3320        }
3321        // Event Manager sleep is interrupted as soon as a matching input event
3322        // is available; callers injecting input between run slices should not
3323        // have to wait for the next foreground CPU step to observe the wake.
3324        // Macintosh Toolbox Essentials 1992, p. 2-22.
3325        self.deliver_pending_wait_next_event_if_available()
3326    }
3327
3328    fn wake_pending_wait_next_event_with_null_event_for_polling_input(&mut self) -> bool {
3329        if self.active_interrupt_callback.is_some() {
3330            return false;
3331        }
3332        if self.dispatcher.pending_wait_sleep_ticks == 0 {
3333            return false;
3334        }
3335        let Some(pending) = self.dispatcher.pending_wait_next_event_return.take() else {
3336            return false;
3337        };
3338
3339        if let (Some(resume_pc), Some(resume_sp)) = (pending.resume_pc, pending.resume_sp) {
3340            let current_pc = self.cpu.read_reg(Register::PC);
3341            let current_sp = self.cpu.read_reg(Register::A7);
3342            if current_pc != resume_pc || current_sp != resume_sp {
3343                self.dispatcher.pending_wait_sleep_ticks = 0;
3344                if crate::trap::dispatch::trace_input_enabled() {
3345                    eprintln!(
3346                        "[INPUT] dropping stale WaitNextEvent polling wake parked pc=${:08X} sp=${:08X}; current pc=${:08X} sp=${:08X}",
3347                        resume_pc, resume_sp, current_pc, current_sp
3348                    );
3349                }
3350                return false;
3351            }
3352        }
3353
3354        let (where_v, where_h) = self.dispatcher.mouse_position();
3355        let modifiers = self.dispatcher.current_event_modifiers();
3356        self.dispatcher.write_event_record(
3357            &mut self.bus,
3358            pending.event_ptr,
3359            0,
3360            0,
3361            where_v,
3362            where_h,
3363            modifiers,
3364        );
3365        self.bus.write_word(pending.result_ptr, 0);
3366        self.dispatcher.pending_wait_sleep_ticks = 0;
3367        if crate::trap::dispatch::trace_input_enabled() {
3368            eprintln!("[INPUT] WaitNextEvent sleep woke with null event for polling input");
3369        }
3370        true
3371    }
3372
3373    fn wake_foreground_after_input(&mut self) {
3374        if self.tick_budget <= 0 {
3375            self.refill_foreground_budget_after_async_return();
3376        }
3377    }
3378
3379    fn refill_foreground_budget_after_async_return(&mut self) {
3380        if self.tick_budget <= 0 {
3381            self.tick_budget = self.instructions_per_tick.max(2) as i32;
3382        }
3383    }
3384
3385    fn service_wait_sleep_ticks(&mut self, tick_cap: Option<u32>) -> bool {
3386        if self.dispatcher.pending_wait_sleep_ticks == 0 || self.active_interrupt_callback.is_some()
3387        {
3388            return false;
3389        }
3390
3391        if self.frozen_ticks.is_some() {
3392            self.dispatcher.pending_wait_sleep_ticks = 0;
3393            self.dispatcher.pending_wait_next_event_return = None;
3394            return false;
3395        }
3396
3397        // On a real Mac, WaitNextEvent returns immediately when an event
3398        // is available, regardless of the requested sleep duration.
3399        // Macintosh Toolbox Essentials 1992, 2-22
3400        if self.deliver_pending_wait_next_event_if_available() {
3401            return false;
3402        }
3403
3404        // If a dialog is being handled by ModalDialog, or a ModalDialog-owned
3405        // dialog is visibly retained between ModalDialog calls, treat
3406        // WaitNextEvent sleep as an app-yield hint rather than a wall-clock
3407        // delay. App-owned visible dialogs created with GetNewDialog can run
3408        // their own WaitNextEvent loops before ever entering ModalDialog; those
3409        // must still honor the requested sleep interval.
3410        let retained_modal_dialog_snapshot = self
3411            .dispatcher
3412            .dialog_visible_snapshots
3413            .keys()
3414            .any(|dialog_ptr| self.dispatcher.dialog_modal_entered.contains(dialog_ptr));
3415        let app_owned_visible_dialog_snapshot = self
3416            .dispatcher
3417            .dialog_visible_snapshots
3418            .keys()
3419            .any(|dialog_ptr| !self.dispatcher.dialog_modal_entered.contains(dialog_ptr));
3420        if tick_cap.is_some()
3421            && (self.dispatcher.is_dialog_tracking() || retained_modal_dialog_snapshot)
3422        {
3423            self.dispatcher.pending_wait_sleep_ticks = 0;
3424            self.dispatcher.pending_wait_next_event_return = None;
3425            return false;
3426        }
3427
3428        // In GUI mode (tick_cap present), suspend foreground guest code until
3429        // either the requested WaitNextEvent sleep expires or this host frame's
3430        // tick cap is reached. The Process Manager makes the process eligible
3431        // to run again only after an event arrives or the sleep time expires;
3432        // if the time expires with no event pending, the app receives a null
3433        // event. Inside Macintosh: Processes 1994, p. 2-8.
3434        if let Some(cap) = tick_cap {
3435            while self.dispatcher.pending_wait_sleep_ticks > 0 && self.bus.read_long(0x016A) < cap {
3436                self.dispatcher.pending_wait_sleep_ticks -= 1;
3437                self.advance_guest_tick();
3438                self.tick_budget = self.instructions_per_tick as i32;
3439                if self.active_interrupt_callback.is_some() {
3440                    break;
3441                }
3442            }
3443
3444            // Yield to the host if the frame tick cap was reached while the
3445            // process is still suspended; the next frame will continue draining
3446            // the remaining sleep without delivering another null event early.
3447            if self.dispatcher.pending_wait_sleep_ticks > 0 && self.bus.read_long(0x016A) >= cap {
3448                return true;
3449            }
3450            self.dispatcher.pending_wait_next_event_return = None;
3451            return false;
3452        }
3453
3454        // Headless mode (no tick_cap from caller).
3455        //
3456        // Default: drain all pending sleep ticks at once (faster wall-clock).
3457        // Opt-in cap (set via `FixtureRunner::set_wait_sleep_cap_in_headless`):
3458        // honor the cap as a per-WNE-call ceiling, mirroring GUI mode's
3459        // 1-tick cap. Used by scripted harnesses to prevent Systemless's tick rate
3460        // from rocketing ahead of Basilisk's during event-loop-heavy
3461        // gameplay. App-owned visible dialogs keep the real sleep even when a
3462        // script sets the cap to zero; otherwise headless probes can run modal
3463        // background work that Basilisk is still sleeping through.
3464        if let Some(cap) = self.wait_sleep_cap_in_headless {
3465            let advance = if app_owned_visible_dialog_snapshot {
3466                self.dispatcher.pending_wait_sleep_ticks
3467            } else {
3468                self.dispatcher.pending_wait_sleep_ticks.min(cap)
3469            };
3470            self.dispatcher.pending_wait_sleep_ticks = 0;
3471            self.dispatcher.pending_wait_next_event_return = None;
3472            for _ in 0..advance {
3473                self.advance_guest_tick();
3474                self.tick_budget = self.instructions_per_tick as i32;
3475                if self.active_interrupt_callback.is_some() {
3476                    break;
3477                }
3478            }
3479            return false;
3480        }
3481
3482        while self.dispatcher.pending_wait_sleep_ticks > 0 {
3483            self.dispatcher.pending_wait_sleep_ticks -= 1;
3484            self.advance_guest_tick();
3485            self.tick_budget = self.instructions_per_tick as i32;
3486
3487            if self.active_interrupt_callback.is_some() {
3488                break;
3489            }
3490        }
3491        self.dispatcher.pending_wait_next_event_return = None;
3492        false
3493    }
3494
3495    fn service_delay_ticks(&mut self, tick_cap: Option<u32>) -> bool {
3496        if self.dispatcher.pending_delay_ticks == 0 || self.active_interrupt_callback.is_some() {
3497            return false;
3498        }
3499
3500        if self.frozen_ticks.is_some() {
3501            self.dispatcher.pending_delay_ticks = 0;
3502            return false;
3503        }
3504
3505        // Drain delay ticks one at a time, firing VBL/timer callbacks each tick.
3506        // In GUI mode with a tick_cap, yield if we reach the cap.
3507        while self.dispatcher.pending_delay_ticks > 0 {
3508            if let Some(cap) = tick_cap {
3509                if self.bus.read_long(0x016A) >= cap {
3510                    return true;
3511                }
3512            }
3513            self.dispatcher.pending_delay_ticks -= 1;
3514            self.advance_guest_tick();
3515            self.tick_budget = self.instructions_per_tick as i32;
3516            if self.active_interrupt_callback.is_some() {
3517                break;
3518            }
3519        }
3520
3521        if self.dispatcher.pending_delay_ticks == 0 {
3522            let final_ticks = self.bus.read_long(0x016A);
3523            self.cpu.write_reg(Register::D0, final_ticks);
3524        }
3525
3526        false
3527    }
3528
3529    fn service_gui_modal_dialog_idle_tick(&mut self, tick_cap: Option<u32>) -> bool {
3530        if self.active_interrupt_callback.is_some() || self.frozen_ticks.is_some() {
3531            return true;
3532        }
3533
3534        if let Some(cap) = tick_cap {
3535            if self.bus.read_long(0x016A) >= cap {
3536                return true;
3537            }
3538        }
3539
3540        self.advance_guest_tick();
3541        self.tick_budget = self.instructions_per_tick as i32;
3542
3543        tick_cap
3544            .map(|cap| self.bus.read_long(0x016A) >= cap)
3545            .unwrap_or(true)
3546    }
3547
3548    fn unfreeze_ticks_to(&mut self, target_tick: Option<u32>) {
3549        self.frozen_ticks = None;
3550        if let Some(target_tick) = target_tick {
3551            self.bus.write_long(0x016A, target_tick);
3552            // Keep `dispatcher.tick_count` in sync with $016A.
3553            // `advance_guest_tick` does this during ordinary advancement.
3554            self.dispatcher.tick_count = target_tick;
3555        }
3556    }
3557
3558    /// Fire the low-memory cursor task vector, if an app has installed one.
3559    ///
3560    /// JCrsrTask runs from interrupt-time cursor/VBL maintenance. MPW
3561    /// Interfaces/AIncludes/LowMemEqu.a names the ProcPtr at $08EE.
3562    fn fire_cursor_task(&mut self) {
3563        if self.active_interrupt_callback.is_some() {
3564            return;
3565        }
3566
3567        let callback_addr = self
3568            .bus
3569            .read_long(crate::memory::globals::addr::J_CRSR_TASK);
3570        if callback_addr == 0 || callback_addr == CURSOR_TASK_NOOP_ADDR {
3571            return;
3572        }
3573
3574        if self.cursor_task_trampoline == 0 {
3575            // JCrsrTask is a no-argument ProcPtr. Invoke it from interrupt
3576            // context and preserve the same volatile register set as VBL and
3577            // Time Manager callbacks. MPW Interfaces/AIncludes/LowMemEqu.a:
3578            // `JCrsrTask EQU $8EE`.
3579            let tramp = self.bus.alloc(16);
3580            self.bus.write_word(tramp, 0x48E7); // MOVEM.L D0-D3/A0-A3,-(SP)
3581            self.bus.write_word(tramp + 2, 0xF0F0);
3582            self.bus.write_word(tramp + 4, 0x4EB9); // JSR abs.L
3583                                                    // +6..+9: callback_addr (patched per-fire)
3584            self.bus.write_word(tramp + 10, 0x4CDF); // MOVEM.L (SP)+,D0-D3/A0-A3
3585            self.bus.write_word(tramp + 12, 0x0F0F);
3586            self.bus.write_word(tramp + 14, 0x4E75); // RTS
3587            self.cursor_task_trampoline = tramp;
3588        }
3589
3590        let tramp = self.cursor_task_trampoline;
3591        self.bus.write_long(tramp + 6, callback_addr);
3592        if trace_vbl_enabled() {
3593            eprintln!(
3594                "[VBL] fire JCrsrTask addr=${:08X} interrupted_pc=${:08X} interrupted_sp=${:08X}",
3595                callback_addr,
3596                self.cpu.read_reg(Register::PC),
3597                self.cpu.read_reg(Register::A7)
3598            );
3599        }
3600        self.inject_interrupt_callback(ActiveInterruptCallbackSource::CursorTask, tramp);
3601    }
3602
3603    /// Fire the next due Vertical Retrace Manager task.
3604    ///
3605    /// VBL tasks run at interrupt time with A0 pointing at the task record.
3606    /// Processes 1994, 4-6 to 4-7; executor src/time/vbl.cpp
3607    fn fire_vbl_tasks(&mut self) {
3608        if self.active_interrupt_callback.is_some() {
3609            return;
3610        }
3611
3612        let mut due_task = None;
3613        for task in &self.dispatcher.vbl_tasks {
3614            let count = self.bus.read_word(task.task_ptr + 10) as i16;
3615            if count <= 0 {
3616                continue;
3617            }
3618            let new_count = count - 1;
3619            self.bus.write_word(task.task_ptr + 10, new_count as u16);
3620            if new_count == 0 {
3621                due_task = Some(task.task_ptr);
3622                break;
3623            }
3624        }
3625
3626        let Some(task_ptr) = due_task else {
3627            return;
3628        };
3629
3630        let callback_addr = self.bus.read_long(task_ptr + 6);
3631        if callback_addr == 0 {
3632            return;
3633        }
3634
3635        if self.vbl_trampoline == 0 {
3636            let tramp = self.bus.alloc(22);
3637            self.bus.write_word(tramp, 0x48E7); // MOVEM.L D0-D3/A0-A3,-(SP)
3638            self.bus.write_word(tramp + 2, 0xF0F0);
3639            self.bus.write_word(tramp + 4, 0x207C); // MOVEA.L #imm,A0
3640            self.bus.write_word(tramp + 10, 0x4EB9); // JSR abs.L
3641            self.bus.write_word(tramp + 16, 0x4CDF); // MOVEM.L (SP)+,D0-D3/A0-A3
3642            self.bus.write_word(tramp + 18, 0x0F0F);
3643            self.bus.write_word(tramp + 20, 0x4E75); // RTS
3644            self.vbl_trampoline = tramp;
3645        }
3646
3647        let tramp = self.vbl_trampoline;
3648        self.bus.write_long(tramp + 6, task_ptr);
3649        self.bus.write_long(tramp + 12, callback_addr);
3650
3651        let current_pc = self.cpu.read_reg(Register::PC);
3652        let sp = self.cpu.read_reg(Register::A7);
3653        let d_regs = [
3654            self.cpu.read_reg(Register::D0),
3655            self.cpu.read_reg(Register::D1),
3656            self.cpu.read_reg(Register::D2),
3657            self.cpu.read_reg(Register::D3),
3658            self.cpu.read_reg(Register::D4),
3659            self.cpu.read_reg(Register::D5),
3660            self.cpu.read_reg(Register::D6),
3661            self.cpu.read_reg(Register::D7),
3662        ];
3663        let a_regs = [
3664            self.cpu.read_reg(Register::A0),
3665            self.cpu.read_reg(Register::A1),
3666            self.cpu.read_reg(Register::A2),
3667            self.cpu.read_reg(Register::A3),
3668            self.cpu.read_reg(Register::A4),
3669            self.cpu.read_reg(Register::A5),
3670            self.cpu.read_reg(Register::A6),
3671            sp,
3672        ];
3673        let ccr = self.cpu.core.get_ccr();
3674        let new_sp = sp.wrapping_sub(4);
3675        self.bus.write_long(new_sp, current_pc);
3676        self.cpu.write_reg(Register::A7, new_sp);
3677        self.active_interrupt_callback = Some(ActiveInterruptCallback {
3678            source: ActiveInterruptCallbackSource::Vbl,
3679            resume_pc: current_pc,
3680            resume_sp: sp,
3681            d_regs,
3682            a_regs,
3683            ccr,
3684            restore_port: None,
3685        });
3686        self.cpu.write_reg(Register::PC, tramp);
3687
3688        if trace_vbl_enabled() {
3689            eprintln!(
3690                "[VBL] fire task=${:08X} addr=${:08X} interrupted_pc=${:08X} interrupted_sp=${:08X} count={}",
3691                task_ptr,
3692                callback_addr,
3693                current_pc,
3694                sp,
3695                self.bus.read_word(task_ptr + 10) as i16
3696            );
3697        }
3698    }
3699
3700    /// Fire any expired Time Manager tasks by injecting a call to their callback.
3701    ///
3702    /// On a real Mac, timer callbacks execute at interrupt time — the 68K hardware
3703    /// saves the entire CPU state (SR + PC + all registers via the exception frame)
3704    /// before dispatching the interrupt handler. The callback may freely clobber
3705    /// A0-A3 and D0-D3 (Processes 1994, 3-22).
3706    ///
3707    /// We simulate this by writing a small native 68K trampoline at a fixed
3708    /// low-memory address ($0110) that:
3709    ///   1. Saves D0-D3/A0-A3 via MOVEM.L to the stack
3710    ///   2. Loads A1 with the task record pointer (from inline data)
3711    ///   3. JSR's to the callback address (from inline data)
3712    ///   4. Restores D0-D3/A0-A3 via MOVEM.L from the stack
3713    ///   5. RTS back to the interrupted code
3714    fn fire_timer_tasks(&mut self, current_tick: u32) {
3715        if self.active_interrupt_callback.is_some() {
3716            return;
3717        }
3718
3719        // Collect tasks that need to fire (avoid borrow issues)
3720        let mut to_fire: Vec<(u32, u32)> = Vec::new(); // (task_ptr, tm_addr)
3721        for task in &mut self.dispatcher.timer_tasks {
3722            if task.active && current_tick >= task.fire_at_tick {
3723                to_fire.push((task.task_ptr, task.tm_addr));
3724                task.active = false; // Mark as fired; callback may re-prime
3725            }
3726        }
3727
3728        // Fire at most one task per tick to avoid deep nesting
3729        if let Some((task_ptr, tm_addr)) = to_fire.into_iter().next() {
3730            if tm_addr == 0 {
3731                return;
3732            }
3733
3734            // Allocate trampoline code in guest heap on first use.
3735            // Layout (22 bytes):
3736            //   +0:  MOVEM.L D0-D3/A0-A3,-(SP)  ; 48E7 F0F0
3737            //   +4:  MOVEA.L #task_ptr,A1         ; 227C xxxx xxxx
3738            //   +10: JSR     tm_addr              ; 4EB9 xxxx xxxx
3739            //   +16: MOVEM.L (SP)+,D0-D3/A0-A3   ; 4CDF 0F0F
3740            //   +20: RTS                          ; 4E75
3741            if self.timer_trampoline == 0 {
3742                let tramp = self.bus.alloc(24); // 22 bytes + 2 padding
3743                self.bus.write_word(tramp, 0x48E7); // MOVEM.L regs,-(SP)
3744                self.bus.write_word(tramp + 2, 0xF0F0); // D0-D3/A0-A3
3745                self.bus.write_word(tramp + 4, 0x227C); // MOVEA.L #imm32,A1
3746                                                        // +6..+9: task_ptr (patched per-fire)
3747                self.bus.write_word(tramp + 10, 0x4EB9); // JSR abs.L
3748                                                         // +12..+15: tm_addr (patched per-fire)
3749                self.bus.write_word(tramp + 16, 0x4CDF); // MOVEM.L (SP)+,regs
3750                self.bus.write_word(tramp + 18, 0x0F0F); // D0-D3/A0-A3
3751                self.bus.write_word(tramp + 20, 0x4E75); // RTS
3752                self.timer_trampoline = tramp;
3753            }
3754
3755            // Patch the inline data for this specific fire
3756            let tramp = self.timer_trampoline;
3757            self.bus.write_long(tramp + 6, task_ptr);
3758            self.bus.write_long(tramp + 12, tm_addr);
3759
3760            // Snapshot the interrupted CPU state before mutating A7 for the
3761            // synthetic return address. The Time Manager callback should resume
3762            // with the guest stack exactly as it was when interrupted.
3763            let current_pc = self.cpu.read_reg(Register::PC);
3764            let sp = self.cpu.read_reg(Register::A7);
3765            let d_regs = [
3766                self.cpu.read_reg(Register::D0),
3767                self.cpu.read_reg(Register::D1),
3768                self.cpu.read_reg(Register::D2),
3769                self.cpu.read_reg(Register::D3),
3770                self.cpu.read_reg(Register::D4),
3771                self.cpu.read_reg(Register::D5),
3772                self.cpu.read_reg(Register::D6),
3773                self.cpu.read_reg(Register::D7),
3774            ];
3775            let a_regs = [
3776                self.cpu.read_reg(Register::A0),
3777                self.cpu.read_reg(Register::A1),
3778                self.cpu.read_reg(Register::A2),
3779                self.cpu.read_reg(Register::A3),
3780                self.cpu.read_reg(Register::A4),
3781                self.cpu.read_reg(Register::A5),
3782                self.cpu.read_reg(Register::A6),
3783                sp,
3784            ];
3785            let ccr = self.cpu.core.get_ccr();
3786
3787            // Inject: push current PC, jump to trampoline
3788            let new_sp = sp.wrapping_sub(4);
3789            self.bus.write_long(new_sp, current_pc);
3790            self.cpu.write_reg(Register::A7, new_sp);
3791            self.active_interrupt_callback = Some(ActiveInterruptCallback {
3792                source: ActiveInterruptCallbackSource::Timer,
3793                resume_pc: current_pc,
3794                resume_sp: sp,
3795                d_regs,
3796                a_regs,
3797                ccr,
3798                restore_port: None,
3799            });
3800            if trace_timer_enabled() {
3801                eprintln!(
3802                    "[TIMER] fire task=${:08X} tm_addr=${:08X} interrupted_pc=${:08X} interrupted_sp=${:08X} ccr=${:02X}",
3803                    task_ptr, tm_addr, current_pc, sp, ccr
3804                );
3805            }
3806            self.cpu.write_reg(Register::PC, tramp);
3807        }
3808    }
3809
3810    /// Check all channels with active double-buffers: if a channel is not
3811    /// currently playing but its current_buffer is ready in guest memory,
3812    /// load the samples so mix_frame() can produce audio.
3813    fn try_load_pending_double_buffers(&mut self) {
3814        if self.active_interrupt_callback.is_some() {
3815            return;
3816        }
3817
3818        let queued_doublebacks = self
3819            .dispatcher
3820            .sound_manager
3821            .pending_callbacks
3822            .iter()
3823            .map(|cb| (cb.chan_ptr, cb.exhausted_buffer_index))
3824            .collect::<Vec<_>>();
3825
3826        for chan in &mut self.dispatcher.sound_manager.channels {
3827            if chan.is_playing() {
3828                continue; // already has data
3829            }
3830            let (header_ptr, buf_idx, sample_rate, num_channels, sample_size) =
3831                match chan.double_buffer {
3832                    Some(ref db) if !db.last_buffer_seen => (
3833                        db.header_ptr,
3834                        db.current_buffer,
3835                        db.sample_rate,
3836                        db.num_channels,
3837                        db.sample_size,
3838                    ),
3839                    _ => continue,
3840                };
3841            let mut load_idx = buf_idx;
3842            let mut buf_ptr = self.bus.read_long(header_ptr + 12 + (buf_idx as u32) * 4);
3843            let mut can_load = buf_ptr != 0
3844                && self.bus.read_long(buf_ptr + 4) & 0x01 != 0
3845                && !queued_doublebacks
3846                    .iter()
3847                    .any(|&(pending_chan, pending_idx)| {
3848                        pending_chan == chan.guest_ptr && pending_idx == buf_idx
3849                    });
3850            let original_idx = load_idx;
3851            if !can_load {
3852                let other_idx = buf_idx ^ 1;
3853                let other_ptr = self.bus.read_long(header_ptr + 12 + (other_idx as u32) * 4);
3854                if other_ptr == 0 {
3855                    continue;
3856                }
3857                let other_flags = self.bus.read_long(other_ptr + 4);
3858                let other_pending =
3859                    queued_doublebacks
3860                        .iter()
3861                        .any(|&(pending_chan, pending_idx)| {
3862                            pending_chan == chan.guest_ptr && pending_idx == other_idx
3863                        });
3864                if other_flags & 0x01 == 0 || other_pending {
3865                    continue; // neither available buffer is ready yet
3866                }
3867                load_idx = other_idx;
3868                buf_ptr = other_ptr;
3869                can_load = true;
3870                if let Some(ref mut db) = chan.double_buffer {
3871                    db.current_buffer = other_idx;
3872                }
3873            }
3874            if !can_load {
3875                continue;
3876            }
3877            let flags = self.bus.read_long(buf_ptr + 4);
3878            if trace_sound_runner_enabled() {
3879                let preview = self
3880                    .bus
3881                    .read_bytes(buf_ptr + 16, 16)
3882                    .iter()
3883                    .map(|byte| format!("{:02X}", byte))
3884                    .collect::<Vec<_>>()
3885                    .join(" ");
3886                eprintln!(
3887                    "[SOUND-DB] load-ready chan=${:08X} header=${:08X} requested_idx={} load_idx={} buf=${:08X} frames={} flags=${:08X} pending={:?} first={}",
3888                    chan.guest_ptr,
3889                    header_ptr,
3890                    original_idx,
3891                    load_idx,
3892                    buf_ptr,
3893                    self.bus.read_long(buf_ptr),
3894                    flags,
3895                    chan.double_buffer
3896                        .as_ref()
3897                        .map(|db| db.pending_callback_buffers)
3898                        .unwrap_or([false; 2]),
3899                    preview
3900                );
3901            }
3902            crate::trap::TrapDispatcher::load_double_buffer_samples(
3903                &mut self.bus,
3904                chan,
3905                buf_ptr,
3906                sample_rate,
3907                num_channels,
3908                sample_size,
3909            );
3910            if flags & 0x01 != 0 {
3911                if let Some(ref mut db) = chan.double_buffer {
3912                    db.current_buffer = load_idx;
3913                    db.complete_callback_for(load_idx);
3914                }
3915            }
3916        }
3917    }
3918
3919    fn dump_invalid_pc_state(&self) {
3920        let d_regs = [
3921            self.cpu.read_reg(Register::D0),
3922            self.cpu.read_reg(Register::D1),
3923            self.cpu.read_reg(Register::D2),
3924            self.cpu.read_reg(Register::D3),
3925            self.cpu.read_reg(Register::D4),
3926            self.cpu.read_reg(Register::D5),
3927            self.cpu.read_reg(Register::D6),
3928            self.cpu.read_reg(Register::D7),
3929        ];
3930        let a_regs = [
3931            self.cpu.read_reg(Register::A0),
3932            self.cpu.read_reg(Register::A1),
3933            self.cpu.read_reg(Register::A2),
3934            self.cpu.read_reg(Register::A3),
3935            self.cpu.read_reg(Register::A4),
3936            self.cpu.read_reg(Register::A5),
3937            self.cpu.read_reg(Register::A6),
3938            self.cpu.read_reg(Register::A7),
3939        ];
3940        eprintln!(
3941            "[RUN_STEPS]   D0-D7: {:08X} {:08X} {:08X} {:08X} {:08X} {:08X} {:08X} {:08X}",
3942            d_regs[0], d_regs[1], d_regs[2], d_regs[3], d_regs[4], d_regs[5], d_regs[6], d_regs[7]
3943        );
3944        eprintln!(
3945            "[RUN_STEPS]   A0-A7: {:08X} {:08X} {:08X} {:08X} {:08X} {:08X} {:08X} {:08X}",
3946            a_regs[0], a_regs[1], a_regs[2], a_regs[3], a_regs[4], a_regs[5], a_regs[6], a_regs[7]
3947        );
3948        eprintln!("[RUN_STEPS]   CCR=${:02X}", self.cpu.core.get_ccr());
3949        if let Some(active) = self.active_interrupt_callback {
3950            eprintln!(
3951                "[RUN_STEPS]   active_callback={:?} resume_pc=${:08X} resume_sp=${:08X}",
3952                active.source, active.resume_pc, active.resume_sp
3953            );
3954        }
3955        self.bus.dump_stack(a_regs[7], "invalid PC");
3956    }
3957
3958    fn inject_interrupt_callback(
3959        &mut self,
3960        source: ActiveInterruptCallbackSource,
3961        trampoline: u32,
3962    ) {
3963        let current_pc = self.cpu.read_reg(Register::PC);
3964        let sp = self.cpu.read_reg(Register::A7);
3965        let d_regs = [
3966            self.cpu.read_reg(Register::D0),
3967            self.cpu.read_reg(Register::D1),
3968            self.cpu.read_reg(Register::D2),
3969            self.cpu.read_reg(Register::D3),
3970            self.cpu.read_reg(Register::D4),
3971            self.cpu.read_reg(Register::D5),
3972            self.cpu.read_reg(Register::D6),
3973            self.cpu.read_reg(Register::D7),
3974        ];
3975        let a_regs = [
3976            self.cpu.read_reg(Register::A0),
3977            self.cpu.read_reg(Register::A1),
3978            self.cpu.read_reg(Register::A2),
3979            self.cpu.read_reg(Register::A3),
3980            self.cpu.read_reg(Register::A4),
3981            self.cpu.read_reg(Register::A5),
3982            self.cpu.read_reg(Register::A6),
3983            sp,
3984        ];
3985        let ccr = self.cpu.core.get_ccr();
3986        let new_sp = sp.wrapping_sub(4);
3987        self.bus.write_long(new_sp, current_pc);
3988        self.cpu.write_reg(Register::A7, new_sp);
3989        self.active_interrupt_callback = Some(ActiveInterruptCallback {
3990            source,
3991            resume_pc: current_pc,
3992            resume_sp: sp,
3993            d_regs,
3994            a_regs,
3995            ccr,
3996            restore_port: None,
3997        });
3998        self.cpu.write_reg(Register::PC, trampoline);
3999    }
4000
4001    /// Fire pending Sound Manager callback procedures and file completion routines.
4002    fn fire_sound_callbacks(&mut self) -> bool {
4003        if self.active_interrupt_callback.is_some() {
4004            return false;
4005        }
4006
4007        if self
4008            .dispatcher
4009            .sound_manager
4010            .pending_sound_callbacks
4011            .is_empty()
4012        {
4013            return false;
4014        }
4015
4016        let cb = self
4017            .dispatcher
4018            .sound_manager
4019            .pending_sound_callbacks
4020            .remove(0);
4021        match cb {
4022            crate::sound::PendingSoundCallback::Command {
4023                callback_addr,
4024                chan_ptr,
4025                cmd,
4026            } => {
4027                if callback_addr == 0 {
4028                    return false;
4029                }
4030
4031                // Sound 1994, 2-152
4032                if self.sound_callback_trampoline == 0 {
4033                    // Sound callback:
4034                    //   PROCEDURE MyCallBack(chan: SndChannelPtr; cmd: SndCommand);
4035                    //
4036                    // In practice shipped apps commonly receive `cmd` as a
4037                    // pointer-sized argument and differ on how much stack they
4038                    // pop on return. Push cmdPtr nearest SP and chan beneath it,
4039                    // then reset SP to the saved-register frame after JSR so
4040                    // one-arg, two-arg, and C-style cleanup all resume safely.
4041                    let tramp = self.bus.alloc(42);
4042                    self.bus.write_word(tramp, 0x48E7); // MOVEM.L regs,-(SP)
4043                    self.bus.write_word(tramp + 2, 0xF0F0); // D0-D3/A0-A3
4044                    self.bus.write_word(tramp + 4, 0x2F3C); // MOVE.L #chan,-(SP)
4045                    self.bus.write_word(tramp + 10, 0x2F3C); // MOVE.L #cmdPtr,-(SP)
4046                    self.bus.write_word(tramp + 16, 0x4EB9); // JSR abs.L
4047                    self.bus.write_word(tramp + 22, 0x2E7C); // MOVEA.L #savedSP,A7
4048                    self.bus.write_word(tramp + 28, 0x4CDF); // MOVEM.L (SP)+,regs
4049                    self.bus.write_word(tramp + 30, 0x0F0F); // D0-D3/A0-A3
4050                    self.bus.write_word(tramp + 32, 0x4E75); // RTS
4051                    self.sound_callback_trampoline = tramp;
4052                }
4053
4054                let tramp = self.sound_callback_trampoline;
4055                let cmd_ptr = tramp + 34;
4056                let interrupted_sp = self.cpu.read_reg(Register::A7);
4057                let saved_regs_sp = interrupted_sp.wrapping_sub(4 + 32);
4058                self.bus.write_long(tramp + 6, chan_ptr);
4059                self.bus.write_long(tramp + 12, cmd_ptr);
4060                self.bus.write_long(tramp + 18, callback_addr);
4061                self.bus.write_long(tramp + 24, saved_regs_sp);
4062                self.bus.write_word(cmd_ptr, cmd.cmd);
4063                self.bus.write_word(cmd_ptr + 2, cmd.param1 as u16);
4064                self.bus.write_long(cmd_ptr + 4, cmd.param2);
4065                self.inject_interrupt_callback(ActiveInterruptCallbackSource::SoundCallback, tramp);
4066                true
4067            }
4068            crate::sound::PendingSoundCallback::FileCompletion {
4069                callback_addr,
4070                chan_ptr,
4071            } => {
4072                if callback_addr == 0 {
4073                    return false;
4074                }
4075
4076                // Sound 1994, 2-151
4077                if self.sound_file_completion_trampoline == 0 {
4078                    let tramp = self.bus.alloc(28);
4079                    self.bus.write_word(tramp, 0x48E7); // MOVEM.L regs,-(SP)
4080                    self.bus.write_word(tramp + 2, 0xF0F0); // D0-D3/A0-A3
4081                    self.bus.write_word(tramp + 4, 0x2F3C); // MOVE.L #chan,-(SP)
4082                    self.bus.write_word(tramp + 10, 0x4EB9); // JSR abs.L
4083                    self.bus.write_word(tramp + 16, 0x2E7C); // MOVEA.L #savedSP,A7
4084                    self.bus.write_word(tramp + 22, 0x4CDF); // MOVEM.L (SP)+,regs
4085                    self.bus.write_word(tramp + 24, 0x0F0F); // D0-D3/A0-A3
4086                    self.bus.write_word(tramp + 26, 0x4E75); // RTS
4087                    self.sound_file_completion_trampoline = tramp;
4088                }
4089
4090                let tramp = self.sound_file_completion_trampoline;
4091                let interrupted_sp = self.cpu.read_reg(Register::A7);
4092                let saved_regs_sp = interrupted_sp.wrapping_sub(4 + 32);
4093                self.bus.write_long(tramp + 6, chan_ptr);
4094                self.bus.write_long(tramp + 12, callback_addr);
4095                self.bus.write_long(tramp + 18, saved_regs_sp);
4096                self.inject_interrupt_callback(
4097                    ActiveInterruptCallbackSource::SoundFileCompletion,
4098                    tramp,
4099                );
4100                true
4101            }
4102        }
4103    }
4104
4105    /// Fire pending SndPlayDoubleBuffer doubleback callbacks.
4106    ///
4107    /// When mix_frame() exhausts a double buffer, it queues a callback request.
4108    /// Here we clear dbBufferReady on the exhausted buffer and inject a
4109    /// trampoline to call the game's doubleback proc to refill it.
4110    ///
4111    /// The doubleback procedure signature (Sound 1994, 2-146):
4112    ///   PROCEDURE MyDoubleBackProc(chan: SndChannelPtr;
4113    ///                              exhaustedBuffer: SndDoubleBufferPtr);
4114    fn fire_sound_doubleback_callbacks(&mut self) -> bool {
4115        if self.active_interrupt_callback.is_some() {
4116            return false;
4117        }
4118
4119        if self.dispatcher.sound_manager.pending_callbacks.is_empty() {
4120            return false;
4121        }
4122
4123        // Take one callback at a time (like timer tasks).
4124        let cb = self.dispatcher.sound_manager.pending_callbacks.remove(0);
4125
4126        // Read the exhausted buffer pointer from the header.
4127        // dbhBufferPtr[0] at header+12, dbhBufferPtr[1] at header+16
4128        let exhausted_buf_ptr = self
4129            .bus
4130            .read_long(cb.header_ptr + 12 + (cb.exhausted_buffer_index as u32) * 4);
4131
4132        // Clear dbBufferReady on the exhausted buffer.
4133        if exhausted_buf_ptr != 0 {
4134            let flags = self.bus.read_long(exhausted_buf_ptr + 4);
4135            if trace_sound_runner_enabled() {
4136                let preview = self
4137                    .bus
4138                    .read_bytes(exhausted_buf_ptr + 16, 16)
4139                    .iter()
4140                    .map(|byte| format!("{:02X}", byte))
4141                    .collect::<Vec<_>>()
4142                    .join(" ");
4143                eprintln!(
4144                    "[SOUND-DB] fire-doubleback tick={} chan=${:08X} header=${:08X} idx={} buf=${:08X} frames={} flags_before=${:08X} callback=${:08X} sr=${:04X} first={}",
4145                    self.bus.read_long(0x016A),
4146                    cb.chan_ptr,
4147                    cb.header_ptr,
4148                    cb.exhausted_buffer_index,
4149                    exhausted_buf_ptr,
4150                    self.bus.read_long(exhausted_buf_ptr),
4151                    flags,
4152                    cb.callback_addr,
4153                    self.cpu.core.get_sr(),
4154                    preview
4155                );
4156            }
4157            self.bus.write_long(exhausted_buf_ptr + 4, flags & !0x01);
4158        }
4159
4160        if cb.callback_addr == 0 {
4161            return false;
4162        }
4163
4164        // Allocate trampoline on first use.
4165        // The doubleback proc is a Pascal procedure (callee pops params):
4166        //   PROCEDURE MyDoubleBackProc(chan: SndChannelPtr;
4167        //                              exhaustedBuffer: SndDoubleBufferPtr);
4168        //
4169        // Trampoline layout (34 bytes):
4170        //   +0:  MOVEM.L D0-D3/A0-A3,-(SP)  ; 48E7 F0F0 (save regs)
4171        //   +4:  MOVE.L  #chanPtr,-(SP)       ; 2F3C xxxx xxxx (Pascal param1 = deeper)
4172        //   +10: MOVE.L  #exhaustedBuf,-(SP) ; 2F3C xxxx xxxx (Pascal param2 = top)
4173        //   +16: JSR     callback             ; 4EB9 xxxx xxxx
4174        //   +22: MOVEA.L #savedRegsSP,A7      ; ignore guest callback cleanup convention
4175        //   +28: MOVEM.L (SP)+,D0-D3/A0-A3   ; 4CDF 0F0F (restore regs)
4176        //   +32: RTS                          ; 4E75
4177        if self.sound_doubleback_trampoline == 0 {
4178            let tramp = self.bus.alloc(34);
4179            self.bus.write_word(tramp, 0x48E7); // MOVEM.L regs,-(SP)
4180            self.bus.write_word(tramp + 2, 0xF0F0); // D0-D3/A0-A3
4181            self.bus.write_word(tramp + 4, 0x2F3C); // MOVE.L #imm,-(SP)
4182                                                    // +6..+9: chan ptr (patched)
4183            self.bus.write_word(tramp + 10, 0x2F3C); // MOVE.L #imm,-(SP)
4184                                                     // +12..+15: exhausted buf ptr (patched)
4185            self.bus.write_word(tramp + 16, 0x4EB9); // JSR abs.L
4186                                                     // +18..+21: callback addr (patched)
4187            self.bus.write_word(tramp + 22, 0x2E7C); // MOVEA.L #savedSP,A7
4188                                                     // +24..+27: saved regs SP (patched)
4189            self.bus.write_word(tramp + 28, 0x4CDF); // MOVEM.L (SP)+,regs
4190            self.bus.write_word(tramp + 30, 0x0F0F); // D0-D3/A0-A3
4191            self.bus.write_word(tramp + 32, 0x4E75); // RTS
4192            self.sound_doubleback_trampoline = tramp;
4193        }
4194
4195        let tramp = self.sound_doubleback_trampoline;
4196        let interrupted_sp = self.cpu.read_reg(Register::A7);
4197        let saved_regs_sp = interrupted_sp.wrapping_sub(4 + 32);
4198        self.bus.write_long(tramp + 6, cb.chan_ptr);
4199        self.bus.write_long(tramp + 12, exhausted_buf_ptr);
4200        self.bus.write_long(tramp + 18, cb.callback_addr);
4201        self.bus.write_long(tramp + 24, saved_regs_sp);
4202
4203        // Doubleback procedures execute at interrupt time, so the interrupted
4204        // guest CPU state must be restored after the callback unwinds.
4205        // Sound 1994, 2-72
4206        self.inject_interrupt_callback(ActiveInterruptCallbackSource::SoundDoubleBack, tramp);
4207        true
4208    }
4209
4210    fn dialog_callback_scratch_base(&self) -> u32 {
4211        DIALOG_CALLBACK_SCRATCH_FALLBACK
4212    }
4213
4214    fn looks_like_dialog_proc_entry(&self, addr: u32) -> bool {
4215        if addr == 0 {
4216            return false;
4217        }
4218        let entry = self.bus.read_word(addr);
4219        entry == 0x4E56 || entry == 0x48E7 || entry == 0x4EF9 || entry == 0x4EFA
4220    }
4221
4222    fn resolve_dialog_draw_proc_addr(&self, proc_addr: u32) -> Option<u32> {
4223        if self.looks_like_dialog_proc_entry(proc_addr) {
4224            return Some(proc_addr);
4225        }
4226        let a5_relative = self.cpu.read_reg(Register::A5).wrapping_add(proc_addr);
4227        if self.looks_like_dialog_proc_entry(a5_relative) {
4228            Some(a5_relative)
4229        } else {
4230            None
4231        }
4232    }
4233
4234    fn inject_dialog_draw_proc(
4235        &mut self,
4236        proc_addr: u32,
4237        item_no: i16,
4238        dialog_ptr: u32,
4239        modeless: bool,
4240    ) -> bool {
4241        if self.active_interrupt_callback.is_some() {
4242            return false;
4243        }
4244
4245        if proc_addr == 0 {
4246            return false;
4247        }
4248
4249        // Many dialogs stuff non-code placeholders into userItem proc fields.
4250        // Only fire callbacks that look like real 68K entry points.
4251        let Some(call_addr) = self.resolve_dialog_draw_proc_addr(proc_addr) else {
4252            if trace_dialog_procs_enabled() {
4253                let a5_relative = self.cpu.read_reg(Register::A5).wrapping_add(proc_addr);
4254                eprintln!(
4255                    "[DIALOG-PROC] skip dialog=${:08X} item={} proc=${:08X} a5rel=${:08X} entry=${:04X} a5entry=${:04X}",
4256                    dialog_ptr,
4257                    item_no,
4258                    proc_addr,
4259                    a5_relative,
4260                    self.bus.read_word(proc_addr),
4261                    self.bus.read_word(a5_relative),
4262                );
4263            }
4264            return false;
4265        };
4266
4267        // Allocate trampoline on first use (32 bytes):
4268        //   +0:  MOVEM.L D0-D3/A0-A3,-(SP)   ; 48E7 F0F0
4269        //   +4:  MOVE.L  #dialogPtr,-(SP)      ; 2F3C xxxx xxxx
4270        //   +10: MOVE.W  #itemNo,-(SP)         ; 3F3C xxxx
4271        //   +14: JSR     proc_addr              ; 4EB9 xxxx xxxx
4272        //   +20: MOVEA.L #savedRegsSP,A7       ; 4FF9 xxxx xxxx
4273        //   +26: MOVEM.L (SP)+,D0-D3/A0-A3    ; 4CDF 0F0F
4274        //   +30: RTS                            ; 4E75
4275        if self.dialog_draw_trampoline == 0 {
4276            let tramp = self.dialog_callback_scratch_base() + DIALOG_DRAW_TRAMPOLINE_OFFSET;
4277            self.bus.write_word(tramp, 0x48E7); // MOVEM.L regs,-(SP)
4278            self.bus.write_word(tramp + 2, 0xF0F0); // D0-D3/A0-A3
4279            self.bus.write_word(tramp + 4, 0x2F3C); // MOVE.L #imm,-(SP)
4280                                                    // +6..+9: dialogPtr (patched per-fire)
4281            self.bus.write_word(tramp + 10, 0x3F3C); // MOVE.W #imm,-(SP)
4282                                                     // +12..+13: itemNo (patched per-fire)
4283            self.bus.write_word(tramp + 14, 0x4EB9); // JSR abs.L
4284                                                     // +16..+19: proc_addr (patched per-fire)
4285            self.bus.write_word(tramp + 20, 0x4FF9); // MOVEA.L #imm,A7
4286                                                     // +22..+25: savedRegsSP (patched per-fire)
4287            self.bus.write_word(tramp + 26, 0x4CDF); // MOVEM.L (SP)+,regs
4288            self.bus.write_word(tramp + 28, 0x0F0F); // D0-D3/A0-A3
4289            self.bus.write_word(tramp + 30, 0x4E75); // RTS
4290            self.dialog_draw_trampoline = tramp;
4291        }
4292
4293        let tramp = self.dialog_draw_trampoline;
4294        self.bus.write_long(tramp + 6, dialog_ptr);
4295        self.bus.write_word(tramp + 12, item_no as u16);
4296        self.bus.write_long(tramp + 16, call_addr);
4297
4298        // The Dialog Manager sets the current port to the dialog before
4299        // calling a userItem draw proc. It does not restore an older
4300        // application port over the callback's final QuickDraw state.
4301        self.dispatcher
4302            .set_current_port_state(&mut self.bus, &mut self.cpu, dialog_ptr, None);
4303
4304        // Inject: push current PC, jump to trampoline
4305        let current_pc = self.cpu.read_reg(Register::PC);
4306        let sp = self.cpu.read_reg(Register::A7);
4307        let d_regs = [
4308            self.cpu.read_reg(Register::D0),
4309            self.cpu.read_reg(Register::D1),
4310            self.cpu.read_reg(Register::D2),
4311            self.cpu.read_reg(Register::D3),
4312            self.cpu.read_reg(Register::D4),
4313            self.cpu.read_reg(Register::D5),
4314            self.cpu.read_reg(Register::D6),
4315            self.cpu.read_reg(Register::D7),
4316        ];
4317        let a_regs = [
4318            self.cpu.read_reg(Register::A0),
4319            self.cpu.read_reg(Register::A1),
4320            self.cpu.read_reg(Register::A2),
4321            self.cpu.read_reg(Register::A3),
4322            self.cpu.read_reg(Register::A4),
4323            self.cpu.read_reg(Register::A5),
4324            self.cpu.read_reg(Register::A6),
4325            sp,
4326        ];
4327        let ccr = self.cpu.core.get_ccr();
4328        let new_sp = sp.wrapping_sub(4);
4329        let saved_regs_sp = new_sp.wrapping_sub(32);
4330        self.bus.write_long(tramp + 22, saved_regs_sp);
4331        self.bus.write_long(new_sp, current_pc);
4332        self.cpu.write_reg(Register::A7, new_sp);
4333        self.active_interrupt_callback = Some(ActiveInterruptCallback {
4334            source: ActiveInterruptCallbackSource::DialogDrawProc,
4335            resume_pc: current_pc,
4336            resume_sp: sp,
4337            d_regs,
4338            a_regs,
4339            ccr,
4340            restore_port: None,
4341        });
4342        if modeless {
4343            self.dispatcher.active_modeless_dialog_draw_proc = Some(dialog_ptr);
4344        }
4345        if trace_dialog_procs_enabled() {
4346            eprintln!(
4347                "[DIALOG-PROC] fire {} dialog=${:08X} item={} proc=${:08X} call=${:08X} return_pc=${:08X}",
4348                if modeless { "modeless" } else { "modal" },
4349                dialog_ptr,
4350                item_no,
4351                proc_addr,
4352                call_addr,
4353                current_pc,
4354            );
4355        }
4356        self.cpu.write_reg(Register::PC, tramp);
4357        true
4358    }
4359
4360    fn fire_modeless_dialog_draw_proc(&mut self) -> bool {
4361        if self.active_interrupt_callback.is_some() {
4362            return false;
4363        }
4364
4365        while let Some((dialog_ptr, proc_addr, item_no)) =
4366            self.dispatcher.modeless_dialog_draw_proc_queue.pop_front()
4367        {
4368            if self.inject_dialog_draw_proc(proc_addr, item_no, dialog_ptr, true) {
4369                return true;
4370            }
4371        }
4372        false
4373    }
4374
4375    /// Fire the next pending dialog userItem draw proc by injecting a trampoline.
4376    ///
4377    /// On a real Mac, ModalDialog calls each userItem's draw proc during
4378    /// the update pass. The draw proc is a Pascal callback:
4379    ///   PROCEDURE MyItem (theWindow: WindowPtr; itemNo: INTEGER);
4380    /// Inside Macintosh Volume I, I-405
4381    ///
4382    /// We simulate this by writing a small 68K trampoline that:
4383    ///   1. Saves D0-D3/A0-A3 via MOVEM.L to the stack
4384    ///   2. Pushes params so MPW-style Pascal prologues see itemNo at
4385    ///      8(A6) and theWindow at 10(A6), matching Pascal's stack layout
4386    ///   3. JSR to draw proc address
4387    ///   4. Resets A7 to the saved-register frame, tolerating callbacks
4388    ///      that return with either `RTD #6` or plain `RTS`
4389    ///   5. Restores D0-D3/A0-A3
4390    ///   6. RTS back to interrupted code (the ModalDialog A-line)
4391    fn fire_dialog_draw_procs(&mut self) -> bool {
4392        if self.active_interrupt_callback.is_some() {
4393            return false;
4394        }
4395
4396        if let Some(tracking) = self
4397            .dispatcher
4398            .dialog_tracking
4399            .as_mut()
4400            .filter(|tracking| !tracking.draw_procs_done)
4401        {
4402            let Some((proc_addr, item_no)) = tracking.draw_proc_queue.pop_front() else {
4403                // All draw procs fired and returned
4404                tracking.draw_procs_done = true;
4405                return false;
4406            };
4407            let dialog_ptr = tracking.dialog_ptr;
4408            return self.inject_dialog_draw_proc(proc_addr, item_no, dialog_ptr, false);
4409        }
4410
4411        self.fire_modeless_dialog_draw_proc()
4412    }
4413
4414    fn fire_menu_hook_proc(&mut self, opcode: u16) -> bool {
4415        if self.active_interrupt_callback.is_some() || (opcode & !0x0400) != 0xA93D {
4416            return false;
4417        }
4418        if self.dispatcher.menu_tracking.is_none() || self.bus.read_byte(0x0172) != 0x00 {
4419            return false;
4420        }
4421
4422        // MenuHook ($0A30)
4423        // Address of a no-argument routine that MenuSelect calls repeatedly
4424        // while the mouse button is down.
4425        // PROCEDURE MyMenuHook;
4426        // Inside Macintosh Volume I, I-356; Inside Macintosh Volume III, III-446
4427        let hook_addr = self.bus.read_long(0x0A30);
4428        let Some(call_addr) = self.resolve_dialog_draw_proc_addr(hook_addr) else {
4429            return false;
4430        };
4431
4432        // Trampoline (22 bytes):
4433        //   +0:  MOVEM.L D0-D3/A0-A3,-(SP)   ; 48E7 F0F0
4434        //   +4:  JSR     hook_addr            ; 4EB9 xxxx xxxx
4435        //   +10: MOVEA.L #savedRegsSP,A7      ; 4FF9 xxxx xxxx
4436        //   +16: MOVEM.L (SP)+,D0-D3/A0-A3   ; 4CDF 0F0F
4437        //   +20: RTS                          ; 4E75
4438        if self.menu_hook_trampoline == 0 {
4439            let tramp = self.dialog_callback_scratch_base() + MENU_HOOK_TRAMPOLINE_OFFSET;
4440            self.bus.write_word(tramp, 0x48E7); // MOVEM.L regs,-(SP)
4441            self.bus.write_word(tramp + 2, 0xF0F0); // D0-D3/A0-A3
4442            self.bus.write_word(tramp + 4, 0x4EB9); // JSR abs.L
4443                                                    // +6..+9: hook_addr
4444            self.bus.write_word(tramp + 10, 0x4FF9); // MOVEA.L #imm,A7
4445                                                     // +12..+15: savedRegsSP
4446            self.bus.write_word(tramp + 16, 0x4CDF); // MOVEM.L (SP)+,regs
4447            self.bus.write_word(tramp + 18, 0x0F0F); // D0-D3/A0-A3
4448            self.bus.write_word(tramp + 20, 0x4E75); // RTS
4449            self.menu_hook_trampoline = tramp;
4450        }
4451
4452        let tramp = self.menu_hook_trampoline;
4453        self.bus.write_long(tramp + 6, call_addr);
4454
4455        let current_pc = self.cpu.read_reg(Register::PC);
4456        let sp = self.cpu.read_reg(Register::A7);
4457        let d_regs = [
4458            self.cpu.read_reg(Register::D0),
4459            self.cpu.read_reg(Register::D1),
4460            self.cpu.read_reg(Register::D2),
4461            self.cpu.read_reg(Register::D3),
4462            self.cpu.read_reg(Register::D4),
4463            self.cpu.read_reg(Register::D5),
4464            self.cpu.read_reg(Register::D6),
4465            self.cpu.read_reg(Register::D7),
4466        ];
4467        let a_regs = [
4468            self.cpu.read_reg(Register::A0),
4469            self.cpu.read_reg(Register::A1),
4470            self.cpu.read_reg(Register::A2),
4471            self.cpu.read_reg(Register::A3),
4472            self.cpu.read_reg(Register::A4),
4473            self.cpu.read_reg(Register::A5),
4474            self.cpu.read_reg(Register::A6),
4475            sp,
4476        ];
4477        let ccr = self.cpu.core.get_ccr();
4478        let new_sp = sp.wrapping_sub(4);
4479        let saved_regs_sp = new_sp.wrapping_sub(32);
4480        self.bus.write_long(tramp + 12, saved_regs_sp);
4481        self.bus.write_long(new_sp, current_pc);
4482        self.cpu.write_reg(Register::A7, new_sp);
4483        self.active_interrupt_callback = Some(ActiveInterruptCallback {
4484            source: ActiveInterruptCallbackSource::MenuHook,
4485            resume_pc: current_pc,
4486            resume_sp: sp,
4487            d_regs,
4488            a_regs,
4489            ccr,
4490            restore_port: None,
4491        });
4492        self.cpu.write_reg(Register::PC, tramp);
4493        true
4494    }
4495
4496    fn dialog_filter_has_real_event_pending(&self, dialog_ptr: u32) -> bool {
4497        self.dispatcher
4498            .event_queue
4499            .iter()
4500            .any(|event| matches!(event.what, 1 | 2 | 3 | 4 | 6))
4501            || self
4502                .dispatcher
4503                .pending_update_event(&self.bus, 1u16 << 6)
4504                .is_some_and(|event| {
4505                    event.message == dialog_ptr
4506                        && !self.dialog_filter_update_event_already_sent_this_tick(
4507                            dialog_ptr,
4508                            event.message,
4509                        )
4510                })
4511    }
4512
4513    fn dialog_filter_null_event_already_sent_this_tick(&self, dialog_ptr: u32) -> bool {
4514        self.dialog_filter_last_null_event_tick
4515            .is_some_and(|(sent_dialog, sent_tick)| {
4516                sent_dialog == dialog_ptr && sent_tick == self.dispatcher.tick_count
4517            })
4518    }
4519
4520    fn dialog_filter_update_event_already_sent_this_tick(
4521        &self,
4522        dialog_ptr: u32,
4523        update_window: u32,
4524    ) -> bool {
4525        self.dialog_filter_last_update_event_tick.is_some_and(
4526            |(sent_dialog, sent_window, sent_tick)| {
4527                sent_dialog == dialog_ptr
4528                    && sent_window == update_window
4529                    && sent_tick == self.dispatcher.tick_count
4530            },
4531        )
4532    }
4533
4534    fn should_fire_dialog_filter_proc(&self) -> bool {
4535        let Some(tracking) = self.dispatcher.dialog_tracking.as_ref() else {
4536            return false;
4537        };
4538
4539        if tracking.filter_proc == 0
4540            || !tracking.draw_procs_done
4541            || tracking.last_filter_event.is_some()
4542        {
4543            return false;
4544        }
4545
4546        let dialog_ptr = tracking.dialog_ptr;
4547        let has_real_event = self.dialog_filter_has_real_event_pending(dialog_ptr);
4548
4549        // A queued mouseDown on a standard dialog item is still a real event:
4550        // ModalDialog passes it to the filter first, then handles it itself if
4551        // the filter returns FALSE. Only suppress idle/null callbacks while
4552        // the mouse is physically held over a dialog item. IM:I 1985 I-415.
4553        if !has_real_event
4554            && (self.dispatcher.mouse_down_over_dialog_button()
4555                || self.dispatcher.mouse_down_over_dialog_plain_user_item()
4556                || self.dispatcher.pending_dialog_plain_user_item_mouse_down())
4557        {
4558            return false;
4559        }
4560
4561        // ModalDialog gets events through GetNextEvent and passes them to the
4562        // filter proc. A null event means there was no real event to dequeue;
4563        // pace those synthetic idle callbacks to one per guest tick so the HLE
4564        // refire loop does not manufacture hundreds of thousands of no-input
4565        // filter calls between VBLs. Mouse/key/update events still bypass this
4566        // gate and are delivered immediately. IM:I 1985 I-415; MTE 1992 6-136.
4567        if !has_real_event && self.dialog_filter_null_event_already_sent_this_tick(dialog_ptr) {
4568            return false;
4569        }
4570
4571        true
4572    }
4573
4574    /// Fire the ModalDialog filter proc for game-managed dialogs.
4575    ///
4576    /// On a real Mac, ModalDialog's internal loop calls GetNextEvent (consuming
4577    /// the event) and then passes it to the filter proc. If the filter returns
4578    /// TRUE, ModalDialog returns immediately with the itemHit value the filter
4579    /// wrote. If FALSE, ModalDialog processes the event itself.
4580    /// Inside Macintosh Volume I, I-415
4581    ///
4582    /// We simulate this by:
4583    /// 1. Consuming the next actionable event from our queue (like GetNextEvent)
4584    /// 2. Writing it to a scratch EventRecord in guest memory
4585    /// 3. Injecting a 68K trampoline that calls the filter proc with correct
4586    ///    Pascal calling convention (Boolean result space + 3 params)
4587    /// 4. The trampoline saves the Boolean return value to a scratch location
4588    ///    so the ModalDialog re-fire path can read it
4589    fn fire_dialog_filter_proc(&mut self) -> bool {
4590        let (filter_proc, dialog_ptr, item_hit_ptr) = {
4591            let tracking = match self.dispatcher.dialog_tracking.as_ref() {
4592                Some(t) => t,
4593                None => return false,
4594            };
4595            (
4596                tracking.filter_proc,
4597                tracking.dialog_ptr,
4598                tracking.item_hit_ptr,
4599            )
4600        };
4601
4602        if filter_proc == 0 {
4603            return false;
4604        }
4605
4606        // Only fire the filter if the proc address contains recognisable 68K
4607        // function entry code. Some games pass a non-nil but invalid filterProc
4608        // (e.g. Marathon passes a stack address reused as a Rect buffer by
4609        // GetDItem, leaving it full of coordinate data, not instructions).
4610        // Executing garbage code would halt the CPU; skip the call instead.
4611        // Standard 68K function preambles: LINK A6 (0x4E56),
4612        //   MOVEM.L regs,-(SP) (0x48E7), JMP abs (0x4EF9), JMP PC+n (0x4EFA).
4613        // Inside Macintosh Volume I, I-415
4614        let entry = self.bus.read_word(filter_proc);
4615        if entry != 0x4E56 && entry != 0x48E7 && entry != 0x4EF9 && entry != 0x4EFA {
4616            if trace_dialog_filter_enabled() {
4617                eprintln!(
4618                    "[DIALOG-FILTER] skip invalid-entry dialog=${:08X} proc=${:08X} entry=${:04X}",
4619                    dialog_ptr, filter_proc, entry
4620                );
4621            }
4622            return false;
4623        }
4624        // Allocate EventRecord scratch space on first use.
4625        // EventRecord = what(2), message(4), when(4), where(4), modifiers(2)
4626        if self.dialog_filter_event == 0 {
4627            self.dialog_filter_event =
4628                self.dialog_callback_scratch_base() + DIALOG_FILTER_EVENT_OFFSET;
4629        }
4630        let evt = self.dialog_filter_event;
4631
4632        // Allocate the 2-byte Boolean result scratch on first use.
4633        if self.dispatcher.dialog_filter_result_addr == 0 {
4634            self.dispatcher.dialog_filter_result_addr =
4635                self.dialog_callback_scratch_base() + DIALOG_FILTER_RESULT_OFFSET;
4636        }
4637        let result_addr = self.dispatcher.dialog_filter_result_addr;
4638
4639        // Clear the filter result before each invocation.
4640        self.bus.write_word(result_addr, 0);
4641
4642        let ticks = self.bus.read_long(0x016A);
4643
4644        // Consume the next actionable event from the queue, mirroring the real
4645        // Mac ModalDialog which calls GetNextEvent before invoking the filter.
4646        // Inside Macintosh Volume I, I-415
4647        let idx = self
4648            .dispatcher
4649            .event_queue
4650            .iter()
4651            .position(|e| matches!(e.what, 1 | 2 | 3 | 4 | 6));
4652        let next_event = idx.map(|i| self.dispatcher.event_queue.remove(i).unwrap());
4653
4654        let filter_event = if let Some(e) = next_event {
4655            e
4656        } else if let Some(update_event) = self
4657            .dispatcher
4658            .pending_update_event(&self.bus, 1u16 << 6)
4659            .filter(|event| {
4660                event.message == dialog_ptr
4661                    && !self.dialog_filter_update_event_already_sent_this_tick(
4662                        dialog_ptr,
4663                        event.message,
4664                    )
4665            })
4666        {
4667            // `GetNextEvent` normally obtains updateEvt records from the
4668            // Window Manager's invalid region state. The queued-event path is
4669            // a one-shot approximation, but apps can flush that queued event
4670            // before entering a nested ModalDialog filter. If the active dialog
4671            // itself is still invalid, deliver that real pending update to the
4672            // filter rather than falling through to a null event. Restrict this
4673            // to the current dialog so unrelated behind-window invalid regions
4674            // cannot flood modal filters. Pace this synthetic update source to
4675            // once per guest tick: IM:I I-8433 says GetNextEvent returns the
4676            // next available event subject to priority rules, and IM:I I-9079
4677            // describes update events as generated from the Window Manager's
4678            // accumulated update region. Re-offering the same still-invalid
4679            // region in a tight ModalDialog filter loop can otherwise starve
4680            // queued user input.
4681            self.dialog_filter_last_update_event_tick =
4682                Some((dialog_ptr, update_event.message, ticks));
4683            update_event
4684        } else {
4685            // Modal filters are called on null events too; many apps render
4686            // their dialog content from this path (e.g., idle redraw).
4687            let (v, h) = self.dispatcher.mouse_position();
4688            crate::trap::dispatch::QueuedEvent {
4689                what: 0,
4690                message: 0,
4691                where_v: v,
4692                where_h: h,
4693                modifiers: self.dispatcher.current_event_modifiers(),
4694            }
4695        };
4696        if let Some(tracking) = self.dispatcher.dialog_tracking.as_mut() {
4697            tracking.last_filter_event = Some(filter_event.clone());
4698        }
4699        let what = filter_event.what;
4700        let message = filter_event.message;
4701        let where_v = filter_event.where_v;
4702        let where_h = filter_event.where_h;
4703        let modifiers = filter_event.modifiers;
4704        if what == 0 {
4705            self.dialog_filter_last_null_event_tick = Some((dialog_ptr, ticks));
4706        } else {
4707            self.dialog_filter_last_null_event_tick = None;
4708        }
4709        self.dispatcher.tick_count = ticks;
4710        self.dispatcher.write_event_record(
4711            &mut self.bus,
4712            evt,
4713            what,
4714            message,
4715            where_v,
4716            where_h,
4717            modifiers,
4718        );
4719        if trace_dialog_filter_enabled() {
4720            eprintln!(
4721                "[DIALOG-FILTER] call dialog=${:08X} proc=${:08X} event=what:{} message=${:08X} where=({}, {}) mods=${:04X}",
4722                dialog_ptr, filter_proc, what, message, where_v, where_h, modifiers
4723            );
4724        }
4725
4726        // Trampoline (48 bytes) with correct Pascal calling convention:
4727        //
4728        // FUNCTION MyFilter(theDialog: DialogPtr; VAR theEvent: EventRecord;
4729        //                   VAR itemHit: INTEGER): BOOLEAN;
4730        // Inside Macintosh Volume I, I-415
4731        //
4732        // Pascal convention: caller pushes 2-byte result space, then params
4733        // left-to-right. Callee pops params; result is left on stack.
4734        //
4735        //   +0:  MOVEM.L D0-D3/A0-A3,-(SP)     ; 48E7 F0F0
4736        //   +4:  CLR.W   -(SP)                   ; 4267 — Boolean result space
4737        //   +6:  MOVE.L  #dialogPtr,-(SP)         ; 2F3C xxxx xxxx
4738        //   +12: MOVE.L  #eventPtr,-(SP)          ; 2F3C xxxx xxxx
4739        //   +18: MOVE.L  #itemHitPtr,-(SP)        ; 2F3C xxxx xxxx
4740        //   +24: JSR     filter_proc              ; 4EB9 xxxx xxxx
4741        //        ; callee popped 12 bytes of params; SP → 2-byte Boolean result
4742        //   +30: MOVE.W  (SP),(result_addr).L     ; 33D7 xxxx xxxx
4743        //   +36: MOVEA.L #savedSP,A7              ; 2E7C xxxx xxxx
4744        //   +42: MOVEM.L (SP)+,D0-D3/A0-A3       ; 4CDF 0F0F
4745        //   +46: RTS                              ; 4E75
4746        if self.dialog_filter_trampoline == 0 {
4747            let tramp = self.dialog_callback_scratch_base() + DIALOG_FILTER_TRAMPOLINE_OFFSET;
4748            self.bus.write_word(tramp, 0x48E7); // MOVEM.L regs,-(SP)
4749            self.bus.write_word(tramp + 2, 0xF0F0); // D0-D3/A0-A3
4750            self.bus.write_word(tramp + 4, 0x4267); // CLR.W -(SP) — result space
4751            self.bus.write_word(tramp + 6, 0x2F3C); // MOVE.L #imm,-(SP)
4752                                                    // +8..+11: dialogPtr
4753            self.bus.write_word(tramp + 12, 0x2F3C); // MOVE.L #imm,-(SP)
4754                                                     // +14..+17: eventPtr
4755            self.bus.write_word(tramp + 18, 0x2F3C); // MOVE.L #imm,-(SP)
4756                                                     // +20..+23: itemHitPtr
4757            self.bus.write_word(tramp + 24, 0x4EB9); // JSR abs.L
4758                                                     // +26..+29: filter_proc
4759            self.bus.write_word(tramp + 30, 0x33D7); // MOVE.W (SP),(abs).L
4760                                                     // +32..+35: result_addr
4761            self.bus.write_word(tramp + 36, 0x2E7C); // MOVEA.L #imm,A7
4762                                                     // +38..+41: savedSP
4763            self.bus.write_word(tramp + 42, 0x4CDF); // MOVEM.L (SP)+,regs
4764            self.bus.write_word(tramp + 44, 0x0F0F); // D0-D3/A0-A3
4765            self.bus.write_word(tramp + 46, 0x4E75); // RTS
4766            self.dialog_filter_trampoline = tramp;
4767        }
4768
4769        let tramp = self.dialog_filter_trampoline;
4770        self.bus.write_long(tramp + 8, dialog_ptr);
4771        self.bus.write_long(tramp + 14, evt);
4772        self.bus.write_long(tramp + 20, item_hit_ptr);
4773        self.bus.write_long(tramp + 26, filter_proc);
4774        self.bus.write_long(tramp + 32, result_addr);
4775
4776        // ModalDialog handles events through DialogSelect, which selects the
4777        // dialog port before event handling. Leave that port current when the
4778        // filter returns so application follow-up drawing/invalidations target
4779        // the active dialog.
4780        self.dispatcher
4781            .set_current_port_state(&mut self.bus, &mut self.cpu, dialog_ptr, None);
4782
4783        // Inject callback execution.
4784        let current_pc = self.cpu.read_reg(Register::PC);
4785        let sp = self.cpu.read_reg(Register::A7);
4786        let d_regs = [
4787            self.cpu.read_reg(Register::D0),
4788            self.cpu.read_reg(Register::D1),
4789            self.cpu.read_reg(Register::D2),
4790            self.cpu.read_reg(Register::D3),
4791            self.cpu.read_reg(Register::D4),
4792            self.cpu.read_reg(Register::D5),
4793            self.cpu.read_reg(Register::D6),
4794            self.cpu.read_reg(Register::D7),
4795        ];
4796        let a_regs = [
4797            self.cpu.read_reg(Register::A0),
4798            self.cpu.read_reg(Register::A1),
4799            self.cpu.read_reg(Register::A2),
4800            self.cpu.read_reg(Register::A3),
4801            self.cpu.read_reg(Register::A4),
4802            self.cpu.read_reg(Register::A5),
4803            self.cpu.read_reg(Register::A6),
4804            sp,
4805        ];
4806        let ccr = self.cpu.core.get_ccr();
4807        let new_sp = sp.wrapping_sub(4);
4808        let saved_sp = new_sp.wrapping_sub(32); // SP after MOVEM save at trampoline entry
4809
4810        // Zero the stack region the filter proc will use as local variables.
4811        //
4812        // On a real Mac, ModalDialog's internal event loop calls GetNextEvent
4813        // and DialogSelect between filter proc invocations, which naturally
4814        // overwrites the stack area with fresh data. In our HLE, the filter
4815        // proc is called directly without these intermediate calls, so stale
4816        // local variables from the previous invocation persist. This causes
4817        // bugs when the filter proc's code reads uninitialized locals that
4818        // happen to contain residual data (e.g., a stale Pascal string length
4819        // byte interpreted as a large count, overflowing a buffer).
4820        //
4821        // Clear 2KB below the filter proc's entry SP to simulate the stack
4822        // hygiene that ModalDialog's real event loop provides.
4823        let filter_entry_sp = saved_sp.wrapping_sub(50); // after MOVEM+params+JSR
4824        let clear_size: u32 = 2048;
4825        let clear_start = filter_entry_sp.wrapping_sub(clear_size);
4826        self.bus.fill_zeros(clear_start, clear_size);
4827
4828        self.bus.write_long(tramp + 38, saved_sp);
4829        self.bus.write_long(new_sp, current_pc);
4830        self.cpu.write_reg(Register::A7, new_sp);
4831        self.active_interrupt_callback = Some(ActiveInterruptCallback {
4832            source: ActiveInterruptCallbackSource::DialogFilterProc,
4833            resume_pc: current_pc,
4834            resume_sp: sp,
4835            d_regs,
4836            a_regs,
4837            ccr,
4838            restore_port: None,
4839        });
4840        self.cpu.write_reg(Register::PC, tramp);
4841
4842        // Mark rendered_pixels stale while the filter proc is executing so
4843        // redraw_chrome skips restoration (which would erase the filter's
4844        // framebuffer output). After the filter returns and ModalDialog refires,
4845        // the re-snapshot path captures the filter's drawing into rendered_pixels.
4846        if let Some(tracking) = self.dispatcher.dialog_tracking.as_mut() {
4847            tracking.rendered_pixels_final = false;
4848        }
4849        true
4850    }
4851
4852    /// Run the 68k guest until it halts or [`FixtureRunnerConfig::max_instructions`]
4853    /// is reached. Returns:
4854    /// - `Ok(())` on a clean halt (`Stopped`, ExitToShell, or invalid PC).
4855    /// - `Err(Error::Halted)` is *not* returned here — halt-via-trap maps
4856    ///   to `Ok(())`. Trap dispatch errors (other than `Halted`) propagate.
4857    /// - [`Error::Timeout`] when the instruction count cap is reached
4858    ///   before any halt condition fires.
4859    ///
4860    /// Most embedders should prefer [`FixtureRunner::run_steps`], which
4861    /// gives you per-call budget control, returns whether the CPU is
4862    /// still running, and exposes per-halt detail via the
4863    /// [`halted_pc`](Self::halted_pc) / [`halted_trap`](Self::halted_trap)
4864    /// accessors.
4865    pub fn run(&mut self) -> Result<()> {
4866        let mut count = 0;
4867
4868        if trace_load_enabled() {
4869            eprintln!("========================================");
4870            eprintln!("        FIXTURE RUNNER STARTING         ");
4871            eprintln!("========================================");
4872
4873            eprintln!(
4874                "[RUN] Starting at PC=${:08X}, A5=${:08X}, A7=${:08X}",
4875                self.cpu.read_reg(Register::PC),
4876                self.cpu.read_reg(Register::A5),
4877                self.cpu.read_reg(Register::A7)
4878            );
4879        }
4880
4881        while count < self.config.max_instructions {
4882            if self.cpu.is_stopped() {
4883                if trace_load_enabled() {
4884                    eprintln!(
4885                        "[RUN] Stopped after {} instructions, PC=${:08X}",
4886                        count,
4887                        self.cpu.read_reg(Register::PC)
4888                    );
4889                }
4890                return Ok(());
4891            }
4892
4893            let pc = self.cpu.read_reg(Register::PC);
4894
4895            // Safety Trigger: If PC jumps outside RAM or to Low Mem, stop immediately
4896            // Allow $60+ since CRT relocation installs trampolines in low memory
4897            if pc >= self.bus.ram_size() || (pc < 0x60 && pc > 0) {
4898                eprintln!(
4899                    "[RUN] CRITICAL: PC jumped to invalid address ${:08X}! Halting trace.",
4900                    pc
4901                );
4902                self.dump_trace();
4903                return Ok(());
4904            }
4905
4906            // Trace: Push current PC/Opcode/Regs (gated on env var).
4907            if trace_buffer_enabled() {
4908                let opcode = self.bus.read_word(pc);
4909                let a0 = self.cpu.read_reg(Register::A0);
4910                let sp = self.cpu.read_reg(Register::A7);
4911                let a6 = self.cpu.read_reg(Register::A6);
4912                let a5 = self.cpu.read_reg(Register::A5);
4913                if self.trace_buffer.len() >= 200 {
4914                    self.trace_buffer.pop_front();
4915                }
4916                self.trace_buffer.push_back((pc, opcode, a0, sp, a6, a5));
4917            }
4918
4919            match self.cpu.step(&mut self.bus) {
4920                StepResult::Ok => {}
4921                StepResult::Stopped => {
4922                    if trace_load_enabled() {
4923                        let stopped_pc = self.cpu.read_reg(Register::PC);
4924                        let opcode = self.bus.read_word(stopped_pc);
4925                        eprintln!(
4926                            "[RUN] Step returned Stopped after {} instructions, PC=${:08X}, Opcode=${:04X}",
4927                            count, stopped_pc, opcode
4928                        );
4929                    }
4930                    self.dump_trace();
4931                    return Ok(());
4932                }
4933                StepResult::Aline(opcode) => {
4934                    match self
4935                        .dispatcher
4936                        .dispatch(opcode, &mut self.cpu, &mut self.bus)
4937                    {
4938                        Ok(()) => {
4939                            // Smart PC Advance:
4940                            // Only advance PC if the trap didn't change it
4941                            // (auto-pop traps set PC to return address)
4942                            let pc_after = self.cpu.read_reg(Register::PC);
4943                            if pc_after == pc {
4944                                self.cpu.write_reg(Register::PC, pc + 2);
4945                            }
4946
4947                            // Log traps to stderr, but don't dump trace unless it's suspicious
4948                            // eprintln!("[RUN] Trap ${:04X} handled...", opcode);
4949                        }
4950                        Err(Error::Halted) => {
4951                            if trace_load_enabled() {
4952                                eprintln!("[RUN] Halted via trap after {} instructions", count);
4953                            }
4954                            self.dump_trace();
4955                            return Ok(());
4956                        }
4957                        Err(e) => {
4958                            self.dump_trace();
4959                            return Err(e);
4960                        }
4961                    }
4962                }
4963            }
4964            count += 1;
4965        }
4966        if trace_load_enabled() {
4967            eprintln!("[RUN] Timeout after {} instructions", count);
4968        }
4969        self.dump_trace();
4970        Err(Error::Timeout(count))
4971    }
4972
4973    /// Walk the trace_buffer (most-recent first) and return the first
4974    /// PC that decode_fakeptr_pc recognises, plus its hint. Used by
4975    /// the halt log to surface drifted PCs that landed in unmapped
4976    /// memory after a JSR through a GetTrapAddress fakeptr — the
4977    /// halted PC itself can be 0x1000+ bytes past the original entry,
4978    /// well outside the documented fakeptr range, so a direct decode
4979    /// of the halted PC misses it. The trace_buffer is opt-in via
4980    /// SYSTEMLESS_TRACE_BUFFER=1; without it this scan returns None.
4981    fn trace_find_fakeptr_entry(&self) -> Option<(u32, String)> {
4982        for (pc, _op, _a0, _sp, _a6, _a5) in self.trace_buffer.iter().rev() {
4983            if let Some(hint) = decode_fakeptr_pc(*pc) {
4984                return Some((*pc, hint));
4985            }
4986        }
4987        None
4988    }
4989
4990    /// Print the last N executed instructions to stderr in PC/Op/Reg
4991    /// form. Used by halt paths in `run` / `run_steps_internal` to
4992    /// surface the run-up to a crash. Early-exits when the trace
4993    /// buffer is empty (the default — `SYSTEMLESS_TRACE_BUFFER=1`
4994    /// must be set to populate the buffer in the first place).
4995    pub fn dump_trace(&self) {
4996        if self.trace_buffer.is_empty() {
4997            return;
4998        }
4999        eprintln!(
5000            "[TRACE] Last {} executed instructions:",
5001            self.trace_buffer.len()
5002        );
5003        eprintln!("  PC        Op    A0       SP       A6       D0");
5004        for (pc, opcode, a0, sp, a6, d0) in &self.trace_buffer {
5005            eprintln!(
5006                "  {:08X}  {:04X}  {:08X} {:08X} {:08X} {:08X}",
5007                pc, opcode, a0, sp, a6, d0
5008            );
5009        }
5010    }
5011}
5012
5013/// Dump the diagnostic histograms when the runner is dropped. Each
5014/// `print_*_histogram` already early-returns when its env-var gate
5015/// isn't set, so this is a no-op for normal runs (including tests).
5016/// Investigate interactive-mode behavior with
5017/// `SYSTEMLESS_TRACE_TRAP_COUNTS=1`, `SYSTEMLESS_TRACE_OPCODE_COUNTS=1`,
5018/// `SYSTEMLESS_TRACE_HOT_PC=1`, or `SYSTEMLESS_TRACE_TRAP_TIMING=1`.
5019impl Drop for FixtureRunner {
5020    fn drop(&mut self) {
5021        self.dispatcher.print_trap_histogram(40);
5022        self.print_opcode_histogram(40);
5023        self.print_pc_histogram(40);
5024        self.dispatcher.print_trap_timing_histogram(40);
5025    }
5026}
5027
5028// =============================================================================
5029// Loader Implementation
5030// =============================================================================
5031
5032fn load_app_generic<M: MemoryBus>(
5033    fork: &ResourceFork,
5034    bus: &mut M,
5035    load_address: u32,
5036) -> Option<LoadedApp> {
5037    // 1. Load CODE 0 Header
5038    let code0 = fork.get_code(0)?;
5039    let header = Code0Header::parse(&code0.data)?;
5040    if trace_load_enabled() {
5041        eprintln!(
5042            "[LOAD] CODE 0 header: above_a5={}, below_a5={}, jt_size={}, jt_offset={}",
5043            header.above_a5, header.below_a5, header.jump_table_size, header.jump_table_offset
5044        );
5045    }
5046
5047    let a5_base = load_address + header.below_a5;
5048    // For classic Mac apps, above_a5 defines the space needed above A5.
5049    // However, some apps place QuickDraw globals at higher offsets (e.g., A5+39KB).
5050    // Add 48KB reserve to accommodate most classic apps.
5051    let qd_globals_reserve = 48 * 1024; // 48KB reserve for QD globals
5052    let globals_end = a5_base + header.above_a5 + qd_globals_reserve;
5053
5054    // Clear A5 world
5055    let globals_zero_end = globals_end + 0x40000;
5056    bus.fill_zeros(load_address, globals_zero_end.saturating_sub(load_address));
5057    bus.write_long(0x0904, a5_base); // CurrentA5
5058    bus.write_word(0x0934, header.jump_table_offset as u16); // CurJTOffset - Inside Macintosh Volume II, II-62
5059    bus.write_word(0x028E, 0x0000); // ROM85
5060
5061    // Write RTS stubs at low-memory jump vectors that some runtimes
5062    // (Think C, CodeWarrior) call directly instead of via A-line traps.
5063    // On a real Mac, these contain ROM routine addresses. In our HLE,
5064    // we place RTS instructions so JSRs to these addresses return safely.
5065    // Only cover $0060-$00FF to avoid corrupting system globals in $0100+
5066    // (e.g., $012D is a debugger presence flag that must remain 0).
5067    // The CRT's relocation pass will populate the real runtime trampolines.
5068    for addr in (0x0060..0x0100).step_by(2) {
5069        bus.write_word(addr, 0x4E75); // RTS
5070    }
5071
5072    // Install default RTE stubs for the "post-instruction" exception
5073    // vectors that real Mac OS would route to SysError. Because these
5074    // exceptions all stack the PC of the *next* instruction (per
5075    // M68000PRM, "Group 2 — internal" — vectors 5/6/7 advance PC past
5076    // the offending op before taking the trap), an RTE simply resumes
5077    // execution at the next instruction without re-entering the fault.
5078    // Inside Macintosh Volume I, I-103 (Exception Vector Table).
5079    //
5080    //   vector 5 ($14): Zero Divide  — DIVU/DIVS with src == 0
5081    //   vector 6 ($18): CHK          — bounds-check trap
5082    //   vector 7 ($1C): TRAPV        — programmed overflow trap
5083    //
5084    // Bus error (vector 2) and address error (vector 3) are deliberately
5085    // NOT installed: they stack PPC (the start of the faulting
5086    // instruction), so RTE-ing would re-execute it and loop forever.
5087    // Properly handling those requires a skip-the-instruction stub
5088    // which is a separate undertaking.
5089    bus.write_word(0x00FE, 0x4E73); // RTE
5090    bus.write_long(0x0014, 0x0000_00FE); // ZeroDivide vector
5091    bus.write_long(0x0018, 0x0000_00FE); // CHK vector
5092    bus.write_long(0x001C, 0x0000_00FE); // TRAPV vector
5093
5094    // Load DATA 0 into A5 world (initialized globals)
5095    // DATA goes below A5 at address (A5 - below_a5) = load_address
5096    if let Some(data) = fork.get(*b"DATA", 0) {
5097        // DATA resource starts at offset 0 from load_address and fills up to A5
5098        let data_dest = load_address;
5099        if trace_load_enabled() {
5100            eprintln!(
5101                "[LOAD] Writing DATA 0 ({} bytes) to ${:08X}",
5102                data.data.len(),
5103                data_dest
5104            );
5105        }
5106        bus.write_bytes(data_dest, &data.data);
5107    }
5108
5109    // 2. Parse Jump Table from CODE 0
5110    let mut jump_table = Vec::new();
5111    let jt_data = &code0.data[16..];
5112
5113    for i in 0..header.num_entries() {
5114        let entry_offset = i * 8;
5115        if entry_offset + 8 > jt_data.len() {
5116            break;
5117        }
5118
5119        let word_2_3 = u16::from_be_bytes([jt_data[entry_offset + 2], jt_data[entry_offset + 3]]);
5120        let (offset, segment) = if word_2_3 == 0xA9F0 {
5121            // FAR Format
5122            let seg = i16::from_be_bytes([jt_data[entry_offset], jt_data[entry_offset + 1]]);
5123            let off = u16::from_be_bytes([jt_data[entry_offset + 6], jt_data[entry_offset + 7]]);
5124            (off, seg)
5125        } else if word_2_3 == 0xFFFF {
5126            // NULL
5127            (0u16, 0i16)
5128        } else {
5129            // NEAR Format
5130            let off = u16::from_be_bytes([jt_data[entry_offset], jt_data[entry_offset + 1]]);
5131            let seg = i16::from_be_bytes([jt_data[entry_offset + 4], jt_data[entry_offset + 5]]);
5132            (off, seg)
5133        };
5134
5135        jump_table.push(JumpTableEntry {
5136            offset,
5137            segment,
5138            loaded: false,
5139            address: 0,
5140        });
5141        if trace_load_enabled() {
5142            eprintln!(
5143                "[LOAD] Parsed JT[{}]: segment={}, offset=0x{:04X}, raw=[{:02X} {:02X} {:02X} {:02X} {:02X} {:02X} {:02X} {:02X}]",
5144                i, segment, offset,
5145                jt_data[entry_offset], jt_data[entry_offset+1],
5146                jt_data[entry_offset+2], jt_data[entry_offset+3],
5147                jt_data[entry_offset+4], jt_data[entry_offset+5],
5148                jt_data[entry_offset+6], jt_data[entry_offset+7]
5149            );
5150        }
5151    }
5152
5153    // 3. Setup Layout
5154    let code0_base = globals_end;
5155    let code0_size = code0.data.len() as u32;
5156    let code0_user = code0_base + 4;
5157    let jt_base = a5_base + header.jump_table_offset;
5158    if trace_load_enabled() {
5159        eprintln!("[LOAD] Memory layout: a5_base=${:08X}, globals_end=${:08X}, code0_user=${:08X}, code0_size={}, jt_base=${:08X} (jt_offset={})",
5160                  a5_base, globals_end, code0_user, code0_size, jt_base, header.jump_table_offset);
5161    }
5162
5163    // 4. Load CODE 0 (Resident)
5164    bus.write_long(code0_base, code0_size);
5165    bus.write_bytes(code0_user, &code0.data);
5166
5167    // Copy the JT data from CODE 0 to the actual JT area at A5+jt_offset.
5168    // On a real Mac, the system writes CODE 0's JT content (bytes after the
5169    // 16-byte header) to A5+CurJTOffset. This populates the initial JT
5170    // entries with unloaded-format stubs (offset, MOVE.W #seg, _LoadSeg).
5171    // Inside Macintosh Volume II, II-60
5172    let jt_content = &code0.data[16..];
5173    if !jt_content.is_empty() {
5174        bus.write_bytes(jt_base, jt_content);
5175    }
5176
5177    // 5. Load all other CODE resources
5178    let mut segment_bases = HashMap::new();
5179    segment_bases.insert(0, code0_user);
5180    crate::trap::dispatch::record_segment_base(0, code0_user);
5181
5182    // Load CODE segments into memory but do NOT pre-patch jump table entries.
5183    // Think C / CodeWarrior apps populate the JT at runtime via their startup
5184    // code (crt0). Our LoadSeg trap handler patches entries on demand when
5185    // segments are first called, matching real Mac Segment Loader behavior.
5186    // Inside Macintosh Volume II, II-60; Executor segment.cpp
5187    //
5188    // Reserve space: scan CODE headers to find max JT extent so CODE segments
5189    // are placed above the JT area.
5190    let mut max_jt_end: u32 = jt_base + (jump_table.len() as u32 * 8);
5191    let mut all_codes = fork.get_all_code();
5192    all_codes.sort_by_key(|c| c.id);
5193
5194    for code_res in &all_codes {
5195        if code_res.id == 0 || code_res.data.len() < 4 {
5196            continue;
5197        }
5198        let Some(segment_header) = CodeSegmentHeader::parse(&code_res.data) else {
5199            continue;
5200        };
5201        let Some(tab_off) = segment_header.jump_table_start_offset() else {
5202            continue;
5203        };
5204        let Some(n_entries) = segment_header.jump_table_entry_count() else {
5205            continue;
5206        };
5207        let end = jt_base + tab_off + n_entries * 8;
5208        if end > max_jt_end {
5209            max_jt_end = end;
5210        }
5211
5212        // Pre-populate unloaded-JT-entry stubs for entries this segment
5213        // owns that are still ALL ZERO (i.e. not yet populated by CODE 0's
5214        // jt_content write). Per Inside Macintosh Volume II, II-60, an
5215        // unloaded entry is `offset(2) + \$3F3C(2) + seg(2) + \$A9F0(2)`
5216        // — JSR-ing to entry+2 fires LoadSeg via the trap. Real System 7
5217        // writes these stubs at app-launch time; without them, segments
5218        // not represented in CODE 0's jt_content stay zeroed, so a
5219        // guest JSR-through-JT walks zeros (or falls into the next
5220        // patched entry) and faults (Centaurian 1.2.1 hits this).
5221        //
5222        // Skip entries with non-zero content — they've already been
5223        // initialised by CODE 0's load (as stubs) or pre-patched as
5224        // loaded JMP.L. Stomping either of those would break MPW-style
5225        // fixtures where CODE 0 carries the canonical layout.
5226        for i in 0..n_entries {
5227            let entry = jt_base + tab_off + i * 8;
5228            let is_empty = bus.read_long(entry) == 0 && bus.read_long(entry + 4) == 0;
5229            let is_null_placeholder = bus.read_word(entry + 2) == 0xFFFF;
5230            if !is_empty && !is_null_placeholder {
5231                continue;
5232            }
5233            if is_null_placeholder {
5234                // Some near-model apps leave segment-owned CODE 0 entries as
5235                // `offset, FFFF, FFFF, FFFF` placeholders and call the slot at
5236                // entry+0. Materialize a Think-style unload stub so that first
5237                // call enters LoadSeg instead of executing the placeholder.
5238                let routine_offset = bus.read_word(entry);
5239                bus.write_word(entry, 0xA9F0);
5240                bus.write_word(entry + 2, 0);
5241                bus.write_word(entry + 4, routine_offset);
5242                bus.write_word(entry + 6, code_res.id as u16);
5243            } else {
5244                bus.write_word(entry, 0);
5245                bus.write_word(entry + 2, 0x3F3C);
5246                bus.write_word(entry + 4, code_res.id as u16);
5247                bus.write_word(entry + 6, 0xA9F0);
5248            }
5249        }
5250    }
5251
5252    let reserved_boundary = std::cmp::max(code0_user + code0_size, max_jt_end);
5253    let mut current_load_ptr = (reserved_boundary + 4) & !3;
5254
5255    for code_res in all_codes {
5256        if code_res.id == 0 {
5257            continue;
5258        }
5259
5260        let size = code_res.data.len() as u32;
5261        let phys_addr = current_load_ptr;
5262        let user_addr = current_load_ptr + 4;
5263
5264        // Dump segment header info
5265        let segment_header = CodeSegmentHeader::parse(&code_res.data);
5266        let hdr_info = match segment_header {
5267            Some(CodeSegmentHeader::MpwFar) => "mpw-far-model".to_string(),
5268            Some(CodeSegmentHeader::Near {
5269                table_offset,
5270                entry_count,
5271            }) => format!("near-model taboff={} n={}", table_offset, entry_count),
5272            Some(CodeSegmentHeader::ThinkFar {
5273                has_relocations,
5274                first_entry_index,
5275                entry_count,
5276            }) => format!(
5277                "think-far-model first_jt={} n={} relocs={}",
5278                first_entry_index, entry_count, has_relocations
5279            ),
5280            None => "unknown".to_string(),
5281        };
5282        if trace_load_enabled() {
5283            eprintln!(
5284                "[LOAD] Loading CODE {} ({} bytes) to ${:08X} [{}]",
5285                code_res.id, size, user_addr, hdr_info
5286            );
5287        }
5288
5289        bus.write_long(phys_addr, size);
5290        bus.write_bytes(user_addr, &code_res.data);
5291
5292        segment_bases.insert(code_res.id, user_addr);
5293        crate::trap::dispatch::record_segment_base(code_res.id, user_addr);
5294
5295        // Only patch JT for CODE 0's entries (far-model segments from the
5296        // original CODE 0 parse). Near-model segments get their JT entries
5297        // populated by the app's startup code and patched by LoadSeg.
5298        if matches!(segment_header, Some(CodeSegmentHeader::MpwFar)) {
5299            // Far-model CODE segment (40-byte header)
5300            let header_size = 40u32;
5301            for (i, entry) in jump_table.iter_mut().enumerate() {
5302                if entry.segment == code_res.id {
5303                    entry.loaded = true;
5304                    let effective_offset = entry.offset as u32;
5305                    entry.address = user_addr + header_size + effective_offset;
5306
5307                    let jt_addr = jt_base + (i as u32 * 8);
5308                    bus.write_word(jt_addr, code_res.id as u16);
5309                    bus.write_word(jt_addr + 2, 0x4EF9); // JMP
5310                    bus.write_long(jt_addr + 4, entry.address);
5311                    if trace_load_enabled() {
5312                        eprintln!(
5313                            "[LOAD] JT[{}] -> CODE {} @ ${:08X} (far-model, off=${:04X})",
5314                            i, code_res.id, entry.address, effective_offset
5315                        );
5316                    }
5317                }
5318            }
5319        }
5320
5321        current_load_ptr = (user_addr + size + 4 + 3) & !3;
5322    }
5323
5324    let size_resource = fork
5325        .get(*b"SIZE", -1)
5326        .and_then(|res| ApplicationSizeResource::parse(&res.data));
5327    let loaded_image_end = align4(globals_zero_end.max(current_load_ptr));
5328    if trace_load_enabled() {
5329        eprintln!("[LOAD] Loaded image end=${:08X}", loaded_image_end);
5330    }
5331
5332    // Stack at top of RAM
5333    let stack_top = bus.ram_size() - 16;
5334
5335    Some(LoadedApp {
5336        code0_header: header,
5337        a5_base,
5338        jump_table,
5339        segment_bases,
5340        loaded_image_end,
5341        initial_sp: stack_top,
5342        size_resource,
5343    })
5344}
5345
5346#[cfg(test)]
5347mod tests {
5348    use super::*;
5349    use crate::audio::AudioBackend;
5350    use crate::loader::{ApplicationSizeResource, Code0Header, LoadedApp};
5351    use crate::sound::{
5352        DoubleBufferState, PendingDoubleBackCallback, PendingSoundCallback, PlaybackKind,
5353        SndChannel, SndCommand, OUTPUT_RATE,
5354    };
5355    use crate::trap::dispatch::{
5356        DialogItem, DialogTrackingState, PendingWaitNextEventReturn, QueuedEvent, TimerTask,
5357        VblTask,
5358    };
5359    use std::cell::RefCell;
5360    use std::collections::{HashMap, VecDeque};
5361    use std::rc::Rc;
5362
5363    #[derive(Clone, Default)]
5364    struct CapturingAudioBackend {
5365        stereo_samples: Rc<RefCell<Vec<u8>>>,
5366    }
5367
5368    impl CapturingAudioBackend {
5369        fn new() -> (Self, Rc<RefCell<Vec<u8>>>) {
5370            let stereo_samples = Rc::new(RefCell::new(Vec::new()));
5371            (
5372                Self {
5373                    stereo_samples: stereo_samples.clone(),
5374                },
5375                stereo_samples,
5376            )
5377        }
5378    }
5379
5380    impl AudioBackend for CapturingAudioBackend {
5381        fn queue_samples(&mut self, samples: &[u8]) {
5382            self.stereo_samples.borrow_mut().extend(samples);
5383        }
5384
5385        fn queue_stereo_samples(&mut self, samples: &[u8]) {
5386            self.stereo_samples.borrow_mut().extend(samples);
5387        }
5388
5389        fn stop(&mut self) {}
5390    }
5391
5392    fn test_region_handle(
5393        bus: &mut crate::memory::MacMemoryBus,
5394        top: i16,
5395        left: i16,
5396        bottom: i16,
5397        right: i16,
5398    ) -> u32 {
5399        let rgn_ptr = 0x0030_0100;
5400        let rgn_handle = 0x0030_0140;
5401        bus.write_long(rgn_handle, rgn_ptr);
5402        bus.write_word(rgn_ptr, 10);
5403        bus.write_word(rgn_ptr + 2, top as u16);
5404        bus.write_word(rgn_ptr + 4, left as u16);
5405        bus.write_word(rgn_ptr + 6, bottom as u16);
5406        bus.write_word(rgn_ptr + 8, right as u16);
5407        rgn_handle
5408    }
5409
5410    #[test]
5411    fn vfs_file_snapshot_round_trips_both_forks_and_metadata() {
5412        let mut runner = FixtureRunner::new(8 * 1024 * 1024, FixtureRunnerConfig::default());
5413        runner
5414            .dispatcher
5415            .vfs
5416            .insert("Pilots/Test Pilot".to_string(), vec![1, 2, 3]);
5417        runner
5418            .dispatcher
5419            .vfs_rsrc
5420            .insert("Pilots/Test Pilot".to_string(), vec![4, 5, 6, 7]);
5421        runner
5422            .dispatcher
5423            .vfs
5424            .insert("__rsrc__Pilots/Test Pilot".to_string(), vec![0xEE]);
5425        runner
5426            .dispatcher
5427            .vfs
5428            .insert("Game Data/Shapes".to_string(), vec![0xAA; 1024]);
5429        runner
5430            .dispatcher
5431            .set_vfs_entry_metadata("Pilots/Test Pilot", *b"PIL ", *b"EVO!", 0x4000);
5432        runner
5433            .dispatcher
5434            .set_vfs_entry_metadata("Game Data/Shapes", *b"shap", *b"26.2", 0);
5435
5436        let summaries = runner.vfs_file_summaries();
5437        assert_eq!(summaries.len(), 2);
5438        assert!(summaries
5439            .iter()
5440            .any(|summary| summary.path == "Game Data/Shapes"));
5441
5442        let summaries = runner.vfs_file_summaries_where(|path| path.starts_with("Pilots/"));
5443        assert_eq!(summaries.len(), 1);
5444        assert_eq!(summaries[0].path, "Pilots/Test Pilot");
5445        assert_eq!(summaries[0].data_len, 3);
5446        assert_eq!(summaries[0].resource_len, 4);
5447        assert_eq!(summaries[0].file_type, u32::from_be_bytes(*b"PIL "));
5448        assert_eq!(summaries[0].creator, u32::from_be_bytes(*b"EVO!"));
5449
5450        let snapshot = runner
5451            .vfs_file_snapshot("Pilots/Test Pilot")
5452            .expect("snapshot");
5453        assert_eq!(snapshot.data_fork, vec![1, 2, 3]);
5454        assert_eq!(snapshot.resource_fork, vec![4, 5, 6, 7]);
5455        assert_eq!(snapshot.finder_flags, 0x4000);
5456
5457        let mut restored = FixtureRunner::new(8 * 1024 * 1024, FixtureRunnerConfig::default());
5458        restored.import_vfs_file(&snapshot);
5459        assert_eq!(
5460            restored.vfs_file_snapshot("Pilots/Test Pilot"),
5461            Some(snapshot)
5462        );
5463
5464        assert!(restored.remove_vfs_file("Pilots/Test Pilot"));
5465        assert_eq!(restored.vfs_file_snapshot("Pilots/Test Pilot"), None);
5466    }
5467
5468    #[test]
5469    fn import_vfs_file_relative_to_launched_app_mounts_under_app_parent() {
5470        let mut runner = FixtureRunner::new(8 * 1024 * 1024, FixtureRunnerConfig::default());
5471        runner
5472            .dispatcher
5473            .set_launched_app_path("EV Override/EV Override");
5474        let plugin = VfsFileSnapshot {
5475            path: "Warblade".to_string(),
5476            data_fork: Vec::new(),
5477            resource_fork: vec![1, 2, 3, 4],
5478            file_type: u32::from_be_bytes(*b"Op.f"),
5479            creator: u32::from_be_bytes(*b"Es.O"),
5480            finder_flags: 0x4000,
5481            created_date: 123,
5482            modified_date: 456,
5483        };
5484
5485        runner
5486            .import_vfs_file_relative_to_launched_app("EV Plug-Ins", &plugin)
5487            .expect("relative plugin import");
5488
5489        let mounted = runner
5490            .vfs_file_snapshot("EV Override/EV Plug-Ins/Warblade")
5491            .expect("mounted plugin snapshot");
5492        assert_eq!(mounted.resource_fork, plugin.resource_fork);
5493        assert_eq!(mounted.file_type, plugin.file_type);
5494        assert_eq!(mounted.creator, plugin.creator);
5495        assert_eq!(mounted.finder_flags, plugin.finder_flags);
5496
5497        let parent_dir_id = runner
5498            .dispatcher
5499            .vfs_metadata
5500            .get("EV Override/EV Plug-Ins/Warblade")
5501            .expect("plugin metadata")
5502            .parent_dir_id;
5503        let entries = runner.dispatcher.list_vfs_catalog_entries(parent_dir_id);
5504        assert!(entries
5505            .iter()
5506            .any(|entry| !entry.is_directory && entry.name == "Warblade"));
5507    }
5508
5509    fn make_resource_fork_bytes(resources: &[([u8; 4], i16, &[u8])]) -> Vec<u8> {
5510        let mut type_groups: Vec<([u8; 4], Vec<(i16, &[u8], u32)>)> = Vec::new();
5511        for (res_type, res_id, data) in resources {
5512            let group_idx = type_groups
5513                .iter()
5514                .position(|(existing_type, _)| existing_type == res_type)
5515                .unwrap_or_else(|| {
5516                    type_groups.push((*res_type, Vec::new()));
5517                    type_groups.len() - 1
5518                });
5519            type_groups[group_idx].1.push((*res_id, *data, 0));
5520        }
5521        type_groups.sort_by_key(|(res_type, _)| *res_type);
5522        for (_, entries) in &mut type_groups {
5523            entries.sort_by_key(|(res_id, _, _)| *res_id);
5524        }
5525
5526        let data_offset = 16u32;
5527        let mut data_section = Vec::new();
5528        for (_, entries) in &mut type_groups {
5529            for (_, data, data_pos) in entries {
5530                *data_pos = data_section.len() as u32;
5531                data_section.extend_from_slice(&(data.len() as u32).to_be_bytes());
5532                data_section.extend_from_slice(data);
5533            }
5534        }
5535
5536        let map_offset = data_offset + data_section.len() as u32;
5537        let type_list_offset = 30u16;
5538        let type_count = type_groups.len();
5539        let resource_count: usize = type_groups.iter().map(|(_, entries)| entries.len()).sum();
5540        let ref_lists_offset = 2 + type_count * 8;
5541        let name_list_offset = type_list_offset as usize + ref_lists_offset + resource_count * 12;
5542        let map_length = name_list_offset as u32;
5543
5544        let mut bytes = vec![0u8; (map_offset + map_length) as usize];
5545        let mut header = [0u8; 16];
5546        header[0..4].copy_from_slice(&data_offset.to_be_bytes());
5547        header[4..8].copy_from_slice(&map_offset.to_be_bytes());
5548        header[8..12].copy_from_slice(&(data_section.len() as u32).to_be_bytes());
5549        header[12..16].copy_from_slice(&map_length.to_be_bytes());
5550        bytes[0..16].copy_from_slice(&header);
5551        bytes[data_offset as usize..data_offset as usize + data_section.len()]
5552            .copy_from_slice(&data_section);
5553
5554        let map_start = map_offset as usize;
5555        bytes[map_start..map_start + 16].copy_from_slice(&header);
5556        bytes[map_start + 24..map_start + 26].copy_from_slice(&type_list_offset.to_be_bytes());
5557        bytes[map_start + 26..map_start + 28]
5558            .copy_from_slice(&(name_list_offset as u16).to_be_bytes());
5559        bytes[map_start + 28..map_start + 30]
5560            .copy_from_slice(&((type_count as u16) - 1).to_be_bytes());
5561
5562        let type_list_start = map_start + type_list_offset as usize;
5563        bytes[type_list_start..type_list_start + 2]
5564            .copy_from_slice(&((type_count as u16) - 1).to_be_bytes());
5565        let mut next_ref_list_offset = ref_lists_offset;
5566        for (i, (res_type, entries)) in type_groups.iter().enumerate() {
5567            let type_entry = type_list_start + 2 + i * 8;
5568            bytes[type_entry..type_entry + 4].copy_from_slice(res_type);
5569            bytes[type_entry + 4..type_entry + 6]
5570                .copy_from_slice(&((entries.len() as u16) - 1).to_be_bytes());
5571            bytes[type_entry + 6..type_entry + 8]
5572                .copy_from_slice(&(next_ref_list_offset as u16).to_be_bytes());
5573
5574            let ref_list_start = type_list_start + next_ref_list_offset;
5575            for (j, (res_id, _, data_pos)) in entries.iter().enumerate() {
5576                let ref_entry = ref_list_start + j * 12;
5577                bytes[ref_entry..ref_entry + 2].copy_from_slice(&(*res_id as u16).to_be_bytes());
5578                bytes[ref_entry + 2..ref_entry + 4].copy_from_slice(&0xFFFFu16.to_be_bytes());
5579                bytes[ref_entry + 4] = 0;
5580                let data_offset_bytes = data_pos.to_be_bytes();
5581                bytes[ref_entry + 5..ref_entry + 8].copy_from_slice(&data_offset_bytes[1..4]);
5582            }
5583
5584            next_ref_list_offset += entries.len() * 12;
5585        }
5586
5587        bytes
5588    }
5589
5590    fn minimal_code0(above_a5: u32, below_a5: u32, jt_size: u32, jt_offset: u32) -> Vec<u8> {
5591        let mut code0 = Vec::with_capacity(16 + jt_size as usize);
5592        code0.extend_from_slice(&above_a5.to_be_bytes());
5593        code0.extend_from_slice(&below_a5.to_be_bytes());
5594        code0.extend_from_slice(&jt_size.to_be_bytes());
5595        code0.extend_from_slice(&jt_offset.to_be_bytes());
5596        code0.resize(16 + jt_size as usize, 0);
5597        code0
5598    }
5599
5600    #[test]
5601    fn init_app_preserves_resources_allocated_before_zone_header() {
5602        let code0 = minimal_code0(0, 0x2000, 0, 0);
5603        let bgas = [0x4E, 0x56, 0xFF, 0xA6, 0x2D, 0x7A, 0x1C, 0x72];
5604        let fork_bytes = make_resource_fork_bytes(&[(*b"BGAS", 128, &bgas), (*b"CODE", 0, &code0)]);
5605        let fork = ResourceFork::parse(&fork_bytes).expect("parse synthetic app fork");
5606        let mut runner = FixtureRunner::new(8 * 1024 * 1024, FixtureRunnerConfig::default());
5607
5608        let app = runner.load_app(&fork).expect("load app");
5609        let (_, bgas_ptr) = runner
5610            .dispatcher
5611            .find_resource_any(*b"BGAS", 128)
5612            .expect("BGAS resource loaded");
5613        assert_eq!(runner.bus.read_bytes(bgas_ptr, bgas.len()), bgas);
5614
5615        runner.init_app(&app);
5616
5617        assert_eq!(
5618            runner.bus.read_bytes(bgas_ptr, bgas.len()),
5619            bgas,
5620            "init_app must not overwrite resources loaded before zone setup"
5621        );
5622    }
5623
5624    #[test]
5625    fn load_app_places_resources_above_large_loaded_image() {
5626        use crate::memory::globals::addr;
5627
5628        let code0 = minimal_code0(0x001D_0000, 0x0340, 0, 0);
5629        let marker = [0xCA, 0xFE, 0xBA, 0xBE, 0x12, 0x34, 0x56, 0x78];
5630        let fork_bytes =
5631            make_resource_fork_bytes(&[(*b"BGAS", 128, &marker), (*b"CODE", 0, &code0)]);
5632        let fork = ResourceFork::parse(&fork_bytes).expect("parse synthetic app fork");
5633        let mut runner = FixtureRunner::new(8 * 1024 * 1024, FixtureRunnerConfig::default());
5634
5635        let app = runner.load_app(&fork).expect("load app");
5636        assert!(
5637            app.loaded_image_end > APP_HEAP_FLOOR,
5638            "fixture should force the loaded image across the default heap floor"
5639        );
5640
5641        let heap_start = app_heap_start_for_loaded_app(&app);
5642        let (_, marker_ptr) = runner
5643            .dispatcher
5644            .find_resource_any(*b"BGAS", 128)
5645            .expect("BGAS resource loaded");
5646        assert!(
5647            marker_ptr >= heap_start + APP_ZONE_HEADER_SIZE,
5648            "resource data must be allocated after the relocated zone header"
5649        );
5650        assert_eq!(runner.bus.read_bytes(marker_ptr, marker.len()), marker);
5651
5652        runner.init_app(&app);
5653
5654        assert_eq!(runner.bus.read_long(addr::APP_L_ZONE), heap_start);
5655        assert_eq!(
5656            runner.bus.read_long(addr::HEAP_END),
5657            heap_start + APP_ZONE_HEADER_SIZE
5658        );
5659        assert_eq!(
5660            runner.bus.read_bytes(marker_ptr, marker.len()),
5661            marker,
5662            "launch initialization must not clobber resources for large loaded images"
5663        );
5664    }
5665
5666    #[test]
5667    fn load_app_records_application_size_resource_id_minus_one() {
5668        let code0 = minimal_code0(0, 0x2000, 0, 0);
5669        let size = [
5670            0x00, 0x80, // mode32BitCompatible
5671            0x00, 0x30, 0x00, 0x00, // preferred partition
5672            0x00, 0x20, 0x00, 0x00, // minimum partition
5673        ];
5674        let fork_bytes = make_resource_fork_bytes(&[(*b"CODE", 0, &code0), (*b"SIZE", -1, &size)]);
5675        let fork = ResourceFork::parse(&fork_bytes).expect("parse synthetic app fork");
5676        let mut runner = FixtureRunner::new(8 * 1024 * 1024, FixtureRunnerConfig::default());
5677
5678        let app = runner.load_app(&fork).expect("load app");
5679
5680        assert_eq!(
5681            app.size_resource,
5682            Some(ApplicationSizeResource {
5683                flags: 0x0080,
5684                preferred_size: 0x0030_0000,
5685                minimum_size: 0x0020_0000,
5686            })
5687        );
5688    }
5689
5690    #[test]
5691    fn event_yield_services_pending_launch_application_from_vfs() {
5692        use crate::memory::globals::addr;
5693
5694        let current_code0 = minimal_code0(0, 0x2000, 0, 0);
5695        let helper_code0 = minimal_code0(0, 0x2000, 0, 0);
5696        let current_fork_bytes = make_resource_fork_bytes(&[(*b"CODE", 0, &current_code0)]);
5697        let helper_fork_bytes = make_resource_fork_bytes(&[(*b"CODE", 0, &helper_code0)]);
5698        let current_fork =
5699            ResourceFork::parse(&current_fork_bytes).expect("parse current app fork");
5700        let mut runner = FixtureRunner::new(8 * 1024 * 1024, FixtureRunnerConfig::default());
5701
5702        runner
5703            .dispatcher
5704            .vfs
5705            .insert("Apps/Main App".to_string(), Vec::new());
5706        runner
5707            .dispatcher
5708            .vfs_rsrc
5709            .insert("Apps/Main App".to_string(), current_fork_bytes);
5710        runner
5711            .dispatcher
5712            .vfs
5713            .insert("Apps/Register Helper".to_string(), Vec::new());
5714        runner
5715            .dispatcher
5716            .vfs_rsrc
5717            .insert("Apps/Register Helper".to_string(), helper_fork_bytes);
5718        runner.dispatcher.ensure_vfs_catalog();
5719        runner.dispatcher.set_launched_app_path("Apps/Main App");
5720
5721        let app = runner.load_app(&current_fork).expect("load current app");
5722        runner.init_app(&app);
5723        runner.bus.write_long(addr::TICKS, 1234);
5724        runner.dispatcher.tick_count = 1234;
5725        runner
5726            .dispatcher
5727            .queue_pending_launch_application("Apps/Register Helper", true);
5728
5729        assert!(
5730            !runner.service_pending_launch_application(false),
5731            "launchContinue target must wait for an Event Manager yield"
5732        );
5733        let switched = runner.service_pending_launch_application(true);
5734
5735        assert!(
5736            switched,
5737            "event yield should service the queued helper launch"
5738        );
5739        assert!(
5740            !runner.is_halted(),
5741            "queued helper launch should not halt the runner"
5742        );
5743        assert_eq!(
5744            runner.dispatcher.launched_app_path.as_deref(),
5745            Some("Apps/Register Helper")
5746        );
5747        assert_eq!(
5748            runner.bus.read_long(addr::TICKS),
5749            1234,
5750            "Process Manager launch must preserve system TickCount"
5751        );
5752        let cur_ap_len = runner.bus.read_byte(addr::CUR_APNAME) as usize;
5753        let cur_ap_name = String::from_utf8(
5754            (0..cur_ap_len)
5755                .map(|i| runner.bus.read_byte(addr::CUR_APNAME + 1 + i as u32))
5756                .collect(),
5757        )
5758        .expect("CurApName is ASCII");
5759        assert_eq!(cur_ap_name, "Register Helper");
5760        assert!(
5761            runner.dispatcher.vfs.contains_key("Apps/Main App"),
5762            "archive VFS entries must survive the foreground app switch"
5763        );
5764        assert!(
5765            runner
5766                .dispatcher
5767                .vfs_rsrc
5768                .contains_key("Apps/Register Helper"),
5769            "launched app resource fork must remain available after the switch"
5770        );
5771    }
5772
5773    #[test]
5774    fn immediate_pending_launch_application_switches_from_vfs_without_event_yield() {
5775        use crate::memory::globals::addr;
5776
5777        let current_code0 = minimal_code0(0, 0x2000, 0, 0);
5778        let helper_code0 = minimal_code0(0, 0x2000, 0, 0);
5779        let current_fork_bytes = make_resource_fork_bytes(&[(*b"CODE", 0, &current_code0)]);
5780        let helper_fork_bytes = make_resource_fork_bytes(&[(*b"CODE", 0, &helper_code0)]);
5781        let current_fork =
5782            ResourceFork::parse(&current_fork_bytes).expect("parse current app fork");
5783        let mut runner = FixtureRunner::new(8 * 1024 * 1024, FixtureRunnerConfig::default());
5784
5785        runner
5786            .dispatcher
5787            .vfs
5788            .insert("Apps/Main App".to_string(), Vec::new());
5789        runner
5790            .dispatcher
5791            .vfs_rsrc
5792            .insert("Apps/Main App".to_string(), current_fork_bytes);
5793        runner
5794            .dispatcher
5795            .vfs
5796            .insert("Apps/Register Helper".to_string(), Vec::new());
5797        runner
5798            .dispatcher
5799            .vfs_rsrc
5800            .insert("Apps/Register Helper".to_string(), helper_fork_bytes);
5801        runner.dispatcher.ensure_vfs_catalog();
5802        runner.dispatcher.set_launched_app_path("Apps/Main App");
5803
5804        let app = runner.load_app(&current_fork).expect("load current app");
5805        runner.init_app(&app);
5806        runner.bus.write_long(addr::TICKS, 4321);
5807        runner.dispatcher.tick_count = 4321;
5808        runner
5809            .dispatcher
5810            .queue_pending_launch_application("Apps/Register Helper", false);
5811
5812        let switched = runner.service_pending_launch_application(false);
5813
5814        assert!(
5815            switched,
5816            "immediate pending launch should not require an Event Manager yield"
5817        );
5818        assert!(
5819            !runner.is_halted(),
5820            "immediate queued helper launch should not halt the runner"
5821        );
5822        assert_eq!(
5823            runner.dispatcher.launched_app_path.as_deref(),
5824            Some("Apps/Register Helper")
5825        );
5826        assert_eq!(
5827            runner.bus.read_long(addr::TICKS),
5828            4321,
5829            "foreground app switch must preserve system TickCount"
5830        );
5831        let cur_ap_len = runner.bus.read_byte(addr::CUR_APNAME) as usize;
5832        let cur_ap_name = String::from_utf8(
5833            (0..cur_ap_len)
5834                .map(|i| runner.bus.read_byte(addr::CUR_APNAME + 1 + i as u32))
5835                .collect(),
5836        )
5837        .expect("CurApName is ASCII");
5838        assert_eq!(cur_ap_name, "Register Helper");
5839    }
5840
5841    #[test]
5842    fn fixture_runner_defaults_to_classic_system7_theme() {
5843        let runner = FixtureRunner::new(8 * 1024 * 1024, FixtureRunnerConfig::default());
5844
5845        assert_eq!(runner.ui_theme_id(), UiThemeId::ClassicSystem7);
5846        assert_eq!(runner.dispatcher().ui_theme_id(), UiThemeId::ClassicSystem7);
5847        assert_eq!(runner.ui_theme().id(), UiThemeId::ClassicSystem7);
5848        assert_eq!(
5849            runner.theme_metrics_mode(),
5850            ThemeMetricsMode::ClassicGuestMetrics
5851        );
5852        assert!(runner.uses_classic_guest_metrics());
5853    }
5854
5855    #[test]
5856    fn fixture_runner_accepts_explicit_systemless_theme_without_themed_metrics() {
5857        let runner = FixtureRunner::new(
5858            8 * 1024 * 1024,
5859            FixtureRunnerConfig {
5860                ui_theme: UiThemeId::SystemlessDefault,
5861                theme_metrics_mode: ThemeMetricsMode::ClassicGuestMetrics,
5862                ..FixtureRunnerConfig::default()
5863            },
5864        );
5865        let classic = UiThemeId::ClassicSystem7.provider();
5866
5867        assert_eq!(runner.ui_theme_id(), UiThemeId::SystemlessDefault);
5868        assert_eq!(
5869            runner.dispatcher().ui_theme_id(),
5870            UiThemeId::SystemlessDefault
5871        );
5872        assert_eq!(runner.ui_theme().id(), UiThemeId::SystemlessDefault);
5873        assert!(runner.uses_classic_guest_metrics());
5874        assert_eq!(runner.ui_theme().menu_metrics(), classic.menu_metrics());
5875        assert_eq!(
5876            runner.ui_theme().control_metrics(),
5877            classic.control_metrics()
5878        );
5879        assert_ne!(runner.ui_theme().palette(), classic.palette());
5880    }
5881
5882    fn write_double_buffer(bus: &mut MacMemoryBus, ptr: u32, samples: &[u8]) {
5883        bus.write_long(ptr, samples.len() as u32);
5884        bus.write_long(ptr + 4, 0x0000_0001);
5885        for (offset, sample) in samples.iter().copied().enumerate() {
5886            bus.write_byte(ptr + 16 + offset as u32, sample);
5887        }
5888    }
5889
5890    #[test]
5891    fn headless_run_steps_does_not_implicitly_mix_audio() {
5892        let mut runner = FixtureRunner::new(8 * 1024 * 1024, FixtureRunnerConfig::default());
5893        let program_start = 0x0001_0000;
5894        for offset in (0..16).step_by(2) {
5895            runner.bus.write_word(program_start + offset, 0x4E71); // NOP
5896        }
5897        runner.cpu.write_reg(Register::PC, program_start);
5898        runner.cpu.write_reg(Register::A7, 0x007F_FFC0);
5899
5900        let mut chan = SndChannel::new(0x0039_38C8, false);
5901        chan.play_buffer(
5902            vec![0x90, 0x91, 0x92],
5903            OUTPUT_RATE << 16,
5904            PlaybackKind::Buffer,
5905            0,
5906        );
5907        runner.dispatcher.sound_manager.channels.push(chan);
5908
5909        let (steps, running) = runner.run_steps(2, None);
5910
5911        assert!(running);
5912        assert_eq!(steps, 2);
5913        assert_eq!(
5914            runner.audio_buffer_len(),
5915            0,
5916            "plain headless stepping must not consume sound buffers"
5917        );
5918        assert_eq!(runner.dispatcher.sound_manager.debug_samples_mixed, 0);
5919
5920        runner.mix_audio(2);
5921        assert_eq!(runner.drain_audio(), vec![0x90, 0x91]);
5922        assert_eq!(runner.dispatcher.sound_manager.debug_samples_mixed, 2);
5923    }
5924
5925    #[test]
5926    fn host_audio_backend_receives_silence_while_sound_manager_idle() {
5927        let mut runner = FixtureRunner::new(8 * 1024 * 1024, FixtureRunnerConfig::default());
5928        let (audio_backend, stereo_samples) = CapturingAudioBackend::new();
5929        runner.set_audio(Box::new(audio_backend));
5930
5931        runner.mix_audio(4);
5932
5933        assert_eq!(
5934            stereo_samples.borrow().as_slice(),
5935            &[0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80]
5936        );
5937        assert_eq!(
5938            runner.audio_buffer_len(),
5939            0,
5940            "host silence must not become captured guest audio"
5941        );
5942        assert_eq!(runner.dispatcher.sound_manager.debug_samples_mixed, 0);
5943    }
5944
5945    #[test]
5946    fn host_audio_backend_receives_low_rate_sample_hold_output() {
5947        let mut runner = FixtureRunner::new(8 * 1024 * 1024, FixtureRunnerConfig::default());
5948        let (audio_backend, stereo_samples) = CapturingAudioBackend::new();
5949        runner.set_audio(Box::new(audio_backend));
5950
5951        let mut chan = SndChannel::new(0x0039_38C8, false);
5952        chan.play_buffer(
5953            vec![0x90, 0xA0],
5954            (OUTPUT_RATE / 2) << 16,
5955            PlaybackKind::Buffer,
5956            0,
5957        );
5958        runner.dispatcher.sound_manager.channels.push(chan);
5959
5960        runner.mix_audio(4);
5961
5962        assert_eq!(
5963            stereo_samples.borrow().as_slice(),
5964            &[
5965                0x90, 0x90, // source[0] at position 0.0
5966                0x90, 0x90, // source[0] held at position 0.5
5967                0xA0, 0xA0, // source[1] at position 1.0
5968                0xA0, 0xA0, // source[1] held at position 1.5
5969            ],
5970            "GUI/host audio path must receive the sample-hold low-rate output, not the old linear midpoint"
5971        );
5972        assert_eq!(runner.dispatcher.sound_manager.debug_samples_mixed, 4);
5973    }
5974
5975    fn dialog_tracking_for_test(filter_proc: u32, item_hit_ptr: u32) -> DialogTrackingState {
5976        DialogTrackingState {
5977            dialog_ptr: 0x0020_0000,
5978            bounds: (0, 0, 32, 32),
5979            title: String::new(),
5980            proc_id: 1,
5981            items: Vec::new(),
5982            default_item: 0,
5983            cancel_item: 0,
5984            edit_text: String::new(),
5985            edit_item: 0,
5986            saved_pixels: Vec::new(),
5987            stack_ptr: 0,
5988            item_hit_ptr,
5989            rendered_pixels: Vec::new(),
5990            flash_remaining: 0,
5991            flash_delay: 0,
5992            flash_item: 0,
5993            edit_text_modified: false,
5994            draw_proc_queue: VecDeque::new(),
5995            draw_procs_done: true,
5996            rendered_pixels_final: true,
5997            filter_proc,
5998            game_managed: false,
5999            last_filter_event: None,
6000            popup_draws: Vec::new(),
6001            active_popup: None,
6002            active_button: None,
6003            active_user_item: None,
6004        }
6005    }
6006
6007    #[test]
6008    fn arrows_as_numpad_remaps_key_and_char_together() {
6009        let mut runner = FixtureRunner::new(8 * 1024 * 1024, FixtureRunnerConfig::default());
6010        runner.set_arrows_as_numpad(true);
6011
6012        assert_eq!(runner.remap_key(0x7B, 28), (0x56, b'4'));
6013        assert_eq!(runner.remap_key(0x7C, 29), (0x58, b'6'));
6014        assert_eq!(runner.remap_key(0x7D, 31), (0x57, b'5'));
6015        assert_eq!(runner.remap_key(0x7E, 30), (0x5B, b'8'));
6016        assert_eq!(runner.remap_key(0x2E, b'm'), (0x2E, b'm'));
6017    }
6018
6019    #[test]
6020    fn arrows_not_remapped_by_default() {
6021        let runner = FixtureRunner::new(8 * 1024 * 1024, FixtureRunnerConfig::default());
6022
6023        assert!(!runner.arrows_as_numpad());
6024        assert_eq!(runner.remap_key(0x7B, 28), (0x7B, 28));
6025        assert_eq!(runner.remap_key(0x7C, 29), (0x7C, 29));
6026        assert_eq!(runner.remap_key(0x2E, b'm'), (0x2E, b'm'));
6027    }
6028
6029    #[test]
6030    fn key_events_sync_low_memory_keymap() {
6031        use crate::memory::globals::addr;
6032
6033        let mut runner = FixtureRunner::new(8 * 1024 * 1024, FixtureRunnerConfig::default());
6034
6035        assert_eq!(runner.bus.read_byte(addr::KEY_MAP_LM + 4), 0);
6036        assert_eq!(runner.bus.read_byte(addr::KEY_MAP_LM + 15), 0);
6037
6038        runner.push_key_down(0x26, b'j');
6039        runner.push_key_down(0x7E, 30);
6040
6041        assert_eq!(
6042            runner.bus.read_byte(addr::KEY_MAP_LM + 4),
6043            0x40,
6044            "J key should be visible to byte/bit KeyMap readers"
6045        );
6046        assert_eq!(
6047            runner.bus.read_byte(addr::KEY_MAP_LM + 5),
6048            0,
6049            "J key should not alias M at KeyMapLM byte 5 bit 6"
6050        );
6051        assert_eq!(
6052            runner.bus.read_byte(addr::KEY_MAP_LM + 15),
6053            0x40,
6054            "up arrow should be visible to byte/bit KeyMap readers"
6055        );
6056        assert_eq!(
6057            runner.bus.read_byte(addr::KEY_MAP_LM + 14),
6058            0,
6059            "up arrow should not be mirrored into the unused raw byte"
6060        );
6061
6062        runner.push_key_up(0x26, b'j');
6063
6064        assert_eq!(
6065            runner.bus.read_byte(addr::KEY_MAP_LM + 4),
6066            0,
6067            "J key release should clear the low-memory mirror"
6068        );
6069        assert_eq!(
6070            runner.bus.read_byte(addr::KEY_MAP_LM + 5),
6071            0,
6072            "J key release should leave the M-key byte clear"
6073        );
6074        assert_eq!(
6075            runner.bus.read_byte(addr::KEY_MAP_LM + 15),
6076            0x40,
6077            "unrelated byte/bit down keys should remain mirrored"
6078        );
6079        assert_eq!(
6080            runner.bus.read_byte(addr::KEY_MAP_LM + 14),
6081            0,
6082            "unused raw byte should stay clear"
6083        );
6084    }
6085
6086    #[test]
6087    fn init_app_seeds_top_of_stack_with_nonzero_bytes() {
6088        let mut runner = FixtureRunner::new(8 * 1024 * 1024, FixtureRunnerConfig::default());
6089        let app = LoadedApp {
6090            code0_header: Code0Header {
6091                above_a5: 0,
6092                below_a5: 0x2000,
6093                jump_table_size: 0,
6094                jump_table_offset: 0,
6095            },
6096            a5_base: 0x0040_0000,
6097            jump_table: Vec::new(),
6098            segment_bases: HashMap::new(),
6099            loaded_image_end: 0,
6100            initial_sp: 0x007F_FFC0,
6101            size_resource: None,
6102        };
6103
6104        runner.init_app(&app);
6105
6106        let stack_seed_start = app.initial_sp.saturating_sub(0x8000);
6107        assert_eq!(
6108            runner.bus.read_long(stack_seed_start),
6109            0xA5A5_A5A5,
6110            "top-of-stack window must not be zeroed"
6111        );
6112    }
6113
6114    #[test]
6115    fn init_app_leaves_application_heap_room_below_appllimit() {
6116        use crate::memory::globals::addr;
6117
6118        let mut runner = FixtureRunner::new(8 * 1024 * 1024, FixtureRunnerConfig::default());
6119        let app = LoadedApp {
6120            code0_header: Code0Header {
6121                above_a5: 0,
6122                below_a5: 0x2000,
6123                jump_table_size: 0,
6124                jump_table_offset: 0,
6125            },
6126            a5_base: 0x0040_0000,
6127            jump_table: Vec::new(),
6128            segment_bases: HashMap::new(),
6129            loaded_image_end: 0,
6130            initial_sp: 0x007F_FFC0,
6131            size_resource: None,
6132        };
6133
6134        runner.init_app(&app);
6135
6136        let heap_end = runner.bus.read_long(addr::HEAP_END);
6137        let appl_limit = runner.bus.read_long(addr::APPL_LIMIT);
6138        assert_eq!(
6139            heap_end,
6140            0x0020_0000 + APP_ZONE_HEADER_SIZE,
6141            "HeapEnd should expose the initial application-zone extent"
6142        );
6143        assert!(
6144            appl_limit.saturating_sub(heap_end) >= 2300 * 1024,
6145            "direct low-memory startup checks should see growable heap room below ApplLimit"
6146        );
6147    }
6148
6149    #[test]
6150    fn init_app_honors_size_resource_preferred_partition_for_heap_reporting() {
6151        use crate::memory::globals::addr;
6152
6153        let mut runner = FixtureRunner::new(8 * 1024 * 1024, FixtureRunnerConfig::default());
6154        let preferred_partition = 3 * 1024 * 1024;
6155        let app = LoadedApp {
6156            code0_header: Code0Header {
6157                above_a5: 0,
6158                below_a5: 0x2000,
6159                jump_table_size: 0,
6160                jump_table_offset: 0,
6161            },
6162            a5_base: 0x0040_0000,
6163            jump_table: Vec::new(),
6164            segment_bases: HashMap::new(),
6165            loaded_image_end: 0,
6166            initial_sp: 0x007F_FFC0,
6167            size_resource: Some(ApplicationSizeResource {
6168                flags: 0x0080,
6169                preferred_size: preferred_partition,
6170                minimum_size: 2 * 1024 * 1024,
6171            }),
6172        };
6173
6174        runner.init_app(&app);
6175
6176        let expected_limit = 0x0020_0000 + preferred_partition - APP_STACK_SAFETY_MARGIN;
6177        let expected_free = expected_limit - (0x0020_0000 + APP_ZONE_HEADER_SIZE);
6178        assert_eq!(runner.bus.read_long(addr::APPL_LIMIT), expected_limit);
6179        assert_eq!(runner.bus.read_long(addr::BUF_PTR), expected_limit);
6180        assert_eq!(runner.bus.read_long(0x0020_0000), expected_limit);
6181        assert_eq!(runner.bus.read_long(0x0020_0000 + 12), expected_free);
6182        assert_eq!(
6183            crate::memory::app_heap_free_bytes(runner.bus()),
6184            expected_free
6185        );
6186        assert!(
6187            expected_free < crate::memory::APP_HEAP_COMPAT_FREE_FLOOR,
6188            "explicit SIZE partitions must bypass the compatibility floor"
6189        );
6190    }
6191
6192    #[test]
6193    fn init_app_application_partition_override_takes_precedence_over_size_resource() {
6194        use crate::memory::globals::addr;
6195
6196        let mut runner = FixtureRunner::new(8 * 1024 * 1024, FixtureRunnerConfig::default());
6197        let size_partition = 3 * 1024 * 1024;
6198        let override_partition = 4 * 1024 * 1024;
6199        runner.set_application_partition_size(Some(override_partition));
6200        let app = LoadedApp {
6201            code0_header: Code0Header {
6202                above_a5: 0,
6203                below_a5: 0x2000,
6204                jump_table_size: 0,
6205                jump_table_offset: 0,
6206            },
6207            a5_base: 0x0040_0000,
6208            jump_table: Vec::new(),
6209            segment_bases: HashMap::new(),
6210            loaded_image_end: 0,
6211            initial_sp: 0x007F_FFC0,
6212            size_resource: Some(ApplicationSizeResource {
6213                flags: 0x0080,
6214                preferred_size: size_partition,
6215                minimum_size: 2 * 1024 * 1024,
6216            }),
6217        };
6218
6219        runner.init_app(&app);
6220
6221        let expected_limit = 0x0020_0000 + override_partition - APP_STACK_SAFETY_MARGIN;
6222        assert_eq!(runner.bus.read_long(addr::APPL_LIMIT), expected_limit);
6223        assert_eq!(
6224            crate::memory::app_heap_free_bytes(runner.bus()),
6225            expected_limit - (0x0020_0000 + APP_ZONE_HEADER_SIZE)
6226        );
6227    }
6228
6229    #[test]
6230    fn init_app_ignores_too_small_application_partition_override() {
6231        use crate::memory::globals::addr;
6232
6233        let mut runner = FixtureRunner::new(8 * 1024 * 1024, FixtureRunnerConfig::default());
6234        runner.set_application_partition_size(Some(64 * 1024));
6235        assert_eq!(runner.application_partition_size(), None);
6236        let app = LoadedApp {
6237            code0_header: Code0Header {
6238                above_a5: 0,
6239                below_a5: 0x2000,
6240                jump_table_size: 0,
6241                jump_table_offset: 0,
6242            },
6243            a5_base: 0x0040_0000,
6244            jump_table: Vec::new(),
6245            segment_bases: HashMap::new(),
6246            loaded_image_end: 0,
6247            initial_sp: 0x007F_FFC0,
6248            size_resource: None,
6249        };
6250
6251        runner.init_app(&app);
6252
6253        assert_eq!(
6254            runner.bus.read_long(addr::APPL_LIMIT),
6255            app.initial_sp - APP_STACK_SAFETY_MARGIN,
6256            "invalid tiny overrides must fall back to the default launch limit"
6257        );
6258    }
6259
6260    #[test]
6261    fn init_app_seeds_appparmhandle_with_empty_finder_information() {
6262        use crate::memory::globals::addr;
6263
6264        let mut runner = FixtureRunner::new(8 * 1024 * 1024, FixtureRunnerConfig::default());
6265        runner
6266            .dispatcher_mut()
6267            .set_launched_app_path("Games/Armor Alley");
6268        let app = LoadedApp {
6269            code0_header: Code0Header {
6270                above_a5: 0,
6271                below_a5: 0x2000,
6272                jump_table_size: 0,
6273                jump_table_offset: 0,
6274            },
6275            a5_base: 0x0040_0000,
6276            jump_table: Vec::new(),
6277            segment_bases: HashMap::new(),
6278            loaded_image_end: 0,
6279            initial_sp: 0x007F_FFC0,
6280            size_resource: None,
6281        };
6282
6283        runner.init_app(&app);
6284
6285        let handle = runner.bus.read_long(addr::APP_PARM_HANDLE);
6286        assert_ne!(
6287            handle, 0,
6288            "AppParmHandle should point at Finder launch information"
6289        );
6290        let data_ptr = runner.bus.read_long(handle);
6291        assert_ne!(
6292            data_ptr, 0,
6293            "Finder launch information handle should be loaded"
6294        );
6295        assert_eq!(
6296            runner.bus.get_alloc_size(data_ptr),
6297            Some(4),
6298            "empty Finder launch information is message/count only"
6299        );
6300        assert_eq!(
6301            runner.bus.read_word(data_ptr),
6302            0,
6303            "message should be appOpen for a normal application launch"
6304        );
6305        assert_eq!(
6306            runner.bus.read_word(data_ptr + 2),
6307            0,
6308            "normal application launch has no selected documents"
6309        );
6310    }
6311
6312    #[test]
6313    fn init_app_sets_legacy_sound_driver_low_memory_defaults() {
6314        let mut runner = FixtureRunner::new(8 * 1024 * 1024, FixtureRunnerConfig::default());
6315        let app = LoadedApp {
6316            code0_header: Code0Header {
6317                above_a5: 0,
6318                below_a5: 0x2000,
6319                jump_table_size: 0,
6320                jump_table_offset: 0,
6321            },
6322            a5_base: 0x0040_0000,
6323            jump_table: Vec::new(),
6324            segment_bases: HashMap::new(),
6325            loaded_image_end: 0,
6326            initial_sp: 0x007F_FFC0,
6327            size_resource: None,
6328        };
6329
6330        runner.init_app(&app);
6331
6332        assert_eq!(
6333            runner
6334                .bus
6335                .read_byte(crate::memory::globals::addr::SD_VOLUME),
6336            1,
6337            "SdVolume ($0260) should boot to the nonzero legacy compatibility value"
6338        );
6339        assert_eq!(
6340            runner
6341                .bus
6342                .read_byte(crate::memory::globals::addr::SOUND_LEVEL),
6343            0,
6344            "SoundLevel ($027F) is a distinct Sound Driver amplitude byte"
6345        );
6346        let sound_base = runner
6347            .bus
6348            .read_long(crate::memory::globals::addr::SOUND_BASE);
6349        assert_eq!(
6350            sound_base, 0x007F_5300,
6351            "SoundBase ($0266) should point at the 370-word legacy sound buffer in reserved display memory"
6352        );
6353        assert_eq!(
6354            runner.bus.read_byte(sound_base),
6355            0x80,
6356            "legacy SoundBase buffer starts at neutral amplitude"
6357        );
6358    }
6359
6360    #[test]
6361    fn init_app_seeds_mmu32bit_low_memory_flag() {
6362        let mut runner = FixtureRunner::new(8 * 1024 * 1024, FixtureRunnerConfig::default());
6363        let app = LoadedApp {
6364            code0_header: Code0Header {
6365                above_a5: 0,
6366                below_a5: 0x2000,
6367                jump_table_size: 0,
6368                jump_table_offset: 0,
6369            },
6370            a5_base: 0x0040_0000,
6371            jump_table: Vec::new(),
6372            segment_bases: HashMap::new(),
6373            loaded_image_end: 0,
6374            initial_sp: 0x007F_FFC0,
6375            size_resource: None,
6376        };
6377
6378        runner.init_app(&app);
6379
6380        assert_eq!(
6381            runner
6382                .bus
6383                .read_byte(crate::memory::globals::addr::MMU32_BIT),
6384            1,
6385            "MMU32Bit ($0CB2) should mirror Systemless's default 32-bit addressing mode"
6386        );
6387    }
6388
6389    #[test]
6390    fn init_app_seeds_cursor_task_low_memory_vector() {
6391        let mut runner = FixtureRunner::new(8 * 1024 * 1024, FixtureRunnerConfig::default());
6392        let app = LoadedApp {
6393            code0_header: Code0Header {
6394                above_a5: 0,
6395                below_a5: 0x2000,
6396                jump_table_size: 0,
6397                jump_table_offset: 0,
6398            },
6399            a5_base: 0x0040_0000,
6400            jump_table: Vec::new(),
6401            segment_bases: HashMap::new(),
6402            loaded_image_end: 0,
6403            initial_sp: 0x007F_FFC0,
6404            size_resource: None,
6405        };
6406
6407        runner.init_app(&app);
6408
6409        assert_eq!(
6410            runner.bus.read_word(CURSOR_TASK_NOOP_ADDR),
6411            0x4E75,
6412            "default cursor task target should be a callable RTS stub"
6413        );
6414        assert_eq!(
6415            runner
6416                .bus
6417                .read_long(crate::memory::globals::addr::J_CRSR_TASK),
6418            CURSOR_TASK_NOOP_ADDR,
6419            "JCrsrTask ($08EE) should boot to a callable no-op vector"
6420        );
6421    }
6422
6423    #[test]
6424    fn cursor_task_noop_vector_does_not_fire_on_guest_tick() {
6425        let mut runner = FixtureRunner::new(8 * 1024 * 1024, FixtureRunnerConfig::default());
6426        let interrupted_pc = 0x0002_0000;
6427        let interrupted_sp = 0x007F_FFC0;
6428
6429        runner.bus.write_long(
6430            crate::memory::globals::addr::J_CRSR_TASK,
6431            CURSOR_TASK_NOOP_ADDR,
6432        );
6433        runner.cpu.write_reg(Register::PC, interrupted_pc);
6434        runner.cpu.write_reg(Register::A7, interrupted_sp);
6435
6436        runner.advance_guest_tick();
6437
6438        assert!(runner.active_interrupt_callback.is_none());
6439        assert_eq!(runner.cursor_task_trampoline, 0);
6440        assert_eq!(runner.cpu.read_reg(Register::PC), interrupted_pc);
6441        assert_eq!(runner.cpu.read_reg(Register::A7), interrupted_sp);
6442        assert_eq!(runner.bus.read_long(crate::memory::globals::addr::TICKS), 1);
6443    }
6444
6445    #[test]
6446    fn cursor_task_callback_arms_interrupt_from_low_memory_vector() {
6447        let mut runner = FixtureRunner::new(8 * 1024 * 1024, FixtureRunnerConfig::default());
6448        let interrupted_pc = 0x0002_0000;
6449        let interrupted_sp = 0x007F_FFC0;
6450        let callback_addr = 0x0004_1234;
6451
6452        runner
6453            .bus
6454            .write_long(crate::memory::globals::addr::J_CRSR_TASK, callback_addr);
6455        runner.cpu.write_reg(Register::PC, interrupted_pc);
6456        runner.cpu.write_reg(Register::A7, interrupted_sp);
6457        runner.cpu.write_reg(Register::D0, 0x1111_1111);
6458        runner.cpu.write_reg(Register::D7, 0x7777_7777);
6459        runner.cpu.write_reg(Register::A0, 0xAAAA_0000);
6460        runner.cpu.write_reg(Register::A6, 0xCCCC_0000);
6461        runner.cpu.core.set_ccr(0x04);
6462
6463        runner.advance_guest_tick();
6464
6465        let active = runner
6466            .active_interrupt_callback
6467            .expect("cursor task callback should have been armed");
6468        assert!(matches!(
6469            active.source,
6470            ActiveInterruptCallbackSource::CursorTask
6471        ));
6472        assert_eq!(active.resume_pc, interrupted_pc);
6473        assert_eq!(active.resume_sp, interrupted_sp);
6474        assert_eq!(active.a_regs[7], interrupted_sp);
6475        assert_eq!(active.a_regs[6], 0xCCCC_0000);
6476        assert_eq!(active.d_regs[0], 0x1111_1111);
6477        assert_eq!(active.d_regs[7], 0x7777_7777);
6478        assert_eq!(active.ccr, 0x04);
6479
6480        assert_ne!(runner.cursor_task_trampoline, 0);
6481        assert_eq!(
6482            runner.cpu.read_reg(Register::PC),
6483            runner.cursor_task_trampoline
6484        );
6485        assert_eq!(runner.cpu.read_reg(Register::A7), interrupted_sp - 4);
6486        assert_eq!(runner.bus.read_long(interrupted_sp - 4), interrupted_pc);
6487        assert_eq!(runner.bus.read_word(runner.cursor_task_trampoline), 0x48E7);
6488        assert_eq!(
6489            runner.bus.read_word(runner.cursor_task_trampoline + 4),
6490            0x4EB9
6491        );
6492        assert_eq!(
6493            runner.bus.read_long(runner.cursor_task_trampoline + 6),
6494            callback_addr
6495        );
6496        assert_eq!(
6497            runner.bus.read_word(runner.cursor_task_trampoline + 10),
6498            0x4CDF
6499        );
6500        assert_eq!(
6501            runner.bus.read_word(runner.cursor_task_trampoline + 14),
6502            0x4E75
6503        );
6504    }
6505
6506    #[test]
6507    fn timer_callback_snapshot_preserves_interrupted_sp() {
6508        let mut runner = FixtureRunner::new(8 * 1024 * 1024, FixtureRunnerConfig::default());
6509        let interrupted_pc = 0x0002_8BAC;
6510        let interrupted_sp = 0x007F_FFC0;
6511
6512        runner.cpu.write_reg(Register::PC, interrupted_pc);
6513        runner.cpu.write_reg(Register::D0, 0x1111_1111);
6514        runner.cpu.write_reg(Register::D7, 0x7777_7777);
6515        runner.cpu.write_reg(Register::A0, 0xAAAA_0000);
6516        runner.cpu.write_reg(Register::A6, 0xCCCC_0000);
6517        runner.cpu.write_reg(Register::A7, interrupted_sp);
6518        runner.cpu.core.set_ccr(0x1F);
6519
6520        runner.dispatcher.timer_tasks.push(TimerTask {
6521            task_ptr: 0x0039_38C8,
6522            tm_addr: 0x0004_1234,
6523            active: true,
6524            fire_at_tick: 10,
6525        });
6526
6527        runner.fire_timer_tasks(10);
6528
6529        let active = runner
6530            .active_interrupt_callback
6531            .expect("timer callback should have been armed");
6532
6533        assert!(matches!(
6534            active.source,
6535            ActiveInterruptCallbackSource::Timer
6536        ));
6537        assert_eq!(active.resume_pc, interrupted_pc);
6538        assert_eq!(active.resume_sp, interrupted_sp);
6539        assert_eq!(active.a_regs[7], interrupted_sp);
6540        assert_eq!(active.a_regs[6], 0xCCCC_0000);
6541        assert_eq!(active.d_regs[0], 0x1111_1111);
6542        assert_eq!(active.d_regs[7], 0x7777_7777);
6543        assert_eq!(active.ccr, 0x1F);
6544
6545        assert_ne!(runner.timer_trampoline, 0);
6546        assert_eq!(runner.cpu.read_reg(Register::PC), runner.timer_trampoline);
6547        assert_eq!(runner.cpu.read_reg(Register::A7), interrupted_sp - 4);
6548        assert_eq!(runner.bus.read_long(interrupted_sp - 4), interrupted_pc);
6549    }
6550
6551    #[test]
6552    fn timer_callback_fired_at_tick_cap_runs_before_yielding() {
6553        let mut runner = FixtureRunner::new(8 * 1024 * 1024, FixtureRunnerConfig::default());
6554        let interrupted_pc = 0x0001_0000;
6555        let interrupted_sp = 0x007F_FFC0;
6556        let callback_addr = 0x0002_0000;
6557
6558        runner.cpu.write_reg(Register::PC, interrupted_pc);
6559        runner.cpu.write_reg(Register::A7, interrupted_sp);
6560        runner.bus.write_long(0x016A, 100);
6561        runner.dispatcher.tick_count = 100;
6562        runner.tick_budget = 0;
6563        runner.bus.write_word(callback_addr, 0x4E75); // RTS
6564        runner.dispatcher.timer_tasks.push(TimerTask {
6565            task_ptr: 0x0039_38C8,
6566            tm_addr: callback_addr,
6567            active: true,
6568            fire_at_tick: 101,
6569        });
6570
6571        let (steps, running) = runner.run_steps(1, Some(101));
6572
6573        assert!(running);
6574        assert_eq!(
6575            steps, 1,
6576            "a timer fired while reaching the tick cap must get a CPU slice"
6577        );
6578        assert_eq!(runner.bus.read_long(0x016A), 101);
6579        assert!(runner.active_interrupt_callback.is_some());
6580        assert_ne!(runner.timer_trampoline, 0);
6581        assert_eq!(
6582            runner.cpu.read_reg(Register::PC),
6583            runner.timer_trampoline + 4
6584        );
6585    }
6586
6587    #[test]
6588    fn timer_callback_return_runs_foreground_before_next_due_timer() {
6589        let mut runner = FixtureRunner::new(8 * 1024 * 1024, FixtureRunnerConfig::default());
6590        let interrupted_pc = 0x0001_0000;
6591        let interrupted_sp = 0x007F_FFC0;
6592        let callback_addr = 0x0002_0000;
6593
6594        runner.bus.write_word(interrupted_pc, 0x4E71); // foreground NOP
6595        runner.cpu.write_reg(Register::PC, interrupted_pc);
6596        runner.cpu.write_reg(Register::A7, interrupted_sp);
6597        runner.bus.write_long(0x016A, 101);
6598        runner.dispatcher.tick_count = 101;
6599        runner.set_instructions_per_tick(1);
6600        runner.tick_budget = 0;
6601        runner.active_interrupt_callback = Some(ActiveInterruptCallback {
6602            source: ActiveInterruptCallbackSource::Timer,
6603            resume_pc: interrupted_pc,
6604            resume_sp: interrupted_sp,
6605            d_regs: [0; 8],
6606            a_regs: [0, 0, 0, 0, 0, 0, 0, interrupted_sp],
6607            ccr: 0,
6608            restore_port: None,
6609        });
6610        runner.dispatcher.timer_tasks.push(TimerTask {
6611            task_ptr: 0x0039_38C8,
6612            tm_addr: callback_addr,
6613            active: true,
6614            fire_at_tick: 102,
6615        });
6616
6617        let (steps, running) = runner.run_steps(1, None);
6618
6619        assert!(running);
6620        assert_eq!(steps, 1);
6621        assert_eq!(
6622            runner.cpu.read_reg(Register::PC),
6623            interrupted_pc + 2,
6624            "resumed foreground instruction should run before the next timer interrupt"
6625        );
6626        assert_eq!(
6627            runner.guest_tick(),
6628            101,
6629            "returning from an interrupt must not immediately spend an exhausted budget on another tick"
6630        );
6631        assert!(runner.active_interrupt_callback.is_none());
6632        assert!(
6633            runner.dispatcher.timer_tasks[0].active,
6634            "the next due timer should remain queued until foreground code gets a slice"
6635        );
6636    }
6637
6638    #[test]
6639    fn sound_doubleback_callback_resume_restores_ccr_before_branch() {
6640        let mut runner = FixtureRunner::new(8 * 1024 * 1024, FixtureRunnerConfig::default());
6641        let interrupted_pc = 0x0001_0000;
6642        let interrupted_sp = 0x007F_FFC0;
6643        let header_ptr = 0x0020_0000;
6644        let exhausted_buf_ptr = 0x0020_1000;
6645
6646        // BEQ.s -> MOVEQ #2,D0 path should be taken when Z is preserved.
6647        runner.bus.write_word(interrupted_pc, 0x6704);
6648        runner.bus.write_word(interrupted_pc + 2, 0x7001);
6649        runner.bus.write_word(interrupted_pc + 4, 0x6002);
6650        runner.bus.write_word(interrupted_pc + 6, 0x7002);
6651        runner.bus.write_word(interrupted_pc + 8, 0x4E71);
6652
6653        runner.cpu.write_reg(Register::PC, interrupted_pc);
6654        runner.cpu.write_reg(Register::A7, interrupted_sp);
6655        runner.cpu.write_reg(Register::D0, 0);
6656        runner.cpu.core.set_ccr(0x04);
6657
6658        runner.bus.write_long(header_ptr + 12, exhausted_buf_ptr);
6659        runner.bus.write_long(exhausted_buf_ptr + 4, 0x0000_0001);
6660        runner
6661            .dispatcher
6662            .sound_manager
6663            .pending_callbacks
6664            .push(PendingDoubleBackCallback {
6665                callback_addr: 0x0004_1234,
6666                chan_ptr: 0x0039_38C8,
6667                header_ptr,
6668                exhausted_buffer_index: 0,
6669            });
6670
6671        runner.fire_sound_doubleback_callbacks();
6672
6673        let active = runner
6674            .active_interrupt_callback
6675            .expect("sound callback should have been armed");
6676        assert!(matches!(
6677            active.source,
6678            ActiveInterruptCallbackSource::SoundDoubleBack
6679        ));
6680        assert_eq!(active.resume_pc, interrupted_pc);
6681        assert_eq!(active.resume_sp, interrupted_sp);
6682
6683        // Simulate the trampoline returning to interrupted code with CCR clobbered.
6684        runner.cpu.write_reg(Register::PC, interrupted_pc);
6685        runner.cpu.write_reg(Register::A7, interrupted_sp);
6686        runner.cpu.core.set_ccr(0);
6687
6688        let (steps, running) = runner.run_steps(3, None);
6689
6690        assert!(running);
6691        assert_eq!(steps, 3);
6692        assert_eq!(runner.cpu.read_reg(Register::D0), 2);
6693        assert!(runner.active_interrupt_callback.is_none());
6694    }
6695
6696    #[test]
6697    fn sound_doubleback_callback_trampoline_stacks_classic_pascal_order() {
6698        let mut runner = FixtureRunner::new(8 * 1024 * 1024, FixtureRunnerConfig::default());
6699        let interrupted_pc = 0x0001_0000;
6700        let interrupted_sp = 0x007F_FFC0;
6701        let callback_addr = runner.bus.alloc(2);
6702        let header_ptr = 0x0020_0000;
6703        let chan_ptr = 0x0039_38C8;
6704        let exhausted_buf_ptr = 0x0020_1000;
6705
6706        runner.bus.write_word(callback_addr, 0x4E75); // RTS without popping args.
6707        runner.bus.write_word(interrupted_pc, 0x60FE); // BRA.S *-0
6708        runner.cpu.write_reg(Register::PC, interrupted_pc);
6709        runner.cpu.write_reg(Register::A7, interrupted_sp);
6710
6711        runner.bus.write_long(header_ptr + 12, exhausted_buf_ptr);
6712        runner.bus.write_long(exhausted_buf_ptr + 4, 0x0000_0001);
6713        runner
6714            .dispatcher
6715            .sound_manager
6716            .pending_callbacks
6717            .push(PendingDoubleBackCallback {
6718                callback_addr,
6719                chan_ptr,
6720                header_ptr,
6721                exhausted_buffer_index: 0,
6722            });
6723
6724        runner.fire_sound_doubleback_callbacks();
6725        let (_steps, running) = runner.run_steps(24, None);
6726
6727        assert!(running);
6728        assert!(runner.active_interrupt_callback.is_none());
6729        let saved_regs_sp = interrupted_sp - 4 - 32;
6730        assert_eq!(
6731            runner.bus.read_long(saved_regs_sp - 4),
6732            chan_ptr,
6733            "first declared Pascal argument is deeper on the stack"
6734        );
6735        assert_eq!(
6736            runner.bus.read_long(saved_regs_sp - 8),
6737            exhausted_buf_ptr,
6738            "last declared Pascal argument is nearest the return address"
6739        );
6740    }
6741
6742    #[test]
6743    fn mix_audio_loads_ready_double_buffer_without_boundary_silence() {
6744        let mut runner = FixtureRunner::new(8 * 1024 * 1024, FixtureRunnerConfig::default());
6745        let chan_ptr = 0x0039_38C8;
6746        let callback_addr = 0x0004_1234;
6747        let header_ptr = runner.bus.alloc(24);
6748        let buf0_ptr = runner.bus.alloc(18);
6749        let buf1_ptr = runner.bus.alloc(18);
6750
6751        runner.bus.write_word(header_ptr, 1);
6752        runner.bus.write_word(header_ptr + 2, 8);
6753        runner.bus.write_long(header_ptr + 8, OUTPUT_RATE << 16);
6754        runner.bus.write_long(header_ptr + 12, buf0_ptr);
6755        runner.bus.write_long(header_ptr + 16, buf1_ptr);
6756        runner.bus.write_long(header_ptr + 20, callback_addr);
6757        write_double_buffer(&mut runner.bus, buf0_ptr, &[0x90, 0x91]);
6758        write_double_buffer(&mut runner.bus, buf1_ptr, &[0xA0, 0xA1]);
6759
6760        let mut chan = SndChannel::new(chan_ptr, false);
6761        chan.double_buffer = Some(DoubleBufferState {
6762            header_ptr,
6763            current_buffer: 0,
6764            callback_addr,
6765            chan_ptr,
6766            sample_rate: OUTPUT_RATE << 16,
6767            num_channels: 1,
6768            sample_size: 8,
6769            last_buffer_seen: false,
6770            waiting_for_callback: false,
6771            pending_callback_buffers: [false; 2],
6772        });
6773        crate::trap::TrapDispatcher::load_double_buffer_samples(
6774            &mut runner.bus,
6775            &mut chan,
6776            buf0_ptr,
6777            OUTPUT_RATE << 16,
6778            1,
6779            8,
6780        );
6781        runner.dispatcher.sound_manager.channels.push(chan);
6782
6783        runner.mix_audio(3);
6784
6785        assert_eq!(
6786            runner.audio_buffer,
6787            vec![0x90, 0x91, 0xA0],
6788            "host mixing must continue into the ready paired buffer, not emit boundary silence"
6789        );
6790        assert_eq!(
6791            runner.bus.read_long(buf0_ptr + 4) & 0x01,
6792            0x01,
6793            "dbBufferReady stays set until the doubleback callback starts"
6794        );
6795        assert_eq!(
6796            runner.bus.read_long(buf1_ptr + 4) & 0x01,
6797            0x01,
6798            "the paired buffer is still marked ready while it is playing"
6799        );
6800        assert_eq!(
6801            runner.dispatcher.sound_manager.pending_callbacks.len(),
6802            1,
6803            "exhausting buffer 0 still queues its doubleback refill"
6804        );
6805        assert_eq!(
6806            runner.dispatcher.sound_manager.pending_callbacks[0].exhausted_buffer_index,
6807            0
6808        );
6809
6810        let chan = &runner.dispatcher.sound_manager.channels[0];
6811        assert!(chan.is_playing(), "buffer 1 should still be playing");
6812        let db = chan.double_buffer.as_ref().expect("double-buffer active");
6813        assert_eq!(db.current_buffer, 1);
6814        assert!(db.waiting_for_callback);
6815    }
6816
6817    #[test]
6818    fn mix_audio_can_queue_other_doubleback_while_callback_is_active() {
6819        let mut runner = FixtureRunner::new(8 * 1024 * 1024, FixtureRunnerConfig::default());
6820        let chan_ptr = 0x0039_38C8;
6821        let callback_addr = 0x0004_1234;
6822        let header_ptr = runner.bus.alloc(24);
6823        let buf0_ptr = runner.bus.alloc(17);
6824        let buf1_ptr = runner.bus.alloc(17);
6825        let interrupted_pc = 0x0001_0000;
6826        let interrupted_sp = 0x007F_FFC0;
6827
6828        runner.bus.write_word(interrupted_pc, 0x4E71);
6829        runner.cpu.write_reg(Register::PC, interrupted_pc);
6830        runner.cpu.write_reg(Register::A7, interrupted_sp);
6831
6832        runner.bus.write_word(header_ptr, 1);
6833        runner.bus.write_word(header_ptr + 2, 8);
6834        runner.bus.write_long(header_ptr + 8, OUTPUT_RATE << 16);
6835        runner.bus.write_long(header_ptr + 12, buf0_ptr);
6836        runner.bus.write_long(header_ptr + 16, buf1_ptr);
6837        runner.bus.write_long(header_ptr + 20, callback_addr);
6838        write_double_buffer(&mut runner.bus, buf0_ptr, &[0xA0]);
6839        runner.bus.write_long(buf1_ptr, 1);
6840        runner.bus.write_long(buf1_ptr + 4, 0);
6841
6842        let mut chan = SndChannel::new(chan_ptr, false);
6843        chan.double_buffer = Some(DoubleBufferState {
6844            header_ptr,
6845            current_buffer: 0,
6846            callback_addr,
6847            chan_ptr,
6848            sample_rate: OUTPUT_RATE << 16,
6849            num_channels: 1,
6850            sample_size: 8,
6851            last_buffer_seen: false,
6852            waiting_for_callback: false,
6853            pending_callback_buffers: [false; 2],
6854        });
6855        crate::trap::TrapDispatcher::load_double_buffer_samples(
6856            &mut runner.bus,
6857            &mut chan,
6858            buf0_ptr,
6859            OUTPUT_RATE << 16,
6860            1,
6861            8,
6862        );
6863        runner.dispatcher.sound_manager.channels.push(chan);
6864
6865        runner.mix_audio(1);
6866        assert_eq!(runner.dispatcher.sound_manager.pending_callbacks.len(), 1);
6867        assert!(
6868            runner.dispatcher.sound_manager.channels[0]
6869                .double_buffer
6870                .as_ref()
6871                .expect("double-buffer active")
6872                .waiting_for_callback
6873        );
6874
6875        runner.fire_sound_doubleback_callbacks();
6876        assert!(matches!(
6877            runner
6878                .active_interrupt_callback
6879                .expect("doubleback callback should be active")
6880                .source,
6881            ActiveInterruptCallbackSource::SoundDoubleBack
6882        ));
6883        assert!(
6884            runner.dispatcher.sound_manager.channels[0]
6885                .double_buffer
6886                .as_ref()
6887                .expect("double-buffer active")
6888                .waiting_for_callback,
6889            "callback remains outstanding until guest refills a buffer"
6890        );
6891
6892        runner.mix_audio(16);
6893
6894        assert_eq!(
6895            runner.dispatcher.sound_manager.pending_callbacks.len(),
6896            1,
6897            "the paired unready buffer may queue its own callback while buffer 0 is active"
6898        );
6899        assert_eq!(
6900            runner.dispatcher.sound_manager.pending_callbacks[0].exhausted_buffer_index, 1,
6901            "buffer 0 must not be duplicated; buffer 1 gets the new callback"
6902        );
6903        let db = runner.dispatcher.sound_manager.channels[0]
6904            .double_buffer
6905            .as_ref()
6906            .expect("double-buffer active");
6907        assert!(db.waiting_for_callback);
6908        assert_eq!(db.pending_callback_buffers, [true, true]);
6909    }
6910
6911    #[test]
6912    fn mix_audio_does_not_load_ready_double_buffer_while_callback_is_active() {
6913        let mut runner = FixtureRunner::new(8 * 1024 * 1024, FixtureRunnerConfig::default());
6914        let chan_ptr = 0x0039_38C8;
6915        let callback_addr = 0x0004_1234;
6916        let header_ptr = runner.bus.alloc(24);
6917        let buf0_ptr = runner.bus.alloc(17);
6918
6919        runner.bus.write_word(header_ptr, 1);
6920        runner.bus.write_word(header_ptr + 2, 8);
6921        runner.bus.write_long(header_ptr + 8, OUTPUT_RATE << 16);
6922        runner.bus.write_long(header_ptr + 12, buf0_ptr);
6923        runner.bus.write_long(header_ptr + 20, callback_addr);
6924        write_double_buffer(&mut runner.bus, buf0_ptr, &[0xA0]);
6925
6926        let mut chan = SndChannel::new(chan_ptr, false);
6927        chan.double_buffer = Some(DoubleBufferState {
6928            header_ptr,
6929            current_buffer: 0,
6930            callback_addr,
6931            chan_ptr,
6932            sample_rate: OUTPUT_RATE << 16,
6933            num_channels: 1,
6934            sample_size: 8,
6935            last_buffer_seen: false,
6936            waiting_for_callback: true,
6937            pending_callback_buffers: [true, false],
6938        });
6939        runner.dispatcher.sound_manager.channels.push(chan);
6940        runner.active_interrupt_callback = Some(ActiveInterruptCallback {
6941            source: ActiveInterruptCallbackSource::SoundDoubleBack,
6942            resume_pc: 0x0001_0000,
6943            resume_sp: 0x007F_FFC0,
6944            d_regs: [0; 8],
6945            a_regs: [0; 8],
6946            ccr: 0,
6947            restore_port: None,
6948        });
6949
6950        runner.mix_audio(1);
6951
6952        assert_eq!(
6953            runner.bus.read_long(buf0_ptr + 4) & 0x01,
6954            0x01,
6955            "ready buffer must not be consumed before the callback returns"
6956        );
6957        assert!(
6958            !runner.dispatcher.sound_manager.channels[0].is_playing(),
6959            "callback-active buffer load should be deferred"
6960        );
6961        assert_eq!(
6962            runner.audio_buffer,
6963            vec![0x80],
6964            "the host stream stays alive with silence while waiting"
6965        );
6966
6967        runner.active_interrupt_callback = None;
6968        runner.try_load_pending_double_buffers();
6969
6970        assert_eq!(
6971            runner.bus.read_long(buf0_ptr + 4) & 0x01,
6972            0x01,
6973            "returned callback buffer stays marked ready while playback owns it"
6974        );
6975        assert!(
6976            runner.dispatcher.sound_manager.channels[0].is_playing(),
6977            "returned callback makes the refilled buffer available to the mixer"
6978        );
6979        let db = runner.dispatcher.sound_manager.channels[0]
6980            .double_buffer
6981            .as_ref()
6982            .expect("double-buffer active");
6983        assert_eq!(db.pending_callback_buffers, [false, false]);
6984
6985        runner.mix_audio(1);
6986        assert_eq!(runner.audio_buffer, vec![0x80, 0xA0]);
6987    }
6988
6989    #[test]
6990    fn try_load_pending_double_buffers_recovers_ready_alternate_after_underrun() {
6991        let mut runner = FixtureRunner::new(8 * 1024 * 1024, FixtureRunnerConfig::default());
6992        let chan_ptr = 0x0039_38C8;
6993        let callback_addr = 0x0004_1234;
6994        let header_ptr = runner.bus.alloc(24);
6995        let buf0_ptr = runner.bus.alloc(18);
6996        let buf1_ptr = runner.bus.alloc(18);
6997
6998        runner.bus.write_word(header_ptr, 1);
6999        runner.bus.write_word(header_ptr + 2, 8);
7000        runner.bus.write_long(header_ptr + 8, OUTPUT_RATE << 16);
7001        runner.bus.write_long(header_ptr + 12, buf0_ptr);
7002        runner.bus.write_long(header_ptr + 16, buf1_ptr);
7003        runner.bus.write_long(header_ptr + 20, callback_addr);
7004        write_double_buffer(&mut runner.bus, buf0_ptr, &[0xA0, 0xA1]);
7005        runner.bus.write_long(buf1_ptr, 2);
7006        runner.bus.write_long(buf1_ptr + 4, 0);
7007
7008        let mut chan = SndChannel::new(chan_ptr, false);
7009        chan.double_buffer = Some(DoubleBufferState {
7010            header_ptr,
7011            current_buffer: 1,
7012            callback_addr,
7013            chan_ptr,
7014            sample_rate: OUTPUT_RATE << 16,
7015            num_channels: 1,
7016            sample_size: 8,
7017            last_buffer_seen: false,
7018            waiting_for_callback: false,
7019            pending_callback_buffers: [false; 2],
7020        });
7021        runner.dispatcher.sound_manager.channels.push(chan);
7022
7023        runner.try_load_pending_double_buffers();
7024
7025        let chan = &runner.dispatcher.sound_manager.channels[0];
7026        assert!(chan.is_playing(), "ready alternate buffer should load");
7027        let db = chan.double_buffer.as_ref().expect("double-buffer active");
7028        assert_eq!(db.current_buffer, 0);
7029        assert!(
7030            !db.waiting_for_callback,
7031            "loading a ready buffer completes the outstanding refill wait"
7032        );
7033        assert_eq!(
7034            runner.bus.read_long(buf0_ptr + 4) & 0x01,
7035            0x01,
7036            "loading a ready alternate must not clear dbBufferReady before playback exhausts"
7037        );
7038    }
7039
7040    #[test]
7041    fn try_load_pending_double_buffers_does_not_replay_callback_pending_slot() {
7042        let mut runner = FixtureRunner::new(8 * 1024 * 1024, FixtureRunnerConfig::default());
7043        let chan_ptr = 0x0039_38C8;
7044        let callback_addr = 0x0004_1234;
7045        let header_ptr = runner.bus.alloc(24);
7046        let buf0_ptr = runner.bus.alloc(17);
7047        let buf1_ptr = runner.bus.alloc(17);
7048
7049        runner.bus.write_word(header_ptr, 1);
7050        runner.bus.write_word(header_ptr + 2, 8);
7051        runner.bus.write_long(header_ptr + 8, OUTPUT_RATE << 16);
7052        runner.bus.write_long(header_ptr + 12, buf0_ptr);
7053        runner.bus.write_long(header_ptr + 16, buf1_ptr);
7054        runner.bus.write_long(header_ptr + 20, callback_addr);
7055        write_double_buffer(&mut runner.bus, buf0_ptr, &[0xA0]);
7056        runner.bus.write_long(buf1_ptr, 1);
7057        runner.bus.write_long(buf1_ptr + 4, 0);
7058
7059        let mut chan = SndChannel::new(chan_ptr, false);
7060        chan.double_buffer = Some(DoubleBufferState {
7061            header_ptr,
7062            current_buffer: 0,
7063            callback_addr,
7064            chan_ptr,
7065            sample_rate: OUTPUT_RATE << 16,
7066            num_channels: 1,
7067            sample_size: 8,
7068            last_buffer_seen: false,
7069            waiting_for_callback: true,
7070            pending_callback_buffers: [true, false],
7071        });
7072        runner.dispatcher.sound_manager.channels.push(chan);
7073        runner
7074            .dispatcher
7075            .sound_manager
7076            .pending_callbacks
7077            .push(PendingDoubleBackCallback {
7078                callback_addr,
7079                chan_ptr,
7080                header_ptr,
7081                exhausted_buffer_index: 0,
7082            });
7083
7084        runner.try_load_pending_double_buffers();
7085
7086        assert!(
7087            !runner.dispatcher.sound_manager.channels[0].is_playing(),
7088            "an exhausted slot must not replay just because dbBufferReady remains set"
7089        );
7090        assert_eq!(
7091            runner.bus.read_long(buf0_ptr + 4) & 0x01,
7092            0x01,
7093            "the flag remains ready until fire_sound_doubleback_callbacks clears it"
7094        );
7095    }
7096
7097    #[test]
7098    fn sound_command_callback_trampoline_passes_sndcommand_pointer() {
7099        let mut runner = FixtureRunner::new(8 * 1024 * 1024, FixtureRunnerConfig::default());
7100        let interrupted_pc = 0x0001_0000;
7101        let interrupted_sp = 0x007F_FFC0;
7102        runner.cpu.write_reg(Register::PC, interrupted_pc);
7103        runner.cpu.write_reg(Register::A7, interrupted_sp);
7104
7105        runner
7106            .dispatcher
7107            .sound_manager
7108            .pending_sound_callbacks
7109            .push(crate::sound::PendingSoundCallback::Command {
7110                callback_addr: 0x0004_5678,
7111                chan_ptr: 0x0039_38C8,
7112                cmd: crate::sound::SndCommand {
7113                    cmd: crate::sound::cmd::CALLBACK,
7114                    param1: 0x1234,
7115                    param2: 0x0001_43FC,
7116                },
7117            });
7118
7119        runner.fire_sound_callbacks();
7120
7121        let active = runner
7122            .active_interrupt_callback
7123            .expect("sound callback should have been armed");
7124        assert!(matches!(
7125            active.source,
7126            ActiveInterruptCallbackSource::SoundCallback
7127        ));
7128        assert_eq!(active.resume_pc, interrupted_pc);
7129        assert_eq!(active.resume_sp, interrupted_sp);
7130
7131        let tramp = runner.sound_callback_trampoline;
7132        let cmd_ptr = tramp + 34;
7133        let saved_regs_sp = interrupted_sp - 4 - 32;
7134        assert_eq!(runner.bus.read_long(tramp + 6), 0x0039_38C8);
7135        assert_eq!(runner.bus.read_long(tramp + 12), cmd_ptr);
7136        assert_eq!(runner.bus.read_long(tramp + 18), 0x0004_5678);
7137        assert_eq!(runner.bus.read_long(tramp + 24), saved_regs_sp);
7138        assert_eq!(runner.bus.read_word(cmd_ptr), crate::sound::cmd::CALLBACK);
7139        assert_eq!(runner.bus.read_word(cmd_ptr + 2), 0x1234);
7140        assert_eq!(runner.bus.read_long(cmd_ptr + 4), 0x0001_43FC);
7141    }
7142
7143    #[test]
7144    fn sound_command_callback_trampoline_tolerates_one_long_pascal_cleanup() {
7145        let mut runner = FixtureRunner::new(8 * 1024 * 1024, FixtureRunnerConfig::default());
7146        let interrupted_pc = 0x0001_0000;
7147        let interrupted_sp = 0x007F_FFC0;
7148        let callback_addr = runner.bus.alloc(4);
7149
7150        // Some Pascal callback epilogues pop one long argument by copying the
7151        // return address over it, then RTS.
7152        runner.bus.write_word(callback_addr, 0x2E9F); // MOVE.L (SP)+,(SP)
7153        runner.bus.write_word(callback_addr + 2, 0x4E75); // RTS
7154        runner.bus.write_word(interrupted_pc, 0x4E71); // NOP
7155        runner.cpu.write_reg(Register::PC, interrupted_pc);
7156        runner.cpu.write_reg(Register::A7, interrupted_sp);
7157
7158        runner
7159            .dispatcher
7160            .sound_manager
7161            .pending_sound_callbacks
7162            .push(crate::sound::PendingSoundCallback::Command {
7163                callback_addr,
7164                chan_ptr: 0x0039_38C8,
7165                cmd: crate::sound::SndCommand {
7166                    cmd: crate::sound::cmd::CALLBACK,
7167                    param1: 0x1234,
7168                    param2: 0x0001_43FC,
7169                },
7170            });
7171
7172        runner.fire_sound_callbacks();
7173        let (steps, running) = runner.run_steps(10, None);
7174
7175        assert!(running, "callback trampoline should resume foreground code");
7176        assert_eq!(steps, 10);
7177        assert!(runner.active_interrupt_callback.is_none());
7178        assert_eq!(runner.cpu.read_reg(Register::PC), interrupted_pc + 2);
7179        assert_eq!(runner.cpu.read_reg(Register::A7), interrupted_sp);
7180        assert!(!runner.is_halted());
7181    }
7182
7183    #[test]
7184    fn sound_file_completion_callback_trampoline_tolerates_c_style_cleanup() {
7185        let mut runner = FixtureRunner::new(8 * 1024 * 1024, FixtureRunnerConfig::default());
7186        let interrupted_pc = 0x0001_0000;
7187        let interrupted_sp = 0x007F_FFC0;
7188        let callback_addr = runner.bus.alloc(2);
7189
7190        runner.bus.write_word(callback_addr, 0x4E75); // RTS without popping chan.
7191        runner.bus.write_word(interrupted_pc, 0x4E71); // NOP
7192        runner.cpu.write_reg(Register::PC, interrupted_pc);
7193        runner.cpu.write_reg(Register::A7, interrupted_sp);
7194
7195        runner
7196            .dispatcher
7197            .sound_manager
7198            .pending_sound_callbacks
7199            .push(crate::sound::PendingSoundCallback::FileCompletion {
7200                callback_addr,
7201                chan_ptr: 0x0039_38C8,
7202            });
7203
7204        runner.fire_sound_callbacks();
7205        let (steps, running) = runner.run_steps(10, None);
7206
7207        assert!(
7208            running,
7209            "file completion trampoline should resume foreground code"
7210        );
7211        assert_eq!(steps, 10);
7212        assert!(runner.active_interrupt_callback.is_none());
7213        assert_eq!(runner.cpu.read_reg(Register::A7), interrupted_sp);
7214        assert!(!runner.is_halted());
7215    }
7216
7217    #[test]
7218    fn sound_doubleback_callback_trampoline_tolerates_c_style_cleanup() {
7219        let mut runner = FixtureRunner::new(8 * 1024 * 1024, FixtureRunnerConfig::default());
7220        let interrupted_pc = 0x0001_0000;
7221        let interrupted_sp = 0x007F_FFC0;
7222        let callback_addr = runner.bus.alloc(2);
7223        let header_ptr = 0x0020_0000;
7224        let exhausted_buf_ptr = 0x0020_1000;
7225
7226        runner.bus.write_word(callback_addr, 0x4E75); // RTS without popping args.
7227        runner.bus.write_word(interrupted_pc, 0x4E71); // NOP
7228        runner.cpu.write_reg(Register::PC, interrupted_pc);
7229        runner.cpu.write_reg(Register::A7, interrupted_sp);
7230
7231        runner.bus.write_long(header_ptr + 12, exhausted_buf_ptr);
7232        runner.bus.write_long(exhausted_buf_ptr + 4, 0x0000_0001);
7233        runner
7234            .dispatcher
7235            .sound_manager
7236            .pending_callbacks
7237            .push(PendingDoubleBackCallback {
7238                callback_addr,
7239                chan_ptr: 0x0039_38C8,
7240                header_ptr,
7241                exhausted_buffer_index: 0,
7242            });
7243
7244        runner.fire_sound_doubleback_callbacks();
7245        let (steps, running) = runner.run_steps(12, None);
7246
7247        assert!(
7248            running,
7249            "doubleback trampoline should resume foreground code"
7250        );
7251        assert_eq!(steps, 12);
7252        assert!(runner.active_interrupt_callback.is_none());
7253        assert_eq!(runner.cpu.read_reg(Register::A7), interrupted_sp);
7254        assert!(!runner.is_halted());
7255    }
7256
7257    #[test]
7258    fn run_pending_sound_work_does_not_advance_ticks_or_foreground_code() {
7259        let mut runner = FixtureRunner::new(8 * 1024 * 1024, FixtureRunnerConfig::default());
7260        let interrupted_pc = 0x0001_0000;
7261        let interrupted_sp = 0x007F_FFC0;
7262        let callback_addr = runner.bus.alloc(2);
7263
7264        runner.bus.write_word(callback_addr, 0x4E75); // RTS without popping args.
7265        runner.bus.write_word(interrupted_pc, 0x4E71); // foreground NOP
7266        runner.cpu.write_reg(Register::PC, interrupted_pc);
7267        runner.cpu.write_reg(Register::A7, interrupted_sp);
7268        runner.bus.write_long(0x016A, 41);
7269        runner.dispatcher.tick_count = 41;
7270        runner.set_instructions_per_tick(1);
7271        runner.tick_budget = 0;
7272
7273        runner
7274            .dispatcher
7275            .sound_manager
7276            .pending_sound_callbacks
7277            .push(PendingSoundCallback::Command {
7278                callback_addr,
7279                chan_ptr: 0x0039_38C8,
7280                cmd: SndCommand {
7281                    cmd: crate::sound::cmd::CALLBACK,
7282                    param1: 0,
7283                    param2: 0,
7284                },
7285            });
7286
7287        let (steps, running) = runner.run_pending_sound_work(32);
7288
7289        assert!(running);
7290        assert!(steps > 0, "sound callback trampoline should execute");
7291        assert_eq!(
7292            runner.guest_tick(),
7293            41,
7294            "callback-only slices must not advance application-visible ticks"
7295        );
7296        assert_eq!(
7297            runner.cpu.read_reg(Register::PC),
7298            interrupted_pc,
7299            "sound callback service must stop before resumed foreground code runs"
7300        );
7301        assert_eq!(runner.cpu.read_reg(Register::A7), interrupted_sp);
7302        assert!(runner.active_interrupt_callback.is_none());
7303        assert!(!runner.has_pending_sound_work());
7304    }
7305
7306    #[test]
7307    fn gui_cpu_slice_does_not_finalize_host_frame() {
7308        let mut runner = FixtureRunner::new(8 * 1024 * 1024, FixtureRunnerConfig::default());
7309        let callback_addr = runner.bus.alloc(2);
7310
7311        runner.bus.write_word(callback_addr, 0x4E75); // RTS
7312        runner
7313            .dispatcher
7314            .sound_manager
7315            .pending_sound_callbacks
7316            .push(PendingSoundCallback::Command {
7317                callback_addr,
7318                chan_ptr: 0x0039_38C8,
7319                cmd: SndCommand {
7320                    cmd: crate::sound::cmd::CALLBACK,
7321                    param1: 0,
7322                    param2: 0,
7323                },
7324            });
7325
7326        let (steps, running) = runner.run_gui_cpu_slice(0, 0);
7327
7328        assert!(running);
7329        assert_eq!(steps, 0);
7330        assert!(
7331            runner.active_interrupt_callback.is_none(),
7332            "CPU-only GUI slices must not fire host-frame sound callbacks"
7333        );
7334        assert!(runner.has_pending_sound_work());
7335    }
7336
7337    #[test]
7338    fn run_steps_paces_pending_sound_doublebacks_to_one_per_slice() {
7339        let mut runner = FixtureRunner::new(8 * 1024 * 1024, FixtureRunnerConfig::default());
7340        let interrupted_pc = 0x0001_0000;
7341        let interrupted_sp = 0x007F_FFC0;
7342        let callback_addr = runner.bus.alloc(2);
7343        let header_ptr = runner.bus.alloc(24);
7344        let buf0_ptr = runner.bus.alloc(16);
7345        let buf1_ptr = runner.bus.alloc(16);
7346
7347        runner.bus.write_word(callback_addr, 0x4E75); // RTS without popping args.
7348        for offset in (0..512).step_by(2) {
7349            runner.bus.write_word(interrupted_pc + offset, 0x4E71); // NOP
7350        }
7351        runner.cpu.write_reg(Register::PC, interrupted_pc);
7352        runner.cpu.write_reg(Register::A7, interrupted_sp);
7353
7354        runner.bus.write_long(header_ptr + 12, buf0_ptr);
7355        runner.bus.write_long(header_ptr + 16, buf1_ptr);
7356        runner.bus.write_long(buf0_ptr + 4, 0x0000_0001);
7357        runner.bus.write_long(buf1_ptr + 4, 0x0000_0001);
7358        runner
7359            .dispatcher
7360            .sound_manager
7361            .pending_callbacks
7362            .push(PendingDoubleBackCallback {
7363                callback_addr,
7364                chan_ptr: 0x0039_38C8,
7365                header_ptr,
7366                exhausted_buffer_index: 0,
7367            });
7368        runner
7369            .dispatcher
7370            .sound_manager
7371            .pending_callbacks
7372            .push(PendingDoubleBackCallback {
7373                callback_addr,
7374                chan_ptr: 0x0039_38C8,
7375                header_ptr,
7376                exhausted_buffer_index: 1,
7377            });
7378
7379        let (_steps, running) = runner.run_steps(96, None);
7380
7381        assert!(running);
7382        assert!(runner.active_interrupt_callback.is_none());
7383        assert_eq!(
7384            runner.dispatcher.sound_manager.pending_callbacks.len(),
7385            1,
7386            "one CPU slice must not drain back-to-back doubleback interrupts"
7387        );
7388        assert_eq!(
7389            runner.dispatcher.sound_manager.pending_callbacks[0].exhausted_buffer_index,
7390            1
7391        );
7392
7393        let (_steps, running) = runner.run_steps(96, None);
7394
7395        assert!(running);
7396        assert!(runner.active_interrupt_callback.is_none());
7397        assert!(
7398            runner.dispatcher.sound_manager.pending_callbacks.is_empty(),
7399            "the next CPU slice may dispatch the next pending doubleback"
7400        );
7401    }
7402
7403    #[test]
7404    fn vbl_callback_arms_interrupt_with_task_ptr_in_a0() {
7405        let mut runner = FixtureRunner::new(8 * 1024 * 1024, FixtureRunnerConfig::default());
7406        let interrupted_pc = 0x0002_0000;
7407        let interrupted_sp = 0x007F_FFC0;
7408        let task_ptr = 0x0020_2000;
7409
7410        runner.cpu.write_reg(Register::PC, interrupted_pc);
7411        runner.cpu.write_reg(Register::A7, interrupted_sp);
7412        runner.cpu.write_reg(Register::A0, 0xAAAA_0000);
7413        runner.cpu.core.set_ccr(0x04);
7414
7415        runner.bus.write_word(task_ptr + 4, 1); // qType = vType
7416        runner.bus.write_long(task_ptr + 6, 0x0004_1234); // vblAddr
7417        runner.bus.write_word(task_ptr + 10, 1); // vblCount
7418        runner.bus.write_word(task_ptr + 12, 0); // vblPhase
7419        runner.dispatcher.vbl_tasks.push(VblTask {
7420            task_ptr,
7421            slot: Some(9),
7422        });
7423
7424        runner.fire_vbl_tasks();
7425
7426        let active = runner
7427            .active_interrupt_callback
7428            .expect("vbl callback should have been armed");
7429        assert!(matches!(active.source, ActiveInterruptCallbackSource::Vbl));
7430        assert_eq!(active.resume_pc, interrupted_pc);
7431        assert_eq!(active.resume_sp, interrupted_sp);
7432        assert_eq!(runner.bus.read_word(task_ptr + 10), 0);
7433
7434        assert_ne!(runner.vbl_trampoline, 0);
7435        assert_eq!(runner.cpu.read_reg(Register::PC), runner.vbl_trampoline);
7436        assert_eq!(runner.cpu.read_reg(Register::A7), interrupted_sp - 4);
7437        assert_eq!(runner.bus.read_long(interrupted_sp - 4), interrupted_pc);
7438        assert_eq!(runner.bus.read_word(runner.vbl_trampoline + 4), 0x207C);
7439        assert_eq!(runner.bus.read_long(runner.vbl_trampoline + 6), task_ptr);
7440        assert_eq!(
7441            runner.bus.read_long(runner.vbl_trampoline + 12),
7442            0x0004_1234
7443        );
7444    }
7445
7446    #[test]
7447    fn custom_instructions_per_tick_controls_tick_cadence() {
7448        let mut runner = FixtureRunner::new(8 * 1024 * 1024, FixtureRunnerConfig::default());
7449        let program_start = 0x0001_0000;
7450        let program_words = 14;
7451
7452        for offset in (0..program_words).step_by(2) {
7453            runner.bus.write_word(program_start + offset, 0x4E71);
7454        }
7455
7456        runner.cpu.write_reg(Register::PC, program_start);
7457        runner.cpu.write_reg(Register::A7, 0x007F_FFC0);
7458        runner.bus.write_long(0x016A, 0);
7459        runner.set_instructions_per_tick(3);
7460
7461        let (steps, running) = runner.run_steps(7, None);
7462
7463        assert!(running);
7464        assert_eq!(steps, 7);
7465        assert_eq!(runner.bus.read_long(0x016A), 2);
7466    }
7467
7468    #[test]
7469    fn non_idle_hle_trap_cost_advances_tick_budget() {
7470        let mut runner = FixtureRunner::new(8 * 1024 * 1024, FixtureRunnerConfig::default());
7471        let base = 0x0001_0000u32;
7472        let sp = 0x0010_0000u32;
7473        let rect = 0x0020_0000u32;
7474
7475        runner.bus.write_word(base, 0xA8A8); // _OffsetRect
7476        runner.cpu.write_reg(Register::PC, base);
7477        runner.cpu.write_reg(Register::A7, sp);
7478        runner.bus.write_word(sp, 1); // dv
7479        runner.bus.write_word(sp + 2, 2); // dh
7480        runner.bus.write_long(sp + 4, rect);
7481        runner.bus.write_word(rect, 10);
7482        runner.bus.write_word(rect + 2, 20);
7483        runner.bus.write_word(rect + 4, 30);
7484        runner.bus.write_word(rect + 6, 40);
7485        runner.bus.write_long(0x016A, 0);
7486        runner.dispatcher.tick_count = 0;
7487        runner.set_instructions_per_tick(5);
7488
7489        let (steps, running) = runner.run_steps(1, None);
7490
7491        assert!(running);
7492        assert_eq!(steps, 1);
7493        assert_eq!(
7494            runner.guest_tick(),
7495            1,
7496            "non-idle HLE traps should consume tick budget beyond the base instruction"
7497        );
7498        assert_eq!(runner.bus.read_word(rect), 11);
7499        assert_eq!(runner.bus.read_word(rect + 2), 22);
7500    }
7501
7502    #[test]
7503    fn idle_hle_traps_do_not_apply_extra_tick_cost() {
7504        let mut runner = FixtureRunner::new(8 * 1024 * 1024, FixtureRunnerConfig::default());
7505        let base = 0x0001_0000u32;
7506
7507        runner.bus.write_word(base, 0xA975); // _TickCount
7508        runner.cpu.write_reg(Register::PC, base);
7509        runner.cpu.write_reg(Register::A7, 0x0010_0000);
7510        runner.bus.write_long(0x016A, 42);
7511        runner.dispatcher.tick_count = 42;
7512        runner.set_instructions_per_tick(5);
7513
7514        let (steps, running) = runner.run_steps(1, None);
7515
7516        assert!(running);
7517        assert_eq!(steps, 1);
7518        assert_eq!(
7519            runner.guest_tick(),
7520            42,
7521            "polling traps should not add synthetic HLE manager cost"
7522        );
7523        assert_eq!(runner.tick_budget, 4);
7524    }
7525
7526    #[test]
7527    fn hle_trap_cost_stops_gui_slice_at_tick_cap() {
7528        let mut runner = FixtureRunner::new(8 * 1024 * 1024, FixtureRunnerConfig::default());
7529        let base = 0x0001_0000u32;
7530        let sp = 0x0010_0000u32;
7531        let rect = 0x0020_0000u32;
7532
7533        runner.bus.write_word(base, 0xA8A8); // _OffsetRect
7534        runner.bus.write_word(base + 2, 0x4E71); // NOP that must wait for the next GUI slice
7535        runner.cpu.write_reg(Register::PC, base);
7536        runner.cpu.write_reg(Register::A7, sp);
7537        runner.bus.write_word(sp, 1);
7538        runner.bus.write_word(sp + 2, 2);
7539        runner.bus.write_long(sp + 4, rect);
7540        runner.bus.write_word(rect, 10);
7541        runner.bus.write_word(rect + 2, 20);
7542        runner.bus.write_word(rect + 4, 30);
7543        runner.bus.write_word(rect + 6, 40);
7544        runner.bus.write_long(0x016A, 0);
7545        runner.dispatcher.tick_count = 0;
7546        runner.set_instructions_per_tick(5);
7547
7548        let (steps, running) = runner.run_gui_slice_with_audio(8, 1, 0);
7549
7550        assert!(running);
7551        assert_eq!(steps, 1);
7552        assert_eq!(runner.guest_tick(), 1);
7553        assert_eq!(
7554            runner.cpu.read_reg(Register::PC),
7555            base + 2,
7556            "the next guest instruction should be deferred once HLE cost reaches the GUI tick cap"
7557        );
7558    }
7559
7560    /// Regression gate for the `tick_count`-sync invariant.
7561    /// `advance_guest_tick` keeps bus `$016A` and
7562    /// `dispatcher.tick_count` lockstep; the unfreeze path also
7563    /// updates both. Any future change that writes `$016A` without
7564    /// updating `dispatcher.tick_count` (or vice versa) will desync
7565    /// double-click detection + the TickCount handler + diagnostic
7566    /// tick printouts.
7567    #[test]
7568    fn dispatcher_tick_count_stays_in_sync_with_bus() {
7569        let mut runner = FixtureRunner::new(8 * 1024 * 1024, FixtureRunnerConfig::default());
7570        let program_start = 0x0001_0000;
7571        let program_words = 20;
7572
7573        // NOPs keep the CPU stepping without producing traps that
7574        // could interfere with tick accounting.
7575        for offset in (0..program_words).step_by(2) {
7576            runner.bus.write_word(program_start + offset, 0x4E71);
7577        }
7578
7579        runner.cpu.write_reg(Register::PC, program_start);
7580        runner.cpu.write_reg(Register::A7, 0x007F_FFC0);
7581        runner.bus.write_long(0x016A, 0);
7582        // Set both sides of the invariant to the same initial value.
7583        runner.dispatcher.tick_count = 0;
7584        runner.set_instructions_per_tick(3);
7585
7586        // Step a few times; ticks should advance roughly every 3
7587        // instructions. After each run_steps, bus and dispatcher
7588        // must agree.
7589        for _ in 0..3 {
7590            let (_, running) = runner.run_steps(3, None);
7591            assert!(running);
7592            assert_eq!(
7593                runner.bus.read_long(0x016A),
7594                runner.dispatcher.tick_count,
7595                "bus $016A ({}) diverged from dispatcher.tick_count ({})",
7596                runner.bus.read_long(0x016A),
7597                runner.dispatcher.tick_count,
7598            );
7599        }
7600    }
7601
7602    /// Regression gate for the TickCount spin fast-forward template A
7603    /// (classic MOVE+SUBQ+CMP+BHI with register target). Builds a
7604    /// synthetic spin body in RAM, calls the fast-forward directly,
7605    /// asserts the exit state matches what the guest loop's final
7606    /// fall-through iteration would produce.
7607    #[test]
7608    fn spin_fastfwd_template_a_advances_ticks_and_skips_loop() {
7609        let mut runner = FixtureRunner::new(8 * 1024 * 1024, FixtureRunnerConfig::default());
7610        let base = 0x0001_0000u32;
7611        // Synthesised spin body:
7612        //   $base+0: SUBQ.W #4, A7   (0x594F)
7613        //   $base+2: _TickCount      (0xA975) ← trap fires before call site
7614        //   $base+4: MOVE.L (A7)+, D0 (0x201F)
7615        //   $base+6: SUBQ.L #1, D0   (0x5380)
7616        //   $base+8: CMP.L D0, D3    (0xB680)
7617        //   $base+10: BHI.S *-12     (0x62F4)
7618        //   $base+12: sentinel       (0x4E71 NOP)
7619        runner.bus.write_word(base, 0x594F);
7620        runner.bus.write_word(base + 2, 0xA975);
7621        runner.bus.write_word(base + 4, 0x201F);
7622        runner.bus.write_word(base + 6, 0x5380);
7623        runner.bus.write_word(base + 8, 0xB680);
7624        runner.bus.write_word(base + 10, 0x62F4);
7625        runner.bus.write_word(base + 12, 0x4E71);
7626
7627        // Initial tick 100, target D3=500 so target_tick = 501.
7628        runner.bus.write_long(0x016A, 100);
7629        runner.dispatcher.tick_count = 100;
7630        runner.cpu.write_reg(Register::D3, 500);
7631        runner.cpu.write_reg(Register::A7, 0x0010_0000);
7632
7633        let pc_after_trap = base + 4;
7634        let mut count = 0usize;
7635        let hit_cap = runner.try_tickcount_spin_fastfwd(pc_after_trap, None, &mut count);
7636
7637        assert!(!hit_cap, "no tick_cap was set, cap should not trip");
7638        assert_eq!(runner.dispatcher.tick_count, 501, "advanced to D3+imm");
7639        assert_eq!(runner.bus.read_long(0x016A), 501, "bus $016A in sync");
7640        // After fall-through: Dn = final_tick - imm = 501 - 1 = 500 (= D3).
7641        assert_eq!(runner.cpu.read_reg(Register::D0), 500);
7642        // A7 += 4 (the popped tick slot).
7643        assert_eq!(runner.cpu.read_reg(Register::A7), 0x0010_0004);
7644        // PC past BHI (base + 12).
7645        assert_eq!(runner.cpu.read_reg(Register::PC), base + 12);
7646        // 4 synthesised instructions accounted for.
7647        assert_eq!(count, 4);
7648    }
7649
7650    /// Rejection case — `MOVE.L (A7)+, D1` followed by `SUBQ.L #imm,
7651    /// D0` (different registers) must NOT match. Ensures the
7652    /// register-consistency check in `try_spin_template_a` guards
7653    /// against false positives where an unrelated MOVE happens to
7654    /// precede a SUBQ+CMP+BHI.
7655    #[test]
7656    fn spin_fastfwd_rejects_register_mismatch() {
7657        let mut runner = FixtureRunner::new(8 * 1024 * 1024, FixtureRunnerConfig::default());
7658        let base = 0x0001_0000u32;
7659        // Same as template A but MOVE.L (A7)+ targets D1, while
7660        // SUBQ/CMP operate on D0. Template detector must reject.
7661        runner.bus.write_word(base, 0x594F);
7662        runner.bus.write_word(base + 2, 0xA975);
7663        runner.bus.write_word(base + 4, 0x221F); // MOVE.L (A7)+, D1 (NOT D0)
7664        runner.bus.write_word(base + 6, 0x5380); // SUBQ.L #1, D0
7665        runner.bus.write_word(base + 8, 0xB680); // CMP.L D0, D3
7666        runner.bus.write_word(base + 10, 0x62F4); // BHI.S
7667
7668        runner.bus.write_long(0x016A, 100);
7669        runner.dispatcher.tick_count = 100;
7670        runner.cpu.write_reg(Register::D3, 500);
7671        runner.cpu.write_reg(Register::A7, 0x0010_0000);
7672        let pc_after_trap = base + 4;
7673        let mut count = 0usize;
7674        runner.try_tickcount_spin_fastfwd(pc_after_trap, None, &mut count);
7675
7676        // No change: template rejected, tick_count stays at 100.
7677        assert_eq!(runner.dispatcher.tick_count, 100);
7678        // PC stays where it was (we passed pc_after_trap but the
7679        // fast-forward must have returned without mutating PC).
7680        assert_eq!(runner.cpu.read_reg(Register::PC), 0);
7681        assert_eq!(count, 0);
7682    }
7683
7684    /// Regression gate for spin fast-forward template B (memory target,
7685    /// BLS variant). Sets up the post-trap state with A6 pointing at
7686    /// a stack frame and a target tick stored at `-4(A6)`; asserts the
7687    /// matcher advances to the memory target and synthesises the
7688    /// correct exit.
7689    #[test]
7690    fn spin_fastfwd_template_b_memory_target_variant() {
7691        let mut runner = FixtureRunner::new(8 * 1024 * 1024, FixtureRunnerConfig::default());
7692        let base = 0x0001_0000u32;
7693        // $base+0: SUBQ.W #4, A7   (0x594F) — pre-trap SP adjust
7694        // $base+2: _TickCount      (0xA975) — trap
7695        // $base+4: MOVE.L (A7)+, D0 (0x201F)
7696        // $base+6: CMP.L (-4, A6), D0 — opcode 0xB0AE, d16=0xFFFC
7697        //          (1011 000 010 101 110 = 0xB0AE; next word 0xFFFC = -4)
7698        // $base+10: BLS.S *-12    (0x63F2) — target = $base+12 + (-14)
7699        //           = $base - 2. So target should be BEFORE $base.
7700        // $base+12: sentinel NOP  (0x4E71)
7701        runner.bus.write_word(base, 0x594F);
7702        runner.bus.write_word(base + 2, 0xA975);
7703        runner.bus.write_word(base + 4, 0x201F);
7704        runner.bus.write_word(base + 6, 0xB0AE);
7705        runner.bus.write_word(base + 8, 0xFFFC);
7706        // Branch target must be < pc_after_trap. pc_after_trap = base+4.
7707        // Simplest: target = base + 2 → disp = target - (base+12) = -10
7708        // (since branch_src = base+10, branch_src+2 = base+12).
7709        // disp8 = -10 = 0xF6.
7710        runner.bus.write_word(base + 10, 0x63F6);
7711        runner.bus.write_word(base + 12, 0x4E71);
7712
7713        // Memory target at -4(A6). A6 points at mid-stack; -4(A6)
7714        // holds the target tick.
7715        let a6 = 0x0010_1000u32;
7716        runner.bus.write_long(a6.wrapping_sub(4), 400);
7717
7718        // Initial state
7719        runner.bus.write_long(0x016A, 100);
7720        runner.dispatcher.tick_count = 100;
7721        runner.cpu.write_reg(Register::A6, a6);
7722        runner.cpu.write_reg(Register::A7, 0x0010_0000);
7723
7724        let pc_after_trap = base + 4;
7725        let mut count = 0usize;
7726        let hit_cap = runner.try_tickcount_spin_fastfwd(pc_after_trap, None, &mut count);
7727
7728        assert!(!hit_cap);
7729        // target_tick = mem_target + 1 = 400 + 1 = 401.
7730        assert_eq!(runner.dispatcher.tick_count, 401);
7731        assert_eq!(runner.bus.read_long(0x016A), 401);
7732        // Template B exit: D0 = final_tick (no SUBQ).
7733        assert_eq!(runner.cpu.read_reg(Register::D0), 401);
7734        assert_eq!(runner.cpu.read_reg(Register::A7), 0x0010_0004);
7735        assert_eq!(runner.cpu.read_reg(Register::PC), base + 12);
7736        // Template B synthesises 3 instructions (MOVE, CMP, BLS).
7737        assert_eq!(count, 3);
7738    }
7739
7740    /// Regression gate for the TickCount spin fast-forward absolute
7741    /// LongInt target variant:
7742    ///
7743    ///   SUBQ.W #4,A7
7744    ///   _TickCount
7745    ///   MOVE.L (A7)+,Dn
7746    ///   CMP.L  (xxx).L,Dn
7747    ///   BCS.S  back-to-SUBQ
7748    ///
7749    /// This is the same wait-until-Ticks-reaches-memory-target shape as
7750    /// template B, but older MPW/Think-era code may address the target
7751    /// through an absolute long global instead of an A-register frame.
7752    #[test]
7753    fn spin_fastfwd_template_c_absolute_long_target_variant() {
7754        let mut runner = FixtureRunner::new(8 * 1024 * 1024, FixtureRunnerConfig::default());
7755        let base = 0x0001_0000u32;
7756        let target_addr = 0x0002_FE44u32;
7757
7758        // $base+0:  SUBQ.W #4, A7      (0x594F)
7759        // $base+2:  _TickCount         (0xA975)
7760        // $base+4:  MOVE.L (A7)+, D0   (0x201F)
7761        // $base+6:  CMP.L (xxx).L, D0  (0xB0B9 + absolute long)
7762        // $base+12: BCS.S $base        (0x65F2; base+14-14)
7763        // $base+14: sentinel NOP
7764        runner.bus.write_word(base, 0x594F);
7765        runner.bus.write_word(base + 2, 0xA975);
7766        runner.bus.write_word(base + 4, 0x201F);
7767        runner.bus.write_word(base + 6, 0xB0B9);
7768        runner.bus.write_long(base + 8, target_addr);
7769        runner.bus.write_word(base + 12, 0x65F2);
7770        runner.bus.write_word(base + 14, 0x4E71);
7771
7772        runner.bus.write_long(target_addr, 400);
7773        runner.bus.write_long(0x016A, 100);
7774        runner.dispatcher.tick_count = 100;
7775        runner.cpu.write_reg(Register::A7, 0x0010_0000);
7776
7777        let pc_after_trap = base + 4;
7778        let mut count = 0usize;
7779        let hit_cap = runner.try_tickcount_spin_fastfwd(pc_after_trap, None, &mut count);
7780
7781        assert!(!hit_cap);
7782        assert_eq!(runner.dispatcher.tick_count, 400);
7783        assert_eq!(runner.bus.read_long(0x016A), 400);
7784        assert_eq!(runner.cpu.read_reg(Register::D0), 400);
7785        assert_eq!(runner.cpu.read_reg(Register::A7), 0x0010_0004);
7786        assert_eq!(runner.cpu.read_reg(Register::PC), base + 14);
7787        assert_eq!(count, 3);
7788    }
7789
7790    #[test]
7791    fn spin_fastfwd_leaves_interrupt_callback_state_unsynthesized() {
7792        let mut runner = FixtureRunner::new(8 * 1024 * 1024, FixtureRunnerConfig::default());
7793        let base = 0x0001_0000u32;
7794        let target_addr = 0x0002_FE44u32;
7795        let task_ptr = 0x0020_2000u32;
7796        let sp = 0x0010_0000u32;
7797
7798        // Same absolute-long TickCount spin as template C. The VBL task
7799        // becomes due during the accelerated tick advance, before the
7800        // loop reaches its target tick.
7801        runner.bus.write_word(base, 0x594F);
7802        runner.bus.write_word(base + 2, 0xA975);
7803        runner.bus.write_word(base + 4, 0x201F);
7804        runner.bus.write_word(base + 6, 0xB0B9);
7805        runner.bus.write_long(base + 8, target_addr);
7806        runner.bus.write_word(base + 12, 0x65F2);
7807        runner.bus.write_word(base + 14, 0x4E71);
7808
7809        runner.bus.write_long(target_addr, 400);
7810        runner.bus.write_long(0x016A, 100);
7811        runner.dispatcher.tick_count = 100;
7812        runner.cpu.write_reg(Register::PC, base + 4);
7813        runner.cpu.write_reg(Register::A7, sp);
7814        runner.cpu.write_reg(Register::D0, 0xDEAD_BEEF);
7815        runner.bus.write_long(sp, 100);
7816
7817        runner.bus.write_word(task_ptr + 4, 1);
7818        runner.bus.write_long(task_ptr + 6, 0x0004_1234);
7819        runner.bus.write_word(task_ptr + 10, 1);
7820        runner.bus.write_word(task_ptr + 12, 0);
7821        runner.dispatcher.vbl_tasks.push(VblTask {
7822            task_ptr,
7823            slot: None,
7824        });
7825
7826        let mut count = 0usize;
7827        let hit_cap = runner.try_tickcount_spin_fastfwd(base + 4, None, &mut count);
7828
7829        assert!(!hit_cap);
7830        assert_eq!(runner.dispatcher.tick_count, 101);
7831        assert_eq!(runner.bus.read_long(0x016A), 101);
7832        assert_eq!(count, 0);
7833        assert_eq!(runner.cpu.read_reg(Register::D0), 0xDEAD_BEEF);
7834        assert_eq!(runner.cpu.read_reg(Register::PC), runner.vbl_trampoline);
7835        assert_eq!(runner.cpu.read_reg(Register::A7), sp - 4);
7836        assert_eq!(runner.bus.read_long(sp - 4), base + 4);
7837
7838        let active = runner
7839            .active_interrupt_callback
7840            .expect("VBL callback should remain active for normal resume handling");
7841        assert!(matches!(active.source, ActiveInterruptCallbackSource::Vbl));
7842        assert_eq!(active.resume_pc, base + 4);
7843        assert_eq!(active.resume_sp, sp);
7844    }
7845
7846    /// Regression gates for the tri-state spin-fastfwd gate. Tests the
7847    /// pure decision function so `OnceLock`-cached env vars don't
7848    /// interfere across tests.
7849    #[test]
7850    fn spin_fastfwd_gate_default_headless_on_gui_off() {
7851        // Neither force_on nor force_off → default behaviour:
7852        //   headless (yield_for_ui = false) → enabled
7853        //   GUI     (yield_for_ui = true)  → disabled
7854        assert!(spin_wait_fastfwd_gate(false, false, false));
7855        assert!(!spin_wait_fastfwd_gate(false, false, true));
7856    }
7857
7858    #[test]
7859    fn spin_fastfwd_gate_force_off_wins() {
7860        // force_off must dominate force_on and override the default in
7861        // either mode.
7862        assert!(!spin_wait_fastfwd_gate(false, true, false));
7863        assert!(!spin_wait_fastfwd_gate(false, true, true));
7864        assert!(!spin_wait_fastfwd_gate(true, true, false));
7865        assert!(!spin_wait_fastfwd_gate(true, true, true));
7866    }
7867
7868    #[test]
7869    fn spin_fastfwd_gate_force_on_overrides_gui_default() {
7870        // force_on (without force_off) flips GUI from off → on.
7871        assert!(spin_wait_fastfwd_gate(true, false, false));
7872        assert!(spin_wait_fastfwd_gate(true, false, true));
7873    }
7874
7875    /// Regression gates for the ModalDialog noop-refire skip. The GUI
7876    /// gate is the most critical because tick-driven animations
7877    /// require real refires.
7878    #[test]
7879    fn modaldialog_refire_skip_gui_mode_never_fires() {
7880        // yield_for_ui=true should ALWAYS prevent the skip,
7881        // regardless of the other conditions.
7882        for has_tracking in [false, true] {
7883            for events in [false, true] {
7884                assert!(
7885                    !modaldialog_refire_is_noop(
7886                        true, // yield_for_ui = GUI
7887                        has_tracking,
7888                        true, // all "noop" conditions
7889                        true,
7890                        true,
7891                        true,
7892                        events,
7893                    ),
7894                    "GUI mode must never skip refires (has_tracking={}, events={})",
7895                    has_tracking,
7896                    events
7897                );
7898            }
7899        }
7900    }
7901
7902    #[test]
7903    fn tracking_refire_freeze_policy_keeps_modaldialog_ticks_live() {
7904        // Menu/control tracking may freeze app-visible ticks while the GUI
7905        // renders intermediate tracking frames.
7906        assert!(tracking_refire_should_freeze_ticks(0xA93D));
7907        assert!(tracking_refire_should_freeze_ticks(0xAD3D));
7908        assert!(tracking_refire_should_freeze_ticks(0xA80B));
7909        assert!(tracking_refire_should_freeze_ticks(0xAC0B));
7910        assert!(tracking_refire_should_freeze_ticks(0xA968));
7911        assert!(tracking_refire_should_freeze_ticks(0xAD68));
7912
7913        // ModalDialog must keep ticks/VBL/sound callbacks live. EV's pilot
7914        // dialog flow plays music through this path.
7915        assert!(!tracking_refire_should_freeze_ticks(0xA991));
7916        assert!(!tracking_refire_should_freeze_ticks(0xAD91));
7917    }
7918
7919    #[test]
7920    fn gui_modaldialog_idle_refire_advances_one_tick() {
7921        let mut runner = FixtureRunner::new(8 * 1024 * 1024, FixtureRunnerConfig::default());
7922        let base = 0x0001_0000u32;
7923        runner.bus.write_word(base, 0xA991); // _ModalDialog
7924        runner.cpu.write_reg(Register::PC, base);
7925        runner.cpu.write_reg(Register::A7, 0x0010_0000);
7926        runner.bus.write_long(0x016A, 0);
7927        runner.dispatcher.tick_count = 0;
7928        runner.set_instructions_per_tick(1_000_000);
7929        runner.dispatcher.dialog_tracking = Some(dialog_tracking_for_test(0, 0));
7930
7931        let (steps, running) = runner.run_gui_slice_with_audio(1, 1, 0);
7932
7933        assert!(running);
7934        assert_eq!(steps, 1);
7935        assert_eq!(runner.guest_tick(), 1);
7936        assert_eq!(runner.tick_budget, runner.instructions_per_tick() as i32);
7937        assert_eq!(runner.cpu.read_reg(Register::PC), base);
7938    }
7939
7940    #[test]
7941    fn gui_modaldialog_idle_refire_runs_until_tick_cap() {
7942        let mut runner = FixtureRunner::new(8 * 1024 * 1024, FixtureRunnerConfig::default());
7943        let base = 0x0001_0000u32;
7944        runner.bus.write_word(base, 0xA991); // _ModalDialog
7945        runner.cpu.write_reg(Register::PC, base);
7946        runner.cpu.write_reg(Register::A7, 0x0010_0000);
7947        runner.bus.write_long(0x016A, 0);
7948        runner.dispatcher.tick_count = 0;
7949        runner.set_instructions_per_tick(1_000_000);
7950        runner.dispatcher.dialog_tracking = Some(dialog_tracking_for_test(0, 0));
7951
7952        let (steps, running) = runner.run_gui_slice_with_audio(16, 2, 0);
7953
7954        assert!(running);
7955        assert_eq!(steps, 2);
7956        assert_eq!(runner.guest_tick(), 2);
7957        assert_eq!(runner.cpu.read_reg(Register::PC), base);
7958    }
7959
7960    #[test]
7961    fn gui_modaldialog_refire_unfreezes_prior_control_tracking() {
7962        let mut runner = FixtureRunner::new(8 * 1024 * 1024, FixtureRunnerConfig::default());
7963        let base = 0x0001_0000u32;
7964        runner.bus.write_word(base, 0xA991); // _ModalDialog
7965        runner.cpu.write_reg(Register::PC, base);
7966        runner.cpu.write_reg(Register::A7, 0x0010_0000);
7967        runner.bus.write_long(0x016A, 182);
7968        runner.dispatcher.tick_count = 182;
7969        runner.frozen_ticks = Some(182);
7970        runner.set_instructions_per_tick(1_000_000);
7971        runner.dispatcher.dialog_tracking = Some(dialog_tracking_for_test(0, 0));
7972
7973        let (steps, running) = runner.run_gui_slice_with_audio(16, 184, 0);
7974
7975        assert!(running);
7976        assert_eq!(steps, 1);
7977        assert_eq!(runner.frozen_ticks, None);
7978        assert_eq!(runner.guest_tick(), 184);
7979        assert_eq!(runner.dispatcher.tick_count, 184);
7980    }
7981
7982    #[test]
7983    fn gui_modaldialog_null_filter_fires_at_tick_cap() {
7984        let mut runner = FixtureRunner::new(8 * 1024 * 1024, FixtureRunnerConfig::default());
7985        let base = 0x0001_0000u32;
7986        let filter_proc = 0x0001_1000u32;
7987        runner.bus.write_word(base, 0xA991); // _ModalDialog
7988        runner.bus.write_word(filter_proc, 0x4E56); // LINK A6, valid filter entry
7989        runner.cpu.write_reg(Register::PC, base);
7990        runner.cpu.write_reg(Register::A7, 0x0010_0000);
7991        runner.bus.write_long(0x016A, 0);
7992        runner.dispatcher.tick_count = 0;
7993        runner.dispatcher.dialog_tracking =
7994            Some(dialog_tracking_for_test(filter_proc, 0x0010_0100));
7995
7996        let (steps, running) = runner.run_gui_slice_with_audio(1, 0, 0);
7997
7998        assert!(running);
7999        assert_eq!(steps, 1);
8000        assert_eq!(runner.guest_tick(), 0);
8001        assert_ne!(runner.cpu.read_reg(Register::PC), base);
8002        assert!(!runner.has_pending_sound_work());
8003        assert!(
8004            !runner
8005                .dispatcher
8006                .dialog_tracking
8007                .as_ref()
8008                .unwrap()
8009                .rendered_pixels_final
8010        );
8011    }
8012
8013    #[test]
8014    fn gui_modaldialog_update_filter_fires_at_tick_cap() {
8015        let mut runner = FixtureRunner::new(8 * 1024 * 1024, FixtureRunnerConfig::default());
8016        let base = 0x0001_0000u32;
8017        let filter_proc = 0x0001_1000u32;
8018        runner.bus.write_word(base, 0xA991); // _ModalDialog
8019        runner.bus.write_word(filter_proc, 0x4E56); // LINK A6, valid filter entry
8020        runner.cpu.write_reg(Register::PC, base);
8021        runner.cpu.write_reg(Register::A7, 0x0010_0000);
8022        runner.bus.write_long(0x016A, 0);
8023        runner.dispatcher.tick_count = 0;
8024        runner.dispatcher.event_queue.push_back(QueuedEvent {
8025            what: 6,
8026            message: 0,
8027            where_v: 0,
8028            where_h: 0,
8029            modifiers: 0,
8030        });
8031        runner.dispatcher.dialog_tracking =
8032            Some(dialog_tracking_for_test(filter_proc, 0x0010_0100));
8033
8034        let (steps, running) = runner.run_gui_slice_with_audio(1, 0, 0);
8035
8036        assert!(running);
8037        assert_eq!(steps, 1);
8038        assert_eq!(runner.guest_tick(), 0);
8039        assert_ne!(runner.cpu.read_reg(Register::PC), base);
8040        assert!(
8041            !runner
8042                .dispatcher
8043                .dialog_tracking
8044                .as_ref()
8045                .unwrap()
8046                .rendered_pixels_final
8047        );
8048    }
8049
8050    #[test]
8051    fn gui_modaldialog_mouse_down_goes_to_filter_before_default_handling() {
8052        let mut runner = FixtureRunner::new(8 * 1024 * 1024, FixtureRunnerConfig::default());
8053        let base = 0x0001_0000u32;
8054        let filter_proc = 0x0001_1000u32;
8055        runner.bus.write_word(base, 0xA991); // _ModalDialog
8056        runner.bus.write_word(filter_proc, 0x4E56); // LINK A6, valid filter entry
8057        runner.cpu.write_reg(Register::PC, base);
8058        runner.cpu.write_reg(Register::A7, 0x0010_0000);
8059        runner.bus.write_long(0x016A, 0);
8060        runner.dispatcher.tick_count = 0;
8061        runner.dispatcher.event_queue.push_back(QueuedEvent {
8062            what: 1,
8063            message: 0,
8064            where_v: 12,
8065            where_h: 24,
8066            modifiers: 0,
8067        });
8068        let mut tracking = dialog_tracking_for_test(filter_proc, 0x0010_0100);
8069        tracking.items.push(DialogItem {
8070            item_type: 4,
8071            rect: (8, 16, 20, 30),
8072            text: String::from("OK"),
8073            resource_id: 0,
8074            proc_ptr: 0,
8075            sel_start: 0,
8076            sel_end: 0,
8077        });
8078        runner.dispatcher.dialog_tracking = Some(tracking);
8079
8080        let (steps, running) = runner.run_gui_slice_with_audio(1, 0, 0);
8081
8082        assert!(running);
8083        assert_eq!(steps, 1);
8084        assert_ne!(runner.cpu.read_reg(Register::PC), base);
8085        let event_ptr = runner.dialog_filter_event;
8086        assert_eq!(runner.bus.read_word(event_ptr), 1);
8087        assert_eq!(runner.bus.read_word(event_ptr + 10), 12);
8088        assert_eq!(runner.bus.read_word(event_ptr + 12), 24);
8089        assert!(
8090            runner.dispatcher.event_queue.is_empty(),
8091            "the filter callback should consume a queued button mouseDown event"
8092        );
8093    }
8094
8095    #[test]
8096    fn modaldialog_filter_null_event_is_paced_per_guest_tick() {
8097        let mut runner = FixtureRunner::new(8 * 1024 * 1024, FixtureRunnerConfig::default());
8098        let filter_proc = 0x0001_1000u32;
8099        let dialog_ptr = 0x0020_0000u32;
8100        runner.dispatcher.dialog_tracking =
8101            Some(dialog_tracking_for_test(filter_proc, 0x0010_0100));
8102        runner.dispatcher.tick_count = 42;
8103        runner.bus.write_long(0x016A, 42);
8104
8105        assert!(runner.should_fire_dialog_filter_proc());
8106
8107        runner.dialog_filter_last_null_event_tick = Some((dialog_ptr, 42));
8108        assert!(
8109            !runner.should_fire_dialog_filter_proc(),
8110            "a synthetic null event should not refire twice in the same guest tick"
8111        );
8112
8113        runner.dispatcher.tick_count = 43;
8114        runner.bus.write_long(0x016A, 43);
8115        assert!(
8116            runner.should_fire_dialog_filter_proc(),
8117            "the next guest tick should allow another ModalDialog null-event filter call"
8118        );
8119    }
8120
8121    #[test]
8122    fn modaldialog_filter_real_events_bypass_null_event_pacing() {
8123        let mut runner = FixtureRunner::new(8 * 1024 * 1024, FixtureRunnerConfig::default());
8124        let filter_proc = 0x0001_1000u32;
8125        let dialog_ptr = 0x0020_0000u32;
8126        runner.dispatcher.dialog_tracking =
8127            Some(dialog_tracking_for_test(filter_proc, 0x0010_0100));
8128        runner.dispatcher.tick_count = 42;
8129        runner.bus.write_long(0x016A, 42);
8130        runner.dialog_filter_last_null_event_tick = Some((dialog_ptr, 42));
8131        runner.dispatcher.event_queue.push_back(QueuedEvent {
8132            what: 1,
8133            message: 0,
8134            where_v: 12,
8135            where_h: 34,
8136            modifiers: 0,
8137        });
8138
8139        assert!(
8140            runner.should_fire_dialog_filter_proc(),
8141            "mouse/key/update events must still enter the filter immediately"
8142        );
8143
8144        runner.dispatcher.event_queue.clear();
8145        let window_ptr = runner.bus.alloc(170);
8146        runner.dispatcher.init_cgraf_window(
8147            &mut runner.bus,
8148            &mut runner.cpu,
8149            window_ptr,
8150            0,
8151            100,
8152            120,
8153            220,
8154            360,
8155            "",
8156            2,
8157            true,
8158            false,
8159            false,
8160            0,
8161        );
8162        runner
8163            .dispatcher
8164            .dialog_tracking
8165            .as_mut()
8166            .unwrap()
8167            .dialog_ptr = window_ptr;
8168        runner.dialog_filter_last_null_event_tick = Some((window_ptr, 42));
8169
8170        assert!(
8171            runner.should_fire_dialog_filter_proc(),
8172            "a pending updateEvt for the active dialog must bypass null-event pacing"
8173        );
8174    }
8175
8176    #[test]
8177    fn modaldialog_refire_skip_headless_requires_all_conditions() {
8178        // In headless mode, ALL noop conditions must be true.
8179        // Each condition false alone must prevent the skip.
8180        assert!(modaldialog_refire_is_noop(
8181            false, true, true, true, true, true, true,
8182        ));
8183
8184        // Each one off in turn should prevent the skip.
8185        assert!(!modaldialog_refire_is_noop(
8186            false, false, true, true, true, true, true,
8187        ));
8188        assert!(!modaldialog_refire_is_noop(
8189            false, true, false, true, true, true, true,
8190        ));
8191        assert!(!modaldialog_refire_is_noop(
8192            false, true, true, false, true, true, true,
8193        ));
8194        assert!(!modaldialog_refire_is_noop(
8195            false, true, true, true, false, true, true,
8196        ));
8197        assert!(!modaldialog_refire_is_noop(
8198            false, true, true, true, true, false, true,
8199        ));
8200        assert!(!modaldialog_refire_is_noop(
8201            false, true, true, true, true, true, false,
8202        ));
8203    }
8204
8205    #[test]
8206    fn spin_fastfwd_rejects_wrong_branch_target() {
8207        let mut runner = FixtureRunner::new(8 * 1024 * 1024, FixtureRunnerConfig::default());
8208        let base = 0x0001_0000u32;
8209        runner.bus.write_word(base, 0x594F);
8210        runner.bus.write_word(base + 2, 0xA975);
8211        runner.bus.write_word(base + 4, 0x201F);
8212        runner.bus.write_word(base + 6, 0x5380);
8213        runner.bus.write_word(base + 8, 0xB680);
8214        // BHI.S with disp8 = 0xF6 (= -10, not -12). Target would
8215        // land at base+8, not at the SUBQ.W #4, A7 at base+0.
8216        runner.bus.write_word(base + 10, 0x62F6);
8217
8218        runner.bus.write_long(0x016A, 100);
8219        runner.dispatcher.tick_count = 100;
8220        runner.cpu.write_reg(Register::D3, 500);
8221        runner.cpu.write_reg(Register::A7, 0x0010_0000);
8222        let pc_after_trap = base + 4;
8223        let mut count = 0usize;
8224        runner.try_tickcount_spin_fastfwd(pc_after_trap, None, &mut count);
8225
8226        assert_eq!(runner.dispatcher.tick_count, 100);
8227        assert_eq!(count, 0);
8228    }
8229
8230    /// Regression gate for the `inline_skipped` counter on the
8231    /// TickCount fast path. Without this, a future change that
8232    /// removes the increment (or moves it to a path that doesn't
8233    /// actually fire) would silently produce wrong real-vs-inline
8234    /// counts in the timing histogram, masking real per-dispatch
8235    /// costs.
8236    #[test]
8237    fn tickcount_inline_skip_increments_inline_skipped() {
8238        let mut runner = FixtureRunner::new(8 * 1024 * 1024, FixtureRunnerConfig::default());
8239        let base = 0x0001_0000u32;
8240        // Plain TickCount call: SUBQ.W #4, A7 ; _TickCount ; NOP
8241        // The runner's pre-dispatch fast path recognises 0xA975,
8242        // writes the tick to (A7), and continues without calling
8243        // dispatch().
8244        runner.bus.write_word(base, 0x594F); // SUBQ.W #4, A7 (reserve LONGINT slot)
8245        runner.bus.write_word(base + 2, 0xA975); // _TickCount
8246        runner.bus.write_word(base + 4, 0x4E71); // NOP (sentinel)
8247        runner.cpu.write_reg(Register::PC, base);
8248        runner.cpu.write_reg(Register::A7, 0x0010_0000);
8249        runner.dispatcher.tick_count = 0;
8250        runner.bus.write_long(0x016A, 0);
8251        runner.set_instructions_per_tick(1_000_000);
8252
8253        let idx = (0xA975u16 & 0xFFF) as usize;
8254        let before = runner.dispatcher.inline_skipped[idx];
8255
8256        // Two steps: the SUBQ first, then the trap that triggers the inline.
8257        let (steps, running) = runner.run_steps(2, None);
8258        assert!(running, "runner should not halt on a plain trap fast path");
8259        assert_eq!(steps, 2);
8260
8261        let after = runner.dispatcher.inline_skipped[idx];
8262        assert_eq!(
8263            after - before,
8264            1,
8265            "TickCount fast path must increment inline_skipped[$0175]"
8266        );
8267        assert_eq!(
8268            runner.dispatcher.trap_histogram[idx], 1,
8269            "the same path must also increment trap_histogram[$0175]"
8270        );
8271    }
8272
8273    #[test]
8274    fn halted_by_exit_to_shell_classifies_clean_application_quit() {
8275        let mut runner = FixtureRunner::new(8 * 1024 * 1024, FixtureRunnerConfig::default());
8276        let base = 0x0001_0000u32;
8277        runner.bus.write_word(base, 0xA9F4); // _ExitToShell
8278        runner.cpu.write_reg(Register::PC, base);
8279        runner.cpu.write_reg(Register::A7, 0x0010_0000);
8280
8281        let (_steps, running) = runner.run_steps(1, None);
8282
8283        assert!(!running, "ExitToShell should stop the runner");
8284        assert!(runner.is_halted());
8285        assert_eq!(runner.halted_trap(), Some(0xA9F4));
8286        assert!(
8287            runner.halted_by_exit_to_shell(),
8288            "ExitToShell halt must be classified as a clean application exit"
8289        );
8290    }
8291
8292    #[test]
8293    fn halted_by_exit_to_shell_rejects_invalid_pc_halts() {
8294        let mut runner = FixtureRunner::new(8 * 1024 * 1024, FixtureRunnerConfig::default());
8295        runner.cpu.write_reg(Register::PC, runner.bus.ram_size());
8296        runner.cpu.write_reg(Register::A7, 0x0010_0000);
8297
8298        let (_steps, running) = runner.run_steps(1, None);
8299
8300        assert!(!running, "invalid PC should stop the runner");
8301        assert!(runner.is_halted());
8302        assert_eq!(runner.halted_trap(), None);
8303        assert!(
8304            !runner.halted_by_exit_to_shell(),
8305            "invalid-PC halts must not be reported as clean application exits"
8306        );
8307    }
8308
8309    #[test]
8310    fn ptinrect_inline_path_matches_pascal_stack_contract() {
8311        let mut runner = FixtureRunner::new(8 * 1024 * 1024, FixtureRunnerConfig::default());
8312        let base = 0x0001_0000u32;
8313        let sp = 0x0010_0000u32;
8314        let rect = 0x0020_0000u32;
8315
8316        runner.bus.write_word(base, 0xA8AD); // _PtInRect
8317        runner.cpu.write_reg(Register::PC, base);
8318        runner.cpu.write_reg(Register::A7, sp);
8319        runner.set_instructions_per_tick(1_000_000);
8320
8321        runner.bus.write_long(sp, rect);
8322        runner.bus.write_word(sp + 4, 20); // pt.v
8323        runner.bus.write_word(sp + 6, 30); // pt.h
8324        runner.bus.write_word(rect, 10); // top
8325        runner.bus.write_word(rect + 2, 25); // left
8326        runner.bus.write_word(rect + 4, 40); // bottom
8327        runner.bus.write_word(rect + 6, 50); // right
8328
8329        let idx = (0xA8ADu16 & 0xFFF) as usize;
8330        let before_inline = runner.dispatcher.inline_skipped[idx];
8331        let before_game = runner.dispatcher.game_trap_count;
8332
8333        let (steps, running) = runner.run_steps(1, None);
8334
8335        assert!(running, "runner should not halt on PtInRect inline path");
8336        assert_eq!(steps, 1);
8337        assert_eq!(runner.cpu.read_reg(Register::PC), base + 2);
8338        assert_eq!(runner.cpu.read_reg(Register::A7), sp + 8);
8339        assert_eq!(runner.bus.read_word(sp + 8), 0x0100);
8340        assert_eq!(runner.dispatcher.inline_skipped[idx] - before_inline, 1);
8341        assert_eq!(runner.dispatcher.trap_histogram[idx], 1);
8342        assert_eq!(runner.dispatcher.game_trap_count - before_game, 1);
8343    }
8344
8345    #[test]
8346    fn eventavail_inline_path_peeks_without_dequeueing() {
8347        let mut runner = FixtureRunner::new(8 * 1024 * 1024, FixtureRunnerConfig::default());
8348        let base = 0x0001_0000u32;
8349        let sp = 0x0010_0000u32;
8350        let event = 0x0020_0000u32;
8351
8352        runner.bus.write_word(base, 0xA971); // _EventAvail
8353        runner.cpu.write_reg(Register::PC, base);
8354        runner.cpu.write_reg(Register::A7, sp);
8355        runner.set_instructions_per_tick(1_000_000);
8356        runner.bus.write_long(sp, event);
8357        runner.bus.write_word(sp + 4, 0x0008); // keyDownMask
8358        runner.dispatcher.push_key_down(0x31, b' ');
8359
8360        let idx = (0xA971u16 & 0xFFF) as usize;
8361        let before_inline = runner.dispatcher.inline_skipped[idx];
8362        let before_game = runner.dispatcher.game_trap_count;
8363
8364        let (steps, running) = runner.run_steps(1, None);
8365
8366        assert!(running, "runner should not halt on EventAvail inline path");
8367        assert_eq!(steps, 1);
8368        assert_eq!(runner.cpu.read_reg(Register::PC), base + 2);
8369        assert_eq!(runner.cpu.read_reg(Register::A7), sp + 6);
8370        assert_eq!(runner.bus.read_word(sp + 6), 0xFFFF);
8371        assert_eq!(runner.bus.read_word(event), 3);
8372        assert_eq!(
8373            runner.bus.read_long(event + 2),
8374            (0x31u32 << 8) | u32::from(b' ')
8375        );
8376        assert_eq!(
8377            runner.dispatcher.event_queue.len(),
8378            1,
8379            "EventAvail must not dequeue the matching event"
8380        );
8381        assert_eq!(runner.dispatcher.inline_skipped[idx] - before_inline, 1);
8382        assert_eq!(runner.dispatcher.trap_histogram[idx], 1);
8383        assert_eq!(
8384            runner.dispatcher.game_trap_count, before_game,
8385            "EventAvail remains excluded from game_trap_count as an idle trap"
8386        );
8387    }
8388
8389    /// Regression gate for the `inline_skipped` counter on the
8390    /// ModalDialog batched no-op refire path. The runner's pre-
8391    /// dispatch fast path increments `inline_skipped[$0191]` once
8392    /// for the entry plus `BATCH-1=63` times in the inner loop.
8393    /// Without this test, a regression that drops the increment
8394    /// would silently make ModalDialog look ~99% inline-skipped
8395    /// without surfacing anywhere else.
8396    #[test]
8397    fn modaldialog_batched_skip_increments_inline_skipped_by_batch() {
8398        use crate::trap::dispatch::DialogTrackingState;
8399        use std::collections::VecDeque;
8400
8401        let mut runner = FixtureRunner::new(8 * 1024 * 1024, FixtureRunnerConfig::default());
8402        let base = 0x0001_0000u32;
8403        // Bare ModalDialog at PC. The runner-level fast path rewinds
8404        // PC after each fire, so re-firing repeatedly into the same
8405        // trap word is the production behaviour.
8406        runner.bus.write_word(base, 0xA991); // _ModalDialog
8407        runner.cpu.write_reg(Register::PC, base);
8408        runner.cpu.write_reg(Register::A7, 0x0010_0000);
8409        runner.dispatcher.tick_count = 0;
8410        runner.bus.write_long(0x016A, 0);
8411        runner.set_instructions_per_tick(1_000_000);
8412
8413        // Populate dialog_tracking so the noop_refire pure-decision
8414        // function returns true. modaldialog_refire_is_noop requires
8415        // ALL of: tracking present, filter_proc=0, flash_remaining=0,
8416        // draw_procs_done, rendered_pixels_final, event queue empty
8417        // (and yield_for_ui = false in headless run_steps).
8418        runner.dispatcher.dialog_tracking = Some(DialogTrackingState {
8419            dialog_ptr: 0x0020_0000,
8420            bounds: (0, 0, 32, 32),
8421            title: String::new(),
8422            proc_id: 1,
8423            items: Vec::new(),
8424            default_item: 0,
8425            cancel_item: 0,
8426            edit_text: String::new(),
8427            edit_item: 0,
8428            saved_pixels: Vec::new(),
8429            stack_ptr: 0,
8430            item_hit_ptr: 0,
8431            rendered_pixels: Vec::new(),
8432            flash_remaining: 0,
8433            flash_delay: 0,
8434            flash_item: 0,
8435            edit_text_modified: false,
8436            draw_proc_queue: VecDeque::new(),
8437            draw_procs_done: true,
8438            rendered_pixels_final: true,
8439            filter_proc: 0,
8440            game_managed: false,
8441            last_filter_event: None,
8442            popup_draws: Vec::new(),
8443            active_popup: None,
8444            active_button: None,
8445            active_user_item: None,
8446        });
8447
8448        let idx = (0xA991u16 & 0xFFF) as usize;
8449        let before_inline = runner.dispatcher.inline_skipped[idx];
8450        let before_hist = runner.dispatcher.trap_histogram[idx];
8451
8452        // BATCH=64 in the runner. With max_steps=64 we should observe
8453        // exactly one entry + 63 batched iterations = 64 increments.
8454        let (steps, _running) = runner.run_steps(64, None);
8455
8456        assert_eq!(
8457            steps, 64,
8458            "max_steps cap exhausted by 64 batched no-op refires"
8459        );
8460        let after_inline = runner.dispatcher.inline_skipped[idx];
8461        let after_hist = runner.dispatcher.trap_histogram[idx];
8462        assert_eq!(
8463            after_inline - before_inline,
8464            64,
8465            "batched skip must increment inline_skipped[$0191] by BATCH=64"
8466        );
8467        assert_eq!(
8468            after_hist - before_hist,
8469            64,
8470            "trap_histogram and inline_skipped must increment in lockstep on the inline path"
8471        );
8472    }
8473
8474    #[test]
8475    fn modaldialog_batched_skip_applies_after_paced_filter_null_event() {
8476        let mut runner = FixtureRunner::new(8 * 1024 * 1024, FixtureRunnerConfig::default());
8477        let base = 0x0001_0000u32;
8478        let filter_proc = 0x0001_1000u32;
8479        let dialog_ptr = 0x0020_0000u32;
8480        runner.bus.write_word(base, 0xA991); // _ModalDialog
8481        runner.cpu.write_reg(Register::PC, base);
8482        runner.cpu.write_reg(Register::A7, 0x0010_0000);
8483        runner.dispatcher.tick_count = 42;
8484        runner.bus.write_long(0x016A, 42);
8485        runner.set_instructions_per_tick(1_000_000);
8486        runner.dispatcher.dialog_tracking =
8487            Some(dialog_tracking_for_test(filter_proc, 0x0010_0100));
8488        runner.dialog_filter_last_null_event_tick = Some((dialog_ptr, 42));
8489
8490        let idx = (0xA991u16 & 0xFFF) as usize;
8491        let before_inline = runner.dispatcher.inline_skipped[idx];
8492        let before_hist = runner.dispatcher.trap_histogram[idx];
8493
8494        let (steps, running) = runner.run_steps(64, None);
8495
8496        assert!(running);
8497        assert_eq!(steps, 64);
8498        assert_eq!(runner.cpu.read_reg(Register::PC), base);
8499        assert_eq!(runner.dispatcher.inline_skipped[idx] - before_inline, 64);
8500        assert_eq!(runner.dispatcher.trap_histogram[idx] - before_hist, 64);
8501    }
8502
8503    #[test]
8504    fn tick_progress_persists_across_multiple_run_slices() {
8505        let mut runner = FixtureRunner::new(8 * 1024 * 1024, FixtureRunnerConfig::default());
8506        let program_start = 0x0001_0000;
8507        let program_words = 12;
8508
8509        for offset in (0..program_words).step_by(2) {
8510            runner.bus.write_word(program_start + offset, 0x4E71);
8511        }
8512
8513        runner.cpu.write_reg(Register::PC, program_start);
8514        runner.cpu.write_reg(Register::A7, 0x007F_FFC0);
8515        runner.bus.write_long(0x016A, 0);
8516        runner.set_instructions_per_tick(5);
8517
8518        let (steps1, running1) = runner.run_steps(3, None);
8519        let (steps2, running2) = runner.run_steps(3, None);
8520
8521        assert!(running1);
8522        assert!(running2);
8523        assert_eq!(steps1, 3);
8524        assert_eq!(steps2, 3);
8525        assert_eq!(runner.bus.read_long(0x016A), 1);
8526    }
8527
8528    #[test]
8529    fn tick_override_breaks_once_target_tick_is_reached() {
8530        let mut runner = FixtureRunner::new(8 * 1024 * 1024, FixtureRunnerConfig::default());
8531        let program_start = 0x0001_0000;
8532        let program_words = 16;
8533
8534        for offset in (0..program_words).step_by(2) {
8535            runner.bus.write_word(program_start + offset, 0x4E71);
8536        }
8537
8538        runner.cpu.write_reg(Register::PC, program_start);
8539        runner.cpu.write_reg(Register::A7, 0x007F_FFC0);
8540        runner.bus.write_long(0x016A, 0);
8541        runner.set_instructions_per_tick(4);
8542
8543        let (steps, running) = runner.run_steps_with_audio(16, Some(0), 0);
8544
8545        assert!(running);
8546        assert_eq!(steps, 3);
8547        assert_eq!(runner.bus.read_long(0x016A), 0);
8548    }
8549
8550    #[test]
8551    fn pending_wait_sleep_ticks_advance_in_headless_mode() {
8552        let mut runner = FixtureRunner::new(8 * 1024 * 1024, FixtureRunnerConfig::default());
8553        let program_start = 0x0001_0000;
8554
8555        runner.bus.write_word(program_start, 0x4E71);
8556        runner.cpu.write_reg(Register::PC, program_start);
8557        runner.cpu.write_reg(Register::A7, 0x007F_FFC0);
8558        runner.bus.write_long(0x016A, 0);
8559        runner.dispatcher.pending_wait_sleep_ticks = 3;
8560
8561        let (steps, running) = runner.run_steps(1, None);
8562
8563        assert!(running);
8564        assert_eq!(steps, 1);
8565        assert_eq!(runner.bus.read_long(0x016A), 3);
8566        assert_eq!(runner.dispatcher.pending_wait_sleep_ticks, 0);
8567    }
8568
8569    #[test]
8570    fn pending_wait_sleep_ticks_capped_to_zero_in_headless() {
8571        // `cap=Some(0)` is the scripted default — `WaitNextEvent`
8572        // sleep is treated as a zero-cost return (matching real Mac OS
8573        // where WNE doesn't directly tick; only the VBL hardware
8574        // interrupt does).
8575        let mut runner = FixtureRunner::new(8 * 1024 * 1024, FixtureRunnerConfig::default());
8576        let program_start = 0x0001_0000;
8577
8578        runner.bus.write_word(program_start, 0x4E71);
8579        runner.cpu.write_reg(Register::PC, program_start);
8580        runner.cpu.write_reg(Register::A7, 0x007F_FFC0);
8581        runner.bus.write_long(0x016A, 0);
8582        runner.set_wait_sleep_cap_in_headless(Some(0));
8583        runner.dispatcher.pending_wait_sleep_ticks = 60;
8584
8585        let (_steps, _running) = runner.run_steps(1, None);
8586
8587        // Zero ticks advanced (cap=0).
8588        assert_eq!(runner.bus.read_long(0x016A), 0);
8589        // But pending sleep is cleared so the game resumes immediately.
8590        assert_eq!(runner.dispatcher.pending_wait_sleep_ticks, 0);
8591    }
8592
8593    #[test]
8594    fn pending_wait_sleep_ticks_capped_in_headless_when_opt_in() {
8595        // Headless callers (e.g. scripted harnesses) can opt in to a
8596        // per-WNE-call sleep tick cap matching GUI mode, preventing
8597        // tick counts from racing ahead of real-Mac VBL pacing during
8598        // event-loop-heavy gameplay.
8599        let mut runner = FixtureRunner::new(8 * 1024 * 1024, FixtureRunnerConfig::default());
8600        let program_start = 0x0001_0000;
8601
8602        runner.bus.write_word(program_start, 0x4E71);
8603        runner.cpu.write_reg(Register::PC, program_start);
8604        runner.cpu.write_reg(Register::A7, 0x007F_FFC0);
8605        runner.bus.write_long(0x016A, 0);
8606        runner.set_wait_sleep_cap_in_headless(Some(1));
8607        runner.dispatcher.pending_wait_sleep_ticks = 60;
8608
8609        let (steps, running) = runner.run_steps(1, None);
8610
8611        assert!(running);
8612        assert_eq!(steps, 1);
8613        // Only 1 tick advanced (cap), not the full 60.
8614        assert_eq!(runner.bus.read_long(0x016A), 1);
8615        assert_eq!(runner.dispatcher.pending_wait_sleep_ticks, 0);
8616        assert_eq!(runner.wait_sleep_cap_in_headless(), Some(1));
8617    }
8618
8619    #[test]
8620    fn pending_wait_sleep_ticks_suspends_foreground_until_gui_tick_cap() {
8621        // In GUI mode (tick_override=Some), WNE sleep advances VBL/timer time
8622        // up to the current frame cap but keeps the foreground app suspended
8623        // until the requested sleep expires. This prevents sleep=60 loops from
8624        // receiving 60 null events per second. Inside Macintosh: Processes
8625        // 1994, p. 2-8.
8626        let mut runner = FixtureRunner::new(8 * 1024 * 1024, FixtureRunnerConfig::default());
8627        let program_start = 0x0001_0000;
8628
8629        runner.bus.write_word(program_start, 0x4E71);
8630        runner.cpu.write_reg(Register::PC, program_start);
8631        runner.cpu.write_reg(Register::A7, 0x007F_FFC0);
8632        runner.bus.write_long(0x016A, 0);
8633        runner.dispatcher.pending_wait_sleep_ticks = 60;
8634
8635        let (steps, running) = runner.run_steps(1, Some(10));
8636
8637        assert!(running);
8638        assert_eq!(
8639            steps, 0,
8640            "foreground code should not resume while WNE sleep remains pending"
8641        );
8642        assert_eq!(runner.bus.read_long(0x016A), 10);
8643        assert_eq!(runner.dispatcher.pending_wait_sleep_ticks, 50);
8644    }
8645
8646    #[test]
8647    fn pending_wait_sleep_ticks_wakes_wait_next_event_with_queued_input() {
8648        let mut runner = FixtureRunner::new(8 * 1024 * 1024, FixtureRunnerConfig::default());
8649        let program_start = 0x0001_0000;
8650        let event_ptr = 0x0020_0000;
8651        let result_ptr = 0x0020_0020;
8652
8653        runner.bus.write_word(program_start, 0x4E71);
8654        runner.cpu.write_reg(Register::PC, program_start);
8655        runner.cpu.write_reg(Register::A7, 0x007F_FFC0);
8656        runner.bus.write_long(0x016A, 0);
8657        runner.bus.write_word(result_ptr, 0);
8658        runner.dispatcher.sent_open_app_event = true;
8659        runner
8660            .dispatcher
8661            .write_event_record(&mut runner.bus, event_ptr, 0, 0, 0, 0, 0);
8662        runner.dispatcher.pending_wait_sleep_ticks = 60;
8663        runner.dispatcher.pending_wait_next_event_return = Some(PendingWaitNextEventReturn {
8664            event_ptr,
8665            result_ptr,
8666            event_mask: 0xFFFF,
8667            mouse_rgn: 0,
8668            resume_pc: None,
8669            resume_sp: None,
8670        });
8671        runner.push_mouse_down(123, 456);
8672
8673        let (steps, running) = runner.run_steps(1, Some(10));
8674
8675        assert!(running);
8676        assert_eq!(steps, 1);
8677        assert_eq!(
8678            runner.bus.read_word(event_ptr),
8679            1,
8680            "queued mouseDown should replace the pending null EventRecord"
8681        );
8682        assert_eq!(runner.bus.read_word(event_ptr + 10), 123u16);
8683        assert_eq!(runner.bus.read_word(event_ptr + 12), 456u16);
8684        assert_eq!(
8685            runner.bus.read_word(result_ptr),
8686            0xFFFF,
8687            "WaitNextEvent result slot should be rewritten to TRUE"
8688        );
8689        assert_eq!(runner.dispatcher.pending_wait_sleep_ticks, 0);
8690        assert!(runner.dispatcher.pending_wait_next_event_return.is_none());
8691    }
8692
8693    #[test]
8694    fn push_mouse_down_wakes_pending_wait_next_event_immediately() {
8695        let mut runner = FixtureRunner::new(8 * 1024 * 1024, FixtureRunnerConfig::default());
8696        let event_ptr = 0x0020_0000;
8697        let result_ptr = 0x0020_0020;
8698
8699        runner.bus.write_word(result_ptr, 0);
8700        runner.dispatcher.sent_open_app_event = true;
8701        runner
8702            .dispatcher
8703            .write_event_record(&mut runner.bus, event_ptr, 0, 0, 0, 0, 0);
8704        runner.dispatcher.pending_wait_sleep_ticks = 60;
8705        runner.dispatcher.pending_wait_next_event_return = Some(PendingWaitNextEventReturn {
8706            event_ptr,
8707            result_ptr,
8708            event_mask: 0xFFFF,
8709            mouse_rgn: 0,
8710            resume_pc: None,
8711            resume_sp: None,
8712        });
8713
8714        runner.push_mouse_down(123, 456);
8715
8716        assert_eq!(
8717            runner.bus.read_word(event_ptr),
8718            1,
8719            "input injection should wake a sleeping WaitNextEvent before the next CPU slice"
8720        );
8721        assert_eq!(runner.bus.read_word(event_ptr + 10), 123u16);
8722        assert_eq!(runner.bus.read_word(event_ptr + 12), 456u16);
8723        assert_eq!(runner.bus.read_word(result_ptr), 0xFFFF);
8724        assert_eq!(runner.dispatcher.pending_wait_sleep_ticks, 0);
8725        assert!(runner.dispatcher.pending_wait_next_event_return.is_none());
8726    }
8727
8728    #[test]
8729    fn set_mouse_position_wakes_pending_wait_next_event_with_mouse_moved_region() {
8730        let mut runner = FixtureRunner::new(8 * 1024 * 1024, FixtureRunnerConfig::default());
8731        let event_ptr = 0x0020_0000;
8732        let result_ptr = 0x0020_0020;
8733        let mouse_rgn = test_region_handle(&mut runner.bus, 10, 20, 30, 40);
8734
8735        runner.set_mouse_position(20, 25);
8736        runner.bus.write_word(result_ptr, 0);
8737        runner.dispatcher.sent_open_app_event = true;
8738        runner
8739            .dispatcher
8740            .write_event_record(&mut runner.bus, event_ptr, 0, 0, 0, 0, 0);
8741        runner.dispatcher.pending_wait_sleep_ticks = 60;
8742        runner.dispatcher.pending_wait_next_event_return = Some(PendingWaitNextEventReturn {
8743            event_ptr,
8744            result_ptr,
8745            event_mask: 0x8000,
8746            mouse_rgn,
8747            resume_pc: None,
8748            resume_sp: None,
8749        });
8750
8751        runner.set_mouse_position(50, 25);
8752
8753        assert_eq!(
8754            runner.bus.read_word(event_ptr),
8755            15,
8756            "mouse movement outside the pending mouseRgn should wake WaitNextEvent with osEvt"
8757        );
8758        assert_eq!(runner.bus.read_long(event_ptr + 2), 0xFA00_0000);
8759        assert_eq!(runner.bus.read_word(event_ptr + 10), 50u16);
8760        assert_eq!(runner.bus.read_word(event_ptr + 12), 25u16);
8761        assert_eq!(runner.bus.read_word(result_ptr), 0xFFFF);
8762        assert_eq!(runner.dispatcher.pending_wait_sleep_ticks, 0);
8763        assert!(runner.dispatcher.pending_wait_next_event_return.is_none());
8764        assert_eq!(
8765            runner.dispatcher.debug_mouse_moved_event_count, 1,
8766            "async wake path should share the normal mouse-moved event accounting"
8767        );
8768    }
8769
8770    #[test]
8771    fn set_mouse_position_wakes_pending_wait_next_event_with_null_for_polling_loop() {
8772        let mut runner = FixtureRunner::new(8 * 1024 * 1024, FixtureRunnerConfig::default());
8773        let program_start = 0x0001_0000;
8774        let event_ptr = 0x0020_0000;
8775        let result_ptr = 0x0020_0020;
8776
8777        runner.bus.write_word(program_start, 0x4E71); // NOP
8778        runner.cpu.write_reg(Register::PC, program_start);
8779        runner.cpu.write_reg(Register::A7, 0x007F_FFC0);
8780        runner.bus.write_long(0x016A, 100);
8781        runner.dispatcher.tick_count = 100;
8782        runner.tick_budget = 0;
8783        runner.bus.write_word(result_ptr, 0xFFFF);
8784        runner.dispatcher.sent_open_app_event = true;
8785        runner.dispatcher.write_event_record(
8786            &mut runner.bus,
8787            event_ptr,
8788            0xFFFF,
8789            0xABCD_EF01,
8790            1,
8791            2,
8792            3,
8793        );
8794        runner.dispatcher.pending_wait_sleep_ticks = 60;
8795        runner.dispatcher.pending_wait_next_event_return = Some(PendingWaitNextEventReturn {
8796            event_ptr,
8797            result_ptr,
8798            event_mask: 0xFFFF,
8799            mouse_rgn: 0,
8800            resume_pc: None,
8801            resume_sp: None,
8802        });
8803
8804        runner.set_mouse_position(123, 456);
8805
8806        assert_eq!(
8807            runner.bus.read_word(event_ptr),
8808            0,
8809            "mouse movement with no mouseRgn event should wake WNE as a null event for polling loops"
8810        );
8811        assert_eq!(runner.bus.read_long(event_ptr + 2), 0);
8812        assert_eq!(runner.bus.read_word(event_ptr + 10), 123u16);
8813        assert_eq!(runner.bus.read_word(event_ptr + 12), 456u16);
8814        assert_eq!(
8815            runner.bus.read_word(result_ptr),
8816            0,
8817            "WaitNextEvent should return FALSE when the wake is only for polling input"
8818        );
8819        assert_eq!(runner.dispatcher.pending_wait_sleep_ticks, 0);
8820        assert!(runner.dispatcher.pending_wait_next_event_return.is_none());
8821
8822        let (steps, running) = runner.run_steps(1, Some(110));
8823        assert!(running);
8824        assert_eq!(
8825            steps, 1,
8826            "polling wake should let foreground code resume before the old sleep expires"
8827        );
8828        assert_eq!(
8829            runner.bus.read_long(0x016A),
8830            100,
8831            "polling wake must not spend the next slice only advancing ticks"
8832        );
8833    }
8834
8835    #[test]
8836    fn push_mouse_down_leaves_pending_wait_next_event_parked_during_interrupt_callback() {
8837        let mut runner = FixtureRunner::new(8 * 1024 * 1024, FixtureRunnerConfig::default());
8838        let event_ptr = 0x0020_0000;
8839        let result_ptr = 0x0020_0020;
8840        let interrupted_pc = 0x0001_0000;
8841        let interrupted_sp = 0x007F_FFC0;
8842
8843        runner.bus.write_word(result_ptr, 0);
8844        runner.dispatcher.sent_open_app_event = true;
8845        runner
8846            .dispatcher
8847            .write_event_record(&mut runner.bus, event_ptr, 0, 0, 0, 0, 0);
8848        runner.dispatcher.pending_wait_sleep_ticks = 60;
8849        runner.dispatcher.pending_wait_next_event_return = Some(PendingWaitNextEventReturn {
8850            event_ptr,
8851            result_ptr,
8852            event_mask: 0xFFFF,
8853            mouse_rgn: 0,
8854            resume_pc: None,
8855            resume_sp: None,
8856        });
8857        runner.active_interrupt_callback = Some(ActiveInterruptCallback {
8858            source: ActiveInterruptCallbackSource::Timer,
8859            resume_pc: interrupted_pc,
8860            resume_sp: interrupted_sp,
8861            d_regs: [0; 8],
8862            a_regs: [0, 0, 0, 0, 0, 0, 0, interrupted_sp],
8863            ccr: 0,
8864            restore_port: None,
8865        });
8866
8867        runner.push_mouse_down(123, 456);
8868
8869        assert_eq!(
8870            runner.bus.read_word(event_ptr),
8871            0,
8872            "input must not rewrite a foreground WaitNextEvent record while an interrupt callback is active"
8873        );
8874        assert_eq!(runner.bus.read_word(result_ptr), 0);
8875        assert_eq!(runner.dispatcher.pending_wait_sleep_ticks, 60);
8876        assert!(runner.dispatcher.pending_wait_next_event_return.is_some());
8877        assert!(
8878            runner
8879                .dispatcher
8880                .event_queue
8881                .iter()
8882                .any(|event| event.what == 1 && event.where_v == 123 && event.where_h == 456),
8883            "the mouseDown should remain queued for the foreground event loop"
8884        );
8885    }
8886
8887    #[test]
8888    fn pending_wait_next_event_drops_stale_return_after_foreground_moves_on() {
8889        let mut runner = FixtureRunner::new(8 * 1024 * 1024, FixtureRunnerConfig::default());
8890        let parked_pc = 0x0001_0000;
8891        let stale_pc = 0x0001_0010;
8892        let parked_sp = 0x007F_FFC0;
8893        let event_ptr = 0x0020_0000;
8894        let result_ptr = 0x0020_0020;
8895
8896        runner.bus.write_word(stale_pc, 0x4E71); // NOP
8897        runner.cpu.write_reg(Register::PC, stale_pc);
8898        runner.cpu.write_reg(Register::A7, parked_sp);
8899        runner.bus.write_word(result_ptr, 0xA582);
8900        runner.dispatcher.sent_open_app_event = true;
8901        runner
8902            .dispatcher
8903            .write_event_record(&mut runner.bus, event_ptr, 0, 0, 0, 0, 0);
8904        runner.dispatcher.pending_wait_sleep_ticks = 60;
8905        runner.dispatcher.pending_wait_next_event_return = Some(PendingWaitNextEventReturn {
8906            event_ptr,
8907            result_ptr,
8908            event_mask: 0xFFFF,
8909            mouse_rgn: 0,
8910            resume_pc: Some(parked_pc),
8911            resume_sp: Some(parked_sp),
8912        });
8913        runner.dispatcher.push_mouse_down(123, 456);
8914
8915        let (steps, running) = runner.run_steps(1, Some(10));
8916
8917        assert!(running);
8918        assert_eq!(steps, 1);
8919        assert_eq!(
8920            runner.bus.read_word(result_ptr),
8921            0xA582,
8922            "a stale WaitNextEvent return slot may now belong to a caller frame"
8923        );
8924        assert_eq!(runner.dispatcher.pending_wait_sleep_ticks, 0);
8925        assert!(runner.dispatcher.pending_wait_next_event_return.is_none());
8926        assert!(
8927            runner
8928                .dispatcher
8929                .event_queue
8930                .iter()
8931                .any(|event| event.what == 1 && event.where_v == 123 && event.where_h == 456),
8932            "stale WNE cleanup should not silently consume a queued event"
8933        );
8934    }
8935
8936    #[test]
8937    fn push_mouse_down_restores_foreground_budget_before_next_tick_cap_run() {
8938        let mut runner = FixtureRunner::new(8 * 1024 * 1024, FixtureRunnerConfig::default());
8939        let program_start = 0x0001_0000;
8940
8941        runner.bus.write_word(program_start, 0x4E71); // NOP
8942        runner.cpu.write_reg(Register::PC, program_start);
8943        runner.cpu.write_reg(Register::A7, 0x007F_FFC0);
8944        runner.bus.write_long(0x016A, 100);
8945        runner.dispatcher.tick_count = 100;
8946        runner.tick_budget = 0;
8947
8948        runner.push_mouse_down(123, 456);
8949
8950        let (steps, running) = runner.run_steps(1, Some(110));
8951
8952        assert!(running);
8953        assert_eq!(
8954            steps, 1,
8955            "input injected at an exhausted tick boundary should let foreground code run"
8956        );
8957        assert_eq!(
8958            runner.bus.read_long(0x016A),
8959            100,
8960            "foreground input wake must not spend the next slice only advancing ticks"
8961        );
8962    }
8963
8964    #[test]
8965    fn set_mouse_position_restores_foreground_budget_before_next_tick_cap_run() {
8966        let mut runner = FixtureRunner::new(8 * 1024 * 1024, FixtureRunnerConfig::default());
8967        let program_start = 0x0001_0000;
8968
8969        runner.bus.write_word(program_start, 0x4E71); // NOP
8970        runner.cpu.write_reg(Register::PC, program_start);
8971        runner.cpu.write_reg(Register::A7, 0x007F_FFC0);
8972        runner.bus.write_long(0x016A, 100);
8973        runner.dispatcher.tick_count = 100;
8974        runner.tick_budget = 0;
8975
8976        runner.set_mouse_position(123, 456);
8977
8978        let (steps, running) = runner.run_steps(1, Some(110));
8979
8980        assert!(running);
8981        assert_eq!(
8982            steps, 1,
8983            "mouse movement at an exhausted tick boundary should let polling foreground code run"
8984        );
8985        assert_eq!(
8986            runner.bus.read_long(0x016A),
8987            100,
8988            "foreground mouse-move wake must not spend the next slice only advancing ticks"
8989        );
8990    }
8991
8992    #[test]
8993    fn pending_wait_sleep_ticks_honors_app_owned_visible_dialog_snapshot_in_gui_mode() {
8994        let mut runner = FixtureRunner::new(8 * 1024 * 1024, FixtureRunnerConfig::default());
8995        let program_start = 0x0001_0000;
8996        let dialog_ptr = 0x0020_0000;
8997
8998        runner.bus.write_word(program_start, 0x4E71);
8999        runner.cpu.write_reg(Register::PC, program_start);
9000        runner.cpu.write_reg(Register::A7, 0x007F_FFC0);
9001        runner.bus.write_long(0x016A, 0);
9002        runner.dispatcher.dialog_visible_snapshots.insert(
9003            dialog_ptr,
9004            crate::trap::dispatch::PersistentDialogSnapshot {
9005                bounds: (10, 10, 40, 40),
9006                pixels: Vec::new(),
9007            },
9008        );
9009        runner.dispatcher.pending_wait_sleep_ticks = 60;
9010
9011        let (steps, running) = runner.run_steps(1, Some(10));
9012
9013        assert!(running);
9014        assert_eq!(
9015            steps, 0,
9016            "app-owned visible dialogs must not collapse WaitNextEvent sleep before ModalDialog"
9017        );
9018        assert_eq!(runner.bus.read_long(0x016A), 10);
9019        assert_eq!(runner.dispatcher.pending_wait_sleep_ticks, 50);
9020    }
9021
9022    #[test]
9023    fn pending_wait_sleep_ticks_honors_app_owned_visible_dialog_snapshot_in_headless_cap_zero() {
9024        let mut runner = FixtureRunner::new(8 * 1024 * 1024, FixtureRunnerConfig::default());
9025        let program_start = 0x0001_0000;
9026        let dialog_ptr = 0x0020_0000;
9027
9028        runner.bus.write_word(program_start, 0x4E71);
9029        runner.cpu.write_reg(Register::PC, program_start);
9030        runner.cpu.write_reg(Register::A7, 0x007F_FFC0);
9031        runner.bus.write_long(0x016A, 0);
9032        runner.set_wait_sleep_cap_in_headless(Some(0));
9033        runner.dispatcher.dialog_visible_snapshots.insert(
9034            dialog_ptr,
9035            crate::trap::dispatch::PersistentDialogSnapshot {
9036                bounds: (10, 10, 40, 40),
9037                pixels: Vec::new(),
9038            },
9039        );
9040        runner.dispatcher.pending_wait_sleep_ticks = 60;
9041
9042        let (steps, running) = runner.run_steps(1, None);
9043
9044        assert!(running);
9045        assert_eq!(
9046            steps, 1,
9047            "headless cap zero must not collapse app-owned dialog sleep"
9048        );
9049        assert_eq!(runner.bus.read_long(0x016A), 60);
9050        assert_eq!(runner.dispatcher.pending_wait_sleep_ticks, 0);
9051    }
9052
9053    #[test]
9054    fn pending_wait_sleep_ticks_collapses_retained_modaldialog_snapshot_in_gui_mode() {
9055        let mut runner = FixtureRunner::new(8 * 1024 * 1024, FixtureRunnerConfig::default());
9056        let program_start = 0x0001_0000;
9057        let dialog_ptr = 0x0020_0000;
9058
9059        runner.bus.write_word(program_start, 0x4E71);
9060        runner.cpu.write_reg(Register::PC, program_start);
9061        runner.cpu.write_reg(Register::A7, 0x007F_FFC0);
9062        runner.bus.write_long(0x016A, 0);
9063        runner.dispatcher.dialog_visible_snapshots.insert(
9064            dialog_ptr,
9065            crate::trap::dispatch::PersistentDialogSnapshot {
9066                bounds: (10, 10, 40, 40),
9067                pixels: Vec::new(),
9068            },
9069        );
9070        runner.dispatcher.dialog_modal_entered.insert(dialog_ptr);
9071        runner.dispatcher.pending_wait_sleep_ticks = 60;
9072
9073        let (steps, running) = runner.run_steps(1, Some(10));
9074
9075        assert!(running);
9076        assert_eq!(
9077            steps, 1,
9078            "retained ModalDialog snapshots keep the existing app-yield path"
9079        );
9080        assert_eq!(runner.bus.read_long(0x016A), 0);
9081        assert_eq!(runner.dispatcher.pending_wait_sleep_ticks, 0);
9082    }
9083
9084    #[test]
9085    fn pending_wait_sleep_ticks_resumes_when_gui_sleep_expires_before_cap() {
9086        let mut runner = FixtureRunner::new(8 * 1024 * 1024, FixtureRunnerConfig::default());
9087        let program_start = 0x0001_0000;
9088
9089        runner.bus.write_word(program_start, 0x4E71);
9090        runner.cpu.write_reg(Register::PC, program_start);
9091        runner.cpu.write_reg(Register::A7, 0x007F_FFC0);
9092        runner.bus.write_long(0x016A, 0);
9093        runner.dispatcher.pending_wait_sleep_ticks = 3;
9094
9095        let (steps, running) = runner.run_steps(1, Some(10));
9096
9097        assert!(running);
9098        assert_eq!(steps, 1);
9099        assert_eq!(runner.bus.read_long(0x016A), 3);
9100        assert_eq!(runner.dispatcher.pending_wait_sleep_ticks, 0);
9101    }
9102
9103    #[test]
9104    fn pending_delay_ticks_advance_in_gui_mode() {
9105        let mut runner = FixtureRunner::new(8 * 1024 * 1024, FixtureRunnerConfig::default());
9106        let program_start = 0x0001_0000;
9107
9108        runner.bus.write_word(program_start, 0x4E71);
9109        runner.cpu.write_reg(Register::PC, program_start);
9110        runner.cpu.write_reg(Register::A7, 0x007F_FFC0);
9111        runner.bus.write_long(0x016A, 0);
9112        runner.dispatcher.pending_delay_ticks = 3;
9113
9114        let (steps, _running) = runner.run_steps(1, Some(10));
9115
9116        assert_eq!(steps, 1);
9117        assert_eq!(runner.bus.read_long(0x016A), 3);
9118        assert_eq!(runner.dispatcher.pending_delay_ticks, 0);
9119        assert_eq!(runner.cpu.read_reg(Register::D0), 3);
9120    }
9121
9122    #[test]
9123    fn dialog_filter_synthesized_null_event_uses_live_modifiers() {
9124        let mut runner = FixtureRunner::new(8 * 1024 * 1024, FixtureRunnerConfig::default());
9125        let filter_proc = 0x0004_2000u32;
9126
9127        runner.bus.write_word(filter_proc, 0x4E56);
9128        runner.cpu.write_reg(Register::PC, 0x0001_0000);
9129        runner.cpu.write_reg(Register::A7, 0x007F_FFC0);
9130        runner.dispatcher.set_mouse_position(222, 333);
9131        runner.dispatcher.dialog_tracking = Some(crate::trap::dispatch::DialogTrackingState {
9132            dialog_ptr: 0x0020_0000,
9133            bounds: (100, 200, 200, 360),
9134            title: String::new(),
9135            proc_id: 2,
9136            items: Vec::new(),
9137            default_item: 1,
9138            cancel_item: 2,
9139            edit_text: String::new(),
9140            edit_item: 0,
9141            saved_pixels: Vec::new(),
9142            stack_ptr: 0x007F_FFC0,
9143            item_hit_ptr: 0x0030_0000,
9144            rendered_pixels: Vec::new(),
9145            flash_remaining: 0,
9146            flash_delay: 0,
9147            flash_item: 0,
9148            edit_text_modified: false,
9149            draw_proc_queue: std::collections::VecDeque::new(),
9150            draw_procs_done: true,
9151            rendered_pixels_final: true,
9152            filter_proc,
9153            game_managed: true,
9154            last_filter_event: None,
9155            popup_draws: Vec::new(),
9156            active_popup: None,
9157            active_button: None,
9158            active_user_item: None,
9159        });
9160
9161        assert!(runner.fire_dialog_filter_proc());
9162        let event_ptr = runner.dialog_filter_event;
9163        assert_eq!(runner.bus.read_word(event_ptr), 0);
9164        assert_eq!(runner.bus.read_word(event_ptr + 10), 222);
9165        assert_eq!(runner.bus.read_word(event_ptr + 12), 333);
9166        assert_eq!(
9167            runner.bus.read_word(event_ptr + 14),
9168            runner.dispatcher.current_event_modifiers()
9169        );
9170        assert_eq!(
9171            runner.dialog_filter_last_null_event_tick,
9172            Some((0x0020_0000, 0))
9173        );
9174    }
9175
9176    #[test]
9177    fn dialog_filter_uses_active_dialog_pending_update_before_null_event() {
9178        let mut runner = FixtureRunner::new(8 * 1024 * 1024, FixtureRunnerConfig::default());
9179        let filter_proc = 0x0004_2000u32;
9180        let dialog_ptr = runner.bus.alloc(170);
9181
9182        runner.bus.write_word(filter_proc, 0x4E56);
9183        runner.cpu.write_reg(Register::PC, 0x0001_0000);
9184        runner.cpu.write_reg(Register::A7, 0x007F_FFC0);
9185        runner.dispatcher.init_cgraf_window(
9186            &mut runner.bus,
9187            &mut runner.cpu,
9188            dialog_ptr,
9189            0,
9190            100,
9191            120,
9192            220,
9193            360,
9194            "",
9195            2,
9196            true,
9197            false,
9198            false,
9199            0,
9200        );
9201        runner.dispatcher.event_queue.clear();
9202        runner.dispatcher.dialog_tracking =
9203            Some(dialog_tracking_for_test(filter_proc, 0x0030_0000));
9204        runner
9205            .dispatcher
9206            .dialog_tracking
9207            .as_mut()
9208            .unwrap()
9209            .dialog_ptr = dialog_ptr;
9210
9211        assert!(runner.fire_dialog_filter_proc());
9212        let event_ptr = runner.dialog_filter_event;
9213        assert_eq!(runner.bus.read_word(event_ptr), 6);
9214        assert_eq!(runner.bus.read_long(event_ptr + 2), dialog_ptr);
9215        assert_eq!(runner.dialog_filter_last_null_event_tick, None);
9216    }
9217
9218    #[test]
9219    fn dialog_filter_paces_synthetic_update_without_starving_queued_input() {
9220        let mut runner = FixtureRunner::new(8 * 1024 * 1024, FixtureRunnerConfig::default());
9221        let filter_proc = 0x0004_2000u32;
9222        let dialog_ptr = runner.bus.alloc(170);
9223
9224        runner.bus.write_word(filter_proc, 0x4E56);
9225        runner.cpu.write_reg(Register::PC, 0x0001_0000);
9226        runner.cpu.write_reg(Register::A7, 0x007F_FFC0);
9227        runner.bus.write_long(0x016A, 17);
9228        runner.dispatcher.tick_count = 17;
9229        runner.dispatcher.init_cgraf_window(
9230            &mut runner.bus,
9231            &mut runner.cpu,
9232            dialog_ptr,
9233            0,
9234            100,
9235            120,
9236            220,
9237            360,
9238            "",
9239            2,
9240            true,
9241            false,
9242            false,
9243            0,
9244        );
9245        runner.dispatcher.event_queue.clear();
9246        runner.dispatcher.dialog_tracking =
9247            Some(dialog_tracking_for_test(filter_proc, 0x0030_0000));
9248        runner
9249            .dispatcher
9250            .dialog_tracking
9251            .as_mut()
9252            .unwrap()
9253            .dialog_ptr = dialog_ptr;
9254
9255        assert!(runner.fire_dialog_filter_proc());
9256        let event_ptr = runner.dialog_filter_event;
9257        assert_eq!(runner.bus.read_word(event_ptr), 6);
9258        assert_eq!(runner.bus.read_long(event_ptr + 2), dialog_ptr);
9259
9260        runner.active_interrupt_callback = None;
9261        runner.cpu.write_reg(Register::PC, 0x0001_0000);
9262        runner.cpu.write_reg(Register::A7, 0x007F_FFC0);
9263        runner
9264            .dispatcher
9265            .dialog_tracking
9266            .as_mut()
9267            .unwrap()
9268            .last_filter_event = None;
9269        assert!(
9270            !runner.dialog_filter_has_real_event_pending(dialog_ptr),
9271            "the same invalid-region update should not refire indefinitely in one guest tick"
9272        );
9273
9274        runner.dispatcher.event_queue.push_back(QueuedEvent {
9275            what: 1,
9276            message: 0,
9277            where_v: 123,
9278            where_h: 234,
9279            modifiers: 0,
9280        });
9281        assert!(
9282            runner.dialog_filter_has_real_event_pending(dialog_ptr),
9283            "queued user input must bypass synthetic update pacing"
9284        );
9285        assert!(runner.fire_dialog_filter_proc());
9286        assert_eq!(runner.bus.read_word(event_ptr), 1);
9287        assert_eq!(runner.bus.read_word(event_ptr + 10), 123);
9288        assert_eq!(runner.bus.read_word(event_ptr + 12), 234);
9289        assert!(
9290            runner.dispatcher.event_queue.is_empty(),
9291            "the queued mouse event should be consumed by the filter call"
9292        );
9293
9294        runner.active_interrupt_callback = None;
9295        runner
9296            .dispatcher
9297            .dialog_tracking
9298            .as_mut()
9299            .unwrap()
9300            .last_filter_event = None;
9301        runner.bus.write_long(0x016A, 18);
9302        runner.dispatcher.tick_count = 18;
9303        assert!(
9304            runner.dialog_filter_has_real_event_pending(dialog_ptr),
9305            "a still-invalid dialog can surface another update event on the next guest tick"
9306        );
9307    }
9308
9309    #[test]
9310    fn dialog_filter_proc_leaves_dialog_port_current() {
9311        let mut runner = FixtureRunner::new(8 * 1024 * 1024, FixtureRunnerConfig::default());
9312        let interrupted_pc = 0x0001_0000u32;
9313        let interrupted_sp = 0x007F_FFC0u32;
9314        let main_port = runner.bus.alloc(170);
9315        let dialog_ptr = runner.bus.alloc(170);
9316
9317        runner.bus.write_word(interrupted_pc, 0x60FE); // BRA.S *-0
9318        runner.cpu.write_reg(Register::PC, interrupted_pc);
9319        runner.cpu.write_reg(Register::A7, interrupted_sp);
9320
9321        runner.dispatcher.init_cgraf_window(
9322            &mut runner.bus,
9323            &mut runner.cpu,
9324            main_port,
9325            0,
9326            0,
9327            0,
9328            600,
9329            800,
9330            "",
9331            2,
9332            true,
9333            false,
9334            false,
9335            0,
9336        );
9337        runner.dispatcher.init_cgraf_window(
9338            &mut runner.bus,
9339            &mut runner.cpu,
9340            dialog_ptr,
9341            0,
9342            120,
9343            180,
9344            240,
9345            420,
9346            "",
9347            2,
9348            true,
9349            false,
9350            false,
9351            0,
9352        );
9353        runner
9354            .dispatcher
9355            .set_current_port_state(&mut runner.bus, &mut runner.cpu, main_port, None);
9356        let filter_proc = runner.bus.alloc(8);
9357        runner.bus.write_word(filter_proc, 0x4E56); // LINK A6, valid filter entry
9358
9359        runner.dispatcher.dialog_tracking =
9360            Some(dialog_tracking_for_test(filter_proc, 0x0030_0000));
9361        runner
9362            .dispatcher
9363            .dialog_tracking
9364            .as_mut()
9365            .unwrap()
9366            .dialog_ptr = dialog_ptr;
9367
9368        assert!(runner.fire_dialog_filter_proc());
9369        assert_eq!(runner.dispatcher.current_port, dialog_ptr);
9370        assert_eq!(
9371            runner
9372                .active_interrupt_callback
9373                .as_ref()
9374                .and_then(|callback| callback.restore_port),
9375            None
9376        );
9377
9378        runner.cpu.write_reg(Register::PC, interrupted_pc);
9379        runner.cpu.write_reg(Register::A7, interrupted_sp);
9380        let (_steps, running) = runner.run_steps(1, None);
9381
9382        assert!(running);
9383        assert_eq!(runner.dispatcher.current_port, dialog_ptr);
9384        assert!(runner.active_interrupt_callback.is_none());
9385    }
9386
9387    #[test]
9388    fn dialog_draw_proc_trampoline_passes_item_first_and_tolerates_plain_rts() {
9389        let mut runner = FixtureRunner::new(8 * 1024 * 1024, FixtureRunnerConfig::default());
9390        let interrupted_pc = 0x0001_0000u32;
9391        let interrupted_sp = 0x007F_FFC0u32;
9392        let dialog_ptr = 0x0020_0000u32;
9393        let proc_addr = 0x0004_2000u32;
9394        let item_no = 5i16;
9395
9396        // Keep foreground execution stable after the callback returns.
9397        runner.bus.write_word(interrupted_pc, 0x60FE); // BRA.S *-0
9398        runner.cpu.write_reg(Register::PC, interrupted_pc);
9399        runner.cpu.write_reg(Register::A7, interrupted_sp);
9400
9401        // MPW-style proc prologue shape. It returns with plain RTS, leaving
9402        // callback parameters on the stack; the trampoline must restore A7.
9403        runner.bus.write_word(proc_addr, 0x4E56); // LINK A6,#0
9404        runner.bus.write_word(proc_addr + 2, 0x0000);
9405        runner.bus.write_word(proc_addr + 4, 0x4E5E); // UNLK A6
9406        runner.bus.write_word(proc_addr + 6, 0x4E75); // RTS
9407
9408        runner.dispatcher.dialog_tracking = Some(crate::trap::dispatch::DialogTrackingState {
9409            dialog_ptr,
9410            bounds: (0, 0, 64, 64),
9411            title: String::new(),
9412            proc_id: 1,
9413            items: Vec::new(),
9414            default_item: 0,
9415            cancel_item: 0,
9416            edit_text: String::new(),
9417            edit_item: 0,
9418            saved_pixels: Vec::new(),
9419            stack_ptr: interrupted_sp,
9420            item_hit_ptr: 0,
9421            rendered_pixels: Vec::new(),
9422            flash_remaining: 0,
9423            flash_delay: 0,
9424            flash_item: 0,
9425            edit_text_modified: false,
9426            draw_proc_queue: VecDeque::from([(proc_addr, item_no)]),
9427            draw_procs_done: false,
9428            rendered_pixels_final: false,
9429            filter_proc: 0,
9430            game_managed: false,
9431            last_filter_event: None,
9432            popup_draws: Vec::new(),
9433            active_popup: None,
9434            active_button: None,
9435            active_user_item: None,
9436        });
9437
9438        assert!(runner.fire_dialog_draw_procs());
9439        let tramp = runner.dialog_draw_trampoline;
9440        assert_eq!(runner.bus.read_word(tramp), 0x48E7);
9441        assert_eq!(runner.bus.read_word(tramp + 4), 0x2F3C);
9442        assert_eq!(runner.bus.read_long(tramp + 6), dialog_ptr);
9443        assert_eq!(runner.bus.read_word(tramp + 10), 0x3F3C);
9444        assert_eq!(runner.bus.read_word(tramp + 12), item_no as u16);
9445        assert_eq!(runner.bus.read_word(tramp + 14), 0x4EB9);
9446        assert_eq!(runner.bus.read_long(tramp + 16), proc_addr);
9447        assert_eq!(runner.bus.read_word(tramp + 20), 0x4FF9);
9448        assert_eq!(runner.bus.read_long(tramp + 22), interrupted_sp - 36);
9449
9450        let (_steps, running) = runner.run_steps(16, None);
9451
9452        assert!(running);
9453        assert!(
9454            runner.active_interrupt_callback.is_none(),
9455            "dialog callback should have resumed foreground code"
9456        );
9457        assert_eq!(runner.cpu.read_reg(Register::PC), interrupted_pc);
9458        assert_eq!(runner.cpu.read_reg(Register::A7), interrupted_sp);
9459    }
9460
9461    #[test]
9462    fn modeless_dialog_draw_proc_accepts_a5_relative_proc_ptr() {
9463        let mut runner = FixtureRunner::new(8 * 1024 * 1024, FixtureRunnerConfig::default());
9464        let interrupted_pc = 0x0001_0000u32;
9465        let interrupted_sp = 0x007F_FFC0u32;
9466        let a5 = 0x0020_0000u32;
9467        let proc_offset = 0x0000_4200u32;
9468        let proc_addr = a5 + proc_offset;
9469        let dialog_ptr = runner.bus.alloc(170);
9470
9471        runner.bus.write_word(interrupted_pc, 0x60FE);
9472        runner.cpu.write_reg(Register::PC, interrupted_pc);
9473        runner.cpu.write_reg(Register::A7, interrupted_sp);
9474        runner.cpu.write_reg(Register::A5, a5);
9475        runner.dispatcher.init_cgraf_window(
9476            &mut runner.bus,
9477            &mut runner.cpu,
9478            dialog_ptr,
9479            0,
9480            120,
9481            180,
9482            240,
9483            420,
9484            "",
9485            2,
9486            true,
9487            false,
9488            false,
9489            0,
9490        );
9491
9492        runner.bus.write_word(proc_addr, 0x4E56); // LINK A6,#0
9493        runner.bus.write_word(proc_addr + 2, 0x0000);
9494        runner.bus.write_word(proc_addr + 4, 0x4E5E); // UNLK A6
9495        runner.bus.write_word(proc_addr + 6, 0x4E75); // RTS
9496        runner
9497            .dispatcher
9498            .modeless_dialog_draw_proc_queue
9499            .push_back((dialog_ptr, proc_offset, 5));
9500
9501        assert!(runner.fire_modeless_dialog_draw_proc());
9502
9503        let tramp = runner.dialog_draw_trampoline;
9504        assert_eq!(runner.bus.read_long(tramp + 16), proc_addr);
9505        assert_eq!(
9506            runner.dispatcher.active_modeless_dialog_draw_proc,
9507            Some(dialog_ptr)
9508        );
9509    }
9510
9511    #[test]
9512    fn modeless_dialog_draw_procs_drain_after_plain_trap() {
9513        let mut runner = FixtureRunner::new(8 * 1024 * 1024, FixtureRunnerConfig::default());
9514        let base = 0x0001_0000u32;
9515        let interrupted_sp = 0x007F_FFC0u32;
9516        let dialog_ptr = runner.bus.alloc(170);
9517        let proc_1 = 0x0004_2000u32;
9518        let proc_2 = 0x0004_2100u32;
9519
9520        runner.bus.write_word(base, 0xA861); // _Random
9521        runner.bus.write_word(base + 2, 0x60FE); // BRA.S *-0
9522        runner.cpu.write_reg(Register::PC, base);
9523        runner.cpu.write_reg(Register::A7, interrupted_sp);
9524        runner.dispatcher.init_cgraf_window(
9525            &mut runner.bus,
9526            &mut runner.cpu,
9527            dialog_ptr,
9528            0,
9529            120,
9530            180,
9531            240,
9532            420,
9533            "",
9534            2,
9535            true,
9536            false,
9537            false,
9538            0,
9539        );
9540
9541        for proc_addr in [proc_1, proc_2] {
9542            runner.bus.write_word(proc_addr, 0x4E56); // LINK A6,#0
9543            runner.bus.write_word(proc_addr + 2, 0x0000);
9544            runner.bus.write_word(proc_addr + 4, 0x4E5E); // UNLK A6
9545            runner.bus.write_word(proc_addr + 6, 0x4E75); // RTS
9546        }
9547        runner.dispatcher.modeless_dialog_draw_proc_queue =
9548            VecDeque::from([(dialog_ptr, proc_1, 3), (dialog_ptr, proc_2, 5)]);
9549
9550        let (_steps, running) = runner.run_steps(128, None);
9551
9552        assert!(running);
9553        assert!(runner.dispatcher.modeless_dialog_draw_proc_queue.is_empty());
9554        assert_eq!(runner.dispatcher.active_modeless_dialog_draw_proc, None);
9555        assert!(
9556            runner.active_interrupt_callback.is_none(),
9557            "modeless draw callbacks should have returned to foreground code"
9558        );
9559    }
9560
9561    #[test]
9562    fn dialog_draw_proc_does_not_restore_over_guest_selected_dialog_port() {
9563        let mut runner = FixtureRunner::new(8 * 1024 * 1024, FixtureRunnerConfig::default());
9564        let interrupted_pc = 0x0001_0000u32;
9565        let interrupted_sp = 0x007F_FFC0u32;
9566        let main_port = runner.bus.alloc(170);
9567        let dialog_ptr = runner.bus.alloc(170);
9568        let proc_addr = 0x0004_2000u32;
9569        let item_no = 5i16;
9570
9571        runner.bus.write_word(interrupted_pc, 0x60FE); // BRA.S *-0
9572        runner.cpu.write_reg(Register::PC, interrupted_pc);
9573        runner.cpu.write_reg(Register::A7, interrupted_sp);
9574
9575        runner.dispatcher.init_cgraf_window(
9576            &mut runner.bus,
9577            &mut runner.cpu,
9578            main_port,
9579            0,
9580            0,
9581            0,
9582            600,
9583            800,
9584            "",
9585            2,
9586            true,
9587            false,
9588            false,
9589            0,
9590        );
9591        runner.dispatcher.init_cgraf_window(
9592            &mut runner.bus,
9593            &mut runner.cpu,
9594            dialog_ptr,
9595            0,
9596            120,
9597            180,
9598            240,
9599            420,
9600            "",
9601            2,
9602            true,
9603            false,
9604            false,
9605            0,
9606        );
9607        runner
9608            .dispatcher
9609            .set_current_port_state(&mut runner.bus, &mut runner.cpu, main_port, None);
9610
9611        runner.bus.write_word(proc_addr, 0x4E56); // LINK A6,#0
9612        runner.bus.write_word(proc_addr + 2, 0x0000);
9613        runner.bus.write_word(proc_addr + 4, 0x2F3C); // MOVE.L #dialog,-(SP)
9614        runner.bus.write_long(proc_addr + 6, dialog_ptr);
9615        runner.bus.write_word(proc_addr + 10, 0xA873); // _SetPort
9616        runner.bus.write_word(proc_addr + 12, 0x4E5E); // UNLK A6
9617        runner.bus.write_word(proc_addr + 14, 0x4E75); // RTS
9618
9619        runner.dispatcher.dialog_tracking = Some(crate::trap::dispatch::DialogTrackingState {
9620            dialog_ptr,
9621            bounds: (120, 180, 240, 420),
9622            title: String::new(),
9623            proc_id: 1,
9624            items: Vec::new(),
9625            default_item: 0,
9626            cancel_item: 0,
9627            edit_text: String::new(),
9628            edit_item: 0,
9629            saved_pixels: Vec::new(),
9630            stack_ptr: interrupted_sp,
9631            item_hit_ptr: 0,
9632            rendered_pixels: Vec::new(),
9633            flash_remaining: 0,
9634            flash_delay: 0,
9635            flash_item: 0,
9636            edit_text_modified: false,
9637            draw_proc_queue: VecDeque::from([(proc_addr, item_no)]),
9638            draw_procs_done: false,
9639            rendered_pixels_final: false,
9640            filter_proc: 0,
9641            game_managed: false,
9642            last_filter_event: None,
9643            popup_draws: Vec::new(),
9644            active_popup: None,
9645            active_button: None,
9646            active_user_item: None,
9647        });
9648
9649        assert!(runner.fire_dialog_draw_procs());
9650        assert_eq!(
9651            runner
9652                .active_interrupt_callback
9653                .as_ref()
9654                .and_then(|callback| callback.restore_port),
9655            None
9656        );
9657
9658        let (_steps, running) = runner.run_steps(32, None);
9659
9660        assert!(running);
9661        assert!(runner.active_interrupt_callback.is_none());
9662        assert_eq!(
9663            runner.dispatcher.current_port, dialog_ptr,
9664            "Dialog Manager must leave the dialog port current after the draw proc"
9665        );
9666    }
9667
9668    #[test]
9669    fn dialog_draw_proc_pascal_stack_places_item_number_before_window_pointer() {
9670        let mut runner = FixtureRunner::new(8 * 1024 * 1024, FixtureRunnerConfig::default());
9671        let interrupted_pc = 0x0001_0000u32;
9672        let interrupted_sp = 0x007F_FFC0u32;
9673        let dialog_ptr = 0x0029_4240u32;
9674        let proc_addr = 0x0004_2000u32;
9675        let item_no = 2i16;
9676        let seen_item_addr = 0x0004_3000u32;
9677        let seen_dialog_addr = 0x0004_3004u32;
9678
9679        runner.bus.write_word(interrupted_pc, 0x60FE); // BRA.S *-0
9680        runner.cpu.write_reg(Register::PC, interrupted_pc);
9681        runner.cpu.write_reg(Register::A7, interrupted_sp);
9682
9683        // PROCEDURE MyItem(theWindow: WindowPtr; itemNo: INTEGER);
9684        // Inside Macintosh Volume I, I-405. MPW Pascal prologues observe
9685        // itemNo at 8(A6) and theWindow at 10(A6).
9686        runner.bus.write_word(proc_addr, 0x4E56); // LINK A6,#0
9687        runner.bus.write_word(proc_addr + 2, 0x0000);
9688        runner.bus.write_word(proc_addr + 4, 0x302E); // MOVE.W 8(A6),D0
9689        runner.bus.write_word(proc_addr + 6, 0x0008);
9690        runner.bus.write_word(proc_addr + 8, 0x33C0); // MOVE.W D0,(abs).L
9691        runner.bus.write_long(proc_addr + 10, seen_item_addr);
9692        runner.bus.write_word(proc_addr + 14, 0x222E); // MOVE.L 10(A6),D1
9693        runner.bus.write_word(proc_addr + 16, 0x000A);
9694        runner.bus.write_word(proc_addr + 18, 0x23C1); // MOVE.L D1,(abs).L
9695        runner.bus.write_long(proc_addr + 20, seen_dialog_addr);
9696        runner.bus.write_word(proc_addr + 24, 0x4E5E); // UNLK A6
9697        runner.bus.write_word(proc_addr + 26, 0x4E75); // RTS
9698
9699        runner.dispatcher.dialog_tracking = Some(crate::trap::dispatch::DialogTrackingState {
9700            dialog_ptr,
9701            bounds: (120, 180, 240, 420),
9702            title: String::new(),
9703            proc_id: 1,
9704            items: Vec::new(),
9705            default_item: 0,
9706            cancel_item: 0,
9707            edit_text: String::new(),
9708            edit_item: 0,
9709            saved_pixels: Vec::new(),
9710            stack_ptr: interrupted_sp,
9711            item_hit_ptr: 0,
9712            rendered_pixels: Vec::new(),
9713            flash_remaining: 0,
9714            flash_delay: 0,
9715            flash_item: 0,
9716            edit_text_modified: false,
9717            draw_proc_queue: VecDeque::from([(proc_addr, item_no)]),
9718            draw_procs_done: false,
9719            rendered_pixels_final: false,
9720            filter_proc: 0,
9721            game_managed: false,
9722            last_filter_event: None,
9723            popup_draws: Vec::new(),
9724            active_popup: None,
9725            active_button: None,
9726            active_user_item: None,
9727        });
9728
9729        assert!(runner.fire_dialog_draw_procs());
9730        let (_steps, running) = runner.run_steps(48, None);
9731
9732        assert!(running);
9733        assert!(runner.active_interrupt_callback.is_none());
9734        assert_eq!(runner.bus.read_word(seen_item_addr) as i16, item_no);
9735        assert_eq!(runner.bus.read_long(seen_dialog_addr), dialog_ptr);
9736    }
9737
9738    #[test]
9739    fn pending_delay_ticks_fire_vbl_tasks_in_headless_mode() {
9740        let mut runner = FixtureRunner::new(8 * 1024 * 1024, FixtureRunnerConfig::default());
9741        let interrupted_pc = 0x0001_0000;
9742        let interrupted_sp = 0x007F_FFC0;
9743        let task_ptr = 0x0020_2000;
9744
9745        runner.bus.write_word(interrupted_pc, 0x4E71);
9746        runner.cpu.write_reg(Register::PC, interrupted_pc);
9747        runner.cpu.write_reg(Register::A7, interrupted_sp);
9748        runner.bus.write_long(0x016A, 0);
9749        runner.dispatcher.pending_delay_ticks = 1;
9750
9751        runner.bus.write_word(task_ptr + 4, 1);
9752        runner.bus.write_long(task_ptr + 6, 0x0004_1234);
9753        runner.bus.write_word(task_ptr + 10, 1);
9754        runner.bus.write_word(task_ptr + 12, 0);
9755        runner.dispatcher.vbl_tasks.push(VblTask {
9756            task_ptr,
9757            slot: None,
9758        });
9759
9760        let (steps, running) = runner.run_steps(1, None);
9761
9762        assert!(running);
9763        assert_eq!(steps, 1);
9764        assert_eq!(runner.bus.read_long(0x016A), 1);
9765        assert_eq!(runner.dispatcher.pending_delay_ticks, 0);
9766        assert_eq!(runner.cpu.read_reg(Register::D0), 1);
9767        assert!(matches!(
9768            runner.active_interrupt_callback,
9769            Some(ActiveInterruptCallback {
9770                source: ActiveInterruptCallbackSource::Vbl,
9771                ..
9772            })
9773        ));
9774    }
9775
9776    /// `set_mouse_position` updates both the dispatcher's tracked
9777    /// position and the six low-memory mouse globals (MTemp $0828,
9778    /// RawMouse $082C, Mouse $0830) so guest code that polls them
9779    /// directly sees the new coordinates without waiting for a click.
9780    /// Inside Macintosh Volume II, II-371.
9781    #[test]
9782    fn set_mouse_position_updates_dispatcher_and_low_mem_globals() {
9783        let mut runner = FixtureRunner::new(8 * 1024 * 1024, FixtureRunnerConfig::default());
9784
9785        runner.set_mouse_position(123, 456);
9786
9787        assert_eq!(runner.dispatcher.mouse_pos, (123, 456));
9788        for off in [0x0828u32, 0x082C, 0x0830] {
9789            assert_eq!(runner.bus.read_word(off), 123u16, "v at ${:04X}", off);
9790            assert_eq!(runner.bus.read_word(off + 2), 456u16, "h at ${:04X}", off);
9791        }
9792    }
9793
9794    /// Running a `DIVU.W D0,D1` with `D0 = 0` must not halt the
9795    /// runner. The `load_app_generic` loader installs an RTE stub at
9796    /// `$00FE` and points vector 5 (`$14`) at it; the m68k crate's
9797    /// zero-divide trap stacks the *next* PC and jumps to that vector,
9798    /// so RTE-ing returns past the DIVU and execution continues.
9799    /// Inside Macintosh Volume I, I-103 (Exception Vector Table);
9800    /// M68000PRM ("If the source operand is zero, the result of the
9801    /// operation is unpredictable").
9802    #[test]
9803    fn zero_divide_rte_handler_resumes_after_divu_by_zero() {
9804        let mut runner = FixtureRunner::new(8 * 1024 * 1024, FixtureRunnerConfig::default());
9805
9806        // Mirror what load_app_generic installs: RTE stub + vector.
9807        runner.bus.write_word(0x00FE, 0x4E73); // RTE
9808        runner.bus.write_long(0x0014, 0x0000_00FE);
9809
9810        let prog = 0x0010_0000u32;
9811        runner.bus.write_word(prog, 0x82C0); // DIVU.W D0, D1
9812        runner.bus.write_word(prog + 2, 0x4E71); // NOP
9813        runner.bus.write_word(prog + 4, 0x4E71); // NOP
9814
9815        runner.cpu.write_reg(Register::PC, prog);
9816        runner.cpu.write_reg(Register::A7, 0x007F_FFC0);
9817        runner.cpu.write_reg(Register::D0, 0);
9818        runner.cpu.write_reg(Register::D1, 100);
9819
9820        // 1 step: DIVU.W traps, vectors to $00FE.
9821        // 2nd step: RTE at $00FE pops SR/PC, returns past DIVU.
9822        // 3rd step: NOP at prog+2.
9823        let (steps, running) = runner.run_steps(3, None);
9824
9825        assert!(running, "runner must not halt on zero-divide");
9826        assert_eq!(steps, 3);
9827        assert_eq!(
9828            runner.cpu.read_reg(Register::PC),
9829            prog + 4,
9830            "PC must advance past the DIVU+NOP without re-entering the trap"
9831        );
9832        assert_eq!(
9833            runner.cpu.read_reg(Register::D1),
9834            100,
9835            "DIVU by zero must leave the destination register unchanged"
9836        );
9837    }
9838
9839    /// CHK exception (vector 6) shares the same `$00FE` RTE stub as
9840    /// the zero-divide handler. A `CHK.W #5, D0` with `D0 = 100`
9841    /// exceeds the bound and triggers the trap; on a real Mac the
9842    /// handler calls SysError, on Systemless we silently RTE so D0 is
9843    /// preserved and the next instruction runs.
9844    /// Inside Macintosh Volume I, I-103.
9845    #[test]
9846    fn chk_rte_handler_resumes_after_bounds_violation() {
9847        let mut runner = FixtureRunner::new(8 * 1024 * 1024, FixtureRunnerConfig::default());
9848
9849        runner.bus.write_word(0x00FE, 0x4E73); // RTE
9850        runner.bus.write_long(0x0018, 0x0000_00FE); // CHK vector
9851
9852        let prog = 0x0010_0000u32;
9853        runner.bus.write_word(prog, 0x41BC); // CHK.W #imm, D0
9854        runner.bus.write_word(prog + 2, 0x0005); // imm = 5
9855        runner.bus.write_word(prog + 4, 0x4E71); // NOP
9856
9857        runner.cpu.write_reg(Register::PC, prog);
9858        runner.cpu.write_reg(Register::A7, 0x007F_FFC0);
9859        runner.cpu.write_reg(Register::D0, 100);
9860
9861        // 1 step: CHK fires (100 > 5), vectors to $00FE.
9862        // 2nd step: RTE pops SR/PC, returns past CHK.
9863        // 3rd step: NOP executes.
9864        let (steps, running) = runner.run_steps(3, None);
9865
9866        assert!(running, "runner must not halt on CHK bounds violation");
9867        assert_eq!(steps, 3);
9868        assert_eq!(
9869            runner.cpu.read_reg(Register::PC),
9870            prog + 6,
9871            "PC must advance past CHK (4 bytes) + NOP (2 bytes)"
9872        );
9873        assert_eq!(runner.cpu.read_reg(Register::D0), 100);
9874    }
9875
9876    /// TRAPV (vector 7) shares the `$00FE` RTE stub. Pre-set the V
9877    /// flag in CCR via the m68k API and execute TRAPV; the trap fires
9878    /// because V is set, vectors to the RTE stub, and resumes at the
9879    /// next instruction. Inside Macintosh Volume I, I-103.
9880    #[test]
9881    fn trapv_rte_handler_resumes_when_v_flag_is_set() {
9882        let mut runner = FixtureRunner::new(8 * 1024 * 1024, FixtureRunnerConfig::default());
9883
9884        runner.bus.write_word(0x00FE, 0x4E73); // RTE
9885        runner.bus.write_long(0x001C, 0x0000_00FE); // TRAPV vector
9886
9887        let prog = 0x0010_0000u32;
9888        runner.bus.write_word(prog, 0x4E76); // TRAPV
9889        runner.bus.write_word(prog + 2, 0x4E71); // NOP
9890
9891        runner.cpu.write_reg(Register::PC, prog);
9892        runner.cpu.write_reg(Register::A7, 0x007F_FFC0);
9893        runner.cpu.core.set_ccr(0x02); // V flag set
9894
9895        // 1: TRAPV traps; 2: RTE; 3: NOP.
9896        let (steps, running) = runner.run_steps(3, None);
9897
9898        assert!(running, "runner must not halt on TRAPV");
9899        assert_eq!(steps, 3);
9900        assert_eq!(runner.cpu.read_reg(Register::PC), prog + 4);
9901    }
9902
9903    /// `set_mouse_position` does NOT modify MBState ($0172) — it's a
9904    /// move-without-button-change, so the button-state byte should
9905    /// retain its prior value. The default at runner construction is
9906    /// 0x80 (button up).
9907    #[test]
9908    fn set_mouse_position_leaves_mb_state_untouched() {
9909        let mut runner = FixtureRunner::new(8 * 1024 * 1024, FixtureRunnerConfig::default());
9910
9911        runner.bus.write_byte(0x0172, 0x80);
9912        runner.set_mouse_position(50, 60);
9913        assert_eq!(runner.bus.read_byte(0x0172), 0x80);
9914
9915        runner.bus.write_byte(0x0172, 0x00);
9916        runner.set_mouse_position(70, 80);
9917        assert_eq!(runner.bus.read_byte(0x0172), 0x00);
9918    }
9919
9920    /// `push_mouse_down` must update MBState ($0172) to 0x00 (button
9921    /// pressed) immediately AND sync the position globals so guest
9922    /// code that polls these bytes directly sees the click without
9923    /// waiting for the next tick advance.
9924    /// Inside Macintosh Volume I, I-258 (MTemp/RawMouse/Mouse);
9925    /// Inside Macintosh Volume II, II-371 (MBState polling).
9926    #[test]
9927    fn push_mouse_down_writes_mb_state_pressed_and_position() {
9928        let mut runner = FixtureRunner::new(8 * 1024 * 1024, FixtureRunnerConfig::default());
9929        runner.bus.write_byte(0x0172, 0x80); // start "button up"
9930
9931        runner.push_mouse_down(123, 456);
9932
9933        assert_eq!(
9934            runner.bus.read_byte(0x0172),
9935            0x00,
9936            "MBState must be 0x00 (pressed) immediately after push_mouse_down"
9937        );
9938        // All three position globals must mirror the click site so
9939        // games that poll them directly (Mouse $0830 etc.) see the
9940        // correct location, not the prior cursor-park position.
9941        assert_eq!(runner.bus.read_word(0x0828), 123u16);
9942        assert_eq!(runner.bus.read_word(0x082A), 456u16);
9943        assert_eq!(runner.bus.read_word(0x082C), 123u16);
9944        assert_eq!(runner.bus.read_word(0x082E), 456u16);
9945        assert_eq!(runner.bus.read_word(0x0830), 123u16);
9946        assert_eq!(runner.bus.read_word(0x0832), 456u16);
9947    }
9948
9949    /// `push_mouse_up` must update MBState ($0172) to 0x80 (button
9950    /// released) immediately. On real hardware the ADB polls at ~200 Hz
9951    /// so the latency between physical release and MBState=0x80 is a
9952    /// few ms; deferring to advance_guest_tick (~16 ms) makes
9953    /// frame-rate-dependent games read the wrong button state for too
9954    /// many loop iterations after click-up. This test pins the
9955    /// immediate-sync contract documented at runner.rs `push_mouse_up`.
9956    #[test]
9957    fn push_mouse_up_writes_mb_state_released_immediately() {
9958        let mut runner = FixtureRunner::new(8 * 1024 * 1024, FixtureRunnerConfig::default());
9959
9960        runner.push_mouse_down(10, 20);
9961        assert_eq!(runner.bus.read_byte(0x0172), 0x00);
9962
9963        runner.push_mouse_up(10, 20);
9964        assert_eq!(
9965            runner.bus.read_byte(0x0172),
9966            0x80,
9967            "MBState must flip back to 0x80 (released) immediately on push_mouse_up — \
9968             not deferred to the next tick"
9969        );
9970    }
9971
9972    /// Regression: advance_guest_tick must NOT keep MBState at 0x00
9973    /// when both mouseDown and a paired mouseUp are queued and
9974    /// unconsumed. Polling-only games (Bonkheads-Deluxe class) never
9975    /// call GetNextEvent — the queue accumulates indefinitely.
9976    /// Pre-fix, the "any pending mouseDown → pressed" override left
9977    /// $0172 stuck at 0x00 forever, so Button() always returned TRUE
9978    /// and click detection broke silently. The fix counts unmatched
9979    /// mouseDowns (mouseDown count − mouseUp count) and only treats
9980    /// those as "still pressed".
9981    #[test]
9982    fn mb_state_releases_when_paired_mouseup_queued_but_unconsumed() {
9983        let mut runner = FixtureRunner::new(8 * 1024 * 1024, FixtureRunnerConfig::default());
9984
9985        runner.push_mouse_down(10, 20);
9986        runner.push_mouse_up(10, 20);
9987        // Both events still queued (no GetNextEvent has run). Drive the
9988        // tick boundary that owns the MBState resync.
9989        runner.advance_guest_tick();
9990
9991        assert_eq!(
9992            runner.bus.read_byte(0x0172),
9993            0x80,
9994            "advance_guest_tick must release MBState to 0x80 once a \
9995             paired mouseUp is queued behind the mouseDown — even when \
9996             nothing has drained the event queue"
9997        );
9998        // Sanity-check the events ARE still in the queue (this test is
9999        // about MBState despite the unconsumed events, not about queue
10000        // state). The dispatcher field is pub(crate); read it through
10001        // the same accessor used by the production sync logic.
10002        assert!(
10003            runner.dispatcher.event_queue.iter().any(|e| e.what == 1),
10004            "mouseDown event must remain in the queue (would be drained by GetNextEvent)"
10005        );
10006        assert!(
10007            runner.dispatcher.event_queue.iter().any(|e| e.what == 2),
10008            "mouseUp event must remain in the queue"
10009        );
10010    }
10011
10012    /// Mirror of `mb_state_releases_when_paired_mouseup_queued_but_unconsumed`:
10013    /// a SOLO mouseDown queued without a paired mouseUp must still pin
10014    /// MBState to 0x00 across tick boundaries. This preserves the
10015    /// original contract — code that hasn't yet started polling when
10016    /// the click was injected gets at least one TRUE pulse — without
10017    /// regressing into the stuck-pressed bug.
10018    #[test]
10019    fn mb_state_stays_pressed_with_solo_pending_mousedown() {
10020        let mut runner = FixtureRunner::new(8 * 1024 * 1024, FixtureRunnerConfig::default());
10021
10022        runner.push_mouse_down(10, 20);
10023        runner.advance_guest_tick();
10024        assert_eq!(
10025            runner.bus.read_byte(0x0172),
10026            0x00,
10027            "MBState must stay pressed across a tick advance while only \
10028             a mouseDown is queued (no paired mouseUp yet)"
10029        );
10030    }
10031
10032    #[test]
10033    fn set_menu_bar_visible_round_trips_through_public_api() {
10034        // Pins the FixtureRunner::set_menu_bar_visible / menu_bar_visible
10035        // pair as the public-API entry point for the kiosk-mode toggle.
10036        // Library embedders should not need to reach through
10037        // dispatcher_mut() into TrapDispatcher::menu_bar_hidden — the
10038        // method-based surface keeps the kiosk-on-by-default contract
10039        // discoverable from the FixtureRunner type alone.
10040        //
10041        // Default (constructor): kiosk on → menu bar NOT visible.
10042        // After set_menu_bar_visible(true): menu bar IS visible.
10043        // After set_menu_bar_visible(false): kiosk back on.
10044        // Skip when SYSTEMLESS_SHOW_MENU_BAR is set in the test env —
10045        // the env var pre-seeds menu_bar_hidden = false at construction
10046        // and would race the round-trip assertion.
10047        if std::env::var_os("SYSTEMLESS_SHOW_MENU_BAR").is_some() {
10048            return;
10049        }
10050        let mut runner = FixtureRunner::new(8 * 1024 * 1024, FixtureRunnerConfig::default());
10051        assert!(
10052            !runner.menu_bar_visible(),
10053            "kiosk default: menu_bar_visible() must report false"
10054        );
10055        runner.set_menu_bar_visible(true);
10056        assert!(
10057            runner.menu_bar_visible(),
10058            "after set_menu_bar_visible(true), menu_bar_visible() must report true"
10059        );
10060        runner.set_menu_bar_visible(false);
10061        assert!(
10062            !runner.menu_bar_visible(),
10063            "after set_menu_bar_visible(false), menu_bar_visible() must report false"
10064        );
10065        // Internal field stays in sync with the public API — guards
10066        // against future refactors that introduce a parallel state
10067        // field but forget to wire it through the toggle.
10068        assert!(
10069            runner.dispatcher().menu_bar_hidden,
10070            "set_menu_bar_visible(false) must clear the kiosk-bypass bit"
10071        );
10072    }
10073
10074    #[test]
10075    fn disassemble_at_decodes_known_opcodes_with_correct_advance() {
10076        // Pins the FixtureRunner::disassemble_at public-API helper.
10077        // This is the library-level entry point for pixel-divergence
10078        // and trap-misroute investigations: pair with
10079        // SYSTEMLESS_TRACE_FB_WRITE_RANGE to see what code lives at a
10080        // suspect PC.
10081        //
10082        // Seed three known instructions in guest RAM, disassemble,
10083        // and verify:
10084        //   1. each entry's PC advances by the previous size
10085        //   2. the mnemonic for $4E71 is "NOP" (well-known fixed
10086        //      instruction; no operand words to consume)
10087        //   3. an A-line trap word ($A8EC = CopyBits) comes back as
10088        //      "DC.W $A8EC" — the m68k crate's convention for opcodes
10089        //      it doesn't have a regular decoder for
10090        //   4. the size returned is at least 2 and at most 10 (the
10091        //      clamp guard that prevents a malformed opcode from
10092        //      consuming wrap-around amounts)
10093        let mut runner = FixtureRunner::new(8 * 1024 * 1024, FixtureRunnerConfig::default());
10094        let pc = 0x10000u32;
10095        // $4E71 NOP
10096        runner.bus.write_word(pc, 0x4E71);
10097        // $A8EC (CopyBits trap-line word)
10098        runner.bus.write_word(pc + 2, 0xA8EC);
10099        // $4E71 NOP again
10100        runner.bus.write_word(pc + 4, 0x4E71);
10101        let out = runner.disassemble_at(pc, 3);
10102        assert_eq!(
10103            out.len(),
10104            3,
10105            "disassemble_at must return exactly count entries"
10106        );
10107        assert_eq!(
10108            out[0].0, pc,
10109            "first entry's PC must equal the requested start"
10110        );
10111        assert!(
10112            out[0].1.contains("NOP"),
10113            "$4E71 must disassemble to NOP, got: {}",
10114            out[0].1
10115        );
10116        assert!(
10117            out[0].2 >= 2 && out[0].2 <= 10,
10118            "instruction size must be in clamp range [2, 10], got {}",
10119            out[0].2
10120        );
10121        assert_eq!(
10122            out[1].0,
10123            pc + out[0].2,
10124            "second entry's PC must equal first PC + first size"
10125        );
10126        assert!(
10127            out[1].1.contains("$A8EC"),
10128            "A-line trap $A8EC must surface in mnemonic (DC.W form), got: {}",
10129            out[1].1
10130        );
10131        assert!(
10132            out[2].1.contains("NOP"),
10133            "third entry must be the second NOP we seeded"
10134        );
10135    }
10136}