Step 0425: Spec-Correct JOYP + Address Wrap (Remove Hacks)

← Return to index

Executive Summary

Definitive correction of the behavior of JOYP (FF00) and address wrapping to 16-bit according toBread Docs, eliminating all the hacks introduced in previous Steps (especially 0419 and 0424). Step 0424 implemented artificial inversion of bits 4-5 in JOYP based on empirical testing observations, but this implementationcontradicts Pan Docswhich specifies that bits 4-5 are read as written.

Critical decision:When a test contradicts Pan Docs,the test is corrected, not the hardware. The goal of this Step is to restore spec-correct behavior by removing:

  • ❌ Artificial inversion of bits 4-5 in JOYP (Joypad.cpp)
  • ❌Bypasstest_mode_allow_rom_writes(3 locations: MMU.cpp, MMU.hpp, mmu.pyx)
  • ❌ Fixturemmu_romwthat allowed writing to ROM (conftest.py)

Result:✅ 8/8 Joypad tests + 11/11 MMU tests pass with spec-correct behavior. ✅ 215/225 total tests (10 pre-existing failures not related to this Step).

Hardware Concept (Pan Docs)

JOYP (FF00) - Registration P1

Fountain:Pan Docs - "Joypad Input" / GBEDG

Registry Structure:

Bit 7-6: Not used (always 1)
Bit 5 (P15): 0 = Selects action buttons (A, B, Select, Start)
Bit 4 (P14): 0 = Selects direction buttons (Right, Left, Up, Down)
Bit 3-0: Button status (0 = pressed, 1 = released) [Read-Only]

Spec-Correct Behavior:

  1. Writing:Only bits 4-5 are writable. Bits 0-3 are read-only.
  2. Reading:Bits 4-5 are readjust as they were written(They are NOT inverted).
  3. Selection:A bit = 0 means "selected". Both rows can be selected simultaneously.
  4. Low nibble (bits 0-3):
    • If no row selected (bits 4-5 = 11): nibble = 0xF
    • If row(s) selected: nibble reflects button status (AND of active rows)

Practical Example:

// Select address row
write(FF00, 0x20) // bits 5-4 = 10 (bit 4 = 0 select address)
// State: "Right" button pressed (bit 0 = 0)
read(FF00) // Returns 0xEE = 1110 1110
            // bits 7-6 = 11 (always)
            // bits 5-4 = 10 (as written, NO inversion)
            // bits 3-0 = 1110 (bit 0 = 0 indicates "Right" pressed)

Address Wrap to 16-bit

Fountain:Pan Docs - "Memory Map"

The Game Boy uses 16-bit addresses (0x0000-0xFFFF). Any address outside this range must wrap automatically usingaddr &= 0xFFFFat the beginning ofMMU::read()andMMU::write().

Example:0x100000x0000, 0x1C0000xC000

Read-Only ROM (Spec-Correct)

Fountain:Pan Docs - "Memory Bank Controllers"

On the real Game Boy, ROM (0x0000-0x7FFF) isalways read-only. The writings in this range are interpreted as commands to the Memory Bank Controller (MBC),NOlike scriptures direct in memory.

Implication for tests:Tests that require custom ROM must useload_rom_py()with a prepared bytearray, do not write directly to ROM.

Implementation

Modified Files

1. Hardware Core (C++)

  • src/core/cpp/Joypad.cpp:
    • Constructor: Initialization to0xCF(bits 4-5 = 00, spec-correct)
    • read_p1(): Removed inversion of bits 4-5, return(p1_register_ & 0x30)directly
    • Special case: If no row selected, nibble = 0xF
  • src/core/cpp/Joypad.hpp: Comments updated to reflect spec-correct behavior
  • src/core/cpp/MMU.cpp:
    • Line 935: Bypass eliminatedtest_mode_allow_rom_writes_
    • Line 1068: Removed bypass ROM_ONLY whenrom_data_.empty()
    • Line 3564: Removed methodset_test_mode_allow_rom_writes()
  • src/core/cpp/MMU.hpp:
    • Removed flagtest_mode_allow_rom_writes_
    • Deleted declaration ofset_test_mode_allow_rom_writes()

