ADRIANOLAUREANO← Artigos

Construímos o videogame. Agora vamos construir um jogo.

Uma continuação de Atari 2600: Do Bit ao Emulador: vamos entender como um Pong funciona e transformar suas regras em uma ROM real de 4 KiB.

Ilustração editorial de uma ROM sendo transformada em uma partida de Pong exibida por linhas em uma televisão
Do código no computador aos sinais que formam o jogo linha por linha.
4 KiBROM completa
128 BRAM disponível
6502opcodes emitidos
NTSCsincronismo de vídeo

01 / DO EMULADOR AO JOGO

O emulador mostrou como o Atari funciona. O jogo mostra por que ele funciona assim.

No livro, construímos um emulador para entender como CPU, memória, vídeo e tempo se conectam dentro do Atari 2600. Mas existe uma pergunta que aparece naturalmente depois disso: como um jogo usa essa máquina?

Este Pong responde pelo caminho inverso. Em vez de receber uma ROM e interpretar seus bytes, agora produzimos os bytes que o console espera encontrar. Precisamos organizar o programa, controlar o feixe de vídeo, ler o joystick, mover os objetos e gerar som dentro das limitações reais do aparelho.

A IDEIA CENTRAL

O emulador executa uma ROM. Neste projeto, construímos a ROM que será executada por ele.

Pong é um ótimo primeiro jogo porque suas regras são fáceis de visualizar: duas raquetes, uma bola, colisões e pontos. Isso nos permite concentrar no que realmente torna o Atari diferente de uma plataforma moderna.

02 / ANTES DO PRIMEIRO PIXEL

O Atari não oferece uma tela pronta para desenhar.

Em um computador atual, o jogo normalmente prepara uma imagem completa em memória e depois pede que ela seja exibida. O Atari 2600 não possui memória de vídeo para guardar esse quadro. A CPU precisa alimentar o chip gráfico, o TIA, enquanto a televisão percorre cada linha da imagem.

Isso significa que o programa não pode simplesmente dizer “desenhe a raquete nesta posição”. Ele precisa escrever nos registradores certos no momento certo. Se uma instrução demora mais do que o planejado, a mudança aparece deslocada na tela. No Atari, o tempo do código também faz parte do desenho.

4 KiB de ROMTodo o código, tabelas, placar, tela inicial e dados precisam caber em 4.096 bytes.
128 bytes de RAMPosições, pontuação, direção, velocidade e variáveis temporárias dividem um espaço minúsculo.
Sem framebufferA imagem não fica pronta na memória: ela é construída enquanto cada linha é exibida.
CPU 6507É uma versão do 6502 com menos pinos de endereço, mas usa o mesmo conjunto de instruções.
TIAO chip responsável pelos objetos gráficos, cores, sincronismo da imagem e dois canais de áudio.
Tempo previsívelCada trecho visível precisa terminar dentro do orçamento de ciclos da linha atual.

03 / O PAPEL DO RUST

Rust constrói o cartucho; o Atari executa código de máquina.

É importante separar as duas partes. O programa escrito em Rust roda no computador de desenvolvimento. Ele organiza módulos, emite instruções do 6502, resolve endereços e grava o arquivo final. Dentro do emulador ou do console não existe Rust: existe apenas a sequência de bytes da ROM.

01Código em Rust
02Opcodes do 6502
03ROM de 4 KiB
04Emulador ou Atari

O Rust existe apenas na primeira etapa. Depois que o arquivo.bin é criado, qualquer emulador compatível enxerga somente uma sequência de 4.096 bytes.

let mut asm = Assembler::new(0xF000);

kernel::emit_reset_and_frame(&mut asm);
splash::emit_splash_kernel(&mut asm);
kernel::emit_game_kernel(&mut asm);
ai::emit_ai(&mut asm);
physics::emit_physics(&mut asm);
audio::emit_audio(&mut asm);
tables::emit_tables(&mut asm);

let rom = asm.build()?;
std::fs::write("build/do_bit_ao_emulador_pong.bin", rom)?;

O arquivo principal funciona como uma linha de montagem. Cada módulo acrescenta uma parte da ROM na ordem necessária: inicialização, tela de abertura, kernel do jogo, inteligência artificial, física, áudio e tabelas.

