Step 0387: Diagnóstico de PC Corrupto en 0xFEE6 (Zelda DX)

Contexto

El Step 0386 aplicó un workaround deshabilitando STAT IRQ para resolver el problema de IF "pegado" en 0x02. Tras esa corrección, se reportó que Zelda DX seguía sin ser jugable y crasheaba hacia PC:0xFEE6, un rango que corresponde a la región OAM/No Usable (0xFE00-0xFEFF).

Ejecutar código en esa región normalmente indica:

  • Corrupción del PC: Un return address inválido sacado del stack
  • Corrupción del Stack (SP): Push/pop desbalanceados o escrituras fuera de rango
  • Salto a dirección calculada incorrectamente: Ej. JP (HL) con HL corrupto

Este step implementa instrumentación exhaustiva para capturar el momento exacto del crash y determinar su causa raíz.

Concepto de Hardware

Región 0xFE00-0xFEFF en Game Boy

Según Pan Docs, el mapa de memoria en esta región es:

  • 0xFE00-0xFE9F: OAM (Object Attribute Memory) - 160 bytes para 40 sprites
  • 0xFEA0-0xFEFF: "Not Usable" - Región no funcional, devuelve valores aleatorios en hardware real

Ejecutar código en esta región es siempre un error. Indica que el PC fue establecido a un valor inválido, típicamente por:

Stack y Retornos (RETI/RET)

El stack de la Game Boy crece hacia abajo:

  • PUSH: SP = SP - 2, escribe high byte en SP+1, low byte en SP
  • POP: Lee low byte de SP, high byte de SP+1, luego SP = SP + 2
  • RETI (0xD9): PC = pop_word(); IME = 1;

Si el stack está corrupto o SP apunta a datos no válidos, RETI restaurará un PC basura (ej. 0xFEE6).

Servicio de Interrupciones

Cuando ocurre una interrupción con IME=1:

  1. CPU deshabilita IME (evita interrupciones anidadas)
  2. Guarda PC actual en el stack: push_word(PC)
  3. Salta al vector (ej. 0x0040 para VBlank)
  4. El handler termina con RETI, que restaura PC desde el stack

Si el PUSH escribe a direcciones incorrectas o el POP lee de una región sobrescrita, el retorno será a una dirección inválida.

Fuente: Pan Docs - Memory Map, Interrupts, Stack Operations

Implementación

1. Ring Buffer de Últimas 64 Instrucciones (CPU.hpp/CPU.cpp)

Se añade un ring buffer circular que captura un snapshot de cada instrucción ejecutada:

// En CPU.hpp
struct InstrSnapshot {
    uint16_t pc, sp, af, bc, de, hl;
    uint8_t bank, op, op1, op2, ime, ie, if_flag;
};
static constexpr int RING_SIZE = 64;
InstrSnapshot ring_buffer_[RING_SIZE];
int ring_idx_;
bool crash_dumped_;

En step(), después del fetch del opcode:

// Capturar snapshot en el ring buffer
ring_buffer_[ring_idx_].pc = original_pc;
ring_buffer_[ring_idx_].sp = regs_->sp;
ring_buffer_[ring_idx_].af = regs_->get_af();
// ... (resto de registros)
ring_idx_ = (ring_idx_ + 1) % RING_SIZE;

// Detectar crash en región FE00-FEFF
if (!crash_dumped_ && original_pc >= 0xFE00 && original_pc <= 0xFEFF) {
    crash_dumped_ = true;
    printf("[CRASH-PC] ⚠️ PC CORRUPTO: PC=0x%04X (región OAM/no usable)\n", original_pc);
    
    // Dump completo del ring buffer (últimas 64 instrucciones)
    for (int i = 0; i < RING_SIZE; i++) {
        int idx = (ring_idx_ + i) % RING_SIZE;
        printf("[CRASH-RING] #%02d PC:0x%04X Bank:%d OP:%02X %02X %02X | SP:%04X AF:%04X...\n",
               i, ring_buffer_[idx].pc, ...);
    }
}

2. Trazado de Stack en IRQ Push (CPU.cpp - handle_interrupts)

// ANTES del push_word(prev_pc)
uint16_t sp_before_push = regs_->sp;
printf("[IRQ-PUSH-PC] ANTES: SP=0x%04X PC_to_push=0x%04X\n", sp_before_push, prev_pc);

push_word(prev_pc);

// DESPUES del push
uint16_t sp_after_push = regs_->sp;
uint8_t byte_low = mmu_->read(sp_after_push);
uint8_t byte_high = mmu_->read(sp_after_push + 1);
printf("[IRQ-PUSH-PC] DESPUES: SP=0x%04X Written=[0x%02X,0x%02X] Reconstruct=0x%04X\n",
       sp_after_push, byte_low, byte_high,
       (static_cast(byte_high) << 8) | byte_low);

// Guardrail: verificar SP en rango peligroso
if (sp_after_push < 0xC000 || sp_after_push >= 0xFE00) {
    printf("[STACK-WARN] ⚠️ SP en rango peligroso: 0x%04X\n", sp_after_push);
}

3. Trazado de RETI Pop (CPU.cpp - case 0xD9)

// ANTES del pop_word()
uint16_t sp_before_pop = regs_->sp;
uint8_t byte_low = mmu_->read(sp_before_pop);
uint8_t byte_high = mmu_->read(sp_before_pop + 1);
uint16_t reconstructed = (byte_high << 8) | byte_low;
printf("[RETI-POP-PC] ANTES: SP=0x%04X Bytes=[0x%02X,0x%02X] Reconstruct=0x%04X\n",
       sp_before_pop, byte_low, byte_high, reconstructed);

