From 2722041c9299976ab5a4ffd40ebe7341bb830507 Mon Sep 17 00:00:00 2001
From: Thomas <thomas@thomasw.dev>
Date: Sun, 24 Dec 2023 20:56:39 +0100
Subject: [PATCH] Add configurable, PAL/NTSC-aware framerate limiter

---
 src/bin/siena/main.rs   | 21 ++++++++++++++++++---
 src/frontend/channel.rs | 18 ------------------
 src/frontend/sdl.rs     | 14 +-------------
 src/snes/bus/mainbus.rs |  4 +++-
 src/snes/cartridge.rs   |  2 +-
 src/snes/ppu/ppu.rs     | 34 +++++++++++++++++++++++++++++++++-
 src/snes/ppu/tests.rs   |  2 +-
 src/test/mod.rs         |  2 +-
 8 files changed, 58 insertions(+), 39 deletions(-)

diff --git a/src/bin/siena/main.rs b/src/bin/siena/main.rs
index 5b7261f..7d6d71d 100644
--- a/src/bin/siena/main.rs
+++ b/src/bin/siena/main.rs
@@ -16,7 +16,7 @@ use siena::frontend::sdl::{SDLEventPump, SDLRenderer};
 use siena::frontend::Renderer;
 use siena::snes::bus::mainbus::{BusTrace, Mainbus};
 use siena::snes::bus::Bus;
-use siena::snes::cartridge::Cartridge;
+use siena::snes::cartridge::{Cartridge, VideoFormat};
 use siena::snes::cpu_65816::cpu::Cpu65816;
 use siena::snes::joypad::{Button, Joypad, JoypadEvent};
 use siena::snes::ppu::ppu::{SCREEN_HEIGHT, SCREEN_WIDTH};