04 / COMEÇANDO DO ZERO

Primeiro criamos uma ferramenta capaz de produzir um cartucho.

Para acompanhar o projeto, você precisa do Rust instalado e de um emulador de Atari 2600. Não é necessário instalar um assembler externo: construiremos um emissor pequeno dentro do próprio projeto. Comece criando uma aplicação de linha de comando:

cargo new pong2600
cd pong2600

# O projeto final não usa bibliotecas externas.
# O Cargo.toml precisa apenas dos dados do pacote:

[package]
name = "pong2600"
version = "0.1.0"
edition = "2024"

[dependencies]

O executável gerado pelo Cargo não é o jogo. Ele é o gerador do jogo. Quando executamos cargo run, esse programa cria um segundo arquivo: a ROM que será aberta pelo Atari ou pelo emulador.

Separar o código em módulos ajuda a raciocinar sobre uma máquina pequena sem misturar sincronismo de vídeo, física e tabelas no mesmo arquivo:

src/
├── main.rs       # coordena a geração da ROM
├── assembler.rs  # transforma operações em bytes
├── tia.rs        # endereços de registradores e da RAM
├── kernel.rs     # sincronismo, desenho e entrada
├── physics.rs    # bola, colisões e pontuação
├── ai.rs         # adversário controlado pela CPU
├── audio.rs      # música e efeitos
├── splash.rs     # tela inicial
└── tables.rs     # dígitos e frequências

Em main.rs, declare cada módulo com mod assembler;, mod tia; e assim por diante. A ordem das chamadas importa porque também determina a ordem dos bytes e das tabelas dentro da ROM.

05 / DESENHANDO O ESTADO ANTES DA TELA

Cada variável precisa de um endereço conhecido.

Em um jogo moderno, criaríamos uma estrutura com campos como ball_x e score. No 6502, as rotinas precisam saber o endereço exato de cada valor. O arquivotia.rs concentra tanto os registradores do hardware quanto o mapa de RAM escolhido pelo jogo.

// Estado do jogo nos 128 bytes de RAM do RIOT
pub const MODE:   u16 = 0x80; // 0 = abertura, 1 = partida
pub const P0Y:    u16 = 0x81; // raquete do jogador
pub const P1Y:    u16 = 0x82; // raquete da IA
pub const BALLX:  u16 = 0x83;
pub const BALLY:  u16 = 0x84;
pub const BALLDX: u16 = 0x85; // 1 ou 255 (-1)
pub const BALLDY: u16 = 0x86; // 1 ou 255 (-1)
pub const SCORE0: u16 = 0x89;
pub const SCORE1: u16 = 0x8A;
pub const SPEED:  u16 = 0x97;

O RIOT disponibiliza RAM entre $80 e$FF. Usar os primeiros endereços para o estado mais acessado permite instruções de zero page, que ocupam apenas dois bytes: um para o opcode e outro para o endereço. Isso economiza ROM e ciclos.

FaixaResponsabilidadeExemplos
$80–$8AEstado persistentemodo, posições, direção, placar
$8B–$90Trabalho temporáriocálculos e dados da splash
$91–$97Ponteiros e ritmodígitos, música e velocidade
Os 128 bytes de RAM como um armário de posições fixas
$80$FF
11 Bestado do jogo
6 Btemporários
7 Bponteiros e ritmo
104 Brestante disponível

Cada bloco tem endereço fixo; não existe alocação dinâmica durante a partida.

Planejar esse mapa antes do kernel evita que duas rotinas usem o mesmo byte para finalidades incompatíveis. Com apenas 128 bytes, nomes e comentários no gerador são a nossa principal documentação.

06 / UM MONTADOR PEQUENO E DIRETO

Transformando intenções em bytes que o 6502 entende.

Um assembler transforma instruções legíveis em código de máquina. O projeto inclui um montador mínimo escrito em Rust, criado apenas para as necessidades desta ROM. Em vez de interpretar um arquivo Assembly, ele oferece funções que acrescentam diretamente os bytes corretos.

pub struct Assembler {
    origin: u16,                     // primeiro endereço: $F000
    pc: u16,                         // endereço da próxima instrução
    code: Vec<u8>,                   // bytes já emitidos
    labels: HashMap<&'static str, u16>,
    fixups: Vec<Fixup>,              // saltos ainda sem destino
}