uint16_t return_addr = pop_word();

// DESPUES del pop
printf("[RETI-POP-PC] DESPUES: return_addr=0x%04X SP=0x%04X IME=1\n", return_addr, regs_->sp);

// Guardrail: verificar return_addr corrupto
if (return_addr >= 0xFE00 && return_addr <= 0xFEFF) {
    printf("[RETI-POP-PC] ⚠️ RETURN ADDRESS CORRUPTO: 0x%04X (región OAM!)\n", return_addr);
}

4. Instrumentación de Escrituras a FE00-FEFF (MMU.cpp)

// Al inicio de MMU::write()
if (addr >= 0xFE00 && addr <= 0xFEFF && fe_write_count < 60) {
    printf("[MMU-FE-WRITE] PC=0x%04X addr=0x%04X value=0x%02X Bank=%d",
           debug_current_pc, addr, value, get_current_rom_bank());
    
    if (addr >= 0xFEA0) {
        printf(" ⚠️ UNUSABLE REGION\n");
    } else {
        printf(" (OAM valid)\n");
    }
    fe_write_count++;
}

Tests y Verificación

Compilación

python3 setup.py build_ext --inplace > build_log_step0387.txt 2>&1
# ✅ Compilación exitosa sin errores

Ejecución de Prueba

timeout 10 python3 main.py roms/zelda-dx.gbc > logs/step0387_fe_pc_probe.log 2>&1

Análisis de Logs (Comandos Seguros)

# 1) Buscar crash en FE00-FEFF
grep -E "\[CRASH-PC\]" logs/step0387_fe_pc_probe.log | head -n 5
# Resultado: ❌ No encontrado (exit code 1)

# 2) Verificar push/pop de IRQ
grep -E "\[(IRQ-PUSH-PC|RETI-POP-PC|STACK-WARN)\]" logs/step0387_fe_pc_probe.log | head -n 60
# Resultado: ❌ No encontrado (las interrupciones no se están procesando)

# 3) Verificar writes a FE00-FEFF
grep -E "\[MMU-FE-WRITE\]" logs/step0387_fe_pc_probe.log | head -n 60
# Resultado: ❌ No encontrado

# 4) CPU Samples (verificar estado general)
grep -E "\[CPU-SAMPLE\]" logs/step0387_fe_pc_probe.log | head -n 20
# Resultado: ✅ CPU ejecutando normalmente (200K+ instrucciones)

Hallazgos Críticos

🔍 Hallazgo Principal: El Crash en 0xFEE6 NO Se Reproduce

Tras ejecutar 10 segundos (≈200K instrucciones), NO se detectó ningún salto a PC en rango 0xFE00-0xFEFF. El crash reportado en Step 0386 NO ocurre en la ejecución actual.

⚠️ Problema Real: Interrupciones Completamente Deshabilitadas

Análisis de los CPU samples revela:

  • PC: 0x6B95-0x6B9B (Bank 60) - Bucle de polling estrecho
  • IME=0 - Interrupciones deshabilitadas globalmente
  • IE=0x00 - NINGUNA interrupción habilitada (ni VBlank, ni STAT, ni Timer...)
  • IF=0x01 - VBlank flag activo pero ignorado (no puede atenderse con IE=0x00)
  • El juego lee P1 (0xFF00) repetidamente - bucle de polling de joypad

Diagnóstico: El workaround del Step 0386 (deshabilitar STAT IRQ) causó un efecto secundario donde el juego deshabilita TODAS las interrupciones (IE=0x00), quedando atascado en un wait-loop.

✅ Evidencia de Renderizado Funcional

  • Frame 94 alcanzado (más de 1.5 segundos de emulación)
  • Framebuffer con píxeles válidos (80/160 no-cero por línea)
  • Distribución de colores normal (índices 0 y 3)

Validación Nativa

Módulo compilado C++ con instrumentación completa en:

  • CPU::step() - Ring buffer de 64 snapshots + detección de crash
  • CPU::handle_interrupts() - Trazado de IRQ push con verificación de SP
  • CPU (case 0xD9) - Trazado de RETI pop con detección de return_addr corrupto
  • MMU::write() - Detección de writes a región FE00-FEFF

La instrumentación funcionó correctamente. No se detectó ningún crash, pero reveló el problema subyacente: IE=0x00 (interrupciones totalmente deshabilitadas).

Archivos Modificados

  • src/core/cpp/CPU.hpp - Añadido struct InstrSnapshot y miembros del ring buffer
  • src/core/cpp/CPU.cpp - Implementación de ring buffer, detección de crash, trazado IRQ/RETI
  • src/core/cpp/MMU.cpp - Trazado de writes a FE00-FEFF
  • build_log_step0387.txt - Log de compilación
  • logs/step0387_fe_pc_probe.log - Log de ejecución (1.8MB)

Conclusión

El Step 0387 implementó instrumentación exhaustiva para diagnosticar el crash reportado en PC:0xFEE6, pero el hallazgo principal es que ese crash NO se reproduce en la ejecución actual.

En su lugar, se identificó el problema real: IE=0x00 (interrupciones completamente deshabilitadas), lo que deja al juego atascado en un bucle de polling sin capacidad de progresar.

Próximos pasos (Step 0388):

  • Revisar el workaround del Step 0386 que deshabilita STAT IRQ
  • Implementar rising edge detection correcto para STAT sin deshabilitar la interrupción completamente
  • Verificar que IE se inicialice correctamente (debería tener al menos VBlank habilitado)
  • Mantener la instrumentación del ring buffer como herramienta de diagnóstico permanente

Referencias