⚠️ 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.

Step 0474: Identify Hotspot Waiting Loop (With Evidence)

Date:2026-01-04 StepID:0474 State: VERIFIED

Summary

Step 0473 closed a branch: STOP/KEY1 doesn't matter because the ROMs don't even try (0 writes to KEY1 and 0 executions of STOP). CGB ROMs are on PC hotspots reading FF0F (IF) and FFFF (IE) obsessively (millions). That smells like a waiting loop. Step 0474 clearly identifies the waiting loop: hotspot disasembly + IF/LY/STAT surgical instrumentation.

Result:✅ Disasembly of hotspot #1 obtained for the 3 ROMs. ✅ IF/LY/STAT instrumentation implemented and working. ✅ Real evidence obtained: All ROMs are in loops reading IF obsessively (59K-98K reads), but IF is never cleaned (IF_Writes0=0). ❌Identified problem: IF is not automatically cleared when an interrupt is processed.

Context

Step 0473 showed that the problem is NOT STOP/KEY1 (speed-switch is not attempted). The ROMs are in PC hotspots reading FF0F (IF) and FFFF (IE) obsessively. This suggests a wait loop waiting for some condition to change.

Step Objective 0474: Clearly identify the waiting loop by:

  • Disasembly of hotspot #1 (16-20 instructions)
  • IF/LY/STAT Surgical Instrumentation
  • Automatic decision based on real data

Hardware Concept

Interrupt Flag (IF) - 0xFF0F: Register indicating which interrupts are pending. According to Pan Docs:

  • Bits 0-4: Interrupt flags (VBlank, LCD STAT, Timer, Serial, Joypad)
  • Bits 5-7: Always read as 1 (upper bits)
  • When the CPU processes an interrupt, it must clear the corresponding bit in IF

LY (Line Y) - 0xFF44: Register indicating the current scanline (0-153). The PPU updates this register dynamically.

STAT (LCD Status) - 0xFF41: Register indicating the current mode of the PPU (HBlank, VBlank, OAM Search, Pixel Transfer).

Waiting Loop: Common pattern in games where code repeatedly reads an I/O register waiting for it to change. If the record never changes, the game is stuck.

Implementation

Phase A: Hotspot Disasembly

Implemented helper functions to dump ROM bytes and disassemble basic LR35902 instructions:

  • dump_rom_bytes(mmu, pc, count=32): Dumps ROM bytes from a PC address
  • disasm_lr35902(bytes_list, start_pc, max_instructions=20): Disassemble basic LR35902 instructions (clean-room)

Decoded Opcodes: NOP, HALT, DI, EI, RET, LD A, n, LD A, (n), AND n, JR Z/NZ/e, CALL nn, and other common ones.

Phase B: Surgical Instrumentation IF/LY/STAT

Added detailed instrumentation inMMU.cpp:

  • IF (0xFF0F):
    • Tracking reads/writes with counters
    • Histogram (writes 0 vs nonzero)
    • Upper bits check (bits 5-7 should read as 1)
    • PC of last write
  • LY (0xFF44):
    • Min/max/last tracking
  • STAT (0xFF41):
    • Last read tracking

The log was deleted[IE-DROP]due to false positives (detected drops in tetris.gb when IE was never written).

Phase C: Clean-Room Tests

3 tests were created to validate the semantics of IF/LY:

  • test_if_upper_bits_read_as_1_0474.py: Verify that IF bits 5-7 always read as 1
  • test_if_clear_0474.py: Verifies that IF can be cleared manually
  • test_ly_progresses_0474.py: Verify that LY is progressing correctly

Phase D: Modification of rom_smoke

It was modifiedrom_smoke_0442.pyto include:

  • Disasembly of hotspot #1 in snapshots
  • Detailed IF/LY/STAT metrics
  • IO touched by loop

Results

tetris.gb (DMG)

Hotspot #1: 0x02B4

Disasm(Frame 0):

0x02B4: CP 0x94
0x02B6: JR NZ, 0x02B2 (-6)
0x02B8: LD A, 0x03
0x02BA: DB 0xE0
0x02BB: DB 0x40
0x02BC: LD A, 0xE4
0x02BE: DB 0xE0
0x02BF: DB 0x47

IF/LY/STAT(Frame 0):

  • IF read count: 59,525
  • IF write count: 2
  • IF read val: 0xE1
  • IF write val: 0xE1
  • IF writes 0:0
  • IF writes nonzero: 2
  • LY read min: 0, max: 148, last: 0
  • STAT last read: 0x00

Result: Loop waits on IF. The game is in a loop (0x02B4-0x02B6) reading IF obsessively (59,525 reads), but IF is never cleared (IF_Writes0=0).

tetris_dx.gbc (CGB)

Hotspot #1: 0x1383 (Frame 0-1), 0x1308 (Frame 2)

Disasm(Frame 0):