pub fn emit(&mut self, bytes: &[u8]) {
    self.code.extend_from_slice(bytes);
    self.pc += bytes.len() as u16;
}

pub fn imm(&mut self, opcode: u8, value: u8) {
    self.emit(&[opcode, value]);
}

O pc, ou contador de programa, representa o endereço que a próxima instrução terá quando a ROM for executada. Ele começa em $F000. Sempre que emitimos bytes, acrescentamos seu tamanho ao contador. Assim, ao registrar uma label, sabemos exatamente para qual endereço ela aponta.

// LDA #$02
asm.imm(0xA9, 0x02);

// STA VSYNC
asm.zp(0x85, tia::VSYNC);

// Salto para um endereço que ainda será resolvido
asm.abs_label(0x4C, "Frame");
Como quatro bytes se tornam duas instruções
OPCODEA9LDA imediato
OPERANDO02valor 2
OPCODE85STA zero page
ENDEREÇO00VSYNC

A9 02 85 00 significa: carregue 2 no acumulador e grave em VSYNC.

Para ler os próximos exemplos, basta conhecer os quatro elementos mais usados da CPU. O acumulador A recebe valores e resultados. X e Y funcionam como contadores e índices. Os flags registram propriedades do último resultado, como “foi zero?”, “ficou negativo?” ou “houve transporte?”. Os branches tomam decisões observando esses flags.

AAcumulador: somas, comparações e escritas no hardware.
XÍndice: laços, objetos do TIA e acesso a tabelas.
YÍndice: linha atual e leitura indireta do placar.
PFlags: zero, negativo, carry e decisões condicionais.
SPPilha: guarda o retorno das chamadas feitas com JSR.

O primeiro argumento é sempre o opcode. $A9 significaLDA imediato: carregar no acumulador o valor que vem no byte seguinte. $85 significa STA em zero page: armazenar o acumulador no endereço seguinte. Portanto, os quatro bytes A9 02 85 00 carregam 2 e escrevem esse valor em VSYNC, cujo endereço é zero.

Alguns saltos apontam para rotinas que ainda não foram emitidas. Por isso, o montador registra labels, nomes dados a endereços, e guarda as referências pendentes. Ao finalizar, ele substitui cada espaço reservado pelo endereço absoluto ou pelo deslocamento relativo correto.

Saltos absolutos, como JMP e JSR, recebem dois bytes de endereço. Desvios condicionais, comoBNE, recebem um deslocamento entre −128 e 127 bytes contado a partir da próxima instrução. O métodobuild calcula os dois formatos e rejeita um branch cujo destino esteja longe demais.

JMP apenas continua a execução em outro endereço.JSR é usado para chamar uma rotina: ele guarda na pilha o endereço de retorno, e a instrução RTSrecupera esse endereço. É assim que MoveBall,Ping e SetObjectX podem ser reutilizadas sem duplicar seus bytes.

Depois disso, o restante dos 4.096 bytes é preenchido com NOP, uma instrução que não altera o estado da CPU. Os últimos seis bytes recebem os vetores de NMI, RESET e IRQ. Neste cartucho, os três apontam para a rotina Reset.

07 / UM ESTADO CONHECIDO

Antes de jogar, precisamos limpar o que ficou na memória.

Quando o console liga, não devemos assumir que a RAM contém zero. A rotina de reset desliga interrupções, garante que o modo decimal não está ativo, prepara a pilha e percorre toda a região$80–$FF escrevendo zero.

asm.label("Reset");
asm.emit(&[0x78, 0xD8]); // SEI: desliga IRQ; CLD: limpa decimal
asm.imm(0xA2, 0xFF);     // LDX #$FF
asm.emit(&[0x9A]);       // TXS: inicializa a pilha

asm.imm(0xA9, 0x00);     // A = 0
asm.imm(0xA2, 0x00);     // X = 0
asm.label("Clear");
asm.emit(&[0x95, 0x80]); // STA $80,X
asm.emit(&[0xE8]);       // INX
asm.branch(0xD0, "Clear"); // BNE Clear

