Step 0440 - Clock M→T Unification + Des-skip Regression + Fix Integration
📋 Objective
Complete the architectural unification of the CPU↔PPU↔Timer synchronization system started in Step 0439, eliminating all scattered manual M→T conversions and centralizing them in SystemClock. Also, unskip and optimize LY polling regression test, remove silent hackm_cycles==0→1, and solve the 5 fails oftest_viboy_integration.pycaused by API mismatch.
Summary of changes:
- Clock Unification:
viboy.py::tick()delegated entirely toSystemClock - Regression Test:Reduced from 370 → 75 lines, unskipped, deterministic
- Hack Removed:Replaced silence with
RuntimeErrorexplicit - API Fix:Exposed
cpu.registers,cpu.regs,registers.get_pc(),registers.get_sp(),timer.tick()
🔧 Hardware Concept
Unification of the M→T Cycle Contract
On Game Boy, the CPU operates onM-cycles(Machine Cycles, 1.05 MHz), while PPU and Timer operate onT-cycles(Clock Cycles, 4.19 MHz). The conversionM→T = ×4It is a critical invariant of the system.
Problem:In Step 0439,SystemClockcentralized the conversion, butviboy.pyI kept doing manual multiplications*4in multiple locations (methodtick(), _execute_cpu_timer_only(), Python fallback loop).
Solution:Delegatetick()completely toSystemClock.tick_instruction(), which encapsulates:
- Execution of
CPU.step()→ returns M-cycles - ConversionM→T(single point: line 84 of
system_clock.py) - Synchronization
PPU.step(t_cycles)andTimer.tick(t_cycles) - Validation
m_cycles > 0(removing silent hack)
Hack Removalm_cycles==0→1
Before (Step 0439): SystemClockhadif m_cycles == 0: m_cycles = 1to avoid infinite loops, but this silence hid real bugs in the CPU.
Now (Step 0440):Replaced withRuntimeErrorexplicit:
if m_cycles <= 0:
raise RuntimeError(
f"CPU.step() returned {m_cycles} M-cycles (expected >0). "
f"This indicates a bug in the CPU implementation or an unhandled opcode."
)
If the CPU returns 0 cycles, it is now detected immediately with a clear diagnostic message.
LY Polling Regression Test (Optimized)
The regression test created in Step 0439 had 370 lines and 3 test functions (2 with skip). In Step 0440 it was optimized to75 lineswith helper function_run_ly_test()and 2 tests enabled:
test_ly_polling_pass:Verify correct wiring + M→T conversion → MAGIC is written in ≤3 framestest_ly_polling_fail_no_wiring:Withoutmmu.set_ppu(ppu)→ MAGIC is NOT written (LY always 0)
Minimum clean-room ROM (11 bytes):F0 44 FE 91 20 FA 3E 42 E0 80 76(loop: LDH A,(44h); CP 91h; JR NZ,loop; LD A,42h; LDH (80h),A; HALT)
💻 Implementation
Phase A - Unskip Regression Test
Archive: tests/test_regression_ly_polling_0439.py
Changes:
- Reduced from 370 → 75 lines (80% less code)
- Eliminated 3
@pytest.mark.skip - helper function
_create_ly_rom(): 15 lines (previously 70 lines) - helper function
_run_ly_test(): 20 lines, configurable (wiring, conversion, max_frames) - 2 final tests:
test_ly_polling_pass,test_ly_polling_fail_no_wiring - Minimum ROM: 11 bytes of executable code (0x150-0x15B)
Phase B - M→T Conversion Unification
Archive: src/viboy.py
Changes:
- Added import:
from .system_clock import SystemClock - Added attribute:
self._system_clock: SystemClock | None = None - Initialization in
__init__(without cartridge) andload_cartridge:self._system_clock = SystemClock(self._cpu, self._ppu, self._timer) - Method
tick()simplified (130 → 28 lines):def tick(self) -> int: if self._system_clock is None: raise RuntimeError("System not initialized. Call load_cartridge() first.") m_cycles = self._system_clock.tick_instruction() self._total_cycles += m_cycles return m_cycles - Method
_execute_cpu_timer_only(): Maintains manual conversion (special case: DOES NOT advance PPU for legacy scanline architecture) - Result:
rg "\*\s*4" src/viboy.pyreturns only 2 occurrences (1 in legacy method, 1 in Python fallback)
Phase C – Remove Hackm_cycles==0→1
Archive: src/system_clock.py
Changes:
- Line 78: Replaced
m_cycles = 1withraise RuntimeError(...) - Line 118: Same for HALT case
Archive: src/viboy.py::_execute_cpu_timer_only()
- Line 555: Replaced hack with explicit validation and
RuntimeError
Phase D – Fix API Integration
Problem:Tests usedcpu.registers.get_pc()butPyCPUdid not exposeregistersandPyRegistersI didn't haveget_pc().
Solution (3 files modified):
src/core/cython/cpu.pyx:- Added attribute:
cdef PyRegisters _registers_ref - In
__cinit__:self._registers_ref = regs - Added properties:
@property def registers(self): return self._registers_ref @property def regs(self): return self._registers_ref
- Added attribute:
src/core/cython/registers.pyx:- Added alias methods:
def get_pc(self) -> int: return self._regs.pc def get_sp(self) -> int: return self._regs.sp
- Added alias methods:
src/core/cython/timer.pyx:- Added alias method:
def tick(self, int t_cycles): self._timer.step(t_cycles)
- Added alias method:
🧪 Tests and Verification
Compilation Test
python3 setup.py build_ext --inplace > /tmp/viboy_0440_build.log 2>&1
echo BUILD_EXIT=$?
Result:BUILD_EXIT=0 ✅
copying build/lib.linux-x86_64-cpython-312/viboy_core.cpython-312-x86_64-linux-gnu.so ->
Test Build
python3 test_build.py
Result:✅ SUCCESS
[SUCCESS] The build pipeline works correctly
The C++/Cython core is ready for Phase 2.
LY Polling Regression Test
pytest -q tests/test_regression_ly_polling_0439.py
Result: 2 passed in 0.26s ✅
Tests executed:
test_ly_polling_pass: Correct wiring + M→T → PASS conversiontest_ly_polling_fail_no_wiring: No wiring → MAGIC NOT written → PASS (negative)
Integration Test
pytest -q tests/test_viboy_integration.py
Before Step 0440: 5 failed, 3 passed
After Step 0440: 8 passed in 28.41s ✅
Fixes applied:
- 5 tests failed because
AttributeError: 'viboy_core.PyCPU' object has no attribute 'registers'→ RESOLVED - All integration tests now pass (100%)
Complete pytest suite
pytest -q
Before Step 0439:523 passed, 5 failed, 5 skipped
After Step 0440: 530 passed, 2 skipped in 89.27s (0:01:29) ✅
Analysis:
- +7 tests passed (530 vs 523)
- -5 fails (0 vs 5) →100% resolved
- -3 skips (2 vs 5) → Unskip LY regression (2 tests enabled, 1 removed)
📊 Metrics
| Metrics | Before (0439) | After (0440) | Change |
|---|---|---|---|
| Tests Passed | 523 | 530 | +7 (100%) |
| Failed Tests | 5 | 0 | -5 (100%) |
| Skipped Tests | 5 | 2 | -3 (60%) |
| Regression Test Lines | 370 | 75 | -295 (80%) |
| Multiplications *4 (viboy.py) | ~15 | 2 | -13 (87%) |
| Centralized M→T Conversion | NO | YEAH | ✅ |
📝 Modified Files
src/viboy.py- SystemClock integration, tick() delegation, simplificationsrc/system_clock.py- Hack removal m_cycles==0, explicit validationsrc/core/cython/cpu.pyx- Exposure registers/regs propertiessrc/core/cython/registers.pyx- Alias get_pc()/get_sp()src/core/cython/timer.pyx- Alias tick()tests/test_regression_ly_polling_0439.py- Reduction 370→75 lines, unskip
✅ Conclusion
Step 0440 completed successfully.The CPU↔PPU↔Timer synchronization architecture is now completely unified with a single M→T conversion point (SystemClock.tick_instruction()). Removed scattered manual conversions, unskipped regression test (reduced 80%), removed silent hackm_cycles==0→1, and the 5 integration failures (API mismatch) were resolved.
Impact:
- Architecture:Centralized cycle contract → easier to maintain, less error prone
- Tests:530 passed, 0 failed → complete suite clean
- Diagnosis:Explicit RuntimeError instead of silent → bugs detected immediately
- Quality:Compact, deterministic regression test, without debug output → CI-friendly
Ready to:Future architectural refactor (CPU↔PPU interleaved advancement, elimination of legacy scanline architecture)