Donkey Kong Country 2 e o Open Bus
(jsgroth.dev)- Em alguns trechos com barris giratórios de Donkey Kong Country 2, há um bug antigo no emulador de SNES ZSNES em que o barril continua girando mesmo depois de soltar o direcional, e a causa é a falta de implementação do comportamento de open bus
- No SNES real, ao ler um endereço não mapeado, o sistema relê o último valor presente no barramento de dados, e DKC2 depende dessa característica nas leituras de $2000/$2001 do bank $B3
- A rotina problemática faz XOR entre a direção anterior do barril e a nova direção, depois executa
and $2000; no hardware real, essa leitura de open bus em 16 bits retorna 0x2020, o que é usado para detectar a passagem pelo limite de direção - Em emuladores como o ZSNES, se a leitura de open bus retorna 0, o resultado do AND é sempre 0, então o desvio que encerraria a rotação nunca é executado, e o barril continua girando até que se pressione a direção oposta
- É bastante provável que essa instrução devesse ser
and #$2000; em uma revisão da ROM, trocar o opcode em $33EDAC de0x2Dpara0x29faz tudo funcionar normalmente mesmo sem open bus
O fenômeno dos barris giratórios que não param no ZSNES
- Donkey Kong Country 2 tem um bug antigo em que alguns barris giratórios de certas fases não funcionam corretamente no ZSNES
- No funcionamento normal, o jogador só consegue controlar a rotação enquanto mantém pressionado o direcional para a esquerda ou para a direita dentro do barril
- No ZSNES, basta pressionar esquerda ou direita uma vez para o barril continuar girando indefinidamente nessa direção; ao pressionar a direção oposta, ele passa a girar indefinidamente para o outro lado
- Em trechos mais adiante, os barris ficam posicionados sobre espinhos ou outros perigos, então esse bug aumenta bastante a dificuldade da fase em relação ao que os desenvolvedores pretendiam
- O mesmo problema parece ter sido descoberto por Anomie há cerca de 20 anos e corrigido no Snes9x; na época, a correção não emulava todo o comportamento de open bus, mas apenas fazia hardcode dos valores de endereços específicos dos quais o jogo dependia
- No ZSNES, esse bug nunca foi corrigido, e o último lançamento do projeto foi em 2007
O open bus do SNES e o endereçamento do 65816
- No SNES, ler um endereço de memória inválido normalmente não faz o programa travar
- Ao ler um endereço não mapeado, a CPU relê o último valor que estava no barramento de dados, no comportamento chamado open bus
- A CPU principal do SNES é a 65C816, ou 65816, incluída no pacote Ricoh 5A22 S-CPU
- O 65816 é uma CPU de 16 bits derivada do 6502, e no SNES usa um barramento de endereços de 24 bits, mas a maioria dos endereços é formada pela combinação de um bank de 8 bits com um offset de 16 bits
- Muitas instruções usam internamente endereços de 16 bits, e o instruction fetch envolve o program bank register, enquanto os acessos a dados envolvem o data bank register
- No estado do barril giratório de DKC2, o jogo lê $2000 e $2001 no bank $B3, e esses endereços não são mapeados em nenhum lugar nesse bank, então a leitura cai em open bus
O acesso à memória na rotina problemática
- Ao soltar esquerda e direita dentro do barril giratório, uma rotina executada a cada frame faz uma leitura de open bus
- O estado inicial tem o data bank register em
$B3, a direct page em$0000, e as flags M/X ambas limpas, então os registradores e os acessos à memória são de 16 bits - Essa rotina usa vários endereços no intervalo
$0000-$2001$0EE6: direção atual do barril$0E0A: quantidade de rotação por frame$0032: um local que parece ser uma variável temporária
- Nos banks $00-$3F e $80-$BF,
$0000-$1FFFé mapeado para os primeiros 8 KB dos 128 KB de WRAM do console $2000-$20FFé uma região não mapeada, então a instruçãoand $2000faz uma leitura de open bus
Como 0x2020 é usado para decidir o fim da rotação
- A rotina soma a quantidade de rotação à direção atual para criar a nova direção e salva esse valor em uma variável temporária
- Em seguida, faz XOR entre a direção anterior e a nova, aplica
and $2000ao resultado e verifica se o resultado é 0 - No hardware real do SNES, a leitura de open bus em 16 bits feita por
and $2000sempre retorna 0x2020- O código de máquina de
and $2000é2D 00 20 - Como o 65816 usa um barramento de dados de 8 bits, ele realiza uma leitura de 16 bits como duas leituras separadas de 8 bits
- Nesse caso, o último byte lido é o high byte do endereço,
0x20, então0x20é retornado nas duas leituras
- O código de máquina de
- Portanto, na prática, o comportamento é funcionalmente equivalente a
and #$2020 - Se o resultado do AND é 0, a rotina grava a nova direção como está e continua a rotação no próximo frame
- Se não é 0, ela zera a quantidade de rotação e então soma
0x1000à nova direção, mascarando depois com0xE000para alinhá-la ao múltiplo mais próximo de0x2000
Por que no ZSNES ele continua girando
- Os valores de direção do barril parecem usar uma escala em que
0x0000aponta para baixo e0x4000aponta para a esquerda - No barril analisado, a rotação no sentido horário é
0x0300e a rotação anti-horária é0xFD00, e uma volta completa de 360 graus leva pouco mais de 85 frames - Isso dá um tempo um pouco menor que 1,5 segundo a 60 fps
- No AND com
0x2020, a única mudança realmente relevante é o bit 13, isto é, 0x2000 - Uma mudança de
0x2000no valor da direção corresponde a um passo entre direções básicas ou diagonais - No hardware normal, ao soltar o direcional, o barril continua girando até a próxima direção básica ou diagonal e então para exatamente apontado para ela
- Se a leitura de open bus sempre retorna 0, o resultado do AND também será sempre 0, então o código que encerra a rotação nunca é executado
Possível erro de digitação e resultado do patch na ROM
and $2000é uma instrução com endereçamento absoluto, e logicamente é bem provável que devesse serand #$2000, com endereçamento imediato- No hardware real, como o open bus retorna 0x2020,
and $2000acaba funcionando por acaso do jeito pretendido - Esse funcionamento acidental depende da condição de que os 6 bits menos significativos da quantidade de rotação por frame sejam sempre 0, o que faz
0x2020ser funcionalmente igual a0x2000 - O opcode incorreto é executado no offset
$EDACdo bank$B3, que na revisão analisada corresponde a$33EDACdentro da ROM de 4 MB do jogo - Se esse byte for trocado de
0x2Dpara0x29, os barris giratórios passam a funcionar normalmente mesmo que a leitura de open bus sempre retorne 0 - A posição exata na ROM pode ser diferente em outras revisões do jogo
- Como o jogo funciona normalmente em quase todos os emuladores de SNES, exceto versões antigas do ZSNES, este caso está mais para uma análise de comportamento dependente de hardware do que para um patch com utilidade prática
1 comentários
Opiniões no Hacker News
Perdi muitas horas da minha vida cometendo erros como esquecer o
#antes de um valor imediato ao escrever assembly 6502, acabando por acessar a memóriaComo neste caso, muitas vezes acontece de funcionar por acaso em algumas situações. Pior ainda é quando se depende de RAM não inicializada em vez de barramento flutuante; por causa das características da DRAM, pode funcionar sempre na minha máquina ou no emulador, mas quebrar na máquina de outra pessoa que usa um chip de DRAM diferente. Normalmente você descobre isso 15 minutos antes da apresentação na demoparty, quando não roda na máquina da festa
LDA #2sendo “carregar o número 2 em A” eLDA 2sendo “carregar em A o valor que está na posição de memória 2”Como Open Bus no título estava em maiúsculas, comecei a ler achando que fosse o nome próprio de algum protocolo ou padrão antigo de barramento que eu nunca tinha ouvido falar
Depois de ler, vi que significava que o decodificador de linhas de endereço não ativava nenhum dispositivo de memória no endereço especificado
$2000, deixando o barramento em um estado “aberto”, sem estar conectado a nada. É bem engraçado que o problema de esquecer o#do endereçamento imediato só tenha aparecido porque emuladores antigos não tratavam leituras de memória como o hardware real. Com a correção, ao usar endereçamento imediato em vez de endereçamento absoluto, não há leitura de memória, então o tempo de execução também deve ficar menor. Nesse bloco de código, pode haver um ganho de cerca de 2µs, mas provavelmente isso só faz sentido em bare metal, e de todo modo é provável que o emulador não tenha precisão de timing perfeitaPelo que sei, Donkey Kong 64 tinha um vazamento de memória que fazia o jogo morrer depois de 8 a 9 horas de jogo contínuo, um tempo irrealisticamente longo para os padrões da época. Não foi pego durante o desenvolvimento, mas é um tempo que se acumula rapidamente se você usa estados salvos do emulador e continua jogando em vez de usar o recurso de salvamento dentro do jogo. Ainda assim, o histórico é meio nebuloso. Algumas fontes afirmam que incluir o Memory Pak foi uma medida de última hora para esconder o bug, empurrando o tempo até o crash de 8–9 horas para 13–20 horas, mas pesquisas recentes parecem indicar que foi coincidência, e que Rare ou Nintendo não lançaram o jogo sabendo desse bug
[0] https://arstechnica.com/gaming/2021/06/how-snes-emulators-go...
Enquanto trabalhava no recurso RunAhead do RetroArch e verificava em que ponto os estados salvos deixavam de coincidir, já vi o SNES Puyo Puyo usando o open bus do PPU
Depois de carregar um estado, o valor lido do PPU Open Bus ficava diferente, então o log de rastreamento da execução da CPU não batia
Não é que eu cometa erros da família 6502 o tempo todo, mas, quando cometo, normalmente é o erro de usar um endereço de memória em vez de um valor imediato
É um erro muito comum e fácil de cometer, e acredito que o próprio Chuck Peddle se arrependeu profundamente da sintaxe que coloca
#no valor imediato, como em#$1234. Configurei o IDE para mostrar o#em vermelho vivo, e isso ajuda um pouco. Até os deuses do assembly da Rare caíram no mesmo problemaintel_syntax noprefixdo GNU assemblerEm instruções que podiam receber tanto valores imediatos quanto endereços de memória, havia uma ambiguidade sintática que permitia interpretar um valor imediato de constante nomeada referenciada à frente como uma referência a símbolo desconhecido. Como resultado, em vez do valor imediato esperado, o código era montado como uma instrução com um endereço de memória placeholder que seria preenchido, no link, com o endereço realocado do símbolo. Foi doloroso depurar
Gosto de textos assim. Sempre sinto que acompanho só uns 60% do código assembly, então a explicação em prosa ao lado ajuda muito
Também é divertido ouvir histórias de bugs em softwares clássicos que ninguém entendeu, ou que talvez ninguém tenha notado até hoje
Mecanismos que são essenciais em sistemas que podem se conectar à rede e que ficaram baratos o bastante para serem colocados até em arquiteturas embarcadas completamente isoladas. No NES original, muitas leituras e escritas eram apenas alternar a tensão em algum fio, e o que acontecesse depois acontecia. O efeito desejado era obtido alternando a tensão de forma muito controlada, exatamente alinhada com o sinal que indicava o intervalo de blanking do CRT. Algumas animações de Super Mario Bros 3 alternavam um multiplexador de RAM para escolher um entre vários bancos de dados de sprites, fazendo o hardware gráfico ler de um chip completamente diferente, com aparência ligeiramente diferente, quando fosse buscar os sprites. Como o timing da TV era importante, também era preciso lançar software separado para regiões de TV NTSC e PAL; os dois padrões têm taxas de varredura diferentes, e essa taxa era o clock que conduzia a lógica de renderização. Era uma época realmente bruta
Pelo que sei, open bus só aparece em sistemas antigos que usavam um barramento síncrono simples
Na maioria dos outros sistemas, acessar um endereço inexistente retorna um valor constante só de 0s ou só de 1s, até onde sei. Isso porque o protocolo de barramento tem um handshake que permite ao mestre saber que não houve resposta; na terminologia PCI, isso corresponde a um “master abort”
Open bus significa literalmente que as linhas do barramento de dados estão em circuito aberto
A CPU colocou no barramento de endereços um endereço não mapeado ou somente para escrita e, como nenhum hardware no barramento respondeu, as linhas do barramento não foram acionadas e ficaram flutuando. Em termos nominais, é um comportamento indefinido no nível de hardware
Para entender o que acontece na prática, é preciso olhar um pouco mais para a estrutura física do barramento de dados. Há longos condutores que levam sinais pela placa-mãe e pelo cartucho, separados do plano de terra por uma fina camada de substrato isolante. Isso se parece com um capacitor e, de fato, engenheiros descrevem e modelam isso como capacitância parasita. Como esse efeito limita a taxa máxima de transferência de dados do barramento, tenta-se minimizá-lo. Mas, por causa dele, quando o barramento não é acionado, ele tende a permanecer na última tensão que o acionou. Ele se comporta como uma pequena célula de DRAM, criando o efeito mencionado no texto: “uma leitura de open bus retorna o último valor que passou pelo barramento”
Não é raro que jogos dependam por acidente do efeito de open bus, como DKC2. No NES, o registrador da porta serial para conexão dos controles aciona apenas os bits inferiores, enquanto os bits superiores ficam em open bus. Há alguns jogos que leem a entrada do controle com a instrução
LDA $4016e esperam o valor$40ou$41. Nesse caso, o 4 é o valor que ficou por causa do open busTambém existem estratégias de speedrun que dependem do comportamento de open bus como parte de exploits de corrupção de memória ou execução de código arbitrário. Por exemplo, o credit warp de Super Mario World envia o contador de programa para memória não mapeada, deixa-o avançar por bastante tempo até chegar finalmente à RAM, e então executa um payload criado por meio de manipulação precisa das posições dos inimigos [1]
No entanto, mesmo em comportamentos de open bus geralmente previsíveis, há exceções. Cartuchos não padrão podem retornar um valor padrão para memória não mapeada ou incluir resistores pull-up/pull-down que afetam o comportamento de open bus. Também há interações interessantes com DMA. O SNES suporta um recurso chamado HDMA, que permite a uma aplicação agendar transferências de dados da CPU para o hardware gráfico em momentos precisos, para fazer upload de dados ou alterar configurações no meio do frame [2]. Essa transferência via DMA pausa brevemente a CPU para usar o barramento; se a transferência de DMA se intercalar no meio da execução de uma instrução, isto é, depois de ler o endereço de destino e antes da leitura real de open bus, ela pode alterar o comportamento dessa leitura
Esse caso-limite muito específico tem grande impacto em um exploit de speedrun de Super Metroid [3]. O exploit provoca um
memcpyfora dos limites para tentar mover um grande bloco de dados do open bus para a RAM. A leitura de open bus quase sempre retorna 0, porque o último byte da instrução de load relacionada é 0. Mas, em certas salas com muitos efeitos gráficos via HDMA, há uma chance razoável de uma transferência de DMA afetar uma dessas leituras; nesse caso, um byte diferente de zero entra em uma posição importante, o exploit deixa de funcionar corretamente e o jogo trava. Isso gerou uma leve discussão na comunidade. Algumas rotas e estratégias só são estáveis em emuladores e firmwares não padrão. Jogadores usando o hardware original ou emuladores muito precisos têm maior probabilidade de sofrer crashes, mas a maioria dos emuladores, incluindo todas as relançamentos oficiais da Nintendo, não emula esse caso-limite específico em que uma transferência de HDMA no meio de uma instrução altera o valor lido de open busAlém disso, a conclusão TAS mais rápida atual de Super Metroid [4] depende dessa interação com HDMA. Encontrou-se uma situação em que tentar executar o open bus causava crash, mas normalmente isso não podia ser controlado de forma útil. Manipulando os inimigos na sala para afetar o timing da CPU, foi possível fazer o HDMA colocar no barramento uma instrução útil no momento certo. No fim, isso fez o console executar entradas do controle como código, obtendo execução completa de código arbitrário
[1]: https://youtu.be/vAHXK2wut_I
[2]: https://youtu.be/K7gWmdgXPgk
[3]: https://youtu.be/CnThmKhtfOs
[4]: https://tasvideos.org/8214S
Graças à série de vídeos dele criando um computador em protoboard com o 6502, o conteúdo do texto e a explicação do problema de hardware realmente fazem sentido. Claro que estou extrapolando o exemplo básico de barramento dele para uma máquina comercial. Sem isso, eu saberia quase nada
https://eater.net
Ao programar o chip Parallax Propeller, também existe um problema parecido em certa medida
É preciso usar
JMP #address, que salta para a posição de memória especificada, mas eu sempre acabo usandoJMP address, que salta para o endereço lido da posição de memória especificada. Parece que a memória muscular do assembler 6502 ainda ficou comigoPropeller:
JMP #address6502:
JMP addressPropeller:
JMP address6502:
JMP (address)O pior é que, como neste texto, o código Propeller com bug às vezes funciona. Até que, em algum momento, para, e você passa horas tentando descobrir por quê
Os gráficos 3D pré-renderizados em SGI de DKC 1 eram de ponta na época. Vector Man, do Genesis, fez algo parecido, mas recebeu menos atenção
Eu não conseguia acreditar no que estava vendo. Na época do lançamento, havia uma fita de vídeo que antecipava o jogo e também mostrava bastidores do desenvolvimento; pelo que lembro, era material promocional que você solicitava em algo como uma caixa de cereal. Assisti muito a essa fita. Eu não tinha DKC, mas podia jogar na casa de um amigo
As revistas da época falavam de forma bastante vaga sobre esse tema e muitas vezes davam a entender que o SNES renderizava personagens e coisas do tipo em tempo real. Na prática, aquilo era essencialmente mais próximo de uma animação em flipbook
Quando você fica travado jogando por emulação, acaba se perguntando se não é, afinal, um bug do emulador
Nesse caso específico, eu teria pensado que o jogo foi projetado assim mesmo e que era simplesmente difícil. Não é algo totalmente relacionado, mas quando um jogo parece realmente difícil, também penso algo parecido: “será que isso é por causa da latência da emulação?”. Acabei me aprofundando nesse problema e, no fim, montei eu mesmo um MiSTer FPGA
Lembro de uma parte em que, depois de pegar o rato, era preciso pressionar quatro teclas ao mesmo tempo. Mas a entrada USB só transmitia 3 de uma vez, então, para passar, era preciso apertar freneticamente as quatro teclas para que todas acabassem sendo registradas em um intervalo muito curto. Tive que tentar várias vezes, e foi muito frustrante
Como foi dito, eu simplesmente achava que acertar o timing do disparo dos barris para criar o ângulo correto era um design intencional do jogo. Fiquei realmente surpreso ao descobrir que era um bug
No começo dos anos 2000, rodei em um emulador e achei muito mais difícil do que eu lembrava. Depois descobri que havia um bug de emulação em que os inimigos não desapareciam mesmo depois de explodir a base, e Ladd continuava congelado. Então, para concluir a fase, era preciso ter cerca de 2 barras a mais de vida. Para ver se era possível, terminei uma vez assim, mas nunca mais fiz isso