O endereçamento $80,X soma o registradorX ao endereço inicial. Como INX passa de 255 para zero, o flag de zero é ativado e BNE encerra o laço. Em poucas instruções, todos os 128 bytes foram limpos.

Depois da limpeza, o código define posições iniciais, direções, cores e modo de execução. A bola começa próxima ao centro, as raquetes recebem a mesma altura e MODE = 0 seleciona a tela de abertura. Só então saltamos para Frame.

POR QUE ISSO IMPORTA

Um jogo determinístico começa sempre do mesmo estado, independentemente dos bytes deixados na RAM.

08 / A TELEVISÃO DITA O RITMO

Cada quadro é uma sequência de períodos com funções diferentes.

A versão NTSC trabalha com um quadro organizado em 262 linhas. Nem todas exibem o jogo. Algumas sincronizam a televisão, outras mantêm o feixe oculto e uma região central forma a imagem visível. O programa repete essa estrutura continuamente.

Um quadro NTSC completo
3VSYNC
37VBLANK
192IMAGEM VISÍVEL
30OVERSCAN

3 + 37 + 192 + 30 = 262 linhas, repetidas aproximadamente 60 vezes por segundo.

1. VSYNC   → informa o início de um novo quadro
2. VBLANK → prepara posições e estado sem exibir imagem
3. VISÍVEL → desenha placar, raquetes, bola e quadra
4. OVERSCAN → atualiza controles, IA, física e áudio
// Três linhas de VSYNC
asm.imm(0xA9, 0x02);       // LDA #$02
asm.zp(0x85, tia::VSYNC);   // STA VSYNC
asm.zp(0x85, tia::WSYNC);
asm.zp(0x85, tia::WSYNC);
asm.zp(0x85, tia::WSYNC);

// Sai do VSYNC e oculta a imagem durante o VBLANK
asm.imm(0xA9, 0x00);
asm.zp(0x85, tia::VSYNC);
asm.imm(0xA9, 0x02);
asm.zp(0x85, tia::VBLANK);

Escrever em WSYNC interrompe temporariamente a CPU até o início da linha seguinte. Por isso não importa o valor armazenado: é o ato da escrita que produz a espera. Três esperas com VSYNC = 2 criam as três linhas de sincronismo.

O registrador WSYNC faz a CPU esperar pelo início da próxima linha. VSYNC marca o começo de um novo quadro e VBLANK impede que alterações internas apareçam na tela. Durante o overscan, um temporizador do RIOT controla quanto tempo a lógica pode usar antes do próximo quadro.

A região visível do projeto usa 192 linhas. Na partida, as primeiras 20 formam os cinco pixels de altura do placar, ampliados verticalmente, e outras 172 desenham a quadra. Durante o overscan, TIM64T recebe $23. EnquantoINTIM não chega a zero, há tempo para atualizar o jogo sem interferir no desenho.

Cada scanline possui 228 pulsos de cor. A CPU avança uma vez a cada três desses pulsos, deixando apenas 76 ciclos de CPU por linha. Parte deles ocorre antes da região visível e parte enquanto os pixels aparecem. É por isso que uma única instrução adicionada ao kernel pode mover um objeto na tela.

KERNEL

No Atari, o kernel é o trecho sincronizado com o feixe que atualiza os registradores gráficos linha por linha.

09 / RAQUETES, BOLA E PLACAR

O TIA já possui objetos simples. O jogo dá significado a eles.

As duas raquetes usam os objetos de jogador do TIA: GRP0 e GRP1. A bola usa ENABL. Em cada linha visível, o kernel compara a linha atual com a posição vertical de cada objeto. Se a linha estiver dentro da altura da raquete ou da bola, ativa o objeto; caso contrário, escreve zero e o oculta.

// Y contém a linha atual, de $AC até zero
asm.label("GameLine");
asm.zp(0x85, tia::WSYNC); // espera uma nova scanline

// linha_atual - P0Y < 16 ? mostra a raquete : oculta
asm.emit(&[0x98, 0x38]);  // TYA, SEC
asm.emit(&[0xE5, 0x81]);  // SBC P0Y
asm.imm(0xC9, 0x10);      // CMP #16
asm.imm(0xA9, 0x00);      // LDA #0
asm.branch(0xB0, "P0Off");
asm.imm(0xA9, 0xFF);      // LDA #$FF
asm.label("P0Off");
asm.zp(0x85, tia::GRP0);  // atualiza os pixels do jogador
O kernel acompanha o feixe da esquerda para a direita
GRP0playfieldENABLGRP1

