- Algunas secciones con barriles giratorios de Donkey Kong Country 2 tienen un bug en el antiguo emulador de SNES ZSNES: siguen girando aunque se suelte la cruceta. La causa es que no implementa el comportamiento de open bus
- En una SNES real, al leer una dirección no mapeada se vuelve a leer el último valor del bus de datos, y DKC2 depende de esta característica al leer $2000/$2001 en el bank $B3
- La rutina problemática hace XOR entre la dirección anterior y la nueva del barril, y luego ejecuta
and $2000; en hardware real, esta lectura open bus de 16 bits devuelve 0x2020, lo que permite determinar si se cruzó un límite de dirección - Si una lectura open bus devuelve 0, como en ZSNES, el resultado de AND siempre es 0, por lo que no se ejecuta la rama que termina la rotación y el barril sigue girando hasta que se presiona la dirección contraria
- Es muy probable que la instrucción debiera haber sido
and #$2000; en una revisión de la ROM, cambiar el opcode en $33EDAC de0x2Da0x29hace que funcione correctamente incluso sin open bus
El fenómeno de los barriles giratorios que no se detienen en ZSNES
- Donkey Kong Country 2 tiene un viejo bug en el que algunos barriles giratorios de ciertos niveles no funcionan correctamente en ZSNES
- En el comportamiento normal, el jugador solo puede controlar la rotación mientras mantiene presionada la izquierda o la derecha en la cruceta dentro del barril
- En ZSNES, al presionar izquierda o derecha una vez, el barril sigue girando en esa dirección; si se presiona la dirección contraria, empieza a girar continuamente hacia el otro lado
- En secciones posteriores, como los barriles están colocados sobre espinas u otros peligros, este bug aumenta mucho la dificultad del nivel respecto de la intención de los desarrolladores
- Al parecer, Anomie encontró el mismo problema hace unos 20 años y lo corrigió en Snes9x; la corrección de entonces no emulaba todo el open bus, sino que hardcodeaba valores de direcciones específicas de las que dependía el juego
- En ZSNES este bug nunca llegó a corregirse, y la última versión del proyecto fue en 2007
Open bus de la SNES y direccionamiento del 65816
- En la SNES, leer una dirección de memoria inválida normalmente no hace que el programa se caiga
- Al leer una dirección no mapeada, ocurre el comportamiento de open bus, en el que la CPU vuelve a leer el último valor que estuvo en el bus de datos
- La CPU principal de la SNES es la 65C816, es decir, el 65816, incluido dentro del paquete Ricoh 5A22 S-CPU
- El 65816 es una CPU de extensión de 16 bits del 6502 y, aunque en la SNES usa un bus de direcciones de 24 bits, la mayoría de las direcciones se forman combinando un bank de 8 bits con un offset de 16 bits
- Muchas instrucciones usan internamente direcciones de 16 bits; en la obtención de instrucciones interviene el program bank register, y en el acceso a datos interviene el data bank register
- En el estado de los barriles giratorios de DKC2 se leen $2000 y $2001 del bank $B3; esas direcciones no están mapeadas a nada en el bank $B3, por lo que se convierten en lecturas open bus
Accesos de memoria de la rutina problemática
- Al soltar izquierda o derecha dentro de un barril giratorio, la rutina que se ejecuta en cada frame realiza una lectura open bus
- El estado inicial tiene el data bank register en
$B3, la direct page en$0000y las flags M/X en clear, por lo que los registros y accesos de memoria son de 16 bits - Esta rutina usa varias direcciones en el rango
$0000-$2001$0EE6: dirección actual del barril$0E0A: cantidad de rotación por frame$0032: ubicación que parece ser una variable temporal
- En los banks $00-$3F y $80-$BF,
$0000-$1FFFse mapea a los primeros 8 KB de los 128 KB de WRAM de la consola $2000-$20FFes una región no mapeada, por lo que la instrucciónand $2000se convierte en una lectura open bus
Cómo se usa 0x2020 para decidir el fin de la rotación
- La rutina suma la cantidad de rotación a la dirección actual para crear la nueva dirección y la guarda en una variable temporal
- Luego hace XOR entre la dirección anterior y la nueva, aplica
and $2000al resultado y comprueba si el resultado es 0 - En hardware real de SNES,
and $2000, que es una lectura open bus de 16 bits, siempre devuelve 0x2020- El código máquina de
and $2000es2D 00 20 - Como el 65816 usa un bus de datos de 8 bits, realiza una lectura de 16 bits como dos lecturas de 8 bits
- En este caso, el último byte leído es el byte alto de la dirección,
0x20, por lo que ambas lecturas devuelven0x20
- El código máquina de
- Por lo tanto, el comportamiento real es funcionalmente equivalente a
and #$2020 - Si el resultado de AND es 0, se guarda la nueva dirección tal cual y la rotación continúa en el siguiente frame
- Si no es 0, se pone la cantidad de rotación en 0, se suma
0x1000a la nueva dirección y luego se aplica una máscara con0xE000para alinearla con la dirección múltiplo de0x2000más cercana
Por qué en ZSNES sigue girando
- El valor de dirección del barril parece usar una escala en la que
0x0000apunta hacia abajo y0x4000apunta hacia la izquierda - El barril analizado tiene una cantidad de rotación en sentido horario de
0x0300y una cantidad de rotación en sentido antihorario de0xFD00; tarda un poco más de 85 frames en completar una vuelta de 360 grados - A 60 fps, esto equivale a un poco menos de 1,5 segundos
- En el AND con
0x2020, el cambio realmente significativo es el bit 13, es decir, 0x2000 - Un cambio de
0x2000en el valor de dirección corresponde a un paso en una dirección cardinal o diagonal - En hardware normal, al soltar la cruceta el barril sigue girando hasta la siguiente dirección cardinal/diagonal y luego se detiene apuntando exactamente en esa dirección
- Si la lectura open bus siempre devuelve 0, el resultado de AND también siempre es 0, así que nunca se ejecuta el código que termina la rotación
Posible typo y resultado del parche de ROM
and $2000es una instrucción con direccionamiento absoluto y, por lógica, es muy probable que debiera haber sidoand #$2000, con direccionamiento inmediato- En hardware real, como el open bus devuelve 0x2020,
and $2000funciona por casualidad de acuerdo con la intención - Este comportamiento accidental depende de la condición de que los 6 bits inferiores de la cantidad de rotación por frame sean siempre 0, lo que hace que
0x2020sea funcionalmente igual a0x2000 - El opcode incorrecto se ejecuta en el bank
$B3, offset$EDAC, y en la revisión analizada se mapea a$33EDACdentro de la ROM de 4 MB del juego - Si se cambia este byte de
0x2Da0x29, los barriles giratorios funcionan correctamente aunque la lectura open bus siempre devuelva 0 - La ubicación exacta en la ROM puede variar en otras revisiones del juego
- Como el juego funciona correctamente en casi todos los emuladores de SNES salvo el antiguo ZSNES, este caso es más bien un análisis de rastreo de un comportamiento dependiente del hardware que un parche práctico
1 comentarios
Opiniones en Hacker News
Perdí mucho tiempo de mi vida por errores como olvidar el
#antes de un valor inmediato al escribir ensamblador 6502, y terminar accediendo a memoriaComo en este caso, muchas veces por casualidad funciona en algunas situaciones. Peor aún es cuando se depende de RAM sin inicializar en vez de un bus flotante: por las características de la DRAM, puede andar siempre bien en mi máquina o emulador y romperse en la máquina de otra persona que usa otro chip DRAM. Normalmente uno lo descubre 15 minutos antes de la presentación en una demoparty, cuando no corre en la máquina del evento
LDA #2, que es “cargar el número 2 en A”, yLDA 2, que es “cargar en A el valor que está en la posición de memoria 2”Como Open Bus en el título estaba escrito con mayúsculas, al principio empecé a leer pensando que era el nombre propio de algún protocolo o estándar de bus antiguo del que nunca había oído hablar
Después de leerlo, resultó que significaba que el decodificador de líneas de dirección no activaba ningún dispositivo de memoria en la dirección especificada
$2000, dejando el bus en un estado “abierto”, sin estar conectado a nada. Es bastante gracioso que el problema de haber olvidado el#del direccionamiento inmediato solo saliera a la luz porque los emuladores antiguos no manejaban las lecturas de memoria como el hardware real. Si la corrección usa direccionamiento inmediato en vez de direccionamiento absoluto, no hará una lectura de memoria, así que el tiempo de ejecución también debería mejorar. En ese bloque de código podría acelerarse aproximadamente 2 µs, aunque probablemente solo importe en bare metal, y es muy posible que los emuladores de todos modos no tengan una precisión de timing completaSegún tengo entendido, Donkey Kong 64 tenía una fuga de memoria que hacía que el juego muriera después de 8 o 9 horas de juego continuo, un tiempo irrealmente largo para la época. No se detectó durante el desarrollo, pero si uno usa estados guardados del emulador y sigue avanzando en lugar de usar la función de guardado dentro del juego, ese tiempo se acumula enseguida. Dicho eso, la historia es ambigua. Algunas fuentes afirman que incluir el Memory Pak fue una medida de último momento para ocultar el bug, alargando el tiempo hasta el crasheo de 8–9 horas a 13–20 horas, pero investigaciones recientes parecen indicar que fue una casualidad y que Rare o Nintendo no lanzaron el juego sabiendo de ese bug
[0] https://arstechnica.com/gaming/2021/06/how-snes-emulators-go...
Mientras trabajaba en la función RunAhead de RetroArch y revisaba en qué punto los estados guardados dejaban de coincidir, alguna vez vi que SNES Puyo Puyo usaba el bus abierto de la PPU
Después de cargar un estado, el valor leído desde PPU Open Bus cambiaba, así que el log de seguimiento de ejecución de la CPU no coincidía
No es que cometa siempre errores de la familia 6502, pero cuando los cometo, normalmente es usar una dirección de memoria en lugar de un valor inmediato
Es un error muy común y fácil de cometer, y creo que el propio Chuck Peddle lamentó profundamente la sintaxis de poner
#en los valores inmediatos, como#$1234. En el IDE hice que el#se viera en rojo brillante y ayudó un poco. Incluso los dioses del ensamblador de Rare cayeron en el mismo problemaintel_syntax noprefixdel ensamblador GNUEn instrucciones que podían recibir tanto valores inmediatos como direcciones de memoria, había una ambigüedad sintáctica por la cual un valor inmediato constante con nombre, referenciado hacia adelante, podía interpretarse como una referencia a un símbolo desconocido. Como resultado, se ensamblaba no como el inmediato esperado, sino como una instrucción con una dirección de memoria de marcador de posición que luego se llenaría, al enlazar, con la dirección reubicada del símbolo. Fue doloroso de depurar
Me gustan estos artículos. Siempre siento que sigo apenas un 60% del código ensamblador, así que la explicación en prosa al lado ayuda muchísimo
También es divertido escuchar historias de bugs dentro de software clásico que nadie entendía, o quizá ni siquiera había notado hasta ahora
Me refiero a mecanismos que son indispensables en sistemas que pueden conectarse a una red, y que ya son lo bastante baratos como para incluirse incluso en arquitecturas embebidas completamente aisladas. En la NES original, muchas lecturas y escrituras no eran más que alternar voltaje en alguna línea, y luego pasaba lo que tuviera que pasar. El efecto deseado se lograba alternando voltajes de forma muy controlada, exactamente sincronizados con la señal que indicaba el intervalo de blanking del CRT. Algunas animaciones de Super Mario Bros 3 alternaban un multiplexor de RAM para elegir uno de varios bancos de datos de sprites, haciendo que cuando el hardware gráfico tomara los sprites leyera desde un chip completamente distinto con una apariencia ligeramente diferente. Como el timing de la TV era importante, también había que lanzar software separado para regiones de TV NTSC y PAL; ambos formatos tienen frecuencias de barrido distintas, y esa frecuencia era el reloj que impulsaba la lógica de renderizado. Fue una época realmente salvaje
Por lo que sé, el bus abierto solo se ve en sistemas tempranos que usaban buses síncronos simples
En la mayoría de los demás sistemas, si se accede a una dirección inexistente, se recibe un valor constante lleno de 0 o lleno de 1. Eso se debe a que el protocolo de bus tiene un handshake que permite al maestro saber que no hubo respuesta, lo que en términos de PCI corresponde a un “master abort”
Open bus significa literalmente que las líneas del bus de datos son un circuito abierto.
La CPU puso en el bus de direcciones una dirección que no estaba mapeada o que era solo de escritura, y como ningún hardware del bus respondió, las líneas del bus quedaron sin ser manejadas, flotando. En términos nominales, es un comportamiento indefinido a nivel de hardware.
Para entender qué pasa en la práctica, hay que mirar un poco más la estructura física del bus de datos. Hay conductores largos que llevan señales por la placa madre y alrededor del cartucho, separados del plano de tierra por una capa delgada de sustrato aislante. Eso se parece a un capacitor y, de hecho, los ingenieros lo describen y modelan como capacitancia parásita. Como este efecto limita la velocidad máxima de transferencia de datos del bus, se intenta minimizarlo. Pero por ese mismo efecto, cuando el bus no está siendo manejado, tiende a quedarse en el último voltaje que lo estaba manejando. Se comporta como una pequeña celda DRAM, lo que produce el efecto mencionado en el artículo: “una lectura de open bus devuelve el último valor que pasó por el bus”.
No es raro que un juego dependa por accidente del efecto de open bus, como DKC2. En la NES, el registro del puerto serial para conectar los controles solo maneja los bits bajos, y los bits altos quedan en open bus. Hay algunos juegos que leen la entrada del control con la instrucción
LDA $4016y esperan el valor$40o$41. Ahí, el 4 es el valor que quedó por el open bus.También hay estrategias de speedrun que dependen del comportamiento de open bus como parte de exploits de corrupción de memoria o ejecución de código arbitrario. Por ejemplo, el credits warp de Super Mario World manda el contador de programa a memoria no mapeada, lo deja avanzar durante un buen rato hasta que finalmente llega a la RAM, y ejecuta un payload creado manipulando con precisión las posiciones de los enemigos [1].
Sin embargo, incluso el comportamiento de open bus que suele ser predecible tiene excepciones. Los cartuchos no estándar pueden devolver valores predeterminados para memoria no mapeada, o incluir resistencias pull-up/pull-down que afectan el comportamiento de open bus. También hay interacciones interesantes con DMA. La SNES soporta una función llamada HDMA, que permite a una aplicación programar transferencias de datos desde la CPU al hardware gráfico con timing preciso para subir datos o cambiar configuraciones a mitad de un frame [2]. Estas transferencias DMA detienen brevemente la CPU para usar el bus durante la transferencia, y si una transferencia DMA se mete en medio de la ejecución de una instrucción —es decir, después de leer la dirección de destino pero antes de la lectura real de open bus— puede cambiar el comportamiento de esa lectura de open bus.
Este caso límite tan específico tiene un gran impacto en un exploit de speedrun de Super Metroid [3]. El exploit provoca un
memcpyfuera de rango e intenta mover un bloque grande de datos desde open bus hacia la RAM. La lectura de open bus casi siempre devuelve 0, porque el último byte de la instrucción de carga relacionada es 0. Pero en ciertas salas con muchos efectos gráficos HDMA, hay una posibilidad considerable de que una transferencia DMA afecte una de esas lecturas; entonces se cuela un byte distinto de 0 en una ubicación crítica, el exploit no funciona correctamente y el juego se crashea. Esto generó una pequeña discusión en la comunidad. Algunas rutas y estrategias solo son estables en emuladores y firmware no estándar. Los jugadores que usan hardware original o emuladores muy precisos tienen más probabilidades de sufrir crashes, pero la mayoría de los emuladores, incluidas todas las reediciones oficiales de Nintendo, no emulan este caso límite específico en el que una transferencia HDMA a mitad de instrucción cambia el valor leído de open bus.Además, el TAS más rápido actual de Super Metroid [4] depende de esta interacción con HDMA. Se encontró una situación en la que al intentar ejecutar open bus se producía un crash, pero normalmente no se podía controlar de forma útil. Manipulando a los enemigos de la sala se pudo afectar el timing de la CPU, y con HDMA poner una instrucción útil en el bus en el momento adecuado. Al final, se logró que la consola ejecutara la entrada del control como código, consiguiendo ejecución completa de código arbitrario.
[1]: https://youtu.be/vAHXK2wut_I
[2]: https://youtu.be/K7gWmdgXPgk
[3]: https://youtu.be/CnThmKhtfOs
[4]: https://tasvideos.org/8214S
Gracias a su serie de videos sobre cómo construir una computadora en protoboard con un 6502, realmente pude entender el contenido del artículo y la explicación del problema de hardware. Claro, es extrapolar su ejemplo básico de bus a una máquina comercial. Si no fuera por eso, casi no sabría nada del tema.
https://eater.net
Al programar el chip Parallax Propeller también hay un problema en cierta medida parecido.
Hay que usar
JMP #address, que salta a la ubicación de memoria especificada, pero siempre termino usandoJMP address, que salta a la dirección leída desde la ubicación de memoria especificada. Supongo que el ensamblador del 6502 todavía me quedó en la memoria muscular.Propeller:
JMP #address6502:
JMP addressPropeller:
JMP address6502:
JMP (address)Lo peor es que, como en este artículo, el código con bug para Propeller a veces funciona. Luego, en algún momento se detiene, y terminas pasando horas tratando de averiguar por qué.
Los gráficos 3D prerenderizados en SGI de DKC 1 eran de punta para la época. Vector Man de Genesis hizo algo parecido, pero recibió menos atención.
No podía creer lo que estaba viendo. Cerca del lanzamiento había un videocassette que adelantaba el juego y también mostraba cosas detrás de cámaras del desarrollo; creo recordar que era material promocional que se pedía desde algo como una caja de cereal. Vi esa cinta muchísimas veces. Nunca llegué a tener DKC, pero sí pude jugarlo en la casa de un amigo.
En esa época, las revistas hablaban del tema de forma bastante difusa y solían insinuar que la potencia de la SNES renderizaba los personajes y demás en tiempo real. Aunque en realidad era esencialmente algo más cercano a una animación tipo flipbook.
Cuando uno se queda trabado jugando mediante emulación, al final suele empezar a preguntarse si se trata de un bug del emulador.
Si fuera este problema en particular, habría pensado que el juego estaba diseñado así originalmente y que simplemente era difícil. No es exactamente lo mismo, pero cuando un juego se siente realmente difícil, pienso algo parecido: “¿será por el retraso de la emulación?”. Me metí tanto en este tema que al final terminé armando mi propio MiSTer FPGA.
Recuerdo que había una parte en la que, después de atrapar a la rata, había que presionar cuatro teclas al mismo tiempo. Pero la entrada por USB solo transmitía 3 a la vez, así que para pasar había que machacar las cuatro teclas hasta que, en un intervalo muy breve, terminaran registrándose todas. Había que intentarlo varias veces y era muy frustrante.
Como dijeron, simplemente pensaba que calcular el momento de disparo desde los barriles para conseguir el ángulo correcto era parte del diseño intencional del juego. Me sorprendió muchísimo enterarme de que era un bug.
Cuando lo corrí en un emulador a principios de los 2000, me pareció mucho más difícil de lo que recordaba. Más tarde vi que había un bug de emulación por el cual, aunque explotaras la base, los enemigos no desaparecían, y Ladd seguía congelado. Así que para completar el nivel necesitabas aproximadamente 2 barras más de vida. Una vez lo terminé así solo para ver si era posible, pero nunca más lo hice.