@@ -99,6 +99,10 @@ struct Args {
     /// Skip cartridge header detection, load as HiROM (mostly for test ROMs)
     #[arg(long)]
     no_header_hirom: bool,
+
+    /// Override frame rate limit (0 = unlimited)
+    #[arg(long)]
+    fps: Option<u64>,
 }
 
 fn main() -> Result<()> {
@@ -118,7 +122,7 @@ fn main() -> Result<()> {
 
     // Initialize cartridge
     let f = fs::read(args.filename)?;
-    let cart = if !args.no_header && !args.no_header_hirom {
+    let cartridge = if !args.no_header && !args.no_header_hirom {
         let c = Cartridge::load(&f);
         println!("Cartridge: {}", &c);
         c
@@ -126,13 +130,24 @@ fn main() -> Result<()> {
         Cartridge::load_nohdr(&f, args.no_header_hirom)
     };
 
+    // Determine frame rate limit, either based on the video format
+    // or what the user specified.
+    let fps = match args.fps {
+        None => match cartridge.get_video_format() {
+            VideoFormat::NTSC => 60,
+            VideoFormat::PAL => 50,
+        },
+        Some(fps) => fps,
+    };
+
     // Initialize S-CPU bus
     let mut bus = Mainbus::<ChannelRenderer>::new(
-        cart,
+        cartridge,
         args.trace_bus,
         displaychannel,
         joypads,
         args.verbose,
+        fps,
     );
     bus.apu.verbose = args.spc_verbose;
     bus.apu.ports.write().unwrap().trace = args.trace_apu_comm;
diff --git a/src/frontend/channel.rs b/src/frontend/channel.rs
index eee5257..46ca8e2 100644
--- a/src/frontend/channel.rs
+++ b/src/frontend/channel.rs
@@ -1,5 +1,4 @@
 use std::sync::Arc;
-use std::time::Instant;
 
 use anyhow::Result;
 use crossbeam_channel::{Receiver, Sender, TrySendError};
@@ -13,9 +12,6 @@ pub struct ChannelRenderer {
     receiver: Receiver<DisplayBuffer>,
     width: usize,
     height: usize,
-
-    fps_count: u64,
-    fps_time: Instant,
 }
 
 impl ChannelRenderer {
@@ -34,9 +30,6 @@ impl Renderer for ChannelRenderer {
             receiver,
             width,
             height,
-
-            fps_count: 0,
-            fps_time: Instant::now(),
         })
     }
 
@@ -46,17 +39,6 @@ impl Renderer for ChannelRenderer {
 
     /// Renders changes to screen
     fn update(&mut self) -> Result<()> {
-        self.fps_count += 1;
-
-        if self.fps_time.elapsed().as_secs() >= 2 {
-            println!(
-                "PPU Frame rate: {:0.2} frames/second",
-                self.fps_count as f32 / self.fps_time.elapsed().as_secs_f32()
-            );
-            self.fps_count = 0;
-            self.fps_time = Instant::now();
-        }
-
         let buffer = std::mem::replace(
             &mut self.displaybuffer,
             new_displaybuffer(self.width, self.height),
diff --git a/src/frontend/sdl.rs b/src/frontend/sdl.rs
index ee1e2f8..0ccd525 100644
--- a/src/frontend/sdl.rs
+++ b/src/frontend/sdl.rs
@@ -1,8 +1,7 @@
 use std::cell::RefCell;
 use std::sync::atomic::AtomicU8;
 use std::sync::Arc;
-use std::thread::sleep;
-use std::time::{Duration, Instant};
+use std::time::Instant;
 
 use anyhow::{anyhow, Result};
 use sdl2::event::Event;
@@ -38,8 +37,6 @@ pub struct SDLRenderer {
     #[allow(dead_code)]
     height: usize,
 
-    last_frame: Instant,
-    frametime: u64,
     fps_count: u64,
     fps_time: Instant,
 }
@@ -70,13 +67,6 @@ impl SDLRenderer {
             self.fps_time = Instant::now();
         }
 
-        // Limit the framerate
-        let framelen = self.last_frame.elapsed().as_micros() as u64;
-        if framelen < self.frametime {
-            //sleep(Duration::from_micros(self.frametime - framelen));
-        }
-        self.last_frame = Instant::now();
-
         Ok(())
     }
 }
@@ -111,8 +101,6 @@ impl Renderer for SDLRenderer {
                 displaybuffer: new_displaybuffer(width, height),
                 width,
                 height,
-                last_frame: Instant::now(),
-                frametime: 1000000 / 50,
                 fps_count: 0,
                 fps_time: Instant::now(),
             })
diff --git a/src/snes/bus/mainbus.rs b/src/snes/bus/mainbus.rs
index 4840b32..b539dac 100644
--- a/src/snes/bus/mainbus.rs
+++ b/src/snes/bus/mainbus.rs
@@ -235,6 +235,7 @@ where
         renderer: TRenderer,
         joypads: [Joypad; JOYPAD_COUNT],
         apu_verbose: bool,
+        fps: u64,
     ) -> Self {
         Self {
             cartridge,
@@ -244,7 +245,7 @@ where
             hdmaen: 0,
             joypads: Some(joypads),
 
-            ppu: PPU::<TRenderer>::new(renderer),
+            ppu: PPU::<TRenderer>::new(renderer, fps),
             apu: Apu::new(apu_verbose),
 
             memsel: 0,
@@ -857,6 +858,7 @@ mod tests {
             NullRenderer::new(0, 0).unwrap(),
             joypads,
             false,
+            0,
         )
     }
 
diff --git a/src/snes/cartridge.rs b/src/snes/cartridge.rs
index 7bb884f..d95007c 100644
--- a/src/snes/cartridge.rs
+++ b/src/snes/cartridge.rs
@@ -93,7 +93,7 @@ impl Cartridge {
         (1 << self.rom[self.header_offset + HDR_RAMSIZE_OFFSET]) * 1024
     }
 
-    fn get_video_format(&self) -> VideoFormat {
+    pub fn get_video_format(&self) -> VideoFormat {
         match self.rom[self.header_offset + HDR_DESTINATION_OFFSET] {
             0 // Japan
             | 1 // North-America
diff --git a/src/snes/ppu/ppu.rs b/src/snes/ppu/ppu.rs
index a629811..4ac2f37 100644
--- a/src/snes/ppu/ppu.rs
+++ b/src/snes/ppu/ppu.rs
@@ -11,6 +11,8 @@ use crate::tickable::{Tickable, Ticks};
 use std::cell::Cell;
 use std::sync::atomic::Ordering;
 use std::sync::Arc;
+use std::thread::sleep;
+use std::time::{Duration, Instant};
 
 pub const SCREEN_WIDTH: usize = 8 * 32;
 pub const SCREEN_HEIGHT: usize = 8 * 28;
@@ -38,6 +40,10 @@ fn _default_none<T>() -> Option<T> {
     None
 }
 
+fn _default_now() -> Instant {
+    Instant::now()
+}
+
 #[derive(Serialize, Deserialize)]
 pub struct PPU<TRenderer: Renderer> {
     pub(super) vram: InnerVram,
@@ -45,6 +51,14 @@ pub struct PPU<TRenderer: Renderer> {
     #[serde(skip, default = "_default_none")]
     pub renderer: Option<TRenderer>,
 
+    /// Timestamp when the last frame was completed
+    #[serde(skip, default = "_default_now")]
+    last_frame: Instant,
+
+    /// The desired speed at which to run the PPU (and with that the emulator)
+    /// in microseconds per frame.
+    desired_frametime: u64,
+
     pub(super) cycles: usize,
     pub(super) last_scanline: usize,
     pub(super) intreq_vblank: bool,
@@ -75,7 +89,9 @@ where
     const VBLANK_START: usize = 0xE1;
     const LINE_HBLANK_START: usize = 274 * 4;
 
-    pub fn new(renderer: TRenderer) -> Self {
+    pub fn new(renderer: TRenderer, fps: u64) -> Self {
+        let desired_frametime = if fps == 0 { 0 } else { 1_000_000 / fps };
+
         Self {
             vram: vec![0; VRAM_WORDS],
 
@@ -96,6 +112,9 @@ where
             vmadd: Cell::new(0),
             vmain: 0,
             vram_prefetch: Cell::new(0),
+
+            last_frame: Instant::now(),
+            desired_frametime,
         }
     }
 
@@ -217,16 +236,29 @@ where
                     // Reload OAMADD
                     self.state.oamadd_addr.set(self.state.oamadd_reload.get());
 
+                    // Roll over the VRAM buffer so changes can be made for the
+                    // next frame.
                     self.state.vram = Arc::new(self.vram.clone());
                 }
             } else {
                 if self.vblank {
+                    // VBlank period has ended
                     self.vblank = false;
 
                     // Send frame to the screen
+                    // Wait for threadpool workers to finish all scanlines
                     self.pool.join();
+
+                    // Present frame to the screen
                     let renderer = self.renderer.as_mut().unwrap();
                     renderer.update()?;
+
+                    // Sync to desired framerate
+                    let frametime = self.last_frame.elapsed().as_micros() as u64;
+                    if frametime < self.desired_frametime {
+                        sleep(Duration::from_micros(self.desired_frametime - frametime));
+                    }
+                    self.last_frame = Instant::now();
                 }
 
                 // Scanline 0 is discarded by the original hardware, so
diff --git a/src/snes/ppu/tests.rs b/src/snes/ppu/tests.rs
index 1dad23e..6c62eff 100644
--- a/src/snes/ppu/tests.rs
+++ b/src/snes/ppu/tests.rs
@@ -8,7 +8,7 @@ fn ppustate() -> PPUState {
     PPUState::new()
 }
 fn ppu() -> PPU<NullRenderer> {
-    PPU::new(NullRenderer {})
+    PPU::new(NullRenderer {}, 0)
 }
 
 #[test]
diff --git a/src/test/mod.rs b/src/test/mod.rs
index f558d5c..dfd868f 100644
--- a/src/test/mod.rs
+++ b/src/test/mod.rs
@@ -19,7 +19,7 @@ fn test_display(rom: &[u8], pass_hash: &[u8], time_limit: u128, stable: bool, hi
     let (display, dispstatus) = TestRenderer::new_test(SCREEN_WIDTH, SCREEN_HEIGHT);
     let (joypads, _) = Joypad::new_channel_all();
     let cart = Cartridge::load_nohdr(rom, hirom);
-    let bus = Mainbus::<TestRenderer>::new(cart, BusTrace::None, display, joypads, false);
+    let bus = Mainbus::<TestRenderer>::new(cart, BusTrace::None, display, joypads, false, 0);
     let reset = bus.read16(0xFFFC);
     let mut cpu = Cpu65816::<Mainbus<TestRenderer>>::new(bus, reset);