A CPU decide quais objetos ficam ativos antes que o feixe atravesse suas posições.

TYA copia o número da linha para o acumulador.SEC prepara a subtração e SBC P0Ycalcula a distância até o começo da raquete. Se o resultado for menor que 16, carregamos $FF em GRP0, acendendo os oito bits do jogador. Repetimos a mesma ideia para a segunda raquete e usamos uma altura de três linhas para a bola.

A posição horizontal exige outra técnica. A rotina SetObjectX primeiro consome blocos de 15 pixels para chegar perto do destino e depois usa o movimento fino do TIA para corrigir a diferença restante. Ao final do VBLANK, uma escrita em HMOVE aplica os ajustes.

O placar reutiliza os dois jogadores para desenhar números de cinco linhas. Cada dígito é armazenado como um pequeno bitmap em uma tabela. Ponteiros calculados durante o VBLANK indicam quais cinco bytes serão mostrados no quadro atual.

O Atari oferece objetos. O jogo nasce quando o código decide onde e quando cada objeto deve existir.

10 / COLOCANDO CADA OBJETO NO X CORRETO

Horizontalmente, posição também é tempo.

O TIA não possui um registrador simples no qual escrevemos “x = 80”. Para posicionar um objeto, esperamos o início da linha, gastamos ciclos até o feixe chegar perto da posição desejada e escrevemos em RESP0, RESP1 ouRESBL. Essa é a posição grosseira.

asm.label("SetObjectX");
asm.zp(0x85, tia::WSYNC);
asm.emit(&[0x38]);           // SEC
asm.label("PosLoop");
asm.imm(0xE9, 0x0F);        // SBC #15
asm.branch(0xB0, "PosLoop"); // repete enquanto ainda for positivo
asm.imm(0x49, 0x07);         // corrige o restante
asm.emit(&[0x0A, 0x0A, 0x0A, 0x0A]); // desloca 4 bits
asm.emit(&[0x95, 0x20]);     // HMP0/HMP1/HMBL,X
asm.emit(&[0x95, 0x10]);     // RESP0/RESP1/RESBL,X
asm.emit(&[0x60]);           // RTS

A rotina recebe a coordenada no acumulador e o tipo do objeto emX. Cada SBC #15 consome um bloco previsível de tempo. Quando o valor passa de zero, o restante é convertido no movimento fino e gravado no registradorHMP0, HMP1 ou HMBL.

O endereçamento indexado permite usar a mesma rotina para os três objetos. No VBLANK, chamamos SetObjectX para a raquete esquerda, para a direita e para a bola. Depois,HMOVE aplica as três correções de uma vez.

11 / CONTROLE E FÍSICA

As regras são simples; traduzi-las para poucos bytes é o desafio.

O joystick está ligado ao RIOT. A rotina lê SWCHA e testa os bits das direções. No hardware, esses sinais são ativos em nível baixo: um bit zerado indica que a direção está pressionada. A raquete esquerda se move dois pixels por atualização e respeita os limites da quadra.

asm.absolute(0xAD, tia::SWCHA); // LDA SWCHA
asm.imm(0x29, 0x10);         // AND #%00010000: cima
asm.branch(0xD0, "TryDown");  // bit 1 = não pressionado

asm.zp(0xA5, tia::P0Y);       // lê a posição atual
asm.imm(0xC9, 0x9A);          // testa o limite
asm.branch(0xB0, "PaddleDone");
asm.emit(&[0xE6, 0x81]);      // INC P0Y
asm.emit(&[0xE6, 0x81]);      // dois pixels por quadro

A máscara %00010000 mantém apenas o bit usado pela direção para cima. Se ele estiver zerado, a direção foi pressionada. Antes de incrementar P0Y, comparamos a posição ao limite da quadra. O movimento para baixo repete a mesma lógica com a máscara %00100000 e usaDEC no lugar de INC.