2. Wrapper Cython

  • src/core/cython/mmu.pyx:Deleted methodset_test_mode_allow_rom_writes()
  • src/core/cython/mmu.pxd: Deleted declarationset_test_mode_allow_rom_writes()

3. Tests (Python)

  • tests/conftest.py: Fixture removedmmu_romw
  • tests/test_core_joypad.py:
    • 8 tests updated with spec-correct values
    • Address with bit4=0: Expected0xEE(before0xDE)
    • Action with bit5=0: Expected0xDE(before0xEE)
    • Rationale: Pan Docs specifies that bits 4-5 are NOT inverted
  • tests/test_mmu_rom_is_readonly_by_default.py:
    • 4 updated tests to useload_rom_py()
    • Eliminated use of fixturemmu_romw
    • load_rom test: Prepare 512 byte ROM, load withload_rom_py(bytes(custom_rom))
  • tests/test_core_mmu.py:
    • 1 updated test:test_mmu_address_wrapping
    • Change: Validate wrap with WRAM (0xC000) instead of ROM (0x0000)
    • Rationale: ROM is read-only, you cannot validate wrap by writing to ROM

Key Code Fragments

JOYP read_p1() Spec-Correct (Joypad.cpp)

uint8_t Joypad::read_p1() const {
    // Start with bits 0-3 to 1 (all loose by default)
    uint8_t nibble = 0x0F;
    
    // Row selection according to Pan Docs: bit=0 select
    bool direction_row_selected = (p1_register_ & 0x10) == 0;
    bool action_row_selected = (p1_register_ & 0x20) == 0;
    
    if (direction_row_selected) {
        nibble &= direction_keys_;
    }
    
    if (action_row_selected) {
        nibble &= action_keys_;
    }
    
    // Special case: if no row is selected
    if (!direction_row_selected && !action_row_selected) {
        nibble = 0x0F;
    }
    
    // Build spec-correct result (WITHOUT inversion bits 4-5)
    uint8_t result = 0xC0 | (p1_register_ & 0x30) | (nibble & 0x0F);
    
    return result;
}

ROM Read-Only Spec-Correct (MMU.cpp)

// In MMU::write() - Switch case for ROM (0x0000-0x7FFF)
case MBCType::ROM_ONLY:
default:
    // Step 0425: ROM is ALWAYS read-only (spec-correct according to Pan Docs).
    // ROM writes are ignored (or interpreted as MBC).
    // DO NOT allow writes even if rom_data_ is empty.
    return;

Tests and Verification

Commands Executed

$python3 setup.py build_ext --inplace
BUILD_EXIT=0 ✅

$python3 test_build.py
TEST_BUILD_EXIT=0 ✅

$ pytest -q tests/test_core_joypad.py
8 passed in 0.37s ✅

$ pytest -q tests/test_mmu_rom_is_readonly_by_default.py
4 passed in 0.32s ✅

$ pytest -q tests/test_core_mmu.py
7 passed in 0.28s ✅

$pytest -q
215 passed, 10 failed in 0.53s
(10 pre-existing NON-related bugs: PPU rendering, Registers init, CPU control)

Test Evidence (Key Fragments)

JOYP Address Test (Spec-Correct)

def test_joypad_selection_direction(self):
    """
    Verify that writing to P1 selects the address row correctly (spec-correct).
    
    Step 0425: Updated to reflect spec-correct behavior according to Pan Docs.
    Bits 4-5 are read AS written (NOT inverted).
    """
    joypad = PyJoypad()
    
    # Press Right (address, index 0)
    joypad.press_button(0)
    
    # Select address row (bit 4 = 0)
    # Write 0x20 = 0b00100000 (bit 5=1, bit 4=0)
    joypad.write_p1(0x20)
    
    # Read Q1. It should show Right pressed (bit 0 = 0)
    # Expected result spec-correct: 0xEE = 1110 1110
    # (bits 7-6=1, bit 5=1, bit 4=0, bit 0=0 pressed, bits 3-1=1 released)
    result = joypad.read_p1()
    assert result == 0xEE, f"Expected 0xEE (spec-correct), obtained 0x{result:02X}"

ROM Read-Only Test (Spec-Correct)