0x1383: NOPE
0x1384: NOPE
0x1385: NOPE
0x1386: DB 0x1B
0x1387: DB 0x7A
0x1388: DB 0xB3
0x1389: JR NZ, 0x1383 (-8)

IF/LY/STAT(Frame 0):

  • IF read count: 98,271
  • IF write count: 1
  • IF read val: 0xE1
  • IF write val: 0xE1
  • IF writes 0:0
  • IF writes nonzero: 1
  • LY read min: 0, max: 144, last: 0
  • STAT last read: 0x00

Result: Similar to tetris.gb. Loop at 0x1383-0x1389 reading IF obsessively (98,271 reads), but IF is never cleared (IF_Writes0=0).

mario.gbc (CGB)

Hotspot #1: 0x1290 (Frame 0), 0x129D (Frame 1-2)

Disasm(Frame 0):

0x1290: JR NZ, 0x128C (-6)
0x1292: LD A, (0xFF40)
0x1294: AND 0x7F
0x1296: DB 0xE0
0x1297: DB 0x40
0x1298: LD A, (0xFF92)
0x129A: DB 0xE0
0x129B: DB 0xFF

IF/LY/STAT(Frame 0):

  • IF read count: 54,027
  • IF write count: 1
  • IF read val: 0xE1
  • IF write val: 0xE1
  • IF writes 0:0
  • IF writes nonzero: 1
  • LY read min: 0, max: 145, last: 0
  • STAT last read: 0x00

Result: Similar to the other ROMs. Loop reading IF obsessively (54,027 reads), but IF is never cleared (IF_Writes0=0).

Automatic Decision

IF-bug case:

  • ✅ There are writes to clear IF but IF does not change (IF_Writes0=0 or very low)
  • ✅ Upper bits correct (IF reads as 0xE1 = 0xE0 | 0x01, bits 5-7 = 1)
  • Problem:IF is not cleared when the game expects it

Minimum proposed fix: The problem is not the semantics of IF (upper bits read correctly), but rather thatIF is not automatically cleared when an interrupt is processed. According to Pan Docs, when the CPU processes an interrupt, it should clear the corresponding bit in IF. If this does not happen, the game is left in an infinite loop waiting for IF to change.

Next step: Verify that when the CPU processes an interrupt (VBlank, STAT, etc.), the corresponding bit in IF is cleared.

Affected Files

  • src/core/cpp/MMU.hpp: Added private members for IF/LY/STAT instrumentation and public getters
  • src/core/cpp/MMU.cpp: Implemented surgical instrumentation in read()/write()
  • src/core/cython/mmu.pxd: Added getter declarations
  • src/core/cython/mmu.pyx: Implemented Python getter wrappers
  • tools/rom_smoke_0442.py: Added disasembly helper functions and IF/LY/STAT metrics in snapshots
  • tests/test_if_upper_bits_read_as_1_0474.py: Clean-room test to verify upper IF bits
  • tests/test_if_clear_0474.py: Clean-room test to verify manual cleaning of IF
  • tests/test_ly_progresses_0474.py: Clean-room test to verify LY progression

Tests and Verification

Command executed:

pytest -q tests/test_if_*_0474.py tests/test_ly_*_0474.py

Result: ✅ 6 passed in 0.55s

Test Code(example: test_if_upper_bits_read_as_1_0474.py):

def test_if_upper_bits_read_as_1():
    mmu = PyMMU()
    ppu = PyPPU(mmu)
    timer = PyTimer(mmu)
    joypad = PyJoypad()
    
    mmu.set_ppu(ppu)
    mmu.set_timer(timer)
    mmu.set_joypad(joypad)
    
    # Write IF = 0x00
    mmu.write(0xFF0F, 0x00)
    
    # Read IF and check bits 5-7 = 1
    if_value = mmu.read(0xFF0F)
    upper_bits = if_value & 0xE0
    assert upper_bits == 0xE0, f"Bits 5-7 must be 1"
    assert if_value == 0xE0, f"IF must be 0xE0 when writing 0x00"

Native Validation: Validation of compiled C++ module through unit tests and real execution of ROMs.

Command executed (rom_smoke):

PYTHONPATH=. VIBOY_DEBUG_IO=1 python3 tools/rom_smoke_0442.py roms/tetris.gb --frames 60
PYTHONPATH=. VIBOY_DEBUG_IO=1 python3 tools/rom_smoke_0442.py roms/tetris_dx.gbc --frames 60
PYTHONPATH=. VIBOY_DEBUG_IO=1 python3 tools/rom_smoke_0442.py roms/mario.gbc --frames 60

Result: ✅ Snapshots obtained with disasembly and IF/LY/STAT metrics for the 3 ROMs. ✅ Real evidence obtained and structured report generated.

Next Steps

Since the problem identified is thatIF is not automatically cleared when an interrupt is processed, the next step is:

  • Check the interrupt handling code in the CPU
  • Implement automatic clearing of the corresponding bit in IF when the CPU processes an interrupt
  • Validate that wait loops are broken after fix