⚠️ Clean-Room / Educational

This project is educational and Open Source. No code is copied from other emulators. Implementation based solely on technical documentation and permitted tests.

FPS Limiter Check and Correction

Date:2025-12-25 StepID:0309 State: VERIFIED

Summary

Verification and correction of the FPS limiter that already existed in the code. Despiteclock.tick(60)was implemented, the FPS report in the title bar showed 300+ FPS, which suggested that the limiter was working but the report did not reflect the limited FPS correctly. Fixed FPS calculations in the title bar and performance monitor to reflect the actual limited FPS (~60 FPS) instead of the uncapped rendering FPS.

Hardware Concept

The original Game Boy runs59.7FPS(approximately 60 FPS), which means that each frame should last approximately16.67ms(1000ms / 60FPS). To maintain correct synchronization with real hardware, the emulator must:

  • Run the emulation at the correct speed: 70,224 T cycles per frame (4.194304 MHz / 59.7 FPS)
  • Limit rendering to 60 FPS: Use an FPS limiter (such aspygame.Clock.tick(60)) to synchronize rendering with real time
  • Report limited FPS correctly: FPS report should reflect capped FPS, not uncapped rendering FPS

The problem encountered was that, although the limiter was active (the emulator was running at a real 60 FPS), the report showed the rendering FPS without limiting (300+ FPS). This happened because:

  • Heclock.get_fps()may not work correctly if called before the tick takes effect
  • The performance monitor measured only the rendering time, not the time between frames (which includes waiting for theclock.tick())

The solution was to calculate the FPS from the real time between consecutive frames (which includes the limiter timeout), instead of just using the render time.

Implementation

Fixes have been implemented in two main areas: the FPS report in the title bar and the performance monitor.

1. Fix FPS Report in Title Bar

Problem detected: The report usedclock.get_fps()which could return uncapped FPS instead of capped FPS.

Solution: Calculate the FPS from thetick_timereturned byclock.tick(), which is more precise:

#Step 0309: Correct FPS calculation to reflect limited FPS
if self._clock is not None:
    tick_time_ms = self._clock.tick(TARGET_FPS)
    
    # Temporary log for verification (every second)
    if self.frame_count % 60 == 0:
        print(f"[FPS-LIMITER] Frame {self.frame_count} | Tick time: {tick_time_ms:.2f}ms | Target: {TARGET_FPS} FPS")

# Title with FPS (every 60 frames to not slow down)
if self.frame_count % 60 == 0 and self._clock is not None:
    import pygame
    import time
    
    # Option A: Use get_fps() which should return limited FPS
    fps_from_clock = self._clock.get_fps()
    
    # Option B: Calculate from tick_time (more precise)
    if tick_time_ms is not None and tick_time_ms > 0:
        fps_calculated = 1000.0 / tick_time_ms
        # Use tick_time based calculation for greater precision
        fps = fps_calculated
    else:
        # Fallback to get_fps() if tick_time is not available
        fps = fps_from_clock if fps_from_clock > 0 else TARGET_FPS
    
    pygame.display.set_caption(f"Viboy Color v0.0.2 - FPS: {fps:.1f}")

2. Synchronization Verification

Added a synchronization check that runs every minute (3600 frames) to detect drift between actual frames and expected frames:

#Step 0309: Synchronization check (every minute)
if not hasattr(self, '_start_time'):
    import time
    self._start_time = time.time()
if self.frame_count % 3600 == 0 and self.frame_count > 0: # Every minute (60 * 60 frames)
    import time
    elapsed_real = time.time() - self._start_time
    expected_frames = elapsed_real * TARGET_FPS
    current_frames = self.frame_count
    drift = actual_frames - expected_frames
    print(f"[SYNC-CHECK] Real: {elapsed_real:.1f}s | Expected: {expected_frames:.0f} frames | Actual: {actual_frames} | Drift: {drift:.0f}")

3. Performance Monitor Fix

Problem detected: The monitor measured only the rendering time, not the time between frames (which includes waiting for theclock.tick()).

Solution: Calculate the time between consecutive frames, which includes the limiter timeout:

# Step 0309: Calculate time between consecutive frames (includes clock.tick())
# Save time from the previous frame to calculate time between frames
self._last_frame_end_time = None

# In the performance monitor:
if self._performance_trace_enabled and frame_start is not None:
    frame_end = time.time()
    frame_time = (frame_end - frame_start) * 1000 # Render time in ms
    
    # Calculate time between consecutive frames (includes clock.tick())
    time_between_frames = None
    fps_limited = None
    if self._last_frame_end_time is not None:
        time_between_frames = (frame_end - self._last_frame_end_time) * 1000 # ms
        fps_limited = 1000.0 / time_between_frames if time_between_frames > 0 else 0
    self._last_frame_end_time = frame_end
    
    if self._performance_trace_count % 10 == 0:
        fps_render = 1000.0 / frame_time if frame_time > 0 else 0
        if fps_limited is not None:
            print(f"[PERFORMANCE-TRACE] Frame {self._performance_trace_count} | "
                  f"Frame time (render): {frame_time:.2f}ms | FPS (render): {fps_render:.1f} | "
                  f"Time between frames: {time_between_frames:.2f}ms | FPS (limited): {fps_limited:.1f} | "
                  f"...")

Benefit: The monitor now reports both the rendering FPS (which can be high) and the limited FPS (which should be ~60 FPS), allowing you to identify synchronization problems.

Design decisions

  • Use oftick_timefor FPS calculation: More precise thanget_fps()because it directly reflects the time the limiter waited
  • Time measurement between frames: Allows you to distinguish between rendered FPS (unlimited) and limited FPS (real)
  • Periodic sync check: Allows you to detect long-term drift that could indicate synchronization problems

Affected Files

  • src/viboy.py- Correction of the FPS report in title bar, limiter verification logs, and synchronization verification
  • src/gpu/renderer.py- Fix performance monitor to calculate time between frames and report limited FPS

Tests and Verification

Verification requires running the emulator and observing:

  • Title bar: Should show FPS ≈ 60.0 (not 300+)
  • Logs [FPS-LIMITER]: Should show tick time ≈ 16.67ms every second
  • Logs [PERFORMANCE-TRACE]: Must show FPS (limited) ≈ 60.0
  • Logs [SYNC-CHECK]: Should show drift close to 0 after 1 minute

Command for verification:

python main.py roms/pkmn.gb > perf_step_0309.log 2>&1

Expected analysis:

  • Average FPS (limited) should be ≈ 60 FPS (not 300+)
  • Minimum FPS must be > 55 FPS
  • Maximum FPS should be< 65 FPS
  • Frame time (limited) must be ≈ 16.67ms (1000ms / 60 FPS)

C++ Compiled Module Validation: No changes required in C++, only fixes in Python/Cython.

Sources consulted

Educational Integrity

What I Understand Now

  • FPS limiter: clock.tick(60)limits rendering to 60 FPS by adding a wait if the frame was rendered too fast. The wait time is returned astick_time.
  • Rendering FPS vs Limited FPS: The rendering FPS can be very high (300+ FPS) if the rendering is fast, but the limited FPS should be ~60 FPS for correct timing.
  • Time between frames: To measure limited FPS correctly, you must measure the time between the end of one frame and the end of the next, not just the rendering time.

What remains to be confirmed

  • Practical verification: Run the emulator and verify that the reported FPS is ~60 FPS instead of 300+ FPS
  • Long-term synchronization: Check with [SYNC-CHECK] that there is no significant drift after several minutes

Hypotheses and Assumptions

I assume thatclock.tick(60)It works correctly and that the problem was only the report. If after these fixes the reported FPS is still incorrect, there could be a problem with the limiter itself or with the synchronization between the emulation loop and the render.

Next Steps

  • [ ] Run hands-on verification to confirm that the reported FPS is ~60 FPS
  • [ ] Analyze logs [SYNC-CHECK] to verify that there is no significant drift
  • [ ] If the FPS is still incorrect, investigate whether there is a problem with the limiter itself
  • [ ] Consider disabling temporary logs [FPS-LIMITER] after successful verification