Este proyecto es educativo y Open Source. No se copia código de otros emuladores. Implementación basada únicamente en documentación técnica y tests permitidas.
CPU: Implementación de Saltos Relativos Condicionales
Resumen
Después de implementar la instrucción de comparación CP d8 (Step 0161), el emulador seguía presentando el síntoma de deadlock (LY=0), indicando que la CPU había encontrado otro opcode no implementado inmediatamente después de la comparación. La causa más probable era una instrucción de salto condicional que el juego utiliza para tomar decisiones basadas en los resultados de las comparaciones. Se implementó la familia completa de saltos relativos condicionales: JR Z, e (0x28), JR NC, e (0x30) y JR C, e (0x38), completando así la capacidad de control de flujo básico de la CPU.
Concepto de Hardware
Las instrucciones de salto relativo condicional son el mecanismo que permite a la CPU tomar decisiones basadas en los resultados de operaciones anteriores (comparaciones, aritméticas, etc.). Son el "cerebro" básico de cualquier programa:
- JR Z, e (Jump Relative if Zero): Salta si el flag Z está activado (Z=1), indicando que el resultado de la última operación fue cero o que dos valores eran iguales.
- JR NZ, e (Jump Relative if Not Zero): Ya estaba implementado. Salta si Z=0.
- JR C, e (Jump Relative if Carry): Salta si el flag C está activado (C=1), indicando que hubo un desbordamiento (carry) o que un valor era menor que otro en una comparación.
- JR NC, e (Jump Relative if No Carry): Salta si C=0, indicando que no hubo carry o que un valor era mayor o igual que otro.
¿Por qué son críticas después de CP? La secuencia típica en código de juego es:
CP 0x0A- Compara A con 10, actualiza flagsJR Z, etiqueta- Si A == 10, salta a "etiqueta"- Código alternativo si A != 10
Timing Condicional: Todas estas instrucciones consumen diferentes cantidades de ciclos según si se toma o no el salto:
- 3 M-Cycles si el salto se toma (la condición es verdadera)
- 2 M-Cycles si el salto NO se toma (la condición es falsa)
Esta diferencia de timing es crítica para la sincronización precisa del emulador. El hardware real siempre lee el offset (para mantener la consistencia del PC), pero solo ejecuta el salto si la condición es verdadera.
La Lógica Ineludible: Después de implementar CP d8, el juego podía hacer preguntas (comparar valores), pero no podía reaccionar a las respuestas (saltar condicionalmente). Esto causaba que la CPU se quedara ejecutando código que no tenía forma de tomar decisiones, resultando en un deadlock lógico.
Referencia: Pan Docs - CPU Instruction Set, secciones "JR Z, e" (0x28), "JR NC, e" (0x30) y "JR C, e" (0x38).
Implementación
Se implementaron los opcodes 0x28 (JR Z), 0x30 (JR NC) y 0x38 (JR C) en la CPU de C++, siguiendo el mismo patrón que la implementación existente de JR NZ (0x20).
Componentes Creados/Modificados
- CPU.cpp: Añadidos casos
0x28,0x30y0x38en el switch de opcodes. - test_core_cpu_jumps.py: Añadidas tres nuevas clases de tests (
TestJumpRelativeConditionalZ,TestJumpRelativeConditionalC) con 6 tests adicionales para validar todas las nuevas instrucciones.
Implementación de los Opcodes
Los tres opcodes siguen el mismo patrón que JR NZ:
case 0x28: // JR Z, e (Jump Relative if Zero)
{
uint8_t offset_raw = fetch_byte();
if (regs_->get_flag_z()) {
// Condición verdadera: saltar
int8_t offset = static_cast<int8_t>(offset_raw);
uint16_t new_pc = (regs_->pc + offset) & 0xFFFF;
regs_->pc = new_pc;
cycles_ += 3; // JR Z consume 3 M-Cycles si salta
return 3;
} else {
// Condición falsa: no saltar, continuar ejecución normal
cycles_ += 2; // JR Z consume 2 M-Cycles si no salta
return 2;
}
}
case 0x30: // JR NC, e (Jump Relative if No Carry)
{
uint8_t offset_raw = fetch_byte();
if (!regs_->get_flag_c()) {
// Condición verdadera: saltar
int8_t offset = static_cast<int8_t>(offset_raw);
uint16_t new_pc = (regs_->pc + offset) & 0xFFFF;
regs_->pc = new_pc;
cycles_ += 3; // JR NC consume 3 M-Cycles si salta
return 3;
} else {
// Condición falsa: no saltar
cycles_ += 2; // JR NC consume 2 M-Cycles si no salta
return 2;
}
}
case 0x38: // JR C, e (Jump Relative if Carry)
{
uint8_t offset_raw = fetch_byte();
if (regs_->get_flag_c()) {
// Condición verdadera: saltar
int8_t offset = static_cast<int8_t>(offset_raw);
uint16_t new_pc = (regs_->pc + offset) & 0xFFFF;
regs_->pc = new_pc;
cycles_ += 3; // JR C consume 3 M-Cycles si salta
return 3;
} else {
// Condición falsa: no saltar
cycles_ += 2; // JR C consume 2 M-Cycles si no salta
return 2;
}
}
Decisiones de Diseño
Patrón consistente: Se siguió exactamente el mismo patrón que JR NZ para mantener la consistencia del código y facilitar el mantenimiento futuro.
Timing preciso: Cada instrucción respeta el timing condicional del hardware real: 3 ciclos si se toma el salto, 2 ciclos si no se toma. Esto es crítico para la sincronización correcta de la emulación.
Conversión de offset: Se usa el mismo mecanismo de cast de uint8_t a int8_t que en JR NZ, aprovechando la representación nativa de complemento a dos de C++ para manejar offsets negativos.
Archivos Afectados
src/core/cpp/CPU.cpp- Añadidos casos0x28,0x30y0x38en el switch de opcodes, justo después de0x20 (JR NZ).tests/test_core_cpu_jumps.py- Añadidas clases de testsTestJumpRelativeConditionalZyTestJumpRelativeConditionalCcon 6 tests nuevos.
Tests y Verificación
Se añadieron 6 nuevos tests al archivo existente tests/test_core_cpu_jumps.py, cubriendo todos los casos posibles para las tres nuevas instrucciones.
- Comando ejecutado:
pytest tests/test_core_cpu_jumps.py -v - Resultado esperado: Todos los tests pasando (tests existentes + 6 nuevos)
Código del Test (fragmento clave - JR Z):
def test_jr_z_taken(self):
"""Verificar JR Z, e cuando el salto se toma (Z=1)."""
mmu = PyMMU()
regs = PyRegisters()
cpu = PyCPU(mmu, regs)
regs.pc = 0x0100
# Activar flag Z (bit 7 de F = 1)
regs.f = regs.f | 0x80
# Escribir JR Z +10 (0x28 0x0A)
mmu.write(0x0100, 0x28) # Opcode JR Z, e
mmu.write(0x0101, 0x0A) # Offset +10
cycles = cpu.step()
# Debe saltar (Z=1, condición verdadera)
assert regs.pc == 0x010C, (
f"PC debe ser 0x010C después de JR Z +10 (Z=1), es 0x{regs.pc:04X}"
)
assert cycles == 3, f"JR Z debe consumir 3 M-Cycles cuando salta, consumió {cycles}"
def test_jr_z_not_taken(self):
"""Verificar JR Z, e cuando el salto NO se toma (Z=0)."""
mmu = PyMMU()
regs = PyRegisters()
cpu = PyCPU(mmu, regs)
regs.pc = 0x0100
# Desactivar flag Z
regs.f = regs.f & 0x7F
# Escribir JR Z +10 (0x28 0x0A)
mmu.write(0x0100, 0x28) # Opcode JR Z, e
mmu.write(0x0101, 0x0A) # Offset +10
cycles = cpu.step()
# NO debe saltar (Z=0, condición falsa)
assert regs.pc == 0x0102, (
f"PC debe ser 0x0102 después de JR Z +10 (Z=0, no salta), es 0x{regs.pc:04X}"
)
assert cycles == 2, f"JR Z debe consumir 2 M-Cycles cuando NO salta, consumió {cycles}"
Validación Nativa: Los tests validan el módulo compilado C++ directamente, verificando que todas las instrucciones funcionan correctamente con timing preciso y manejo correcto de flags.
Verificación en Emulador: Al ejecutar python main.py roms/tetris.gb, el emulador debería superar el deadlock LY=0 y comenzar a avanzar. Se espera uno de dos resultados:
- ¡Éxito! El deadlock desaparece y
LYcomienza a incrementarse, indicando que la CPU ha superado toda la fase de inicialización. - Progreso: El emulador avanza y se encuentra con el siguiente opcode no implementado, que será reportado por el warning del caso
default.
Fuentes Consultadas
- Pan Docs: CPU Instruction Set - JR Z, e (opcode 0x28)
- Pan Docs: CPU Instruction Set - JR NC, e (opcode 0x30)
- Pan Docs: CPU Instruction Set - JR C, e (opcode 0x38)
- Pan Docs: CPU Instruction Set - Flags (Z, C) - CPU Registers and Flags
Integridad Educativa
Lo que Entiendo Ahora
- Control de Flujo Básico: Las instrucciones de salto condicional son el mecanismo fundamental que permite a cualquier programa tomar decisiones. Sin ellas, un programa solo puede ejecutarse de forma lineal, sin capacidad de reaccionar a diferentes condiciones.
- El Patrón CP + JR: La secuencia "comparar y luego saltar condicionalmente" es el patrón más común en código de bajo nivel. Permite implementar estructuras de control de alto nivel (if/else, while, for) en código de máquina.
- Timing Condicional: El hecho de que las instrucciones de salto condicional consuman diferentes cantidades de ciclos según si se toma o no el salto es una característica del hardware real que debe replicarse fielmente para lograr sincronización precisa.
- Flags como Estado Global: Los flags (Z, C, N, H) son estado global compartido entre todas las instrucciones. Una comparación actualiza los flags, y la siguiente instrucción de salto condicional los lee para tomar una decisión.
Lo que Aprendí del Proceso de Debugging
- "Pelar la Cebolla": El proceso de debugging es iterativo. Cada vez que resolvemos un problema, revelamos el siguiente. Esto no es un fracaso, es el proceso natural de construcción incremental.
- La Lógica Ineludible: Cuando un síntoma persiste (como
LY=0), pero sabemos que hemos corregido algo, significa que hemos avanzado un paso y chocado contra la siguiente barrera. Esto es progreso, no estancamiento. - Pensar como Programador de 1990: Después de una comparación, la siguiente acción lógica es un salto condicional. Pensar en términos de "¿Qué necesita el código para funcionar?" nos guía hacia las implementaciones correctas.
- Familias de Instrucciones: Las instrucciones de salto condicional forman una familia lógica. Implementarlas todas juntas tiene sentido porque comparten la misma estructura y patrón de comportamiento.
Lo que Queda por Entender
- Saltos Absolutos Condicionales: Además de los saltos relativos condicionales (JR), existen saltos absolutos condicionales (JP Z, JP NZ, JP C, JP NC) que saltan a direcciones de 16 bits. Estos serán necesarios para control de flujo más complejo.
- CALL y RET Condicionales: Las llamadas a subrutinas y retornos también pueden ser condicionales, permitiendo implementar funciones que solo se ejecutan bajo ciertas condiciones.
- Optimizaciones de Compilador: Los compiladores modernos generan código de máquina que aprovecha estas instrucciones de manera muy eficiente. Entender cómo se usan en la práctica nos ayudará a optimizar nuestro emulador.
Próximos Pasos
- Recompilar y Ejecutar: Recompilar el módulo C++ con
.\rebuild_cpp.ps1y ejecutar el emulador para verificar que el deadlock se resuelve. - Monitorear Progreso: Observar si
LYcomienza a incrementarse, indicando que la CPU está funcionando correctamente. - Identificar Siguiente Opcode: Si aparece otro warning de opcode no implementado, identificarlo y implementarlo en el siguiente step.
- Implementar Más Saltos: Considerar implementar saltos absolutos condicionales (JP Z, JP NZ, JP C, JP NC) si son necesarios para el progreso.
- Validar con Tests: Asegurarse de que todos los tests pasen correctamente antes de continuar.