def test_rom_is_readonly_by_default(self, mmu):
    """
    Validate that ROM (0x0000-0x7FFF) is read-only (spec-correct).
    
    Hardware Concept (Pan Docs):
    ---------------------------
    On real Game Boy, ROM (0x0000-0x7FFF) is read-only memory.
    Writes in this range are interpreted as commands for the
    Memory Bank Controller (MBC), NOT as direct writes.
    
    Step 0425: Removed use of test_mode (hack not spec-correct).
    """
    # Attempt to write to ROM (should be interpreted as MBC command, not direct write)
    original_value = mmu.read(0x0000)
    mmu.write(0x0000, 0x3E) # Try to write 0x3E
    
    # Verify that it was NOT written (should still be the original value)
    readback = mmu.read(0x0000)
    assert readback == original_value, (
        f"ROM must be read-only (spec-correct)."
        f"We tried to write 0x3E to 0x0000, but 0x{readback:02X} was read"
    )

C++ Compiled Module Validation

✅ All tests run against the native moduleviboy_core.cpython-312-x86_64-linux-gnu.so

✅ Validation of hardware-accurate behavior through direct unit tests

✅ Debug logs confirm spec-correct behavior (bits 4-5 without inversion)

Results and Analysis

Hacks Removed (100% Successful)

Location Hack Removed State
Joypad.cpp:52 Artificial inversion bits 4-5 REMOVED
MMU.cpp:935 Bypass test_mode_allow_rom_writes REMOVED
MMU.cpp:1068 Bypass ROM_ONLY empty check REMOVED
MMU.cpp:3564 set_test_mode_allow_rom_writes() method REMOVED
MMU.hpp:372 Flag test_mode_allow_rom_writes_ REMOVED
mmu.pyx:403 Wrapper Python set_test_mode_allow_rom_writes REMOVED
conftest.py:74 Fixture mmu_romw REMOVED

Updated Tests (13 tests, 100% pass)

  • test_core_joypad.py: 8/8 tests with spec-correct values
  • test_mmu_rom_is_readonly_by_default.py: 4/4 tests without test_mode
  • test_core_mmu.py: 1/1 updated test (address wrap with WRAM)

Test Coverage

Category Tests Result
─────────────────────── ───────────────────────
Joypad (JOYP FF00) 8/8 ✅ PASS
MMU (ROM read-only) 4/4 ✅ PASS
MMU (Core functionality) 7/7 ✅ PASS
─────────────────────── ───────────────────────
TOTAL Step 0425 19/19 ✅ 100%

Complete Suite 215/225 ✅ 95.6%
(10 pre-existing bugs in PPU/Registers/CPU, NOT related)

Impact on the Code

  • Removed lines:~150 lines (code + hack comments)
  • Updated lines:~80 lines (tests)
  • Reduced complexity:Elimination of conditional flags and bypasses
  • Improved integrity:100% spec-correct code according to Pan Docs

Lessons Learned

1. Primacy of Pan Docs over Empirical Tests

Problem:Step 0424 implemented bit inversion 4-5 based on test observations, contradicting Pan Docs.

Solution:When a test contradicts official documentation,correct the test, not the hardware. Pan Docs is the source of truth for spec-correct behavior.

2. Test Mode is Technical Debt

Problem:The flagtest_mode_allow_rom_writesallowed writing to ROM, violating real hardware behavior.

Solution:Tests that require custom ROM must useload_rom_py(), which correctly simulates the loading of a cartridge. This maintains the integrity of the emulator.

3. Validation with WRAM for Address Wrap

Problem:Address wrap cannot be validated by writing to ROM (it is read-only).

Solution:Use WRAM (0xC000) which is writable. Example:0x1C000 & 0xFFFF = 0xC000.

4. Incremental Migration Strategy

Success:Eliminating hacks atomically (one Step) minimizes risk. The updated tests they explicitly document the change with references to Pan Docs.

Next Steps

  1. Step 0426:Full audit of real ROMs (Tetris DX, Zelda DX, Pokemon Red) with JOYP spec-correct behavior
  2. Step 0427:Fix of 10 remaining bugs (PPU rendering, Registers initialization, CPU IME control)
  3. Step 0428:Audio Implementation (APU) - Phase 2 complete

References