A bola mantém quatro valores principais na RAM: posição horizontal, posição vertical e as duas direções de movimento. Somar 1 move para a direita ou para baixo; somar 255 equivale a subtrair um em uma variável de oito bits, movendo para a esquerda ou para cima.

// Move a bola
BALLX = BALLX + BALLDX;
BALLY = BALLY + BALLDY;

// Rebate no teto ou no piso
if BALLY < 3       { BALLDY = 1;   ping(); }
if BALLY >= 0xAA  { BALLDY = 255; ping(); }

// Ao chegar à raquete, testa a sobreposição vertical
if BALLX >= 0x8F && BALLX < 0x9F && toca(P1Y, BALLY) {
    BALLDX = 255;
    BALLX = 0x8E;
    aumenta_velocidade();
    ping();
}
Colisão por sobreposição de intervalos
1. A bola chegou à faixa X da raquete?2. As faixas Y se sobrepõem?Se as duas respostas forem “sim”, BALLDX é invertido.

O bloco acima traduz a rotina de opcodes para uma forma mais legível. Na ROM, cada soma é feita com CLC seguido de ADC. Os testes usam CMP e branches. Quando BALLDX contém $FF, a soma de oito bits produz exatamente o efeito de −1.

01Atualizar X e Y
02Testar teto e piso
03Testar as raquetes
04Marcar ponto ou rebater

A colisão com uma raquete não depende do sistema automático de colisões do TIA. O código compara a posição da bola com a faixa horizontal da raquete e depois verifica se as áreas verticais se sobrepõem. Quando há contato, inverte a direção horizontal, reposiciona a bola fora da raquete e dispara o efeito sonoro.

Quando a bola ultrapassa uma das laterais, o adversário recebe um ponto. A bola volta ao centro, a velocidade retorna ao início e sua direção horizontal é definida para a próxima jogada. O placar vai de zero a nove e recomeça depois disso.

12 / PONTOS, REINÍCIO E VELOCIDADE

Uma rodada termina, mas o loop do jogo continua.

Se a bola passa de $9F à direita, o jogador da esquerda marca. Se passa de $02 à esquerda, a IA marca. A rotina incrementa o byte correspondente e limita o valor a um dígito:

asm.label("PointLeft");
asm.emit(&[0xE6, 0x89]);      // INC SCORE0
asm.zp(0xA5, tia::SCORE0);
asm.imm(0xC9, 0x0A);          // chegou a 10?
asm.branch(0x90, "LeftScoreOK");
asm.imm(0xA9, 0x00);          // volta para zero
asm.zp(0x85, tia::SCORE0);
asm.label("LeftScoreOK");
asm.abs_label(0x20, "ResetBall");

ResetBall devolve a bola ao centro, redefine sua direção vertical, zera o nível de velocidade e inicia o som de ponto. A cor de fundo é alterada com um XOR, criando uma confirmação visual sem gastar uma estrutura adicional.

A variável SPEED começa em zero e pode chegar a três. No primeiro nível, a bola é atualizada em quadros alternados. Nos seguintes, é atualizada uma ou duas vezes no mesmo quadro. Em vez de trabalhar com números fracionários, o jogo muda a frequência com que a mesma rotina de movimento é chamada.

Para desenhar o placar, cada número possui cinco bytes emtables.rs. As tabelas DigitLo eDigitHi fornecem as duas partes do endereço do dígito. Durante o VBLANK, o placar seleciona os ponteiros; durante a região visível, o kernel lê um byte por linha e o entrega aGRP0 e GRP1.

13 / O QUE TRANSFORMA A DEMONSTRAÇÃO EM JOGO

Um adversário, progressão, som e uma tela de abertura.

A raquete direita é controlada por uma inteligência artificial compacta. Quando a bola está vindo em sua direção, a rotina projeta uma posição vertical aproximada conforme a distância restante. A raquete segue esse alvo em passos de um ou dois pixels. Quando a bola se afasta, ela retorna lentamente ao centro.

se a bola estiver se afastando:
    mover a raquete lentamente para o centro

se a bola estiver vindo para a direita:
    estimar onde ela estará quando alcançar a raquete
    limitar o alvo à área jogável
    comparar o alvo com o centro da raquete
    mover um pixel; em velocidades altas, mover dois

