use std::num::NonZeroU32;
use std::path::PathBuf;
use std::rc::Rc;
use systemless::display;
use systemless::game;
use systemless::runner::FixtureRunner;
use softbuffer::Surface;
use winit::application::ApplicationHandler;
use winit::event::{ElementState, MouseButton, WindowEvent};
use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop};
use winit::keyboard::{Key, KeyCode, NamedKey, PhysicalKey};
#[cfg(target_os = "macos")]
use winit::platform::macos::WindowAttributesExtMacOS;
use winit::window::Window;
use winit::window::WindowAttributes;
use winit::window::WindowId;
const INITIAL_SCREEN_WIDTH: u32 = 800;
const INITIAL_SCREEN_HEIGHT: u32 = 600;
const SCALE: u32 = 1;
const FRAME_DURATION: std::time::Duration = std::time::Duration::from_micros(16_625);
const MIN_RENDER_HEADROOM: std::time::Duration = std::time::Duration::from_micros(1_500);
const MAX_RENDER_HEADROOM: std::time::Duration = std::time::Duration::from_micros(8_000);
const RENDER_HEADROOM_MARGIN: std::time::Duration = std::time::Duration::from_micros(500);
const CPU_BATCH_INSTRUCTIONS: usize = 10_000;
const SOUND_CALLBACK_SLICE_INSTRUCTIONS: usize = CPU_BATCH_INSTRUCTIONS;
const SOUND_CALLBACK_RESERVED_INSTRUCTIONS_PER_FRAME: usize = 25_000;
const AUDIO_CALLBACK_CHUNK_SAMPLES: usize = 32;
const DEFAULT_GUI_ARROWS_AS_NUMPAD: bool = false;
#[cfg(target_os = "macos")]
fn platform_window_attrs(attrs: WindowAttributes) -> WindowAttributes {
attrs
.with_disallow_hidpi(true)
.with_accepts_first_mouse(true)
}
#[cfg(not(target_os = "macos"))]
fn platform_window_attrs(attrs: WindowAttributes) -> WindowAttributes {
attrs
}
fn service_pending_sound_work(
runner: &mut FixtureRunner,
_cpu_deadline: std::time::Instant,
slice_budget: usize,
total_steps: usize,
reserved_sound_steps: &mut usize,
) -> Option<usize> {
if !runner.has_pending_sound_work() || runner.is_halted() {
return None;
}
let remaining = slice_budget.saturating_sub(total_steps);
let using_reserved_slice = remaining == 0;
let callback_budget = if using_reserved_slice {
let reserved_remaining =
SOUND_CALLBACK_RESERVED_INSTRUCTIONS_PER_FRAME.saturating_sub(*reserved_sound_steps);
if reserved_remaining == 0 {
return None;
}
reserved_remaining.min(SOUND_CALLBACK_SLICE_INSTRUCTIONS)
} else {
remaining.min(SOUND_CALLBACK_SLICE_INSTRUCTIONS)
};
let (steps, _running) = runner.run_pending_sound_work(callback_budget);
if using_reserved_slice {
*reserved_sound_steps = reserved_sound_steps.saturating_add(steps);
}
Some(steps)
}
struct App {
window: Option<Rc<Window>>,
surface: Option<Surface<Rc<Window>, Rc<Window>>>,
surface_size: Option<(u32, u32)>,
frame_argb: Vec<u32>,
scaled_row: Vec<u32>,
runner: Option<FixtureRunner>,
game_path: PathBuf,
initialized: bool,
total_instructions: u64,
start_time: Option<std::time::Instant>,
next_frame_time: Option<std::time::Instant>,
render_headroom: std::time::Duration,
next_cpu_budget_time: Option<std::time::Instant>,
cpu_instruction_credit: f64,
audio_sample_remainder: f64,
mouse_physical: (f64, f64),
current_screen_width: u32,
current_screen_height: u32,
frame_count: u64,
last_presented_guest_tick: Option<u32>,
force_next_render: bool,
arrows_as_numpad: bool,
emulated_ips: Option<f64>,
show_menu_bar: bool,
}
impl App {
fn new(
game_path: PathBuf,
arrows_as_numpad: bool,
cpu_mhz: Option<f64>,
show_menu_bar: bool,
) -> Self {
Self {
window: None,
surface: None,
surface_size: None,
frame_argb: Vec::new(),
scaled_row: Vec::new(),
runner: None,
game_path,
initialized: false,
total_instructions: 0,
start_time: None,
next_frame_time: None,
render_headroom: MIN_RENDER_HEADROOM,
next_cpu_budget_time: None,
cpu_instruction_credit: 0.0,
audio_sample_remainder: 0.0,
mouse_physical: (0.0, 0.0),
current_screen_width: INITIAL_SCREEN_WIDTH,
current_screen_height: INITIAL_SCREEN_HEIGHT,
frame_count: 0,
last_presented_guest_tick: None,
force_next_render: true,
arrows_as_numpad,
emulated_ips: cpu_mhz.map(|mhz| mhz * 1_000_000.0),
show_menu_bar,
}
}
fn physical_to_mac(&self, px: f64, py: f64) -> (i16, i16) {
let sw = self.current_screen_width;
let sh = self.current_screen_height;
let size = self
.window
.as_ref()
.map(|w| w.inner_size())
.unwrap_or(winit::dpi::PhysicalSize::new(sw, sh));
let scale_x = size.width as f64 / sw as f64;
let scale_y = size.height as f64 / sh as f64;
let scale = scale_x.min(scale_y).max(1.0);
let mac_x = (px / scale) as i16;
let mac_y = (py / scale) as i16;
(mac_y.clamp(0, sh as i16 - 1), mac_x.clamp(0, sw as i16 - 1))
}
fn init_game(&mut self) {
if self.initialized {
return;
}
let mut runner = game::new_runner();
if self.show_menu_bar {
runner.set_menu_bar_visible(true);
}
let app =
game::load_game_from_path(&mut runner, &self.game_path).expect("Failed to load game");
game::init_game(&mut runner, &app);
runner.set_arrows_as_numpad(self.arrows_as_numpad);
let ipt = (systemless::runner::DEFAULT_REALTIME_INSTRUCTIONS_PER_SECOND
/ systemless::runner::DEFAULT_VBL_HZ) as u32;
runner.set_instructions_per_tick(ipt);
eprintln!("[SYSTEMLESS] Instructions per tick: {}", ipt);
if let Some(audio) = systemless::audio::CpalAudioBackend::new() {
runner.set_audio(Box::new(audio));
} else {
eprintln!("[SYSTEMLESS] Warning: could not initialize audio output");
}
eprintln!("[SYSTEMLESS] Game loaded: {}", self.game_path.display());
eprintln!(
"[SYSTEMLESS] A5=${:08X}, Entry=${:08X}",
app.a5_base,
app.entry_point(app.a5_base)
);
self.runner = Some(runner);
self.initialized = true;
}
fn cpu_budget_for_duration(duration: std::time::Duration, ips: f64, credit: &mut f64) -> usize {
*credit += duration.as_secs_f64() * ips;
let budget = credit.floor().min(game::MAX_INSTRUCTIONS_PER_FRAME as f64) as usize;
*credit -= budget as f64;
budget
}
fn tick_due_at(origin: std::time::Instant, at: std::time::Instant) -> u32 {
at.checked_duration_since(origin)
.unwrap_or_default()
.as_secs_f64()
.mul_add(systemless::runner::DEFAULT_VBL_HZ, 0.0)
.floor() as u32
}
fn audio_samples_for_duration(duration: std::time::Duration, remainder: &mut f64) -> usize {
let total_samples = duration
.as_secs_f64()
.mul_add(systemless::sound::OUTPUT_RATE as f64, *remainder);
let whole_samples = total_samples.floor();
*remainder = total_samples - whole_samples;
whole_samples as usize
}
fn next_render_headroom(render_time: std::time::Duration) -> std::time::Duration {
let target = render_time.saturating_add(RENDER_HEADROOM_MARGIN);
target.clamp(MIN_RENDER_HEADROOM, MAX_RENDER_HEADROOM)
}
fn next_frame_target(
now: std::time::Instant,
scheduled: std::time::Instant,
) -> (std::time::Instant, bool) {
if now.saturating_duration_since(scheduled) >= FRAME_DURATION {
(now + FRAME_DURATION, true)
} else {
(scheduled + FRAME_DURATION, false)
}
}
fn step_frame(&mut self) {
let Some(runner) = self.runner.as_ref() else {
return;
};
if runner.is_halted() {
return;
}
let now = runner.host_now();
let start = *self.start_time.get_or_insert(now);
let scheduled_frame_end = self.next_frame_time.unwrap_or(now + FRAME_DURATION);
let target_tick = Self::tick_due_at(start, scheduled_frame_end);
let current_tick = runner.guest_tick();
let ticks_behind = target_tick.saturating_sub(current_tick);
if ticks_behind > 4 {
self.start_time = Some(
now - std::time::Duration::from_secs_f64(
(current_tick + 2) as f64 / systemless::runner::DEFAULT_VBL_HZ,
),
);
}
let effective_target = current_tick.saturating_add(ticks_behind.min(2));
let cpu_deadline = scheduled_frame_end
.checked_sub(self.render_headroom)
.map(|d| d.max(now))
.unwrap_or(now);
let slice_budget = if let Some(ips) = self.emulated_ips {
let cpu_interval_start = self.next_cpu_budget_time.unwrap_or(now);
let cpu_interval = cpu_deadline
.checked_duration_since(cpu_interval_start)
.unwrap_or_default();
let budget =
Self::cpu_budget_for_duration(cpu_interval, ips, &mut self.cpu_instruction_credit);
self.next_cpu_budget_time = Some(cpu_deadline);
budget
} else {
self.next_cpu_budget_time = Some(cpu_deadline);
game::MAX_INSTRUCTIONS_PER_FRAME
};
let audio_samples =
Self::audio_samples_for_duration(FRAME_DURATION, &mut self.audio_sample_remainder);
let runner = self.runner.as_mut().expect("runner checked above");
let mut audio_mixed = 0usize;
let mut total_steps = 0usize;
let mut foreground_steps = 0usize;
let mut reserved_sound_steps = 0usize;
loop {
if runner.guest_tick() >= effective_target || runner.is_halted() {
break;
}
if runner.host_now() >= cpu_deadline {
break;
}
let remaining = slice_budget.saturating_sub(total_steps);
if remaining == 0 {
break;
}
let batch_size = remaining.min(CPU_BATCH_INSTRUCTIONS);
let remaining_audio = audio_samples.saturating_sub(audio_mixed);
let batches_left = remaining.div_ceil(CPU_BATCH_INSTRUCTIONS).max(1);
let batch_audio = if remaining_audio == 0 {
0
} else {
remaining_audio.div_ceil(batches_left)
};
let (steps, running) =
runner.run_gui_slice_with_audio(batch_size, effective_target, batch_audio);
total_steps += steps;
foreground_steps += steps;
audio_mixed += batch_audio;
if batch_audio > 0 {
if let Some(steps) = service_pending_sound_work(
runner,
cpu_deadline,
slice_budget,
total_steps,
&mut reserved_sound_steps,
) {
total_steps += steps;
}
}
if !running {
break;
}
}
if audio_mixed < audio_samples {
if let Some(steps) = service_pending_sound_work(
runner,
cpu_deadline,
slice_budget,
total_steps,
&mut reserved_sound_steps,
) {
total_steps += steps;
}
}
if audio_mixed < audio_samples {
let mut remaining_audio = audio_samples - audio_mixed;
while remaining_audio > 0 && !runner.is_halted() {
let chunk_audio = remaining_audio.min(AUDIO_CALLBACK_CHUNK_SAMPLES);
runner.mix_gui_audio_slice(chunk_audio);
remaining_audio -= chunk_audio;
if let Some(steps) = service_pending_sound_work(
runner,
cpu_deadline,
slice_budget,
total_steps,
&mut reserved_sound_steps,
) {
total_steps += steps;
}
}
}
if let Some(steps) = service_pending_sound_work(
runner,
cpu_deadline,
slice_budget,
total_steps,
&mut reserved_sound_steps,
) {
total_steps += steps;
}
self.total_instructions += total_steps as u64;
if foreground_steps > 0 && runner.guest_tick() == current_tick {
self.force_next_render = true;
}
if std::env::var_os("SYSTEMLESS_TRACE_TICK_LAG").is_some() {
let final_tick = runner.guest_tick();
let advanced = final_tick.saturating_sub(current_tick);
let steady_sample = self.frame_count.is_multiple_of(60);
if ticks_behind > 0 || steady_sample {
let tag = if ticks_behind > 0 { "LAG" } else { "OK " };
eprintln!(
"[TICK_LAG {}] frame={} target={} current={} behind={} \
advanced={} budget={} used={}",
tag,
self.frame_count,
target_tick,
current_tick,
ticks_behind,
advanced,
slice_budget,
total_steps,
);
}
}
}
fn should_render_frame(&self) -> bool {
if self.force_next_render {
return true;
}
let Some(runner) = self.runner.as_ref() else {
return false;
};
runner.is_halted()
|| runner.is_ui_tracking_active()
|| self.last_presented_guest_tick != Some(runner.guest_tick())
}
fn render_frame(&mut self) {
let render_start = std::time::Instant::now();
let size = {
let Some(window) = self.window.as_ref() else {
return;
};
window.inner_size()
};
if size.width == 0 || size.height == 0 {
return;
}
let Some(runner) = self.runner.as_mut() else {
return;
};
runner.composite_frame();
let presented_tick = runner.guest_tick();
let (_, _, scrn_right, scrn_bottom, _) = runner.dispatcher().screen_mode;
let game_w = scrn_right as u32;
let game_h = scrn_bottom as u32;
let buf_w = size.width;
let buf_h = size.height;
if buf_w == 0 || buf_h == 0 || game_w == 0 || game_h == 0 {
return;
}
let screen_mode = runner.dispatcher().screen_mode;
let device_clut = runner.dispatcher().device_clut;
let cursor = runner.dispatcher().cursor().cloned();
let mouse_pos = runner.dispatcher().mouse_position();
let mut frame_argb = std::mem::take(&mut self.frame_argb);
display::render_screen_argb(runner.bus(), screen_mode, &device_clut, &mut frame_argb);
if let Some(cursor) = cursor.as_ref() {
display::render_cursor_argb(&mut frame_argb, game_w, game_h, cursor, mouse_pos);
}
let scale = (buf_w / game_w).min(buf_h / game_h).max(1) as usize;
let draw_w = game_w as usize * scale;
let draw_h = game_h as usize * scale;
let mut scaled_row = std::mem::take(&mut self.scaled_row);
let Some(surface) = self.surface.as_mut() else {
self.frame_argb = frame_argb;
self.scaled_row = scaled_row;
return;
};
if self.surface_size != Some((buf_w, buf_h)) {
surface
.resize(
NonZeroU32::new(buf_w).unwrap(),
NonZeroU32::new(buf_h).unwrap(),
)
.expect("Failed to resize surface");
self.surface_size = Some((buf_w, buf_h));
}
let mut buffer = surface.buffer_mut().expect("Failed to get buffer");
if draw_w != buf_w as usize || draw_h != buf_h as usize {
buffer.fill(0xFF000000);
}
if scale == 1 {
for row in 0..game_h as usize {
let src_row = &frame_argb[row * game_w as usize..(row + 1) * game_w as usize];
let dst_offset = row * buf_w as usize;
buffer[dst_offset..dst_offset + game_w as usize].copy_from_slice(src_row);
}
} else {
scaled_row.resize(draw_w, 0xFF000000);
for row in 0..game_h as usize {
let src_row = &frame_argb[row * game_w as usize..(row + 1) * game_w as usize];
for (dst_chunk, &pixel) in scaled_row.chunks_exact_mut(scale).zip(src_row.iter()) {
dst_chunk.fill(pixel);
}
let dst_row_start = row * scale * buf_w as usize;
for repeat in 0..scale {
let dst_offset = dst_row_start + repeat * buf_w as usize;
buffer[dst_offset..dst_offset + draw_w].copy_from_slice(&scaled_row);
}
}
}
self.frame_argb = frame_argb;
self.scaled_row = scaled_row;
buffer.present().expect("Failed to present buffer");
self.last_presented_guest_tick = Some(presented_tick);
self.force_next_render = false;
self.render_headroom = Self::next_render_headroom(render_start.elapsed());
}
}
impl ApplicationHandler for App {
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
if self.window.is_none() {
let window_attrs = Window::default_attributes()
.with_title("Systemless - Macintosh Emulator")
.with_inner_size(winit::dpi::LogicalSize::new(
INITIAL_SCREEN_WIDTH * SCALE,
INITIAL_SCREEN_HEIGHT * SCALE,
))
.with_resizable(true);
let window_attrs = platform_window_attrs(window_attrs);
let window = Rc::new(
event_loop
.create_window(window_attrs)
.expect("Failed to create window"),
);
let context =
softbuffer::Context::new(window.clone()).expect("Failed to create context");
let surface = Surface::new(&context, window.clone()).expect("Failed to create surface");
self.window = Some(window);
self.surface = Some(surface);
self.init_game();
}
}
fn window_event(&mut self, event_loop: &ActiveEventLoop, _id: WindowId, event: WindowEvent) {
match event {
WindowEvent::CloseRequested => {
eprintln!(
"[SYSTEMLESS] Window closed. Total instructions: {}",
self.total_instructions
);
event_loop.exit();
}
WindowEvent::CursorMoved { position, .. } => {
self.force_next_render = true;
self.mouse_physical = (position.x, position.y);
let (v, h) = self.physical_to_mac(position.x, position.y);
if let Some(runner) = self.runner.as_mut() {
runner.set_mouse_position(v, h);
runner.dispatcher_mut().show_cursor();
}
}
WindowEvent::MouseInput {
state,
button: MouseButton::Left,
..
} => {
self.force_next_render = true;
let (v, h) = self.physical_to_mac(self.mouse_physical.0, self.mouse_physical.1);
if let Some(runner) = self.runner.as_mut() {
match state {
ElementState::Pressed => {
runner.push_mouse_down(v, h);
}
ElementState::Released => {
runner.push_mouse_up(v, h);
}
}
}
}
WindowEvent::KeyboardInput { event, .. } => {
self.force_next_render = true;
if let Some(runner) = self.runner.as_mut() {
let (mac_key, char_code) = host_key_to_mac(
&event.logical_key,
&event.physical_key,
event.text.as_ref().map(|t| t.as_str()),
);
if std::env::var_os("SYSTEMLESS_TRACE_GUI_KEY").is_some() {
eprintln!(
"[GUI-KEY] state={:?} physical_key={:?} mac_key=${:02X} char=${:02X} text={:?}",
event.state,
event.physical_key,
mac_key,
char_code,
event.text,
);
}
match event.state {
ElementState::Pressed => {
runner.push_key_down(mac_key, char_code);
}
ElementState::Released => {
runner.push_key_up(mac_key, char_code);
}
}
}
}
WindowEvent::Resized(_) => {
self.force_next_render = true;
}
WindowEvent::RedrawRequested => {}
_ => {}
}
}
fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) {
let now = std::time::Instant::now();
let next = self.next_frame_time.unwrap_or(now);
if now < next {
event_loop.set_control_flow(ControlFlow::WaitUntil(next));
return;
}
let (next_target, dropped_missed_frame) = Self::next_frame_target(now, next);
if dropped_missed_frame {
self.next_cpu_budget_time = Some(now);
self.cpu_instruction_credit = 0.0;
}
self.next_frame_time = Some(next_target);
event_loop.set_control_flow(ControlFlow::WaitUntil(next_target));
self.step_frame();
if let Some(runner) = &self.runner {
let (_, _, sw, sh, _) = runner.dispatcher().screen_mode;
let sw = sw as u32;
let sh = sh as u32;
if sw != self.current_screen_width || sh != self.current_screen_height {
self.current_screen_width = sw;
self.current_screen_height = sh;
if let Some(window) = &self.window {
let _ = window
.request_inner_size(winit::dpi::LogicalSize::new(sw * SCALE, sh * SCALE));
}
self.force_next_render = true;
}
}
if self.should_render_frame() {
self.render_frame();
}
self.frame_count += 1;
}
}
fn run_gui(game_path: PathBuf, arrows_as_numpad: bool, cpu_mhz: Option<f64>, show_menu_bar: bool) {
let event_loop = EventLoop::new().expect("Failed to create event loop");
match cpu_mhz {
Some(mhz) => eprintln!("[SYSTEMLESS] GUI CPU cap: {:.1} MHz", mhz),
None => eprintln!("[SYSTEMLESS] GUI CPU cap: uncapped"),
}
eprintln!(
"[SYSTEMLESS] GUI arrow keys: {}",
if arrows_as_numpad {
"keypad flight controls"
} else {
"literal Mac arrow keys"
}
);
let mut app = App::new(game_path, arrows_as_numpad, cpu_mhz, show_menu_bar);
event_loop.run_app(&mut app).expect("Event loop failed");
}
fn save_screenshot(runner: &FixtureRunner, num: usize) {
let (_, _, scrn_width, scrn_height, _) = runner.dispatcher().screen_mode;
let w = scrn_width as u32;
let h = scrn_height as u32;
if w == 0 || h == 0 {
eprintln!(
"[HEADLESS] Screenshot #{}: skipped (screen not initialized)",
num
);
return;
}
let rgba = display::render_screen(
runner.bus(),
runner.dispatcher().screen_mode,
&runner.dispatcher().device_clut,
);
let img = image::RgbImage::from_fn(w, h, |x, y| {
let idx = ((y * w + x) * 4) as usize;
image::Rgb([rgba[idx], rgba[idx + 1], rgba[idx + 2]])
});
let ticks = runner.guest_tick();
let path = format!("/tmp/systemless_headless_{:04}.png", num);
img.save(&path).expect("Failed to save screenshot");
eprintln!("[HEADLESS] Screenshot #{}: {} (ticks={})", num, path, ticks);
}
fn run_headless(game_path: &std::path::Path, max_instructions: usize, show_menu_bar: bool) {
eprintln!("[HEADLESS] Starting: {}", game_path.display());
eprintln!("[HEADLESS] Max instructions: {}", max_instructions);
let mut runner = game::new_runner();
if show_menu_bar {
runner.set_menu_bar_visible(true);
}
let app = game::load_game_from_path(&mut runner, game_path).expect("Failed to load game");
game::init_game(&mut runner, &app);
let chunk = 100_000;
let mut total: usize = 0;
let mut last_screenshot = 0usize;
while total < max_instructions {
let steps_to_run = chunk.min(max_instructions - total);
let (steps, running) = runner.run_steps(steps_to_run, None);
total += steps;
let screenshot_num = total / 500_000;
if screenshot_num > last_screenshot {
last_screenshot = screenshot_num;
runner.composite_frame();
save_screenshot(&runner, screenshot_num);
}
if !running {
eprintln!("[HEADLESS] CPU stopped after {} instructions", total);
break;
}
}
eprintln!("[HEADLESS] Completed {} instructions", total);
save_screenshot(&runner, 9999);
}
fn main() {
let args: Vec<String> = std::env::args().collect();
let mut headless = false;
let mut arrows_as_numpad = DEFAULT_GUI_ARROWS_AS_NUMPAD;
let mut cpu_mhz: Option<f64> = None;
let mut game_path_str = None;
let mut max_instructions: Option<usize> = None;
let mut show_menu_bar = false;
let mut i = 1;
while i < args.len() {
match args[i].as_str() {
"--headless" => headless = true,
"--arrows-as-numpad" => arrows_as_numpad = true,
"--literal-arrows" | "--no-arrows-as-numpad" => arrows_as_numpad = false,
"--show-menu-bar" => show_menu_bar = true,
"--cpu-mhz" => {
i += 1;
if let Some(mhz) = args.get(i).and_then(|s| s.parse::<f64>().ok()) {
cpu_mhz = Some(mhz);
}
}
"--max-instructions" => {
i += 1;
max_instructions = args.get(i).and_then(|s| s.parse().ok());
}
_ => {
if game_path_str.is_none() {
game_path_str = Some(args[i].clone());
}
}
}
i += 1;
}
let game_path = match game_path_str {
Some(p) => PathBuf::from(p),
None => {
eprintln!(
"Usage: {} [--headless] [--arrows-as-numpad] [--literal-arrows] \
[--cpu-mhz N] [--max-instructions N] \
[--show-menu-bar] <game>",
args[0]
);
std::process::exit(1);
}
};
if !game_path.exists() {
eprintln!("Error: Game file not found: {}", game_path.display());
std::process::exit(1);
}
eprintln!("[SYSTEMLESS] Starting emulator...");
eprintln!("[SYSTEMLESS] Game: {}", game_path.display());
if headless {
run_headless(
&game_path,
max_instructions.unwrap_or(5_000_000),
show_menu_bar,
);
} else {
run_gui(game_path, arrows_as_numpad, cpu_mhz, show_menu_bar);
}
}
fn logical_arrow_to_mac(key: &Key) -> Option<(u8, u8)> {
match key {
Key::Named(NamedKey::ArrowLeft) => Some((0x7B, 28)),
Key::Named(NamedKey::ArrowRight) => Some((0x7C, 29)),
Key::Named(NamedKey::ArrowDown) => Some((0x7D, 31)),
Key::Named(NamedKey::ArrowUp) => Some((0x7E, 30)),
_ => None,
}
}
fn physical_numpad_to_mac(key: &PhysicalKey) -> Option<(u8, u8)> {
match key {
PhysicalKey::Code(
KeyCode::NumpadDecimal
| KeyCode::NumpadMultiply
| KeyCode::NumpadAdd
| KeyCode::NumpadDivide
| KeyCode::NumpadEnter
| KeyCode::NumpadSubtract
| KeyCode::NumpadEqual
| KeyCode::Numpad0
| KeyCode::Numpad1
| KeyCode::Numpad2
| KeyCode::Numpad3
| KeyCode::Numpad4
| KeyCode::Numpad5
| KeyCode::Numpad6
| KeyCode::Numpad7
| KeyCode::Numpad8
| KeyCode::Numpad9,
) => Some((keycode_to_mac(key), keycode_to_mac_char(key))),
_ => None,
}
}
fn host_key_to_mac(logical_key: &Key, physical_key: &PhysicalKey, text: Option<&str>) -> (u8, u8) {
let (mac_key, mac_char_fallback) = physical_numpad_to_mac(physical_key)
.or_else(|| logical_arrow_to_mac(logical_key))
.unwrap_or_else(|| {
(
keycode_to_mac(physical_key),
keycode_to_mac_char(physical_key),
)
});
let char_code = if mac_char_fallback != 0 {
mac_char_fallback
} else {
text.and_then(|t| t.bytes().next())
.unwrap_or_else(|| keycode_to_mac_printable_char(physical_key))
};
(mac_key, char_code)
}
fn keycode_to_mac(key: &PhysicalKey) -> u8 {
match key {
PhysicalKey::Code(code) => match code {
KeyCode::KeyA => 0x00,
KeyCode::KeyS => 0x01,
KeyCode::KeyD => 0x02,
KeyCode::KeyF => 0x03,
KeyCode::KeyH => 0x04,
KeyCode::KeyG => 0x05,
KeyCode::KeyZ => 0x06,
KeyCode::KeyX => 0x07,
KeyCode::KeyC => 0x08,
KeyCode::KeyV => 0x09,
KeyCode::KeyB => 0x0B,
KeyCode::KeyQ => 0x0C,
KeyCode::KeyW => 0x0D,
KeyCode::KeyE => 0x0E,
KeyCode::KeyR => 0x0F,
KeyCode::KeyY => 0x10,
KeyCode::KeyT => 0x11,
KeyCode::Digit1 => 0x12,
KeyCode::Digit2 => 0x13,
KeyCode::Digit3 => 0x14,
KeyCode::Digit4 => 0x15,
KeyCode::Digit6 => 0x16,
KeyCode::Digit5 => 0x17,
KeyCode::Equal => 0x18,
KeyCode::Digit9 => 0x19,
KeyCode::Digit7 => 0x1A,
KeyCode::Minus => 0x1B,
KeyCode::Digit8 => 0x1C,
KeyCode::Digit0 => 0x1D,
KeyCode::BracketRight => 0x1E,
KeyCode::KeyO => 0x1F,
KeyCode::KeyU => 0x20,
KeyCode::BracketLeft => 0x21,
KeyCode::KeyI => 0x22,
KeyCode::KeyP => 0x23,
KeyCode::Enter => 0x24,
KeyCode::KeyL => 0x25,
KeyCode::KeyJ => 0x26,
KeyCode::Quote => 0x27,
KeyCode::KeyK => 0x28,
KeyCode::Semicolon => 0x29,
KeyCode::Backslash => 0x2A,
KeyCode::Comma => 0x2B,
KeyCode::Slash => 0x2C,
KeyCode::KeyN => 0x2D,
KeyCode::KeyM => 0x2E,
KeyCode::Period => 0x2F,
KeyCode::Tab => 0x30,
KeyCode::Space => 0x31,
KeyCode::Backquote => 0x32,
KeyCode::Backspace => 0x33,
KeyCode::Escape => 0x35,
KeyCode::SuperLeft => 0x37,
KeyCode::ShiftLeft => 0x38,
KeyCode::CapsLock => 0x39,
KeyCode::AltLeft => 0x3A,
KeyCode::ControlLeft => 0x3B,
KeyCode::ShiftRight => 0x3C,
KeyCode::AltRight => 0x3D,
KeyCode::ControlRight => 0x3E,
KeyCode::NumpadDecimal => 0x41,
KeyCode::NumpadMultiply => 0x43,
KeyCode::NumpadAdd => 0x45,
KeyCode::NumLock => 0x47,
KeyCode::NumpadDivide => 0x4B,
KeyCode::NumpadEnter => 0x4C,
KeyCode::NumpadSubtract => 0x4E,
KeyCode::NumpadEqual => 0x51,
KeyCode::Numpad0 => 0x52,
KeyCode::Numpad1 => 0x53,
KeyCode::Numpad2 => 0x54,
KeyCode::Numpad3 => 0x55,
KeyCode::Numpad4 => 0x56,
KeyCode::Numpad5 => 0x57,
KeyCode::Numpad6 => 0x58,
KeyCode::Numpad7 => 0x59,
KeyCode::Numpad8 => 0x5B,
KeyCode::Numpad9 => 0x5C,
KeyCode::ArrowLeft => 0x7B,
KeyCode::ArrowRight => 0x7C,
KeyCode::ArrowDown => 0x7D,
KeyCode::ArrowUp => 0x7E,
KeyCode::F1 => 0x7A,
KeyCode::F2 => 0x78,
KeyCode::F3 => 0x63,
KeyCode::F4 => 0x76,
KeyCode::F5 => 0x60,
_ => 0xFF,
},
_ => 0xFF,
}
}
fn keycode_to_mac_char(key: &PhysicalKey) -> u8 {
match key {
PhysicalKey::Code(code) => match code {
KeyCode::Enter | KeyCode::NumpadEnter => 13,
KeyCode::Tab => 9,
KeyCode::Space => 32,
KeyCode::Backspace => 8,
KeyCode::Escape => 27,
KeyCode::ArrowLeft => 28,
KeyCode::ArrowRight => 29,
KeyCode::ArrowUp => 30,
KeyCode::ArrowDown => 31,
_ => 0,
},
_ => 0,
}
}
fn keycode_to_mac_printable_char(key: &PhysicalKey) -> u8 {
match key {
PhysicalKey::Code(code) => match code {
KeyCode::KeyA => b'a',
KeyCode::KeyB => b'b',
KeyCode::KeyC => b'c',
KeyCode::KeyD => b'd',
KeyCode::KeyE => b'e',
KeyCode::KeyF => b'f',
KeyCode::KeyG => b'g',
KeyCode::KeyH => b'h',
KeyCode::KeyI => b'i',
KeyCode::KeyJ => b'j',
KeyCode::KeyK => b'k',
KeyCode::KeyL => b'l',
KeyCode::KeyM => b'm',
KeyCode::KeyN => b'n',
KeyCode::KeyO => b'o',
KeyCode::KeyP => b'p',
KeyCode::KeyQ => b'q',
KeyCode::KeyR => b'r',
KeyCode::KeyS => b's',
KeyCode::KeyT => b't',
KeyCode::KeyU => b'u',
KeyCode::KeyV => b'v',
KeyCode::KeyW => b'w',
KeyCode::KeyX => b'x',
KeyCode::KeyY => b'y',
KeyCode::KeyZ => b'z',
KeyCode::Digit0 | KeyCode::Numpad0 => b'0',
KeyCode::Digit1 | KeyCode::Numpad1 => b'1',
KeyCode::Digit2 | KeyCode::Numpad2 => b'2',
KeyCode::Digit3 | KeyCode::Numpad3 => b'3',
KeyCode::Digit4 | KeyCode::Numpad4 => b'4',
KeyCode::Digit5 | KeyCode::Numpad5 => b'5',
KeyCode::Digit6 | KeyCode::Numpad6 => b'6',
KeyCode::Digit7 | KeyCode::Numpad7 => b'7',
KeyCode::Digit8 | KeyCode::Numpad8 => b'8',
KeyCode::Digit9 | KeyCode::Numpad9 => b'9',
KeyCode::Minus | KeyCode::NumpadSubtract => b'-',
KeyCode::Equal | KeyCode::NumpadEqual => b'=',
KeyCode::BracketLeft => b'[',
KeyCode::BracketRight => b']',
KeyCode::Backslash => b'\\',
KeyCode::Semicolon => b';',
KeyCode::Quote => b'\'',
KeyCode::Comma => b',',
KeyCode::Period | KeyCode::NumpadDecimal => b'.',
KeyCode::Slash | KeyCode::NumpadDivide => b'/',
KeyCode::NumpadMultiply => b'*',
KeyCode::NumpadAdd => b'+',
KeyCode::Backquote => b'`',
_ => 0,
},
_ => 0,
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::RefCell;
use std::rc::Rc;
struct CountingAudioBackend {
queued_stereo_bytes: Rc<RefCell<usize>>,
}
struct RecordingAudioBackend {
queued_stereo_bytes: Rc<RefCell<Vec<u8>>>,
}
impl systemless::audio::AudioBackend for CountingAudioBackend {
fn queue_samples(&mut self, samples: &[u8]) {
*self.queued_stereo_bytes.borrow_mut() += samples.len() * 2;
}
fn queue_stereo_samples(&mut self, samples: &[u8]) {
*self.queued_stereo_bytes.borrow_mut() += samples.len();
}
fn stop(&mut self) {}
}
impl systemless::audio::AudioBackend for RecordingAudioBackend {
fn queue_samples(&mut self, samples: &[u8]) {
self.queued_stereo_bytes.borrow_mut().extend(samples);
}
fn queue_stereo_samples(&mut self, samples: &[u8]) {
self.queued_stereo_bytes.borrow_mut().extend(samples);
}
fn stop(&mut self) {}
}
fn gui_runner_with_counting_audio() -> (FixtureRunner, Rc<RefCell<usize>>) {
let queued = Rc::new(RefCell::new(0usize));
let mut runner = FixtureRunner::new(
8 * 1024 * 1024,
systemless::runner::FixtureRunnerConfig::default(),
);
runner.set_audio(Box::new(CountingAudioBackend {
queued_stereo_bytes: queued.clone(),
}));
(runner, queued)
}
fn gui_runner_with_recording_audio() -> (FixtureRunner, Rc<RefCell<Vec<u8>>>) {
let queued = Rc::new(RefCell::new(Vec::new()));
let mut runner = FixtureRunner::new(
8 * 1024 * 1024,
systemless::runner::FixtureRunnerConfig::default(),
);
runner.set_audio(Box::new(RecordingAudioBackend {
queued_stereo_bytes: queued.clone(),
}));
(runner, queued)
}
#[test]
fn gui_defaults_to_literal_arrow_controls() {
let app = App::new(
PathBuf::from("dummy"),
DEFAULT_GUI_ARROWS_AS_NUMPAD,
None,
false,
);
assert!(
!app.arrows_as_numpad,
"the interactive GUI should leave arrow keys literal by default; --arrows-as-numpad opts into keypad movement"
);
}
#[test]
fn physical_numpad_events_keep_keypad_identity_even_when_logical_key_is_arrow() {
assert_eq!(
host_key_to_mac(
&Key::Named(NamedKey::ArrowLeft),
&PhysicalKey::Code(KeyCode::Numpad4),
None,
),
(0x56, b'4')
);
assert_eq!(
host_key_to_mac(
&Key::Named(NamedKey::ArrowRight),
&PhysicalKey::Code(KeyCode::Numpad6),
None,
),
(0x58, b'6')
);
assert_eq!(
host_key_to_mac(
&Key::Named(NamedKey::ArrowDown),
&PhysicalKey::Code(KeyCode::Numpad2),
None,
),
(0x54, b'2')
);
assert_eq!(
host_key_to_mac(
&Key::Named(NamedKey::ArrowUp),
&PhysicalKey::Code(KeyCode::Numpad8),
None,
),
(0x5B, b'8')
);
}
#[test]
fn physical_arrow_events_keep_literal_arrow_identity() {
assert_eq!(
host_key_to_mac(
&Key::Named(NamedKey::ArrowLeft),
&PhysicalKey::Code(KeyCode::ArrowLeft),
None,
),
(0x7B, 28)
);
assert_eq!(
host_key_to_mac(
&Key::Named(NamedKey::ArrowRight),
&PhysicalKey::Code(KeyCode::ArrowRight),
None,
),
(0x7C, 29)
);
assert_eq!(
host_key_to_mac(
&Key::Named(NamedKey::ArrowDown),
&PhysicalKey::Code(KeyCode::ArrowDown),
None,
),
(0x7D, 31)
);
assert_eq!(
host_key_to_mac(
&Key::Named(NamedKey::ArrowUp),
&PhysicalKey::Code(KeyCode::ArrowUp),
None,
),
(0x7E, 30)
);
}
#[test]
fn printable_physical_keys_have_char_fallbacks() {
assert_eq!(
keycode_to_mac_printable_char(&PhysicalKey::Code(KeyCode::KeyJ)),
b'j'
);
assert_eq!(
keycode_to_mac_printable_char(&PhysicalKey::Code(KeyCode::KeyM)),
b'm'
);
assert_eq!(
keycode_to_mac_printable_char(&PhysicalKey::Code(KeyCode::Numpad8)),
b'8'
);
assert_eq!(
keycode_to_mac_printable_char(&PhysicalKey::Code(KeyCode::ArrowUp)),
0,
"control keys use canonical Mac control-character fallback instead"
);
}
#[test]
fn service_pending_sound_work_uses_reserved_slice_after_spent_frame_budget() {
use systemless::cpu::Register;
use systemless::memory::MemoryBus;
use systemless::runner::FixtureRunnerConfig;
use systemless::sound::{PendingSoundCallback, SndCommand};
let mut runner = FixtureRunner::new(8 * 1024 * 1024, FixtureRunnerConfig::default());
let resume_pc = runner.bus_mut().alloc(16);
runner.bus_mut().write_word(resume_pc, 0x4E71); let callback_addr = runner.bus_mut().alloc(2);
runner.bus_mut().write_word(callback_addr, 0x4E75); runner.cpu_mut().write_reg(Register::PC, resume_pc);
runner.cpu_mut().write_reg(Register::A7, 0x0008_0000);
runner
.dispatcher_mut()
.sound_manager
.pending_sound_callbacks
.push(PendingSoundCallback::Command {
callback_addr,
chan_ptr: 0x0001_2340,
cmd: SndCommand {
cmd: systemless::sound::cmd::CALLBACK,
param1: 0,
param2: 0,
},
});
let spent_deadline = std::time::Instant::now() - std::time::Duration::from_millis(1);
let mut reserved_sound_steps = 0usize;
let steps = service_pending_sound_work(
&mut runner,
spent_deadline,
0,
SOUND_CALLBACK_SLICE_INSTRUCTIONS,
&mut reserved_sound_steps,
);
assert!(
steps.is_some_and(|steps| steps > 0),
"sound callbacks should run from their reserved interrupt slice even after the foreground frame budget/deadline is spent"
);
assert!(
!runner.has_pending_sound_work(),
"sound callback should complete from the reserved interrupt slice"
);
assert!(!runner.is_halted());
}
#[test]
fn service_pending_sound_work_caps_reserved_slice_per_frame() {
use systemless::cpu::Register;
use systemless::memory::MemoryBus;
use systemless::runner::FixtureRunnerConfig;
use systemless::sound::{PendingSoundCallback, SndCommand};
let mut runner = FixtureRunner::new(8 * 1024 * 1024, FixtureRunnerConfig::default());
let resume_pc = runner.bus_mut().alloc(16);
runner.bus_mut().write_word(resume_pc, 0x4E71); let callback_addr = runner.bus_mut().alloc(2);
runner.bus_mut().write_word(callback_addr, 0x60FE); runner.cpu_mut().write_reg(Register::PC, resume_pc);
runner.cpu_mut().write_reg(Register::A7, 0x0008_0000);
runner
.dispatcher_mut()
.sound_manager
.pending_sound_callbacks
.push(PendingSoundCallback::Command {
callback_addr,
chan_ptr: 0x0001_2340,
cmd: SndCommand {
cmd: systemless::sound::cmd::CALLBACK,
param1: 0,
param2: 0,
},
});
let spent_deadline = std::time::Instant::now() - std::time::Duration::from_millis(1);
let mut reserved_sound_steps = 0usize;
let first_steps = service_pending_sound_work(
&mut runner,
spent_deadline,
0,
SOUND_CALLBACK_SLICE_INSTRUCTIONS,
&mut reserved_sound_steps,
)
.expect("first reserved sound slice should run");
assert_eq!(first_steps, SOUND_CALLBACK_SLICE_INSTRUCTIONS);
assert_eq!(reserved_sound_steps, SOUND_CALLBACK_SLICE_INSTRUCTIONS);
assert!(
runner.has_pending_sound_work(),
"spinning callback should remain pending after one reserved sound slice"
);
let second_steps = service_pending_sound_work(
&mut runner,
spent_deadline,
0,
SOUND_CALLBACK_SLICE_INSTRUCTIONS + first_steps,
&mut reserved_sound_steps,
);
assert_eq!(second_steps, Some(SOUND_CALLBACK_SLICE_INSTRUCTIONS));
assert!(runner.has_pending_sound_work());
assert!(!runner.is_halted());
let final_partial_steps = service_pending_sound_work(
&mut runner,
spent_deadline,
0,
SOUND_CALLBACK_SLICE_INSTRUCTIONS * 2 + first_steps,
&mut reserved_sound_steps,
);
assert_eq!(
final_partial_steps,
Some(
SOUND_CALLBACK_RESERVED_INSTRUCTIONS_PER_FRAME
- SOUND_CALLBACK_SLICE_INSTRUCTIONS * 2
)
);
assert!(runner.has_pending_sound_work());
assert!(!runner.is_halted());
let exhausted_steps = service_pending_sound_work(
&mut runner,
spent_deadline,
0,
SOUND_CALLBACK_RESERVED_INSTRUCTIONS_PER_FRAME + first_steps,
&mut reserved_sound_steps,
);
assert_eq!(
exhausted_steps, None,
"same-frame reserved sound work should stop at the cap so the GUI event loop can process input"
);
assert_eq!(
reserved_sound_steps,
SOUND_CALLBACK_RESERVED_INSTRUCTIONS_PER_FRAME
);
}
#[test]
fn audio_samples_for_duration_preserves_fractional_rate() {
let mut remainder = 0.0;
let mut total = 0usize;
for _ in 0..120 {
let samples = App::audio_samples_for_duration(FRAME_DURATION, &mut remainder);
assert!(samples > 0);
total += samples;
}
let expected =
(FRAME_DURATION.as_secs_f64() * systemless::sound::OUTPUT_RATE as f64 * 120.0).floor()
as usize;
assert_eq!(total, expected);
assert!(remainder >= 0.0);
assert!(remainder < 1.0);
}
#[test]
fn step_frame_mixes_one_audio_frame_when_guest_tick_does_not_advance() {
let now = std::time::Instant::now();
let (runner, queued) = gui_runner_with_counting_audio();
let mut app = App::new(PathBuf::from("dummy"), false, None, false);
app.runner = Some(runner);
app.start_time = Some(now);
app.next_frame_time = Some(now);
app.step_frame();
assert!(
(732..=734).contains(&*queued.borrow()),
"same-tick GUI/menu frames should still queue one host audio frame, got {} bytes",
*queued.borrow()
);
}
#[test]
fn step_frame_forces_render_after_same_tick_foreground_progress() {
use systemless::cpu::Register;
use systemless::memory::MemoryBus;
let now = std::time::Instant::now();
let mut runner = FixtureRunner::new(
8 * 1024 * 1024,
systemless::runner::FixtureRunnerConfig::default(),
);
let pc = runner.bus_mut().alloc(256 * 1024);
for offset in (0..256 * 1024).step_by(2) {
runner.bus_mut().write_word(pc + offset, 0x4E71); }
runner.cpu_mut().write_reg(Register::PC, pc);
runner.cpu_mut().write_reg(Register::A7, 0x0008_0000);
runner.bus_mut().write_long(0x016A, 0);
runner.set_instructions_per_tick(1_000_000);
let mut app = App::new(PathBuf::from("dummy"), false, Some(1.0), false);
app.runner = Some(runner);
app.start_time = Some(now - FRAME_DURATION);
app.next_frame_time = Some(now + FRAME_DURATION * 4);
app.next_cpu_budget_time = Some(now);
app.last_presented_guest_tick = Some(0);
app.force_next_render = false;
app.step_frame();
let runner = app.runner.as_ref().unwrap();
assert!(
app.total_instructions > 0,
"test setup should execute foreground startup work"
);
assert_eq!(
runner.guest_tick(),
0,
"test setup should stay within the same VBL tick"
);
assert!(
app.should_render_frame(),
"same-tick foreground drawing progress should force a present"
);
}
#[test]
fn step_frame_services_pending_sound_before_late_same_tick_audio_mix() {
use systemless::cpu::Register;
use systemless::memory::MemoryBus;
use systemless::sound::{
DoubleBufferState, PendingDoubleBackCallback, SndChannel, OUTPUT_RATE,
};
const FRAMES: usize = 512;
let now = std::time::Instant::now();
let scheduled_frame_end = now;
let (mut runner, queued) = gui_runner_with_recording_audio();
let interrupted_pc = runner.bus_mut().alloc(2);
runner.bus_mut().write_word(interrupted_pc, 0x4E71); runner.cpu_mut().write_reg(Register::PC, interrupted_pc);
runner.cpu_mut().write_reg(Register::A7, 0x0008_0000);
let chan_ptr = 0x0001_2340;
let header_ptr = runner.bus_mut().alloc(24);
let buf0_ptr = runner.bus_mut().alloc(16 + FRAMES as u32);
let callback_addr = runner.bus_mut().alloc((FRAMES / 4) as u32 * 10 + 12);
runner.bus_mut().write_word(header_ptr, 1);
runner.bus_mut().write_word(header_ptr + 2, 8);
runner
.bus_mut()
.write_long(header_ptr + 8, OUTPUT_RATE << 16);
runner.bus_mut().write_long(header_ptr + 12, buf0_ptr);
runner.bus_mut().write_long(header_ptr + 16, 0);
runner.bus_mut().write_long(header_ptr + 20, callback_addr);
runner.bus_mut().write_long(buf0_ptr, FRAMES as u32);
runner.bus_mut().write_long(buf0_ptr + 4, 0);
let mut pc = callback_addr;
for offset in (0..FRAMES).step_by(4) {
runner.bus_mut().write_word(pc, 0x23FC); runner.bus_mut().write_long(pc + 2, 0xA0A0_A0A0);
runner
.bus_mut()
.write_long(pc + 6, buf0_ptr + 16 + offset as u32);
pc += 10;
}
runner.bus_mut().write_word(pc, 0x23FC); runner.bus_mut().write_long(pc + 2, 0x0000_0001);
runner.bus_mut().write_long(pc + 6, buf0_ptr + 4);
runner.bus_mut().write_word(pc + 10, 0x4E75);
let mut chan = SndChannel::new(chan_ptr, false);
chan.double_buffer = Some(DoubleBufferState {
header_ptr,
current_buffer: 0,
callback_addr,
chan_ptr,
sample_rate: OUTPUT_RATE << 16,
num_channels: 1,
sample_size: 8,
last_buffer_seen: false,
waiting_for_callback: true,
pending_callback_buffers: [true, false],
});
runner.dispatcher_mut().sound_manager.channels.push(chan);
runner
.dispatcher_mut()
.sound_manager
.pending_callbacks
.push(PendingDoubleBackCallback {
callback_addr,
chan_ptr,
header_ptr,
exhausted_buffer_index: 0,
});
let mut app = App::new(PathBuf::from("dummy"), false, None, false);
app.runner = Some(runner);
app.start_time = Some(scheduled_frame_end);
app.next_frame_time = Some(scheduled_frame_end);
app.step_frame();
let queued = queued.borrow();
assert!(
(732..=734).contains(&queued.len()),
"same-tick GUI frame should queue one host audio frame, got {} bytes",
queued.len()
);
assert!(
queued.iter().any(|&sample| sample == 0xA0),
"pending doubleback must refill before same-tick audio is mixed"
);
assert!(
!app.runner.as_ref().unwrap().has_pending_sound_work(),
"sound callback should complete during the GUI sound-work slice"
);
}
#[test]
fn step_frame_services_doubleback_between_late_audio_chunks() {
use systemless::cpu::Register;
use systemless::memory::MemoryBus;
use systemless::sound::{DoubleBufferState, SndChannel, OUTPUT_RATE};
const REFILL_FRAMES: usize = 64;
let now = std::time::Instant::now();
let (mut runner, queued) = gui_runner_with_recording_audio();
let interrupted_pc = runner.bus_mut().alloc(2);
runner.bus_mut().write_word(interrupted_pc, 0x4E71); runner.cpu_mut().write_reg(Register::PC, interrupted_pc);
runner.cpu_mut().write_reg(Register::A7, 0x0008_0000);
let chan_ptr = 0x0001_2340;
let header_ptr = runner.bus_mut().alloc(24);
let buf0_ptr = runner.bus_mut().alloc(16 + REFILL_FRAMES as u32);
let buf1_ptr = runner.bus_mut().alloc(16 + REFILL_FRAMES as u32);
let callback_addr = runner
.bus_mut()
.alloc((REFILL_FRAMES as u32 / 4 + 2) * 20 + 2);
runner.bus_mut().write_word(header_ptr, 1);
runner.bus_mut().write_word(header_ptr + 2, 8);
runner
.bus_mut()
.write_long(header_ptr + 8, OUTPUT_RATE << 16);
runner.bus_mut().write_long(header_ptr + 12, buf0_ptr);
runner.bus_mut().write_long(header_ptr + 16, buf1_ptr);
runner.bus_mut().write_long(header_ptr + 20, callback_addr);
runner.bus_mut().write_long(buf0_ptr, 1);
runner.bus_mut().write_long(buf0_ptr + 4, 0x0000_0001);
runner.bus_mut().write_byte(buf0_ptr + 16, 0x90);
runner.bus_mut().write_long(buf1_ptr, REFILL_FRAMES as u32);
runner.bus_mut().write_long(buf1_ptr + 4, 0);
let mut pc = callback_addr;
for buf_ptr in [buf0_ptr, buf1_ptr] {
runner.bus_mut().write_word(pc, 0x23FC); runner.bus_mut().write_long(pc + 2, REFILL_FRAMES as u32);
runner.bus_mut().write_long(pc + 6, buf_ptr);
pc += 10;
for offset in (0..REFILL_FRAMES).step_by(4) {
runner.bus_mut().write_word(pc, 0x23FC); runner.bus_mut().write_long(pc + 2, 0xB0B0_B0B0);
runner
.bus_mut()
.write_long(pc + 6, buf_ptr + 16 + offset as u32);
pc += 10;
}
runner.bus_mut().write_word(pc, 0x23FC); runner.bus_mut().write_long(pc + 2, 0x0000_0001);
runner.bus_mut().write_long(pc + 6, buf_ptr + 4);
pc += 10;
}
runner.bus_mut().write_word(pc, 0x4E75);
let mut chan = SndChannel::new(chan_ptr, false);
chan.double_buffer = Some(DoubleBufferState {
header_ptr,
current_buffer: 0,
callback_addr,
chan_ptr,
sample_rate: OUTPUT_RATE << 16,
num_channels: 1,
sample_size: 8,
last_buffer_seen: false,
waiting_for_callback: false,
pending_callback_buffers: [false; 2],
});
systemless::trap::TrapDispatcher::load_double_buffer_samples(
runner.bus_mut(),
&mut chan,
buf0_ptr,
OUTPUT_RATE << 16,
1,
8,
);
runner.dispatcher_mut().sound_manager.channels.push(chan);
let mut app = App::new(PathBuf::from("dummy"), false, None, false);
app.runner = Some(runner);
app.start_time = Some(now);
app.next_frame_time = Some(now);
app.step_frame();
let queued = queued.borrow();
assert!(
(732..=734).contains(&queued.len()),
"same-tick GUI frame should still queue one host audio frame, got {} bytes",
queued.len()
);
let first_refill_frame = queued
.chunks_exact(2)
.position(|frame| frame[0] == 0xB0 && frame[1] == 0xB0)
.expect("refilled double-buffer samples should be heard in the same GUI frame");
assert!(
first_refill_frame <= AUDIO_CALLBACK_CHUNK_SAMPLES + 1,
"doubleback refill should be serviced between late-audio chunks, not after a long silence tail; first refill frame={}",
first_refill_frame
);
}
#[test]
fn step_frame_mixes_audio_for_actual_guest_tick_advance() {
use systemless::cpu::Register;
use systemless::memory::MemoryBus;
let now = std::time::Instant::now();
let (mut runner, queued) = gui_runner_with_counting_audio();
let pc = runner.bus_mut().alloc(4);
runner.bus_mut().write_word(pc, 0x4E71); runner.bus_mut().write_word(pc + 2, 0x4E71); runner.cpu_mut().write_reg(Register::PC, pc);
runner.cpu_mut().write_reg(Register::A7, 0x0008_0000);
runner.set_instructions_per_tick(1);
let mut app = App::new(PathBuf::from("dummy"), false, None, false);
app.runner = Some(runner);
app.start_time = Some(now - FRAME_DURATION * 2);
app.next_frame_time = Some(now + FRAME_DURATION);
app.step_frame();
assert!(
(732..=734).contains(&*queued.borrow()),
"one GUI frame should queue about 367 stereo frames, got {} bytes",
*queued.borrow()
);
}
#[test]
fn cpu_budget_for_duration_preserves_average_mhz() {
let mut credit = 0.0;
let mut total = 0usize;
let ips = systemless::runner::DEFAULT_REALTIME_INSTRUCTIONS_PER_SECOND;
total += App::cpu_budget_for_duration(
FRAME_DURATION.saturating_sub(MIN_RENDER_HEADROOM),
ips,
&mut credit,
);
for _ in 1..120 {
total += App::cpu_budget_for_duration(FRAME_DURATION, ips, &mut credit);
}
let total_duration = FRAME_DURATION
.saturating_sub(MIN_RENDER_HEADROOM)
.as_secs_f64()
+ FRAME_DURATION.as_secs_f64() * 119.0;
let expected = (total_duration * ips).floor() as usize;
assert_eq!(total, expected);
assert!(credit >= 0.0);
assert!(credit < 1.0);
}
#[test]
fn render_headroom_tracks_render_cost_with_bounds() {
assert_eq!(
App::next_render_headroom(std::time::Duration::from_micros(200)),
MIN_RENDER_HEADROOM
);
assert_eq!(
App::next_render_headroom(std::time::Duration::from_micros(3_000)),
std::time::Duration::from_micros(3_500)
);
assert_eq!(
App::next_render_headroom(std::time::Duration::from_micros(20_000)),
MAX_RENDER_HEADROOM
);
}
#[test]
fn frame_scheduler_preserves_cadence_when_on_time_or_slightly_late() {
let scheduled = std::time::Instant::now();
let half_frame = std::time::Duration::from_secs_f64(FRAME_DURATION.as_secs_f64() / 2.0);
let (on_time_target, on_time_dropped) = App::next_frame_target(scheduled, scheduled);
assert_eq!(on_time_target, scheduled + FRAME_DURATION);
assert!(!on_time_dropped);
let (late_target, late_dropped) = App::next_frame_target(scheduled + half_frame, scheduled);
assert_eq!(late_target, scheduled + FRAME_DURATION);
assert!(!late_dropped);
}
#[test]
fn frame_scheduler_drops_missed_host_frame_instead_of_catchup_burst() {
let scheduled = std::time::Instant::now();
let full_frame_late = scheduled + FRAME_DURATION;
let (full_frame_target, full_frame_dropped) =
App::next_frame_target(full_frame_late, scheduled);
assert_eq!(full_frame_target, full_frame_late + FRAME_DURATION);
assert!(full_frame_dropped);
let several_frames_late = scheduled + FRAME_DURATION * 4;
let (late_target, late_dropped) = App::next_frame_target(several_frames_late, scheduled);
assert_eq!(late_target, several_frames_late + FRAME_DURATION);
assert!(late_dropped);
}
#[test]
fn gui_foreground_batches_stay_well_below_one_realtime_vbl() {
let realtime_instructions_per_tick =
(systemless::runner::DEFAULT_REALTIME_INSTRUCTIONS_PER_SECOND
/ systemless::runner::DEFAULT_VBL_HZ) as usize;
assert!(
CPU_BATCH_INSTRUCTIONS <= realtime_instructions_per_tick / 32,
"GUI batches should yield frequently during heavy drawing and slow HLE startup paths; batch={} vbl_budget={}",
CPU_BATCH_INSTRUCTIONS,
realtime_instructions_per_tick
);
assert_eq!(
SOUND_CALLBACK_SLICE_INSTRUCTIONS, CPU_BATCH_INSTRUCTIONS,
"Sound Manager callback slices should stay aligned with GUI yield cadence"
);
}
#[test]
fn render_gate_waits_for_guest_tick_unless_forced() {
let mut app = App::new(PathBuf::from("dummy"), false, None, false);
app.runner = Some(FixtureRunner::new(
8 * 1024 * 1024,
systemless::runner::FixtureRunnerConfig::default(),
));
assert!(
app.should_render_frame(),
"initial forced render should present the first frame"
);
let tick = app.runner.as_ref().unwrap().guest_tick();
app.last_presented_guest_tick = Some(tick);
app.force_next_render = false;
assert!(
!app.should_render_frame(),
"same guest tick should not present another partial frame"
);
app.force_next_render = true;
assert!(app.should_render_frame(), "host input can force a present");
app.force_next_render = false;
app.runner.as_mut().unwrap().force_advance_guest_tick();
assert!(
app.should_render_frame(),
"a new guest tick is a fresh VBL presentation point"
);
}
}