a cada 64 quadros:
    hesitar por um quadro para não jogar perfeitamente

Pequenas imperfeições deliberadas evitam que o adversário pareça uma parede impossível de vencer. Em alguns quadros a IA hesita, e sua previsão é uma aproximação. Conforme a velocidade da bola aumenta, acompanhar a trajetória fica mais difícil.

A previsão não simula toda a trajetória. Ela começa emBALLY e soma ou subtrai um deslocamento escolhido de acordo com BALLX: quanto mais longe a bola está, maior a antecipação. O resultado é limitado entre$04 e $A0 antes de ser comparado ao centro da raquete.

O TIA possui dois canais de áudio. Na tela inicial, tabelas com divisores de frequência formam melodia e baixo. Durante a partida, um contador mantém efeitos curtos para colisões e pontos; ao chegar a zero, os volumes são desligados.

// Canal 0: efeito de colisão
asm.imm(0xA9, 0x04);
asm.zp(0x85, tia::AUDC0); // tipo de som
asm.imm(0xA9, 0x08);
asm.zp(0x85, tia::AUDF0); // divisor de frequência
asm.imm(0xA9, 0x06);
asm.zp(0x85, tia::AUDV0); // volume

// SOUNDT conta quantos quadros o efeito permanece ativo.
asm.imm(0xA9, 0x04);
asm.zp(0x85, tia::SOUNDT);

AUDC seleciona o tipo de onda ou ruído,AUDF controla o divisor que determina a altura do som e AUDV define o volume. Como o TIA não toca uma nota por duração automaticamente, SOUNDT é decrementado a cada quadro. Quando chega a zero, os dois volumes são desligados.

A abertura “DO BIT AO EMULADOR” é formada por seis tabelas de 96 bytes. O kernel usa uma linha para preparar os dados e a seguinte para trocar as duas metades do playfield no momento correto. Assim, o mesmo componente gráfico limitado produz uma imagem maior e assimétrica.

O uso de duas scanlines por linha lógica resolve um problema de tempo. Na primeira, os seis bytes da próxima linha são copiados para RAM. Na segunda, o kernel escreve os três bytes da metade esquerda, espera o feixe atravessar a tela e troca os registradores pelos três bytes da metade direita. As instruçõesNOP no meio não fazem trabalho; elas existem para consumir exatamente os ciclos necessários antes da troca.

14 / GERANDO E TESTANDO A ROM

Do código-fonte a um cartucho que qualquer emulador compatível pode abrir.

O projeto não possui dependências externas. Com Rust e Git instalados, clone o repositório, entre na pasta e execute o gerador:

git clone https://github.com/sl4ureano/pong2600_rust.git
cd pong2600_rust
cargo run --release

O gerador cria build/do_bit_ao_emulador_pong.bin. O arquivo tem exatamente 4.096 bytes e deve ser aberto como um cartucho 4K NTSC. Para executar no emulador construído no livro:

atari2600-rust build/do_bit_ao_emulador_pong.bin

Ao iniciar, você verá a tela “DO BIT AO EMULADOR” acompanhada por música. Qualquer direção do joystick, o botão de ação ou as chaves do console inicia a partida. O joystick move a raquete esquerda; a raquete direita passa a ser controlada pela IA.

Tela sem sincronismoConte novamente VSYNC, VBLANK, região visível e overscan. Um quadro NTSC precisa manter o total esperado de linhas.
Objeto deslocadoRevise os ciclos da rotina horizontal e confirme se HMOVE é aplicado depois de posicionar os objetos.
Jogo muda ao editarUma instrução acrescentada no kernel visível altera o tempo da linha. Mova cálculos para VBLANK ou overscan.
Branch fora do alcanceBranches relativos alcançam apenas −128 a 127 bytes. Use um branch invertido seguido de JMP para destinos distantes.

A melhor forma de estudar o projeto é alterar uma parte por vez: cores, altura das raquetes, velocidade, comportamento da IA, notas da música ou formato dos dígitos. Cada mudança conecta uma regra de jogo a registradores, bytes e ciclos reais do Atari.

O PRÓXIMO PASSO

Depois de construir a máquina, criar um jogo para ela fecha o ciclo: agora entendemos tanto quem executa quanto aquilo que é executado.