Engineering

NES 에뮬레이터 만들기

6502 CPU, PPU, 타일, 오디오와 WASM을 연결해 브라우저에서 NES를 에뮬레이션합니다.

검증일 근거 자료

「슈퍼 마리오 브라더스」의 카트리지 용량은 40KB다. 프로그램 32KB에 그래픽 데이터 8KB. 지금 이 페이지를 여는 데 오간 데이터보다 훨씬 작다. 그리고 이 게임이 돌아가던 기계의 메모리는 2KB였다. 요즘 스마트폰 메모리의 사백만분의 일쯤 되는 공간에서 스크롤되는 배경과 뛰어다니는 마리오와 음악이 동시에 나왔다는 뜻이다.

이 글에서는 그 기계, NES를 소프트웨어로 다시 만들어본다. 에뮬레이터를 만드는 것이다. 미리 말해두자면 목표는 상용 에뮬레이터 수준의 완성도가 아니다. 에뮬레이터 제작은 컴퓨터 과학을 배우는 수단으로서 대단히 훌륭하고, 이 글의 목적은 그쪽이다. CPU가 명령어를 어떻게 실행하는지, 메모리 맵이 무엇인지, 화면은 어떻게 그려지고 인터럽트는 왜 필요한지. 컴퓨터 구조 교과서 한 학기 분량의 개념이 이 작은 게임기 안에 전부 들어 있고, 에뮬레이터를 만들다 보면 그걸 시험공부가 아니라 게임을 돌리기 위해 배우게 된다.

글은 다섯 단계로 간다. NES라는 기계를 소개하고, 에뮬레이터가 어떤 원리로 가능한지 짚은 다음, CPU를 만들어 스네이크 게임부터 돌려본다. 그리고 화면을 그리는 PPU와 소리를 만드는 APU까지 붙여 진짜 NES 게임이 돌아가는 에뮬레이터를 완성한다. 구현은 Rust로 하고 WebAssembly로 컴파일하는 경계까지 설명한다. Rust를 몰라도 걱정할 것 없다. 읽는 데 필요한 문법은 그때그때 설명하고, 로직 자체는 어떤 언어로 옮겨도 되는 수준으로 단순하다.

슈퍼마리오가 돌아가던 컴퓨터

NESNintendo Entertainment System는 닌텐도가 1983년 일본에서 패밀리 컴퓨터, 줄여서 패미컴이라는 이름으로 출시한 가정용 게임기다. 가격은 14,800엔. 당시 경쟁 기기의 절반 수준이었다. 2년 뒤 북미에 NES라는 이름으로 다시 출시됐는데, 하필 그 직전에 북미 게임 시장 전체가 무너진 참이라1 닌텐도는 이걸 "게임기"가 아니라 "엔터테인먼트 시스템"이라고 불러야 했다. 결과적으로 전 세계에 6천만 대 넘게 팔리며 몰락한 시장을 되살렸고, 이후 30년간 이어질 콘솔 게임 산업의 틀을 만들었다.

우리에게 중요한 건 역사보다 기계 그 자체다. NES의 사양을 보자.

부품사양
CPU리코 2A03 (MOS 6502 호환), 1.79MHz
메모리 (RAM)2KB
그래픽 (PPU)리코 2C02, 256×240 해상도
비디오 메모리 (VRAM)2KB
스프라이트8×8픽셀 64개
사운드 (APU)5채널, CPU에 내장
게임 저장 매체롬 카트리지

1.79MHz는 요즘 CPU의 대략 2,000분의 1 클럭이고, 코어도 하나뿐이다. RAM 2KB에는 한글 700자가 채 안 들어간다.2 이 사양이 초라해 보인다면 그게 정상인데, 뒤집어 말하면 이 글의 좋은 소식이기도 하다. 부품 수가 적고 각 부품이 단순해서, 혼자서 전체를 이해하고 바닥부터 재현할 수 있는 규모라는 뜻이다. 현대 컴퓨터로는 불가능한 일이다.

6502라는 혈통

NES의 심장인 6502는 알고 보면 유명한 CPU다. 1975년 MOS 테크놀로지가 25달러에 내놓은 8비트 프로세서인데, 당시 모토로라 6800이 175달러였으니, 가격을 7분의 1로 무너뜨리고 나온 것이다.3 이 저렴함 덕분에 6502는 1970~80년대 개인용 컴퓨터 혁명의 심장이 됐다. Apple II, 코모도어 64, 아타리 2600, BBC 마이크로가 전부 6502 또는 그 변형을 썼다. 스티브 워즈니악이 Apple I을 6502로 만든 이유도 단순했다. 제일 쌌기 때문이다.

NES의 2A03은 이 6502에서 십진 연산 모드를 떼어내고4 사운드 생성 회로(APU, 5채널)를 붙인 커스텀 칩이다. CPU 코어 자체는 6502 그대로라서, 우리가 이 글에서 6502 에뮬레이터를 만들면 그건 NES의 두뇌인 동시에 Apple II와 코모도어 64의 두뇌이기도 하다. 명령어 수가 적고 구조가 정직해서 지금도 어셈블리와 컴퓨터 구조 입문 교재로 애용된다.

왜 하필 NES인가

에뮬레이터를 만들어볼 기계로 NES를 고른 이유는 세 가지다.

첫째, 위에서 말했듯 규모가 적당하다. 게임보이나 CHIP-8도 좋은 입문 대상이지만, NES는 단순하면서도 "진짜 컴퓨터"의 구성 요소를 전부 갖췄다. CPU, 메모리, 별도의 그래픽 프로세서, 인터럽트, DMA, 메모리 맵드 I/O. 이 용어들이 아직 낯설어도 괜찮다. 하나씩 전부 다루게 된다.

둘째, 자료가 압도적으로 풍부하다. NES는 수십 년간 팬들이 하드웨어를 리버스 엔지니어링해온 기계로, NesDev 위키5에는 칩의 동작이 사이클 단위로 문서화되어 있다. 막히면 찾아볼 곳이 있다는 건 이런 프로젝트에서 생각보다 큰 위안이다.

셋째, 결과물이 게임기다. 정렬 알고리즘을 구현하면 정렬된 배열이 나오지만, 에뮬레이터를 구현하면 게임이 나온다. 만드는 동안의 동기부여가 다를 수밖에 없다.

소프트웨어로 만드는 기계

본격적으로 만들기 전에, 애초에 이게 왜 가능한 일인지부터 짚어보자. 40년 전의 게임기를 소프트웨어로 재현한다는 것은 무슨 뜻일까.

CPU는 단순한 기계다

CPU는 신비로운 물건이 아니다. 전원이 켜진 순간부터 꺼질 때까지, CPU는 단 한 가지 일을 무한히 반복한다.

  1. 메모리에서 다음 명령어를 읽어온다 — 인출Fetch
  2. 그 명령어가 무슨 뜻인지 해석한다 — 해석Decode
  3. 해석한 대로 실행한다 — 실행Execute

이걸 인출-해석-실행 사이클이라고 부른다. "명령어"라고 해봐야 대단한 게 아니다. "메모리 어디의 값을 읽어라", "두 수를 더해라", "결과가 0이면 다른 곳으로 점프해라" 같은 아주 잘게 쪼개진 지시들이고, 6502의 경우 이런 명령어가 151종이다. 게임도, 운영체제도, 지금 이 글을 렌더링하는 브라우저도, 결국 이 단순한 지시의 나열이다.

그런데 이 사이클을 다시 보자. "읽고, 해석하고, 실행한다"를 반복하는 것. 이건 소프트웨어로도 얼마든지 쓸 수 있는 루프다.

loop {
    let opcode = memory[pc];      // 인출: 현재 위치의 명령어를 읽고
    let instruction = decode(opcode); // 해석: 무슨 명령인지 알아낸 뒤
    execute(instruction);         // 실행: 그대로 수행한다
}

실제 6502는 이 루프가 실리콘 회로로 새겨져 있고, 우리는 같은 루프를 프로그래밍 언어로 쓴다. 그 차이뿐이다. 하드웨어의 동작을 소프트웨어로 흉내 내는 프로그램, 이것이 에뮬레이터Emulator다. 그리고 명령어를 하나씩 읽어 해석하고 실행하는 프로그램에는 이미 익숙한 이름이 따로 있다. 파이썬이나 자바스크립트를 실행하는 인터프리터Interpreter다. 에뮬레이터의 CPU 부분은 정확히 인터프리터다. 다만 해석하는 언어가 파이썬 코드가 아니라 6502 기계어일 뿐이다.6

컴퓨터는 컴퓨터를 흉내 낼 수 있다

"기계를 소프트웨어로 흉내 낸다"는 아이디어는 사실 컴퓨터라는 개념의 뿌리에 있다. 앨런 튜링이 1936년에 증명한 것이 바로 이것이다. 다른 모든 계산 기계의 동작 규칙을 입력으로 받아 그 기계를 그대로 흉내 낼 수 있는 하나의 기계, 보편 만능 기계Universal machine가 존재한다는 것.7 우리가 쓰는 컴퓨터가 바로 그 보편 만능 기계의 물리적 구현이고, 그래서 충분한 메모리와 시간만 있다면 어떤 컴퓨터든 다른 어떤 컴퓨터를 시뮬레이트할 수 있다. 에뮬레이터는 이 정리를 가장 실감 나게 체험하는 방법이다. 우리가 지금부터 하려는 일이 바로 튜링의 이 증명을 게임기로 재연하는 것이다.

물론 이론적 가능성과 실용적 가능성은 다르다. 흉내에는 비용이 든다. 진짜 6502가 회로에서 한 사이클에 처리하는 일을 우리는 수십 개의 명령어로 흉내 내야 하니, 에뮬레이터는 원본 기계보다 훨씬 빠른 기계 위에서만 제속도가 난다. 다행히 1.79MHz짜리 기계를 요즘 컴퓨터에서 흉내 내는 건 여유롭다. 브라우저 안에서 자바스크립트로 돌려도 남아돈다. 우리가 만들 에뮬레이터가 WebAssembly로 브라우저에서 돌아가는 이유다.

어디까지 똑같아야 하는가

에뮬레이터의 목표는 이렇게 요약할 수 있다. 그 위에서 도는 소프트웨어가 진짜 기계와 구분하지 못할 만큼 겉모습이 같은 기계를 만드는 것. 게임 입장에서 "메모리 주소 X를 읽으면 Y가 나온다"는 관찰 결과만 같으면, 그 값이 실리콘에서 왔는지 Rust 코드에서 왔는지는 알 길이 없다. 인터페이스만 지키면 구현은 자유라는, 소프트웨어 설계에서 늘 하는 그 이야기다.

문제는 "구분하지 못할 만큼"의 기준이다. 여기에는 스펙트럼이 있다.

  • 명령어 수준: 명령어 하나가 끝날 때마다 레지스터와 메모리 상태가 실물과 같다. 대부분의 게임은 이걸로 충분하다.
  • 사이클 수준: 클럭 사이클 하나하나의 타이밍까지 실물과 같다. 하드웨어의 타이밍 틈새를 곡예하듯 파고든 일부 게임은 이 수준을 요구한다.
  • 회로 수준: 칩 내부 트랜지스터의 전기적 상태까지 재현한다. 여기까지 가면 보통 에뮬레이션 대신 시뮬레이션Simulation이라고 부른다. 실제로 6502의 칩 사진을 떠서 트랜지스터 3,510개를 통째로 시뮬레이트하는 Visual 6502라는 프로젝트가 있다.8

정확도를 올릴수록 구현은 어려워지고 실행은 느려진다. 상용 에뮬레이터들은 이 스펙트럼 위에서 저마다의 자리를 잡는다.9 우리의 목표는 이렇게 정하자. CPU는 명령어 수준으로 구현하되 명령어별 소요 사이클은 정확히 세고, 그래픽은 화면의 가로줄(스캔라인) 단위로 그린다. 실제 게임이 무리 없이 돌아가는, 학습용으로 딱 좋은 지점이다.

NES 해부하기

이제 우리가 흉내 낼 기계의 몸속을 들여다보자. NES 본체의 기판에는 손에 꼽을 만큼의 부품만 올라가 있고, 그중 우리가 재현해야 할 것은 넷이다.

  • CPU (2A03): 프로그램을 실행하는 두뇌. 6502 코어에 사운드 생성기(APU)가 함께 들어 있다.
  • PPU (2C02): 화면 그리기 전담 프로세서Picture Processing Unit. CPU와 별개의 칩이고, 별개의 클럭으로 돈다.
  • RAM 2KB: CPU의 작업 공간. 게임의 변수들(마리오의 좌표, 점수, 남은 목숨)이 여기 산다.
  • 카트리지: 게임 코드가 담긴 PRG-ROM과 그래픽 타일이 담긴 CHR-ROM, 두 종류의 롬 칩이 들어 있다.

여기에 컨트롤러가 붙는다. PPU가 쓰는 VRAM 2KB도 따로 있지만, 그건 PPU를 다룰 때 보기로 하자.

CPU의 세계는 주소 공간이 전부다

이 부품들은 서로 어떻게 연결될까. 이 질문의 답이 에뮬레이터 전체의 설계를 결정하기 때문에 천천히 보자.

6502에는 주소선이 16개 있다. 주소선이 16개라는 건 2¹⁶, 즉 65,536개(64KB)의 주소를 지정할 수 있다는 뜻이다. CPU가 바깥세상과 소통하는 방법은 단 두 가지뿐이다. 어떤 주소의 값을 읽거나, 어떤 주소에 값을 쓰거나. CPU에게 세상은 64KB짜리 주소 공간이 전부이고, 그 주소 너머에 뭐가 있는지 CPU는 전혀 모른다.

그런데 그 주소 너머에 있는 것이 RAM만은 아니다. NES의 설계자들은 64KB 주소 공간을 구역별로 잘라 서로 다른 부품에 배정했다. 이 배치도를 메모리 맵Memory map이라고 부른다.

주소 범위연결된 부품
$0000 ~ $07FFRAM 2KB
$0800 ~ $1FFFRAM의 거울상 (미러링, 뒤에서 설명)
$2000 ~ $2007PPU 제어 레지스터 8개
$2008 ~ $3FFF위 8개의 거울상
$4000 ~ $4017APU와 입출력 (컨트롤러 포함)
$4020 ~ $FFFF카트리지 영역 (게임 코드는 보통 $8000부터)

$는 6502 진영에서 16진수를 나타내는 관례적 표기다. 이 표가 말하는 바를 음미해보자. CPU가 주소 $2001에 값을 쓰면 그 값은 RAM이 아니라 PPU에게 간다. PPU에게는 "화면 그리기를 켜라/꺼라" 같은 지시가 된다. 주소 $4016을 읽으면 컨트롤러의 버튼 상태가 나온다. 즉 CPU는 메모리를 읽고 쓰는 동작만으로 주변 장치와 대화한다. 이 방식을 메모리 맵드 I/OMemory-mapped I/O라고 부른다. NES만의 꼼수도 아니어서, 지금 이 글을 읽고 있는 기기를 포함해 대부분의 컴퓨터가 이 방식을 쓴다.

그리고 주소를 보고 어느 부품으로 신호를 보낼지 정하는 교통정리 담당이 있다. 부품들을 잇는 전선 다발과 그 위의 신호 규약을 통틀어 버스Bus라고 부른다. 우리 에뮬레이터에서 버스는 "주소를 받아 알맞은 부품에 읽기/쓰기를 전달하는" 하나의 모듈이 될 것이다. 이 구조 덕분에 에뮬레이터의 뼈대는 놀랄 만큼 명료해진다. CPU는 버스에 읽기/쓰기를 요청할 뿐이고, 버스가 주소를 보고 RAM, PPU, 카트리지 중 알맞은 곳으로 넘긴다.

두 개의 클럭, 하나의 박자

부품이 여럿이면 박자를 맞춰야 한다. NES 기판에는 21.47MHz짜리 수정 발진자가 하나 있고, 이것이 마스터 클럭이다. CPU는 이 클럭을 12분주해서(12번에 한 번) 1.79MHz로 돌고, PPU는 4분주해서 5.37MHz로 돈다. 결과적으로 CPU가 1사이클 도는 동안 PPU는 정확히 3사이클 돈다. 3:1. 이 비율은 뒤에서 에뮬레이터의 심장 박동이 되니 기억해두자.

왜 이런 박자까지 신경 써야 할까. 게임은 "PPU가 화면 한 장을 다 그리는 데 걸리는 시간" 안에 그다음 화면을 준비하는 식으로, 두 칩의 상대 속도에 기대어 작성되어 있기 때문이다. CPU만 빨리 돌리거나 PPU만 느리게 돌리면 그 가정이 무너져 게임이 깨진다. 에뮬레이션의 어려움은 각 부품의 재현보다 이 박자의 재현에 있다고 해도 과언이 아니다.

구현 지도

이제 만들 것의 목록이 나왔다. Rust 모듈 하나가 부품 하나에 대응한다.

emulator/
├── cpu.rs        # 6502 CPU — 인출·해석·실행
├── bus.rs        # 버스 — 주소를 보고 부품에 배달
├── cartridge.rs  # 카트리지 — 롬 파일 파싱
├── ppu.rs        # PPU — 화면 그리기
├── apu.rs        # APU — 소리 합성
└── joypad.rs     # 컨트롤러

만드는 순서는 실제 에뮬레이터 개발자들이 걷는 길을 그대로 따른다. CPU를 먼저 만들고, 화면 없이도 돌릴 수 있는 작은 게임으로 CPU를 검증한 다음, PPU와 APU를 붙여 진짜 게임으로 간다.

두뇌 만들기: 6502 CPU

CPU 에뮬레이션이라고 하면 거창하게 들리지만, 6502가 가진 것을 목록으로 적어보면 김이 샐 정도로 짧다. 먼저 레지스터Register는 CPU 몸속에 있는 초고속 저장 칸이다. CPU는 메모리에서 값을 레지스터로 가져와 계산하고, 결과를 다시 메모리로 돌려보내는 식으로 일한다. 6502의 레지스터는 8비트짜리 다섯 개와 16비트짜리 한 개. 이게 전부다.

cpu.rs
pub struct Cpu {
    pub a: u8,      // 누산기 — 산술 연산의 주인공
    pub x: u8,      // 인덱스 레지스터
    pub y: u8,      // 인덱스 레지스터
    pub sp: u8,     // 스택 포인터
    pub pc: u16,    // 프로그램 카운터 — 다음 명령어의 주소
    pub p: u8,      // 상태 레지스터 — 플래그 8개
    pub cycles: u64, // (에뮬레이터용) 지금까지 소비한 클럭 사이클
}

u8은 부호 없는 8비트 정수, u16은 16비트 정수다. 하나씩 보자.

AAccumulator는 누산기라고 부르는 계산 전용 레지스터다. 6502에서 덧셈, 뺄셈, 논리 연산은 전부 A를 거친다. "메모리의 값을 A에 더해라"는 있어도 "메모리끼리 더해라"는 없다. XY는 인덱스 레지스터로, 주로 배열을 순회할 때 "몇 번째"를 담는다. PCProgram Counter는 다음에 실행할 명령어의 메모리 주소다. 주소는 16비트이므로 PC만 16비트다. SPStack Pointer는 스택의 꼭대기 위치를 가리키는데, 6502의 스택은 메모리의 $0100~$01FF 구간 256바이트에 고정되어 있어 8비트로 충분하다. 마리오의 점프 궤적 계산도, 젤다의 인벤토리 관리도, 전부 이 좁은 레지스터 몇 개를 통과해갔다.

CPU의 표정: 상태 레지스터

마지막 남은 PProcessor status는 조금 특별하다. 값 하나가 아니라 1비트짜리 플래그Flag 8개를 묶은 것으로, 직전 연산의 결과가 어땠는지를 기록한다. 말하자면 CPU의 표정이다.

cpu.rs
pub const CARRY: u8             = 0x01; // C: 덧셈에서 자리올림이 생겼다
pub const ZERO: u8              = 0x02; // Z: 결과가 0이었다
pub const INTERRUPT_DISABLE: u8 = 0x04; // I: 인터럽트를 받지 않겠다
pub const DECIMAL: u8           = 0x08; // D: 십진 모드 (NES에선 사용 안 함)
pub const BREAK: u8             = 0x10; // B: BRK 명령으로 인터럽트가 걸렸다
pub const UNUSED: u8            = 0x20; // 항상 1로 고정된 미사용 비트
pub const OVERFLOW: u8          = 0x40; // V: 부호 있는 연산이 넘쳤다
pub const NEGATIVE: u8          = 0x80; // N: 결과가 음수였다 (최상위 비트가 1)

이 플래그들이 왜 중요하냐면, 6502의 if문이 바로 이것이기 때문이다. 6502에는 "A가 10보다 크면 점프해라" 같은 명령이 없다. 대신 "비교해라"(플래그를 세운다)와 "Z 플래그가 서 있으면 점프해라"(플래그를 본다)가 분리되어 있다. 고수준 언어의 if (hp == 0) gameOver()는 6502에서 "HP를 A에 로드한다 → Z 플래그가 서면 gameOver로 분기한다"가 된다. 모든 조건 분기가 이 8개 비트를 매개로 일어난다.

Z와 N은 직관적인데 C와 V는 헷갈리기 쉬우니 예를 들어보자. 8비트 레지스터는 0~255까지만 담는다. 200 + 100을 하면 300이 안 되고 44가 되면서 C(자리올림)가 선다. 한편 같은 비트 패턴을 부호 있는 수로 해석하는 프로그램이라면 0x50 + 0x50, 즉 80 + 80이 문제다. 결과 0xA0은 부호 있는 해석으로는 -96이다. 양수 둘을 더했는데 음수가 나온 것이다. 이때 V(오버플로)가 선다. 같은 덧셈 회로가 두 해석의 사고를 각각 C와 V로 보고하고, 어느 쪽을 신경 쓸지는 프로그램의 선택이다.10

명령어를 읽는 법: 어드레싱 모드

이제 CPU가 실행할 명령어가 어떻게 생겼는지 보자. 6502의 명령어는 단순한 구조다. 첫 1바이트가 무슨 일을 할지를 나타내는 opcode고, 뒤에 0~2바이트의 피연산자가 붙는다.

A9 44     LDA #$44   ; "44라는 값 자체를 A에 넣어라"       (2바이트)
A5 44     LDA $44    ; "주소 $44의 값을 A에 넣어라"        (2바이트)
AD 44 13  LDA $1344  ; "주소 $1344의 값을 A에 넣어라"      (3바이트)
BD 44 13  LDA $1344,X ; "주소 $1344+X의 값을 A에 넣어라"   (3바이트)

전부 같은 LDALoad A, 즉 "A에 값을 넣어라"인데 opcode가 A9, A5, AD, BD로 다 다르다. 다른 것은 피연산자를 어디서 찾을 것인가다. 이 "찾는 방법"을 어드레싱 모드Addressing mode라고 부르고, 6502에는 13종이 있다. LDA 하나에 어드레싱 모드가 8종 붙어 있으니 opcode도 8개인 것이다.

모드표기 예
ImmediateLDA #$44피연산자가 값 그 자체
Zero PageLDA $44주소 $0000~$00FF의 1바이트 주소
Zero Page,XLDA $44,X제로 페이지 주소 + X
AbsoluteLDA $1344온전한 2바이트 주소
Absolute,X / YLDA $1344,X2바이트 주소 + X (또는 Y)
(Indirect,X)LDA ($44,X)제로 페이지에서 주소를 읽어 그 주소로
(Indirect),YLDA ($44),Y제로 페이지의 주소 + Y로
Implied / AccumulatorINX, ASL A피연산자 없음
RelativeBNE $F5분기 전용, 현 위치로부터의 거리
IndirectJMP ($1344)JMP 전용, 주소가 담긴 주소

제로 페이지라는 이름이 낯설 텐데, 주소 공간의 첫 256바이트($0000~$00FF)를 가리키는 말이다. 이 구간은 주소를 1바이트로 지정할 수 있어 명령어가 짧고 1사이클 빠르다. 그래서 6502 프로그램들은 가장 자주 쓰는 변수를 제로 페이지에 모아둔다. 레지스터가 3개뿐인 CPU에서 제로 페이지는 사실상 "조금 느린 레지스터 256개"로 통했다. 그리고 (Indirect),Y처럼 주소가 담긴 곳을 거쳐 가는 모드는 오늘날의 포인터 역참조에 해당한다. C의 *(p + i)가 하드웨어에 새겨져 있는 셈이다.

어드레싱 모드를 Rust로 옮기면 열거형 하나와, 모드에 따라 실효 주소를 계산하는 함수 하나가 된다.

cpu.rs (발췌)
pub enum Mode {
    Imp, Acc, Imm, Zp, Zpx, Zpy, Abs, Abx, Aby, Ind, Izx, Izy, Rel,
}

/// 어드레싱 모드에 따라 피연산자의 실효 주소를 계산한다.
/// 두 번째 반환값은 인덱싱 중 페이지 경계를 넘었는지 여부(뒤에서 설명).
fn operand_addr(&mut self, bus: &mut impl Bus, mode: Mode) -> (u16, bool) {
    match mode {
        Mode::Imm => {
            let addr = self.pc;              // 피연산자 = PC 위치의 값 그 자체
            self.pc = self.pc.wrapping_add(1);
            (addr, false)
        }
        Mode::Zp => (self.fetch8(bus) as u16, false),
        Mode::Abs => (self.fetch16(bus), false),
        Mode::Abx => {
            let base = self.fetch16(bus);
            let addr = base.wrapping_add(self.x as u16);
            (addr, page_crossed(base, addr))
        }
        Mode::Rel => {
            let offset = self.fetch8(bus) as i8; // 부호 있는 오프셋 (-128~127)
            (self.pc.wrapping_add(offset as u16), /* ... */)
        }
        // ... 나머지 모드도 같은 요령
    }
}

fetch8/fetch16은 PC 위치에서 바이트를 읽고 PC를 전진시키는 헬퍼이고, wrapping_add는 넘치면 0으로 되돌아가는 덧셈이다. Rust는 기본 덧셈이 오버플로를 오류로 취급하기 때문에, 오버플로가 정상 동작인 CPU 세계에서는 이 함수를 쓴다. 실물 하드웨어는 어차피 비트가 넘치면 그냥 버려지니, 오히려 하드웨어의 진실에 가까운 표기다.

심장: 인출-해석-실행 루프

준비가 끝났다. 앞에서 예고한 인출-해석-실행 사이클을 진짜 코드로 만들 차례다.

먼저 "해석"을 어떻게 할지 정해야 한다. opcode는 1바이트, 즉 0~255다. 그렇다면 256칸짜리 표를 만들어 opcode 값을 표의 인덱스로 쓰면 해석은 표 한 번 찾아보기로 끝난다.

cpu.rs (발췌)
/// opcode 테이블 한 칸
pub struct OpInfo {
    pub name: &'static str, // 니모닉 ("LDA", "STA", ...)
    pub mode: Mode,         // 어드레싱 모드
    pub cycles: u8,         // 기본 소요 사이클
    pub cross: bool,        // 페이지 경계를 넘으면 1사이클 추가되는 명령인지
    pub unofficial: bool,   // 비공식 opcode인지 (뒤에서 설명)
}

pub const OPCODES: [OpInfo; 256] = [
    // 0x00              0x01                ...
    op("BRK", Imp, 7), op("ORA", Izx, 6), /* ... 256칸 전부 */
];

256칸을 채우는 일은 지루하지만, 6502 레퍼런스 문서를 보고 옮겨 적으면 된다.11 이 표가 있으면 심장부인 step 함수는 이렇게 된다.

cpu.rs (발췌)
/// 명령어 하나를 실행하고 소요된 사이클 수를 돌려준다.
pub fn step(&mut self, bus: &mut impl Bus) -> u32 {
    let opcode = bus.read(self.pc);              // 1. 인출
    self.pc = self.pc.wrapping_add(1);
    let info = OPCODES[opcode as usize];         // 2. 해석 (표 참조)

    let (addr, crossed) = self.operand_addr(bus, info.mode); // 피연산자 주소 계산
    let mut cycles = info.cycles as u32;
    if info.cross && crossed {
        cycles += 1; // 페이지 경계 추가 사이클
    }

    match info.name {                            // 3. 실행
        "LDA" => {
            self.a = bus.read(addr);
            self.set_zn(self.a); // 결과에 따라 Z, N 플래그 갱신
        }
        "STA" => bus.write(addr, self.a),
        "ADC" => {
            let v = bus.read(addr);
            self.adc(v);
        }
        "JMP" => self.pc = addr,
        "BNE" => cycles += self.branch(self.p & ZERO == 0, addr, crossed),
        // ... 명령어 56종
        _ => {}
    }

    self.cycles += cycles as u64;
    cycles
}

이게 CPU 에뮬레이터의 전부다. 나머지는 match의 팔을 채우는 일이고, 명령어 대부분은 위의 LDA나 STA처럼 두어 줄이면 끝난다. 6502의 공식 명령어는 151개의 opcode로 이루어져 있지만, LDA·STA처럼 사람이 읽도록 붙인 이름(니모닉Mnemonic)으로 묶으면 56종이고, 그중 몇 개만 들여다보면 나머지는 뻔해진다.

들여다볼 가치가 있는 것 하나가 ADCAdd with Carry, 캐리를 포함한 덧셈이다.

cpu.rs (발췌)
fn adc(&mut self, v: u8) {
    let carry = (self.p & CARRY) as u16;
    let sum = self.a as u16 + v as u16 + carry; // 캐리까지 3항 덧셈
    let result = sum as u8;
    self.set_flag(CARRY, sum > 0xFF);
    // 부호가 같은 두 수를 더했는데 결과 부호가 다르면 오버플로
    self.set_flag(OVERFLOW, (self.a ^ result) & (v ^ result) & 0x80 != 0);
    self.a = result;
    self.set_zn(result);
}

"캐리를 포함"하는 이유는 8비트를 넘는 계산 때문이다. 16비트 덧셈을 하려면 하위 바이트를 먼저 더하고, 거기서 넘친 자리올림(C)을 상위 바이트 덧셈에 이어받아야 한다. ADC는 그 이어달리기를 위한 설계다. 오버플로 판정의 XOR 트릭은 "A와 결과의 부호가 다르고, 더한 값과 결과의 부호도 다르면" 즉 같은 부호끼리 더했는데 부호가 뒤집혔으면 V를 세운다는 뜻이다. 덤도 있다. 뺄셈 SBC가 공짜로 따라온다. 이진수에서 A - MA + (M의 비트반전) + 캐리와 같아서,12 구현은 self.adc(!v) 한 줄이다.

스택도 잠깐 보자. 서브루틴 호출 JSRJump to Subroutine은 "지금 위치를 스택에 적어두고 점프"이고, RTSReturn from Subroutine는 "스택에서 꺼낸 위치로 복귀"다. 함수 호출이라는 고수준 개념의 바닥에는 이 두 명령이 있다.

cpu.rs (발췌)
"JSR" => {
    // 복귀 주소는 다음 명령어 주소 - 1 (RTS가 +1 해서 돌아온다)
    self.push16(bus, self.pc.wrapping_sub(1));
    self.pc = addr;
}
"RTS" => self.pc = self.pull16(bus).wrapping_add(1),

"-1 해서 넣고 +1 해서 돌아온다"는 이상한 규약이 눈에 띄는데, 6502 내부 회로 사정으로 생긴 기벽이다. 이런 기벽까지 그대로 옮겨야 실물과 같은 기계가 된다. 게임 코드는 이 규약을 전제로 스택을 직접 조작하기도 하기 때문이다.

사이클 세기

step이 사이클 수를 일일이 세서 반환하는 것을 봤을 것이다. 명령어마다 소요 시간이 다르기 때문인데(레지스터끼리의 이동은 2사이클, 메모리를 읽고 고쳐 쓰는 ROL 같은 명령은 5~7사이클), 여기에 미묘한 예외가 둘 있다.

하나는 페이지 경계다. 6502는 64KB 주소 공간을 256바이트씩 묶어 페이지라고 부른다. LDA $12F0,X에서 X가 커서 실효 주소가 $1300을 넘어가면, 주소의 상위 바이트를 고쳐 계산하는 데 1사이클이 더 든다. 다른 하나는 분기로, 분기하지 않으면 2사이클, 분기하면 +1, 분기 목적지가 페이지를 넘으면 +2다. 위 코드의 crossedinfo.cross가 이걸 처리한다.

이런 것까지 세야 하나 싶지만, 앞서 말한 박자 문제 때문에 세야 한다. CPU가 사이클을 잘못 세면 PPU와의 3:1 비율이 미세하게 틀어지고, 화면 타이밍에 기대어 작성된 게임에서는 그 오차가 화면 찢어짐이나 미묘한 버그로 나타난다. 지금 당장은 "명령어마다 사이클을 정확히 세서 반환한다"만 지키면 뒤에서 편해진다.

CPU 단계 검증

작은 검증 프로그램은 앞에서 이야기한 것들을 한 번씩 건드린다. LDA #$50으로 A에 값을 넣고, ADC #$50에서 부호 오버플로가 일어나 V와 N이 켜지는지 확인한다. 이어지는 INX/DEXBNE의 루프에서는 Z 플래그가 분기를 조종하는 것과 분기 성공 시 사이클이 1 더 붙는 것을 검증할 수 있다.

첫 게임: 스네이크

CPU가 생겼다. PPU도 카트리지도 아직 없지만, 게임을 돌려보고 싶은 마음이 벌써 든다. 놀랍게도 방법이 있다.

세상에서 제일 작은 게임기

6502 학습 사이트인 easy6502에는 브라우저에서 도는 가상의 6502 컴퓨터가 있다.13 이 컴퓨터의 사양이 걸작인데, NES보다 훨씬 단순하다.

  • 평평한 64KB 메모리. 미러링도 매퍼도 없다.
  • 화면: 32×32 픽셀. $0200~$05FF의 1,024바이트가 곧 화면이고, 1바이트가 픽셀 하나다. 바이트 값이 색을 정한다 (0=검정, 1=흰색, ...).
  • 입력: 마지막으로 누른 키의 아스키 코드가 $FF에 들어 있다.
  • 난수: $FE를 읽으면 매번 다른 난수가 나온다.

그리고 이 사양으로 작성된 309바이트짜리 스네이크 게임이 있다. NES용 게임이 아니라 이 가상 컴퓨터용 게임이지만, CPU는 똑같은 6502다. 즉 우리 CPU가 제대로 만들어졌다면 이 게임이 그대로 돌아야 한다.

이 가짜 콘솔을 만드는 데 필요한 코드가 얼마나 되는지 보면, 버스 추상화의 위력이 드러난다. 우리 CPU는 Bus라는 트레이트(인터페이스)를 통해서만 바깥을 본다고 했다.

bus.rs
pub trait Bus {
    fn read(&mut self, addr: u16) -> u8;
    fn write(&mut self, addr: u16, val: u8);
}

그러니 이 두 함수만 구현하면 어떤 세계든 CPU에게 물려줄 수 있다.

snake.rs (발췌)
/// 스네이크 게임이 도는 가짜 콘솔의 버스.
/// 평평한 64KB 메모리에 특별한 주소 둘: $FE는 난수, $FF는 키 입력.
pub struct SnakeBus {
    pub mem: Box<[u8; 0x10000]>,
    rng: u64,
}

impl Bus for SnakeBus {
    fn read(&mut self, addr: u16) -> u8 {
        if addr == 0xFE {
            self.next_rand() // 이 주소는 읽을 때마다 다른 난수가 나온다
        } else {
            self.mem[addr as usize]
        }
    }

    fn write(&mut self, addr: u16, val: u8) {
        self.mem[addr as usize] = val;
    }
}

이게 전부다. 여기에 게임 코드를 메모리에 올리고 PC를 그 시작 주소로 맞추면 게임기가 완성된다.

snake.rs (발췌)
pub fn new(seed: u32) -> SnakeMachine {
    let mut bus = SnakeBus::new(seed);
    // 게임 코드를 $0600에 로드하고 그 주소에서 실행을 시작한다
    bus.mem[0x0600..0x0600 + SNAKE_GAME.len()].copy_from_slice(&SNAKE_GAME);
    let mut cpu = Cpu::new();
    cpu.pc = 0x0600;
    SnakeMachine { cpu, bus, game_over: false }
}

SNAKE_GAME은 앞서 말한 309바이트 기계어 배열이다. 어셈블리 소스를 보면 로직은 여느 스네이크와 같다. 루프를 돌며 $FF를 읽어 방향을 바꾸고, 뱀의 좌표를 전진시키고, 화면 메모리에 바이트를 써서 뱀과 사과를 그린다. 주목할 것은 이 게임에 "그리기 API" 같은 것이 전혀 없다는 사실이다. 메모리 주소 $0200에 1을 쓰는 것, 그게 곧 화면 왼쪽 위에 흰 점을 찍는 행위다. 방금 배운 메모리 맵드 I/O가 게임 코드 쪽에서는 이렇게 보인다.

죽음 판정에는 꼼수가 하나 있다. 뱀이 자기 몸에 부딪히면 게임 코드는 아무것도 없는 메모리 영역으로 점프해버리는데, 빈 메모리는 온통 0x00이고 opcode 0x00은 BRK(소프트웨어 인터럽트)다. 그래서 에뮬레이터 쪽에서는 "다음 opcode가 BRK면 게임 오버"로 판정하면 된다.

실행 속도와 타이밍

이 스네이크 프로그램에는 프레임 개념도 시계도 없어서 CPU가 빨리 돌면 게임도 그만큼 빨라진다. 프레임당 실행할 명령어 수를 늘리면 게임 속도도 그대로 빨라진다. 옛날 게임을 현대 에뮬레이터에서 클럭 보정 없이 실행하면 지나치게 빨라지는 이유이자, 에뮬레이터가 원본 기계의 클럭과 타이밍을 재현해야 하는 이유다.

무엇보다 이 장에서 기억할 것은 이것이다. CPU 코드는 단 한 줄도 바뀌지 않았다. Bus 구현체 하나 갈아 끼웠더니 같은 CPU가 다른 기계가 됐다. NES를 만드는 일도 결국 이 버스를 NES의 메모리 맵으로 바꿔 끼우는 일이다.

검증: 8,991줄의 로그와 싸우기

스네이크가 돌아가니 CPU가 완성된 것 같지만, 함정이 있다. 스네이크가 쓰는 명령어는 151개 중 30개 남짓이다. 나머지 약 120개에 버그가 있어도 스네이크는 모른다.

그리고 CPU 버그는 발견되는 순간이 최악이다. ADC의 오버플로 플래그 하나가 틀렸다고 하자. 게임은 잘 돌아간다. 몇 분 뒤 어떤 적이 이상한 위치에 나타나거나, 점수가 65,535점에서 0점이 되거나, 아예 화면이 멈춘다. 그 증상에서 "수백만 명령어 전에 실행된 ADC의 V 플래그"로 거슬러 올라가는 일은 고문에 가깝다. 실제 상용 게임은 저마다 다른 명령어 조합을 저마다 다른 기벽에 기대어 쓰기 때문에, "게임이 돌아간다"는 검증 방법은 게임 수만큼의 디버깅 지옥을 예약하는 것과 같다.

테스트 롬이라는 문화

다행히 이 지옥은 우리보다 먼저 걸은 사람들이 닦아놓은 길이 있다. NES 에뮬레이터 커뮤니티에는 테스트 롬이라는 문화가 있다. 에뮬레이터의 특정 부분을 집요하게 검사하도록 작성된 NES 롬들인데, 그중 CPU 검증의 표준이 nestest다.

nestest가 훌륭한 건 롬 자체보다 함께 배포되는 로그 파일 덕분이다. 검증된 에뮬레이터로 nestest를 실행하며 매 명령어 직전의 CPU 상태를 한 줄씩 기록한 것으로, 8,991줄짜리 정답지다.

C000  4C F5 C5  JMP $C5F5     A:00 X:00 Y:00 P:24 SP:FD PPU:  0, 21 CYC:7
C5F5  A2 00     LDX #$00      A:00 X:00 Y:00 P:24 SP:FD PPU:  0, 30 CYC:10
C5F7  86 00     STX $00 = 00  A:00 X:00 Y:00 P:26 SP:FD PPU:  0, 36 CYC:12

우리 에뮬레이터로 같은 롬을 실행하면서 매 스텝 PC, A, X, Y, P, SP, 누적 사이클을 이 로그와 대조하면, 틀린 명령어가 틀린 그 순간에 잡힌다. 수백만 명령어 뒤의 미스터리가 아니라, "3,349번째 줄, JMP의 목적지가 정답과 다름"이라는 즉답으로.

tests/nestest.rs (발췌)
for (i, line) in log.lines().enumerate() {
    let lineno = i + 1;
    let pc = u16::from_str_radix(&line[0..4], 16).unwrap(); // 로그에서 정답 파싱
    let a = hex_field(line, " A:");
    // ... X, Y, P, SP, CYC도 같은 방식으로 파싱한 뒤,

    // 실행 전 상태가 로그의 해당 줄과 정확히 일치해야 한다
    assert_eq!(cpu.pc, pc, "line {lineno}: PC expected {pc:04X}, got {:04X}", cpu.pc);
    assert_eq!(cpu.a, a, "line {lineno}: A expected {a:02X}, got {:02X}", cpu.a);
    // ...

    cpu.step(&mut bus);
}

우리 CPU는 이 8,991줄 대조를 통과했다.14 다만 "통과했다"고만 쓰면 이 로그가 실제로 뭘 잡아주는지 감이 안 올 테니, 6502 구현에서 가장 흔히 틀리는 지점 세 곳을 일부러 틀리게 바꿔서 nestest가 어디서 어떻게 잡아내는지 실측해봤다.

실험 1: JMP의 하드웨어 버그를 "고쳐서" 구현하면. 6502에는 유명한 버그가 있다. JMP ($02FF)처럼 간접 주소가 페이지 끝에 걸치면, 주소의 상위 바이트를 $0300이 아니라 같은 페이지의 첫 바이트인 $0200에서 읽어버린다. 순진하게 "올바른" 간접 점프로 구현하면 3,349번째 줄에서 PC expected 0300, got A900으로 실패한다. nestest는 이 버그의 재현 여부를 검사한다. 실물에 버그가 있으면 에뮬레이터도 버그가 있어야 한다. 여기서는 버그가 스펙이다.

실험 2: PLP에서 B 플래그를 그대로 복원하면. 스택에서 P를 꺼내는 PLP를 순진하게 p = pull()로 구현하면 104번째 줄, 시작하고 300사이클도 안 돼서 P expected EF, got FF로 잡힌다. PHP가 P를 스택에 쌓을 때 B 플래그를 켜서 쌓기 때문에, PLP가 그 비트를 걸러내지 않으면 P에 유령 B 플래그가 남는다.15

실험 3: 간접 어드레싱의 제로 페이지 랩어라운드를 빼먹으면. (Indirect,X)(Indirect),Y는 제로 페이지에서 2바이트 주소를 읽어오는데, 포인터가 $FF에 있으면 상위 바이트를 $0100이 아니라 $0000에서 읽어야 한다(제로 페이지 안에서 한 바퀴 돈다). 두 모드가 공유하는 이 읽기 헬퍼를 일반 덧셈으로 구현하면 1,101번째 줄에서 A expected 5D, got 04. LDA ($FF,X)가 엉뚱한 주소의 값을 읽어온 것이다.

셋 다 게임을 며칠씩 돌려도 만나기 어려운 구석이고, 만나도 원인을 특정하기 어려운 종류다. 그걸 로그 대조는 줄 번호까지 찍어서 잡아준다. 에뮬레이터 개발의 실질적인 교훈이 여기 있다. 구현보다 검증 수단을 먼저 확보하라. 이건 에뮬레이터만의 이야기는 아닐 것이다.

명령어 151개의 거짓말

nestest에는 뒷이야기가 하나 더 있다. 공식 opcode 151개를 다 맞추고 나면, 로그의 5,004번째 줄부터 이상한 것들이 나온다. *NOP, *LAX 같은, 별표가 붙은 명령어들이다.

opcode는 1바이트라 256칸인데 공식 명령어는 151개라고 했다. 그럼 나머지 105칸은? 6502 설계자들은 이 칸들을 "정의되지 않음"으로 남겼지만, 실리콘 회로는 빈칸에서도 뭔가를 한다. 해석 회로의 배선이 우연히 겹치면서 LDA와 LDX가 동시에 실행되는(LAX) 식의 부수 효과가 생기는데, 이 우연은 모든 6502 칩에서 완벽하게 동일하게 재현된다. 그러자 게임 개발자들이 이걸 써버렸다. 한 바이트라도 아쉬운 시절이라, 두 명령어를 한 opcode로 처리하는 공짜 최적화를 마다할 이유가 없던 것이다.16

그래서 문서에 없는 이 비공식 opcodeUnofficial opcodes들도 에뮬레이터는 재현해야 한다. 우리 구현은 nestest가 검사하는 80개의 비공식 opcode를 지원하고, 그제서야 8,991줄 전체가 통과된다. "스펙에 없는 동작도 관찰 가능하면 누군가 의존한다"는 하이럼의 법칙17이 1980년대 게임 카트리지 안에서 이미 작동하고 있었던 것이다.

카트리지: 게임은 어떻게 담겨 있나

이제 진짜 NES를 향해 간다. 첫 부품은 게임이 담긴 카트리지다.

NES 카트리지를 열면 보통 롬 칩이 두 개 나온다. 하나는 PRG-ROMProgram ROM으로 6502가 실행할 게임 코드가, 다른 하나는 CHR-ROMCharacter ROM으로 그래픽 타일 데이터가 담겨 있다. 프로그램과 그래픽이 물리적으로 다른 칩에 분리되어 있는 것인데, 이유는 연결되는 곳이 다르기 때문이다. PRG는 CPU의 버스에, CHR은 PPU의 버스에 직결된다. NES에는 주소 공간이 두 개 있고(CPU용 64KB, PPU용 16KB), 카트리지는 커넥터를 통해 두 버스에 동시에 꽂히는 부품이다. 그래서 CPU는 그래픽 데이터를 볼 수 없고, PPU는 게임 코드를 볼 수 없다.

iNES 포맷

에뮬레이터에서 카트리지는 롬 파일이다. 사실상 표준인 iNES 포맷18은 단순하다. 16바이트 헤더 뒤에 PRG와 CHR 데이터가 순서대로 붙는다. 예제로 살펴볼 게임인 「Chase」19의 파일 첫 부분을 열어보면 이렇다.

4E 45 53 1A 01 01 00 00 00 00 00 00 00 00 00 00
└─ "NES" + $1A ─┘ │  │  └ flags: 미러링, 매퍼 번호, ...
                  │  └ CHR 크기: 8KB × 1
                  └ PRG 크기: 16KB × 1

파싱 코드도 헤더 그대로다.

cartridge.rs (발췌)
pub fn from_ines(bytes: &[u8]) -> Result<Cartridge, String> {
    if bytes.len() < 16 || &bytes[0..4] != b"NES\x1a" {
        return Err("iNES 헤더가 아니다".into());
    }
    let prg_size = bytes[4] as usize * 16 * 1024; // 16KB 단위
    let chr_size = bytes[5] as usize * 8 * 1024;  // 8KB 단위
    let mirroring = if bytes[6] & 0x01 != 0 { Mirroring::Vertical } else { Mirroring::Horizontal };
    let mapper = (bytes[7] & 0xF0) | (bytes[6] >> 4);
    // ... 헤더 뒤의 PRG, CHR 데이터를 잘라 담는다
}

mirroring은 PPU 장에서 다룰 화면 배치 설정이고, 지금 주목할 것은 mapper라는 필드다.

32KB의 벽과 뱅크 스위칭

CPU 메모리 맵에서 카트리지에 배정된 영역은 $8000~$FFFF, 32KB다. 그런데 「슈퍼 마리오 3」의 PRG-ROM은 256KB다. 32KB짜리 창문으로 256KB를 어떻게 내보낼까.

카트리지 쪽의 답은 창문에 보이는 내용을 갈아 끼우는 것이다. 후기 카트리지에는 롬 칩 말고 회로가 하나 더 들어 있다. 이 회로는 CPU가 롬 영역에 뭔가를 쓰는 것을 감시한다. 롬은 어차피 쓰기가 안 되니, 그 쓰기를 "명령"으로 재해석하는 것이다. 예를 들어 게임이 $8000에 3을 쓰면, 회로는 "이제부터 $8000~$BFFF 창문에 롬의 3번째 16KB 조각을 보여줘라"로 알아듣고 배선을 바꾼다. 이 조각을 뱅크Bank, 이 기법을 뱅크 스위칭Bank switching, 이 회로를 매퍼Mapper라고 부른다.

좁은 주소 공간에 큰 데이터를 창문 단위로 갈아 끼워 보여주는 것. 어디서 들어본 구조라면 제대로 본 것이다. 현대 운영체제의 가상 메모리 페이징이 정확히 이 발상의 후손이고, 그래서 매퍼를 이해하면 컴퓨터 구조 교과서의 그 장이 갑자기 구체적으로 읽힌다. 차이라면 NES에서는 하드웨어가 강제한 궁여지책이었다는 것 정도다.

매퍼는 카트리지에 들어 있으므로 게임마다 다르다. 수백 종이 알려져 있고, 에뮬레이터가 "지원하는 게임 목록"은 사실상 "지원하는 매퍼 목록"이다. 다행히 초기 게임들은 매퍼 없이 롬을 버스에 직결했는데, 이 구성을 NROM 또는 매퍼 0번이라고 부른다. 「동키콩」, 「팩맨」, 초대 「슈퍼 마리오 브라더스」가 전부 NROM이다. 우리는 매퍼 0만 구현한다. 읽기 코드는 미러링 한 줄이 전부다.

cartridge.rs (발췌)
/// CPU의 $8000~$FFFF 읽기. PRG가 16KB뿐이면 같은 내용이 두 번 보인다.
pub fn read_prg(&self, addr: u16) -> u8 {
    let mut offset = (addr - 0x8000) as usize;
    if self.prg.len() == 16 * 1024 {
        offset %= 16 * 1024; // 16KB 롬은 $8000과 $C000에 미러링
    }
    self.prg[offset]
}

PRG가 16KB뿐인 게임은 32KB 창문의 앞뒤에 같은 내용이 두 번 보인다. 이렇게 하는 이유가 있다. 6502는 부팅하면 무조건 $FFFC에 적힌 2바이트를 읽어 그 주소에서 실행을 시작하는데(이 자리를 리셋 벡터Reset vector라고 부른다), $FFFC는 주소 공간의 맨 끝이므로 롬이 얼마나 작든 그 자리에는 반드시 뭔가가 보여야 하기 때문이다.

화면을 그리는 기계: PPU

이제 이 글에서 가장 큰 산이다. 에뮬레이터 개발자들 사이에서 "CPU는 일주일, PPU는 한 달"이라는 말이 있을 정도로 PPU는 만만치 않다. 어려운 이유는 코드가 길어서가 아니라, 현대의 그래픽 상식이 하나도 안 통하는 물건이기 때문이다. 상식을 하나씩 내려놓는 것부터 시작하자.

프레임버퍼가 없는 세계

요즘 화면 그리기의 기본 전제는 프레임버퍼다. 화면의 모든 픽셀에 대응하는 메모리가 있고, 거기에 색을 쓰면 화면에 나온다. 256×240 화면이면 픽셀당 1바이트씩만 잡아도 60KB다.

NES의 비디오 메모리는 2KB다.

60KB짜리 그림을 2KB에 담을 수는 없다. 그래서 PPU는 완성된 그림을 저장하지 않는다. 대신 그림을 만드는 조립법을 저장하고, 화면에 내보내는 그 순간에 실시간으로 조립한다. 조립법은 재료 세 가지로 구성된다.

  • 타일Tile: 8×8 픽셀짜리 도장들. 카트리지의 CHR-ROM에 들어 있다.
  • 네임테이블Nametable: "몇 번 타일을 어디에 찍을지" 적힌 배치도. VRAM 2KB의 정체가 이것이다.
  • 팔레트Palette: 어떤 색으로 칠할지. 별도의 작은 메모리에 있다.

배경 화면은 32×30칸의 격자에 타일 도장을 찍어 만든다. 배치도는 칸마다 1바이트, 총 960바이트면 된다. 60KB짜리 그림이 1KB 남짓의 "조립법"으로 줄어든다. 레고 조립 설명서와 같은 원리고, 오늘날로 치면 이미지 대신 HTML(구조)과 CSS(색)를 저장하는 쪽에 가깝다. 대신 대가가 있다. 이 세계에서는 격자에 맞지 않는 그림, 타일에 없는 그림은 그릴 수 없다. 8비트 게임 특유의 반복되는 벽돌 무늬, 계단처럼 각진 언덕의 미학은 취향이 아니라 이 하드웨어 제약의 직접적인 표현이다.

타일: 픽셀당 2비트

타일부터 뜯어보자. 8×8 = 64픽셀인데, 한 타일은 CHR-ROM에서 딱 16바이트를 차지한다. 픽셀당 2비트, 즉 픽셀 하나가 가질 수 있는 값은 0~3뿐이라는 계산이 나온다.

이 2비트의 저장 방식이 재미있다. 픽셀 순서대로 2비트씩 이어 붙이는 대신, 모든 픽셀의 하위 비트만 모은 8바이트(평면 0)와 상위 비트만 모은 8바이트(평면 1)를 따로 저장한다. 이런 방식을 비트플레인Bitplane이라고 부른다. 디코딩은 두 평면에서 비트를 하나씩 뽑아 합치면 된다.

wasm.rs (발췌)
/// CHR 타일 하나(16바이트)를 64개의 색 번호(0~3)로 디코드한다.
pub fn decode_tile(tile: &[u8]) -> Vec<u8> {
    let mut out = vec![0u8; 64];
    for row in 0..8 {
        let lo = tile[row];     // 평면 0: 이 행 8픽셀의 하위 비트
        let hi = tile[row + 8]; // 평면 1: 이 행 8픽셀의 상위 비트
        for bit in 0..8 {
            out[row * 8 + bit] = ((hi >> (7 - bit)) & 1) << 1 | ((lo >> (7 - bit)) & 1);
        }
    }
    out
}

여기서 결정적인 사실 하나. 디코드 결과는 색이 아니라 0~3이라는 번호다. 타일에는 색 정보가 아예 없다. 같은 타일에 다른 팔레트를 적용하면 전혀 다른 색으로 칠해진다. 이게 앞서 말한 "타일은 도장"의 정확한 의미다. 마리오 게임에서 구름과 수풀이 같은 모양인 것은 유명한 이야기인데, 진상이 이것이다. 같은 타일에 다른 팔레트를 입힌 것.

팔레트: 25색의 경제학

그럼 번호에 색을 입히는 팔레트를 보자. PPU는 몸속에 64색의 마스터 팔레트를 품고 있다.20 게임은 이 64색 중에서 골라 자기 팔레트를 구성하는데, 구성이 상당히 짜다.

  • 배경용 팔레트 4개 + 스프라이트용 팔레트 4개
  • 팔레트 하나 = 색 3개 + 공용 배경색 1개

색 번호 1~3은 팔레트의 색으로 칠해지고, **색 번호 0은 "투명"**이다. 배경 타일에서 0은 공용 배경색으로 뚫리고, 스프라이트에서 0은 뒤에 있는 배경이 비쳐 보인다. 결국 화면에 동시에 나올 수 있는 색은 배경 12색 + 스프라이트 12색 + 배경색 1색, 최대 25색이다. NES 게임들이 공유하는 특유의 색감은 상당 부분 이 빡빡한 색 예산에서 나온 것이다.

네임테이블: 배치도와 스크롤

배치도인 네임테이블은 32×30 = 960바이트의 타일 번호다. 그런데 960바이트를 채우고 나면 1KB에서 64바이트가 남는다. 이 자투리가 어트리뷰트 테이블Attribute table로, "이 구역은 몇 번 팔레트로 칠할지"를 담는다. 64바이트로 화면 전체의 팔레트 배정을 해야 하니 해상도가 거칠다. 1바이트가 4×4타일(32×32픽셀) 구역을 담당하고, 그 안에서 2×2타일(16×16픽셀) 블록마다 2비트씩 배정된다. 즉 16×16픽셀 블록 안에서는 팔레트를 섞을 수 없다. 옛날 게임에서 배경 물체들이 16픽셀 격자에 딱딱 맞춰 배치된 것도, 물체 경계에서 색이 뭉개지는 것도 이 제약이다.

여기까지가 화면 한 장이다. 그런데 마리오는 옆으로 스크롤된다. 화면이 반 타일쯤 밀린 상태는 어떻게 만들까.

PPU의 답은 네임테이블을 여러 장 두고 그 사이의 임의 지점에서 화면을 오려내는 것이다. PPU의 주소 공간에는 네임테이블 4장 분량의 자리가 있고, 게임은 $2005(PPUSCROLL) 레지스터에 "왼쪽 위 모서리를 어디서부터 오려낼지"를 픽셀 단위로 알려준다. 마리오가 전진하면 게임은 화면 밖 오른쪽의 보이지 않는 네임테이블 영역에 다가올 지형을 미리 그려두고, 스크롤 값을 1픽셀씩 올린다. 무한히 이어지는 것처럼 보이는 세계는 사실 두 장의 배치도를 번갈아 재활용하는 회전 무대다.

"4장 분량의 자리"라고 말한 데는 이유가 있다. 자리는 4장인데 실제 VRAM은 2KB, 즉 2장뿐이다. 나머지 2장은 있는 2장을 다시 보여주는 미러링Mirroring으로 채워진다. 어느 방향으로 미러링할지는 카트리지의 배선이 결정하는데(iNES 헤더에서 파싱했던 그 플래그다), 좌우로 스크롤하는 게임은 네임테이블이 좌우로 두 장 필요하니 수직 미러링을, 상하 스크롤 게임은 수평 미러링을 쓴다.21

ppu.rs (발췌)
/// 네임테이블 주소(4장 분량의 논리 공간)를 2KB 실제 VRAM 인덱스로 접는다
fn nt_index(mirroring: &Mirroring, addr: u16) -> usize {
    let idx = (addr as usize - 0x2000) & 0x0FFF;
    match mirroring {
        Mirroring::Vertical => idx & 0x07FF,                              // 좌우 두 장
        Mirroring::Horizontal => (idx & 0x03FF) | ((idx & 0x0800) >> 1),  // 상하 두 장
    }
}

램 부족을 주소 접기로 때우는 이 수법, CPU 메모리 맵에서 램 2KB가 $0000~$1FFF에 네 번 반복되던 것과 같은 것이다. 저 시절 하드웨어의 상투적인 절약술이니 이제 미러링이라는 말이 나와도 놀라지 말자.

스프라이트: 격자를 벗어나는 자유

배경은 격자에 묶여 있으니, 픽셀 단위로 자유롭게 움직이는 마리오는 다른 메커니즘이 필요하다. 그게 스프라이트Sprite다. PPU는 배경과 별개로 "아무 좌표에나 떠 있는 8×8 타일" 64개를 지원한다. 스프라이트 정보는 OAMObject Attribute Memory이라는 전용 메모리 256바이트에 산다. 64개 × 4바이트, 딱 떨어진다.

바이트 0: Y 좌표
바이트 1: 타일 번호
바이트 2: 속성 (팔레트 2비트, 좌우/상하 뒤집기, 배경 뒤로 갈지)
바이트 3: X 좌표

마리오처럼 큰 캐릭터는 8×8 스프라이트 여러 장을 이어 붙인 것이다. 그리고 여기에 NES 게임의 오랜 미스터리를 푸는 열쇠가 하나 있다. 하드웨어 회로의 한계로 PPU는 한 스캔라인(화면의 가로 한 줄)에 스프라이트를 8개까지만 그릴 수 있다. 적이 많이 나오는 장면에서 캐릭터가 깜빡거리던 것, 그게 이 제약이다. 9개째부터는 그냥 안 그려지기 때문에, 게임들이 "매 프레임 OAM의 순서를 뒤섞어서 안 그려지는 놈을 매번 바꾸는" 꼼수로 전멸 대신 깜빡임을 선택한 결과다. 어릴 적 봤던 그 깜빡임이 사실은 개발자의 고육지책이었던 것이다.

OAM 채우기에도 별미가 있다. 게임은 매 프레임 스프라이트 64개의 좌표를 갱신해야 하는데, $2003/$2004로 한 바이트씩 256번 옮기는 건 너무 느리다. 그래서 전용 지름길이 있다. CPU가 $4014에 페이지 번호를 쓰면, 하드웨어가 CPU 램의 그 페이지 256바이트를 통째로 OAM에 복사한다. CPU를 잠시 세워두고 하드웨어가 대신 메모리를 나르는 이 방식이 DMADirect Memory Access다. 오늘날 디스크나 네트워크 카드가 CPU를 거치지 않고 메모리에 데이터를 쏟아붓는 그 DMA와 이름도 원리도 같다.

시간과의 싸움: 스캔라인과 VBlank

재료는 다 모였다. 마지막 재료는 공간이 아니라 시간이다.

NES가 연결되던 CRT TV는 전자빔이 화면을 왼쪽 위부터 가로 한 줄씩 훑으며 그린다. 이 가로 한 줄이 스캔라인Scanline이다. PPU는 이 빔과 발맞춰 픽셀을 실시간으로 조립해 쏘아 보낸다. 어디까지나 빔이 지금 지나가는 자리의 픽셀을 만드는 것이라, PPU에게 "화면 전체를 다시 그려라" 같은 명령은 존재하지 않는다. 그리기는 멈출 수 없는 컨베이어 벨트처럼 초당 60번, 한 프레임에 262개의 스캔라인 박자로 무한히 돈다.

  • 스캔라인 0~239: 가시 영역. 픽셀을 조립해 내보내는 중이다.
  • 스캔라인 240: 렌더링이 끝난 뒤의 대기 한 줄.
  • 스캔라인 241~260: VBlankVertical blank. 빔이 화면 아래에서 위로 되돌아가는 휴식 시간이다.
  • 스캔라인 261: 다음 프레임 준비.

이 구분이 왜 중요하냐면, 가시 영역 동안에는 CPU가 VRAM을 건드릴 수 없기 때문이다. PPU가 조립에 쓰고 있는 재료를 옆에서 바꾸면 화면이 깨진다. 결국 게임이 화면을 바꿀 수 있는 시간은 VBlank의 약 20스캔라인, 시간으로 1.3ms 남짓뿐이다.

그럼 게임은 VBlank가 시작되는 순간을 어떻게 알까. 두 가지 방법이 있다. 하나는 $2002(PPUSTATUS)의 VBlank 비트를 루프로 계속 읽으며 기다리는 폴링Polling. 다른 하나가 더 우아한데, PPUCTRL의 설정 비트를 켜두면 PPU가 VBlank에 진입하는 순간 CPU의 NMINon-Maskable Interrupt 핀을 당겨준다. 인터럽트가 걸리면 CPU는 하던 일을 멈추고 미리 정해둔 주소($FFFA의 NMI 벡터)로 점프한다. 폴링이 "다 됐나?"를 계속 물어보는 것이라면 인터럽트는 초인종이다.

이 초인종이 NES 게임의 심장 박동이다. 전형적인 NES 게임의 구조는 이렇다. 메인 루프는 게임 로직(입력 처리, 물리, 적 AI)을 돌고, NMI 핸들러가 초당 60번 울리면서 준비된 그래픽 변경분을 VRAM에 밀어 넣고 OAM DMA를 쏜다. 우리가 요즘 쓰는 requestAnimationFrame, 게임 엔진의 "프레임 콜백"의 조상이 여기 있다.

우리의 PPU 구현

원리를 알았으니 에뮬레이터로 옮기자. 진짜 PPU는 도트(픽셀) 단위로 재료를 미리 가져오는 파이프라인이 돌아가는 복잡한 기계지만, 우리는 학습용 단순화를 택한다. 스캔라인이 시작되는 시점에 그 줄 전체를 한 번에 조립하는 것이다. 스캔라인 중간에 일어나는 곡예 같은 효과는 포기하지만,22 대부분의 게임은 문제없이 돌아간다.

타이밍의 뼈대는 이렇다. tick이 도트 하나에 해당하고, CPU 1사이클마다 3번 불리게 된다.

ppu.rs (발췌)
/// PPU 도트 1개 진행. CPU 1사이클마다 3번 불린다.
pub fn tick(&mut self, cart: &mut Cartridge) {
    // 가시 스캔라인은 시작 도트에서 한 줄을 통째로 그린다 (단순화)
    if self.scanline < 240 && self.dot == 1 {
        self.render_scanline(cart);
    }

    // VBlank 진입: 플래그를 세우고, 설정되어 있으면 NMI를 울린다
    if self.scanline == 241 && self.dot == 1 {
        self.status |= STATUS_VBLANK;
        if self.ctrl & CTRL_NMI != 0 {
            self.nmi_pending = true;
        }
    }
    if self.scanline == 261 && self.dot == 1 {
        self.status &= !(STATUS_VBLANK | STATUS_SPRITE0 | STATUS_OVERFLOW);
    }

    // 도트 341개 = 스캔라인 1개, 스캔라인 262개 = 프레임 1개
    self.dot += 1;
    if self.dot == 341 {
        self.dot = 0;
        self.scanline += 1;
        if self.scanline == 262 {
            self.scanline = 0;
            self.frame_count += 1;
        }
    }
}

한 줄을 조립하는 render_scanline은 이 장에서 배운 것들의 총집합이다. 배경 부분만 추리면 이렇다.

ppu.rs (발췌)
for tile in 0..33 {  // fine x 스크롤 때문에 32개가 아니라 33개
    // 1. 배치도에서 타일 번호를 읽고
    let tile_id = self.mem_read(cart, 0x2000 | (v & 0x0FFF)) as u16;
    // 2. 어트리뷰트 테이블에서 이 구역의 팔레트 번호를 찾고
    let attr = self.mem_read(cart, attr_addr);
    let palette_hi = ((attr >> shift) & 0x03) << 2;
    // 3. 패턴 테이블에서 타일의 두 평면을 가져와 픽셀로 디코드
    let lo = self.mem_read(cart, pattern_addr);
    let hi = self.mem_read(cart, pattern_addr + 8);
    for bit in 0..8 {
        let color = ((hi >> (7 - bit)) & 1) << 1 | ((lo >> (7 - bit)) & 1);
        // 색 번호 0은 투명 → 공용 배경색, 1~3은 팔레트 색
    }
    v = Self::coarse_x_increment(v); // 다음 칸으로
}

네임테이블에서 번호를 읽고, 어트리뷰트에서 팔레트를 정하고, 패턴 테이블에서 모양을 가져와 조립한다. 그 뒤로 스프라이트 64개를 순회하며 이 줄에 걸친 놈들을 겹쳐 그리면(라인당 8개 제한과 우선순위 판정 포함) 한 줄이 완성된다.

스크롤 구현에 대해서는 고백할 것이 있다. 위 코드의 v는 스크롤 위치와 VRAM 주소를 겸하는 15비트 내부 레지스터로, 하드웨어를 리버스 엔지니어링한 커뮤니티에서 loopy 레지스터라고 부르는 물건이다.23 $2005에 쓴 스크롤 값이 이 레지스터의 기묘한 비트 배치로 조립되고, 렌더링 중에 스스로 증가하며 미러링 경계를 넘나든다. 원리는 지금까지 설명한 그대로지만("네임테이블 위의 오려내기 시작점") 비트 수준의 세부는 지면에 안 맞아서, 본문에서는 그 존재만 알리고 넘어간다. 전체 코드는 저장소에서 볼 수 있다.

화면은 어떻게 검증하나

남은 걱정은 검증이다. CPU에는 nestest라는 줄 단위 정답지가 있었지만 PPU의 출력은 그림이라, 결국 프레임버퍼를 이미지 파일로 덤프해서 눈으로 봐야 했다. 이 과정에서 두 번 뒤통수를 맞았다. 한 번은 "화면에 색이 4가지 이상이어야 통과"라는 자동 검사를 만들었는데 nestest 메뉴 화면에서 실패했다. 버그를 찾아 헤매다 이미지를 열어 보니 화면은 완벽했다. nestest 메뉴는 원래 검은 바탕에 흰 글자, 딱 2색이다. 틀린 건 에뮬레이터가 아니라 테스트의 기대치였다. 또 한 번은 Start 버튼 신호를 넣어줘도 게임 화면이 안 나와서 입력 처리를 의심했는데, 스크린샷을 보니 「Chase」의 "LEVEL 1" 안내 화면이었다. 게임이 원래 2초를 기다리는 것을, 에뮬레이션이 정확한 덕에 대기 시간까지 충실히 재현하고 있었던 것이다. 검증 자동화는 필요하지만, 화면이 관련된 문제는 일단 눈으로 봐야 한다는 교훈을 두 번 반복해서 배웠다.

소리를 만드는 회로: APU

화면이 나오면 다 만든 것 같지만, 게임기에는 아직 목소리가 없다. 소리 담당인 APUAudio Processing Unit를 만들 차례다. 3장에서 지나가듯 말했지만 APU는 별도의 칩이 아니라 CPU 칩(2A03) 안에 함께 새겨져 있다. CPU와의 인터페이스는 $4000~$4017의 레지스터 22개, 즉 여기서도 메모리 맵드 I/O다.

재생이 아니라 합성

만들기 전에 근본적인 질문 하나. 게임 음악을 어떻게 40KB 카트리지에 담을까. 요즘처럼 녹음된 오디오를 넣는 건 애초에 불가능하다. 3분짜리 노래를 CD 음질로 담으면 30MB, 카트리지 수백 개 분량이다. 그래서 NES는 소리를 재생하지 않고 그 자리에서 합성한다. APU는 발진기 다섯 개짜리 작은 신시사이저이고, 게임 코드가 그 건반을 실시간으로 누르는 연주자다. 카트리지에 저장되는 것은 악보다. 언제 어느 채널을 몇 헤르츠로, 얼마나 크게 울릴지의 지시가 전부라서 몇 KB면 충분하다.

다섯 채널은 음색과 역할이 나뉘어 있다.

채널파형주로 맡는 것
펄스 1, 2사각파멜로디, 화음
삼각파32계단 삼각파베이스 라인24
노이즈의사 난수 잡음드럼, 폭발음
DMC1비트 델타 샘플녹음된 소리25

사각파: 8비트 음악의 목소리

펄스 채널부터 보자. "삐-" 하는 그 전형적인 8비트 음색의 주인공이다. 원리는 신호를 켰다 껐다 하는 것이 전부다. 11비트 타이머가 정해진 주기로 8칸짜리 시퀀스를 한 칸씩 돌고, 각 칸의 0/1이 파형이 된다.

apu.rs (발췌)
/// 펄스 듀티 4종: 12.5%, 25%, 50%, 75%(25%의 반전)
const DUTY_TABLE: [[u8; 8]; 4] = [
    [0, 1, 0, 0, 0, 0, 0, 0],
    [0, 1, 1, 0, 0, 0, 0, 0],
    [0, 1, 1, 1, 1, 0, 0, 0],
    [1, 0, 0, 1, 1, 1, 1, 1],
];

한 주기에서 신호가 켜져 있는 비율을 듀티 사이클Duty cycle이라고 부르는데, 이게 음색을 정한다. 50%는 클라리넷처럼 둥글고, 12.5%는 가늘고 날카롭다. 같은 음이라도 듀티를 바꾸면 다른 악기처럼 들려서, 채널이 둘뿐인 멜로디를 풍성하게 만드는 중요한 손잡이다. 음높이는 게임이 레지스터에 쓴 11비트 주기 t가 정한다. 시퀀스 한 바퀴가 파형 한 주기이므로 주파수는 1789773 / (16 × (t + 1))Hz. 라(A4, 440Hz)를 내고 싶으면 t=253을 쓰면 된다. CPU 클럭이 곧 조율 기준이라는 것인데, 그래서 CPU 클럭이 7% 느린 유럽판(PAL) NES에서는 주기 테이블을 손보지 않은 게임의 음이 반음 넘게 처진다.

여기에 자동화 장치가 셋 붙는다. 엔벨로프는 볼륨을 15에서 0으로 자동 감쇠시켜 "띵—" 하고 사라지는 타건감을 만들고, 길이 카운터는 정해진 박자 뒤에 음을 자동으로 끊고, 스윕은 주파수를 자동으로 미끄러뜨린다(포탄 떨어지는 소리를 생각하면 된다). 셋 다 "CPU가 매 순간 개입하지 않아도 소리가 알아서 진행되게" 하는 장치다. CPU는 1.79MHz로 게임 로직을 돌리기에도 바쁘니, 음 하나하나의 볼륨 곡선까지 그릴 여유가 없다.

그럼 이 자동화 장치들은 누가 박자에 맞춰 돌려줄까. APU 안에는 프레임 카운터라는 메트로놈이 따로 있어서, 초당 240번 엔벨로프를, 120번 길이 카운터와 스윕을 두드린다.26 PPU의 VBlank가 화면의 박자였다면 이건 소리의 박자다.

apu.rs (발췌)
/// 프레임 카운터. nesdev의 3728.5 APU 사이클 등을 CPU 사이클 정수로 근사.
fn tick_frame_counter(&mut self) {
    self.frame_cycle += 1;
    match self.frame_cycle {
        7457 => self.clock_quarter(),          // 엔벨로프·선형 카운터
        14913 => { self.clock_quarter(); self.clock_half(); } // + 길이·스윕
        22371 => self.clock_quarter(),
        // 4스텝 모드의 마지막: 둘 다 클럭하고 처음으로 (5스텝 모드는 생략)
        29829 => { self.clock_quarter(); self.clock_half(); self.frame_cycle = 0; }
        _ => {}
    }
}

노이즈: 시프트 레지스터로 만드는 드럼

드럼과 폭발음을 맡는 노이즈 채널은 잡음을 만들어야 하는데, 디지털 회로는 진짜 무작위를 모른다. 6502에 난수 명령이 없어서 스네이크 머신에 $FE 난수 주소를 달아줬던 것을 기억할 것이다. APU의 답은 선형 되먹임 시프트 레지스터LFSR, Linear Feedback Shift Register다. 15비트 레지스터를 한 비트씩 밀면서, 맨 끝 비트와 그 옆 비트(모드에 따라 6번 비트)를 XOR한 결과를 반대쪽 끝에 다시 넣는다.

apu.rs (발췌)
// 비트0과 비트1(또는 모드에 따라 비트6)을 XOR해서 맨 위로 되돌린다
let tap = if self.mode { self.lfsr >> 6 } else { self.lfsr >> 1 };
let feedback = (self.lfsr ^ tap) & 1;
self.lfsr = (self.lfsr >> 1) | (feedback << 14);

결과는 32,767스텝을 주기로 반복되는 0/1 수열인데, 사람 귀에는 충분히 무작위라 "치익—" 하는 백색 잡음으로 들린다. 결정적인 회로에서 무작위처럼 보이는 것을 뽑아내는 이 기법은 지금도 의사 난수 생성기와 통신 부호화에 살아 있는 고전이다. 참고로 스네이크의 $FE에 우리가 달아준 xorshift 난수도 같은 계보다.

믹서: 채널을 더하는 법

다섯 채널의 출력을 한 줄의 소리로 합쳐야 한다. 그냥 더하면 될 것 같지만, 실물 APU의 아날로그 출력단은 채널끼리 서로를 간섭해서 단순 덧셈이 아니다. 커뮤니티가 실기를 측정해 얻은 근사식이 있고, 우리는 그걸 그대로 옮긴다.

apu.rs (발췌)
/// nesdev 표준 비선형 믹서. 반환값은 0.0~1.0 (무음 0.0).
fn mix(&self) -> f32 {
    let p = (self.pulse1.output() + self.pulse2.output()) as f64;
    let pulse_out = if p == 0.0 { 0.0 } else { 95.88 / (8128.0 / p + 100.0) };

    let tnd = self.triangle.output() as f64 / 8227.0
        + self.noise.output() as f64 / 12241.0
        + self.dmc.output as f64 / 22638.0;
    let tnd_out = if tnd == 0.0 { 0.0 } else { 159.79 / (1.0 / tnd + 100.0) };

    (pulse_out + tnd_out) as f32
}

식이 어디서 왔는지 몰라도 괜찮다. 요점은 "삼각파가 클 때 노이즈가 상대적으로 작게 들리는" 식의 비선형 상호작용까지 측정값으로 재현한다는 것이다.

1.79MHz를 44.1kHz로

마지막 관문은 속도 차이다. APU는 CPU와 함께 초당 179만 번 갱신되지만, 요즘 사운드 카드는 초당 44,100개의 샘플을 원한다. 약 40.6 CPU 사이클마다 믹서 출력을 하나씩 뽑아 내려보내야 한다.

여기서 실제로 한 번 넘어졌다. 처음에는 40.6사이클마다 "그 순간의 값 하나"를 찍는 단순한 방식(포인트 샘플링)으로 구현했는데, 소리는 나지만 스펙트로그램을 뽑아 보니 배음 사다리 위로 전 대역에 잡음 벽이 깔려 있었다. 사각파에는 사람이 못 듣는 초음파 대역의 배음이 잔뜩 들어 있는데, 듬성듬성 샘플링하면 이 성분들이 가청 대역으로 접혀 들어온다. 신호 처리에서 말하는 앨리어싱Aliasing이다.27 샘플 하나를 찍는 대신 그 사이 40여 사이클의 평균을 내는 것으로 바꾸자 잡음 벽이 사라졌다.

apu.rs (발췌)
// 약 40.58사이클마다 한 샘플. 그 순간의 값 하나만 찍으면(포인트 샘플링)
// 구형파의 높은 배음이 접혀 들어와 잡음이 되므로, 구간 평균을 쓴다.
self.mix_sum += self.mix() as f64;
self.mix_count += 1;
self.sample_acc += 1.0;
if self.sample_acc >= CYCLES_PER_SAMPLE {
    self.sample_acc -= CYCLES_PER_SAMPLE;
    self.samples.push((self.mix_sum / self.mix_count as f64) as f32);
    self.mix_sum = 0.0;
    self.mix_count = 0;
}

소리는 어떻게 검증하나

CPU에는 nestest라는 정답지가 있었고 PPU는 스크린샷을 눈으로 봤다. 소리는? 파형을 WAV 파일로 덤프해서 귀로 듣고, 스펙트로그램(시간-주파수 그림)으로 눈으로도 본다. 아래는 우리 APU가 만들어낸 「Chase」의 소리, Start를 누른 직후 5초의 스펙트로그램이다.

멜로디가 있는 소리는 이렇게 기본음 위로 배음이 수평 사다리를 그리고, 드럼은 전 대역을 덮는 세로 기둥으로 나타난다. 잡음 벽지처럼 보이면 믹서나 LFSR 버그, 텅 비어 있으면 프레임 카운터나 채널 enable 버그를 의심하면 된다.

여기서도 첫 검증은 헛다리였다. 타이틀 화면의 소리를 덤프했더니 완벽한 무음이 나왔다. 믹서를 의심하고 프레임 카운터를 의심하며 한참을 뒤졌는데, 범인을 찾으려고 APU 레지스터 쓰기를 전부 로깅해 보니 게임이 노트를 켜는 레지스터를 아예 쓰지 않고 있었다. 「Chase」의 타이틀 화면은 원래 무음이고, 음악은 Start를 누른 뒤부터 나온다. 원작 소스까지 받아서 확인했다. 에뮬레이터는 무죄, 침묵까지 정확하게 재현하고 있었던 것이다. PPU의 "LEVEL 1 대기 화면" 소동과 정확히 같은 종류의 교훈이라, 이제는 순서를 안다. 출력이 이상하면 구현을 파기 전에 입력(게임이 레지스터에 뭘 쓰는지)부터 본다.

남은 일은 이 샘플들을 브라우저 스피커까지 나르는 배관 공사인데, 그건 다음 장에서 전체를 조립하며 함께 잇는다.

조립: 부품에서 게임기로

부품이 다 모였다. CPU, 카트리지, PPU, APU. 마지막 남은 부품 하나를 붙이고 전체를 조립하자.

컨트롤러: 버튼 8개, 전선 하나

NES 컨트롤러는 버튼이 8개다(A, B, Select, Start, 십자키 4방향). 그런데 본체로 가는 데이터 선은 하나뿐이다. 8비트를 어떻게 1비트로 보낼까. 답은 직렬화다. 컨트롤러 안에는 시프트 레지스터가 있어서(노이즈 채널에서 만난 그 회로다. 이번엔 되먹임 없이 밀기만 한다), CPU가 $4016에 신호를 주면 8개 버튼의 상태를 사진 찍듯 저장하고, 이후 $4016을 읽을 때마다 저장된 비트를 A, B, Select, Start, 상, 하, 좌, 우 순서로 하나씩 내보낸다.

joypad.rs (발췌)
/// $4016 읽기: 버튼 상태를 한 비트씩. 8개를 다 읽은 뒤에는 1이 나온다.
pub fn read(&mut self) -> u8 {
    if self.index >= 8 {
        return 1;
    }
    let bit = (self.buttons >> self.index) & 0x01;
    self.index += 1;
    bit
}

게임 쪽 코드는 매 프레임 $4016을 8번 읽어 버튼 상태를 복원한다. 마리오가 점프하는 순간의 진실은 "주소 $4016을 8번 읽었더니 첫 번째 비트가 1이었다"는 것이다.

버스: 메모리 맵을 코드로

이제 3장에서 본 메모리 맵 표를 그대로 코드로 옮길 수 있다. 스네이크 때 SnakeBus를 갈아 끼웠듯, 이번엔 진짜 NES의 버스를 만들어 CPU에 물린다.

nes.rs (발췌)
impl Bus for NesBus {
    fn read(&mut self, addr: u16) -> u8 {
        match addr {
            // 2KB 램이 $0000~$1FFF에 네 번 미러된다
            0x0000..=0x1FFF => self.ram[addr as usize & 0x07FF],
            // PPU 레지스터 8개가 $2000~$3FFF에 반복된다
            0x2000..=0x3FFF => self.ppu.read_reg(&mut self.cart, addr & 0x07),
            0x4015 => self.apu.read_status(),
            0x4016 => self.joypad1.read(),
            0x4000..=0x7FFF => 0, // 나머지 IO는 스텁
            0x8000..=0xFFFF => self.cart.read_prg(addr),
        }
    }
    // write도 같은 요령 ($4000~$4013·$4015·$4017 → APU, $4014 → OAM DMA)
}

3장에서 표로 봤던 메모리 맵이 match 문 하나가 됐다. 미러링도 여기서는 비트 연산 한 번이다. addr & 0x07FF는 "하위 11비트만 남긴다", 즉 2KB 안으로 주소를 접는다. 실물 하드웨어가 주소선 일부를 아예 연결하지 않아서 미러링이 생긴다고 했는데, 연결하지 않은 주소선이 코드에서는 마스크로 지워지는 비트가 된 것이다.

심장 박동: 3:1

마지막 조립. CPU와 PPU를 한 박자로 묶는다.

nes.rs (발췌)
/// CPU 명령어 하나를 실행하고, 그 사이클 수의 3배만큼 PPU를 돌린다.
pub fn step(&mut self) -> u32 {
    let mut cycles = 0;
    // PPU가 VBlank에서 올린 NMI를 CPU에 전달
    if self.bus.ppu.nmi_take() {
        self.cpu.nmi(&mut self.bus);
        cycles += 7;
    }
    cycles += self.cpu.step(&mut self.bus);
    // OAM DMA가 있었다면 그동안 CPU는 멈춰 있었다
    cycles += std::mem::take(&mut self.bus.dma_stall);

    // PPU는 CPU보다 3배 빠르다
    for _ in 0..cycles * 3 {
        self.bus.ppu.tick(&mut self.bus.cart);
    }
    // APU는 CPU와 같은 속도로 돈다
    for _ in 0..cycles {
        self.bus.apu.tick(&self.bus.cart);
    }
    cycles
}

/// 프레임 하나가 완성될 때까지 실행
pub fn run_frame(&mut self) {
    let frame = self.bus.ppu.frame_count;
    while self.bus.ppu.frame_count == frame {
        self.step();
    }
}

CPU가 4사이클짜리 명령어를 실행하면 PPU가 12도트 전진한다. 3장에서 예고한 3:1 비율이 이 루프다. CPU가 사이클을 정확히 세도록 만들어둔 것이 여기서 빛을 발한다. 사이클이 틀리면 PPU의 VBlank 타이밍이 밀리고, VBlank 타이밍에 기대어 사는 게임 전체가 밀린다.

엄밀히 말하면 이것도 단순화다. 진짜 하드웨어는 두 칩이 병렬로 도는데, 우리는 "CPU 한 명령 → PPU 몰아서 진행"으로 직렬화했다. 명령어 실행 도중의 PPU 상태 변화를 그 명령어가 관찰할 수 없다는 뜻인데, 이 오차에 민감한 게임도 있다. 상용 에뮬레이터들이 사이클 정확도를 광고하는 이유가 이런 데 있다.

브라우저로: WebAssembly 브릿지

콘솔은 완성됐다. 이제 화면과 키보드를 연결할 차례인데, 우리는 브라우저를 본체 삼기로 했다. Rust 코어를 WebAssembly로 컴파일하고, wasm-bindgen으로 자바스크립트에서 부를 수 있는 손잡이를 붙인다.

wasm.rs (발췌)
#[wasm_bindgen]
pub struct NesConsole {
    nes: Nes,
}

#[wasm_bindgen]
impl NesConsole {
    #[wasm_bindgen(constructor)]
    pub fn new(rom: &[u8]) -> Result<NesConsole, JsValue> { /* 카트리지 삽입 */ }
    pub fn run_frame(&mut self) { self.nes.run_frame(); }
    pub fn frame(&self) -> Vec<u8> { self.nes.bus.ppu.frame.to_vec() }
    pub fn audio(&mut self) -> Vec<f32> { self.nes.bus.apu.take_samples() }
    pub fn set_button(&mut self, idx: u8, pressed: bool) { /* 컨트롤러 */ }
}

자바스크립트 쪽은 놀랄 만큼 짧다. requestAnimationFrame 콜백에서 run_frame()을 부르고, 돌려받은 RGBA 버퍼를 캔버스에 그리고, 키보드 이벤트를 set_button으로 넘기면 끝이다. 소리는 프레임마다 쌓인 샘플(60fps 기준 약 735개)을 audio()로 뽑아 WebAudio에 흘려보낸다.

const loop = () => {
  console.run_frame(); // 1/60초어치 에뮬레이션
  const buf = new Uint8ClampedArray(console.frame());
  ctx.putImageData(new ImageData(buf, 256, 240), 0, 0);
  audioNode.port.postMessage(console.audio()); // APU 샘플을 스피커로
  requestAnimationFrame(loop);
};

오디오 배관에는 지면에 담지 않은 잔손질이 좀 있다. 브라우저는 오디오 재생 스레드가 따로 있어서 메인 스레드가 보낸 샘플을 링 버퍼로 받아 소비하는 AudioWorklet을 쓰고, 에뮬레이션과 재생의 미세한 속도 차로 버퍼가 마르거나 넘치는 것도 관리해야 한다. 또 하나, 브라우저는 사용자가 페이지와 상호작용하기 전에는 소리를 못 내게 막는다(자동 재생 정책).

NMI가 게임의 심장 박동이었듯 requestAnimationFrame은 브라우저의 심장 박동이니, 초당 60번 뛰는 심장 두 개를 맞물린 셈이다.28 성능도 재봤다. 네이티브 빌드에서 「Chase」 3,600프레임(게임 시간 60초)을 돌리는 데 2.67초, 프레임당 0.74ms가 걸렸다. APU의 오디오 합성까지 포함해 프레임 예산 16.7ms의 4.4%, 실시간의 약 22배속이다. WebAssembly에서는 이보다 조금 느리지만 여전히 예산이 남아돈다. 1.79MHz짜리 기계를 흉내 내는 일은 요즘 컴퓨터에겐 이 정도로 가볍다.

게임을 연결할 때

Shiru가 퍼블릭 도메인으로 공개한 홈브류 게임 「Chase」는 매퍼 0 카트리지 파서, 6502, PPU의 배경과 스프라이트, 컨트롤러를 모두 사용한다. 다른 게임도 같은 경계로 연결할 수 있지만 이 구현은 매퍼 0만 받아준다. 뱅크 스위칭이 필요한 게임은 카트리지 파서 단계에서 거절해야 한다.

남은 지평선

우리 에뮬레이터는 게임이 돌아가는 수준까지 왔지만, 진짜 에뮬레이터 개발의 세계는 여기서부터 시작이다. 어디가 비어 있는지 정리해두는 것이 이 프로젝트의 정직한 마무리일 것이다.

소리는 나지만 아직 거칠다. 우리 APU는 프레임 카운터의 반사이클 타이밍을 정수로 근사했고, 프레임 IRQ와 DMC의 IRQ·DMA 스톨을 생략했으며, 실기 출력단의 아날로그 필터 특성(90Hz·440Hz 하이패스 등)도 재현하지 않았다(출력단의 DC 제거용 필터가 전부다). 음악과 효과음은 제대로 나오지만, APU 타이밍에 민감한 테스트 롬들 앞에 서면 고칠 곳이 줄줄이 나올 것이다.

매퍼가 0번뿐이다. 「슈퍼 마리오 브라더스」까지는 돌지만 「젤다의 전설」(MMC1), 「슈퍼 마리오 3」(MMC3)는 못 돈다. MMC3쯤 가면 매퍼가 스캔라인을 세어 자체 인터럽트까지 쏘기 때문에, 매퍼 구현은 그 자체로 긴 여행이 된다. 실제 에뮬레이터들의 게임 호환성 표는 사실상 매퍼 지원 표다.

타이밍이 스캔라인 단위다. 스캔라인 중간에 팔레트나 스크롤을 바꾸는 게임, 스프라이트 0 히트29를 도트 단위로 감지해 화면을 정확한 픽셀에서 쪼개는 게임은 화면이 어긋난다. 이걸 해결하려면 PPU를 도트 단위 파이프라인으로 다시 쓰고, CPU와 PPU를 사이클 단위로 맞물리게 해야 한다. 우리가 명령어 단위로 직렬화했던 그 단순화를 되무르는 일이고, 정확도 스펙트럼의 다음 칸으로 넘어가는 일이다.

이런 것들이 채워지면 어디까지 가느냐 하면, 트랜지스터 수준 시뮬레이션과 대조해 검증하는 에뮬레이터, 실기의 아날로그 비디오 신호 왜곡까지 재현하는 필터 같은 세계가 있다. 완벽한 재현까지는 아득히 멀지만, 더 가보고 싶다면 NesDev 위키가 좋은 길잡이가 되어줄 것이다.30

마치며

돌아보면 우리는 게임기 하나를 핑계로 컴퓨터 구조의 주요 장을 한 바퀴 돌았다.

CPU를 만들며 인출-해석-실행 사이클과 레지스터, 플래그, 어드레싱 모드, 스택을 만났다. 인터프리터와 보편 만능 기계라는 이론적 바닥도 밟았다. 버스를 만들며 메모리 맵드 I/O와 미러링을, 카트리지에서 뱅크 스위칭이라는 가상 메모리의 조상을 만났다. PPU에서는 타일과 팔레트라는 데이터 압축, VBlank라는 시간 예산, 그리고 인터럽트가 게임 루프의 심장 박동이 되는 것을 봤다. APU에서는 소리를 저장하는 대신 합성하는 신시사이저와 LFSR, 샘플링과 앨리어싱을 만났다. DMA, 시프트 레지스터, 클럭 분주까지. 교과서라면 장별로 흩어져 있었을 개념들이 여기서는 전부 맞물려 하나의 기계로 돌아간다. 그 맞물림을 자기 손으로 조립해보는 것, 그것이 에뮬레이터 제작이 주는 특별한 종류의 이해다.

에뮬레이터는 완성도가 아니라 이해의 깊이로 평가받는 물건이라는 점에서 바퀴의 재발명 중에서도 유독 수지가 맞는 장사다. mesen이나 fceux 같은 기존 에뮬레이터는 우리보다 백배 정확하지만, 그걸 쓰는 동안에는 아무것도 배우지 않는다. 직접 만든 조잡한 에뮬레이터 위에서 첫 게임이 돌아가는 순간 배우는 것과는 비교가 안 된다.

마지막으로, 40년 전 엔지니어들에 대한 이야기를 하나만 보태고 싶다. RAM 2KB, 동시 발색 25색, 스캔라인당 스프라이트 8개. 이 글에서 우리가 "제약"이라고 불러온 것들은 당시 엔지니어들에게는 그냥 주어진 예산이었다. 그리고 그 예산 안에서 스크롤이 나왔고, 깜빡임으로 8개 제한을 속이는 꼼수가 나왔고, 슈퍼 마리오가 나왔다. 풍족한 하드웨어 위에서 일하는 우리가 이 기계에서 배울 것은 어쩌면 컴퓨터 구조보다 그 태도인지도 모른다.

이 글의 전체 코드는 GitHub 저장소에서 볼 수 있다.

Footnotes

  1. 1983년의 북미 비디오 게임 시장 붕괴, 이른바 아타리 쇼크다. 저질 게임이 시장에 범람하면서 소비자 신뢰가 무너져 산업 매출이 3년 만에 97% 증발했다. 닌텐도가 카트리지 생산을 라이선스로 통제하는 정책을 만든 것도 이 사태의 교훈 때문이다.

  2. UTF-8 인코딩에서 한글 한 글자가 3바이트라는 기준이다. 물론 저 시절 게임이 한글을 UTF-8로 담을 리는 없으니 어디까지나 크기 감각을 위한 비유다.

  3. 6502를 설계한 척 페들은 원래 모토로라에서 6800을 만들던 엔지니어였다. 6800이 너무 비싸 시장이 못 큰다고 판단해 "저렴한 6800"을 제안했다가 회사가 거부하자, 동료들과 MOS 테크놀로지로 옮겨 6502를 만들었다.

  4. 6502에는 십진수를 이진화 십진법(BCD)으로 직접 연산하는 모드가 있는데, 2A03에서는 이 회로가 비활성화되어 있다. 특허 분쟁을 피하려는 조치였다는 설이 유력하다.

  5. nesdev.org. NES 에뮬레이터 개발자들의 성지로, 이 글의 하드웨어 세부 사항도 대부분 이 위키의 문서를 근거로 한다.

  6. 인터프리터 방식 말고, 6502 기계어를 현재 컴퓨터의 기계어로 즉석 번역해두고 직접 실행하는 방식도 있다. 동적 재컴파일 또는 JIT라고 부르며, PS2나 스위치처럼 원본 기계가 빠를수록 필수가 된다. NES 정도는 인터프리터로 충분하다.

  7. Alan Turing, "On Computable Numbers, with an Application to the Entscheidungsproblem"(1936). 흔히 "튜링 머신" 논문으로 불린다. 프로그램을 데이터처럼 읽어 실행하는 기계라는 발상은 현대 컴퓨터의 이론적 원형이 됐다.

  8. visual6502.org. 실제 6502 칩의 표면을 현미경으로 촬영해 회로를 복원하고, 트랜지스터 수준에서 통째로 시뮬레이트한다. 브라우저에서 칩 다이가 실행되는 모습을 볼 수 있다.

  9. 참고로 흉내 내려는 기계와 흉내 내는 기계의 CPU가 같은 종류라면, 명령어 해석을 생략하고 하드웨어가 직접 실행하게 두는 훨씬 빠른 방법이 있다. 이것이 가상화(Virtualization)이고, Docker나 클라우드 서버 밑에서 돌아가는 기술이다. 에뮬레이션은 서로 다른 기계 사이의 번역이라는 점이 다르다.

  10. 8비트에서 음수는 2의 보수로 표현한다. 0x800xFF를 -128-1로 해석하는 방식으로, 최상위 비트가 부호를 겸해서 N 플래그가 "최상위 비트가 1인가"만 보고도 음수 여부를 보고할 수 있다.

  11. 실물 6502에는 당연히 이런 테이블 자료구조가 없고, opcode의 비트 패턴을 해석 회로가 직접 배선으로 풀어낸다. 다만 그 배선이 하는 일을 표로 옮긴 것이니 의미상 같은 물건이다.

  12. 2의 보수 표현에서 -M = !M + 1이기 때문이다. A - M = A + (-M) = A + !M + 1이 되고, 마지막 +1을 캐리 비트가 담당한다. 그래서 6502에서 뺄셈 전에는 캐리를 1로 세팅(SEC)하는 것이 관례다.

  13. Nick Morgan의 easy6502. 브라우저에서 6502 어셈블리를 바로 실행해볼 수 있는 최고의 입문 튜토리얼이다. 스네이크 게임은 Willem van der Jagt가 이 가상 콘솔용으로 작성했다.

  14. 정확히는 PC·레지스터뿐 아니라 누적 사이클(CYC)까지 대조해야 완전하다. 페이지 경계 추가 사이클이나 분기 사이클 같은 타이밍 버그는 레지스터 값으로는 안 잡히고 사이클 열에서만 드러난다.

  15. B는 사실 P 레지스터에 물리적으로 존재하지 않는 유령 플래그다. P를 스택에 쌓는 순간에만 "인터럽트의 출처가 명령어(BRK)였는지 하드웨어였는지"를 표시하기 위해 끼워 넣어진다. 6502에서 손꼽히게 헷갈리는 구석이다.

  16. 「Super Cars」나 「Puzznic」 같은 상용 게임들이 비공식 opcode를 실제로 사용한 것으로 알려져 있다. 이런 게임을 돌리려면 에뮬레이터도 그 우연의 산물을 똑같이 재현할 수밖에 없다.

  17. "API 사용자가 충분히 많으면, 스펙에 뭐라고 적혀 있든 관찰 가능한 모든 동작에 누군가는 의존하게 된다." 구글 엔지니어 하이럼 라이트의 이름을 딴 경험 법칙이다.

  18. 1990년대의 에뮬레이터 iNES가 쓰던 파일 형식이 그대로 사실상 표준이 됐다. 확장 형식인 NES 2.0도 있지만 하위 호환된다.

  19. Shiru가 「Programming NES games in C」 강좌의 예제로 만들어 소스와 함께 공개한 홈브류 게임이다. 지금도 NES용 게임을 만드는 사람들이 있고, 이런 게임을 홈브류라고 부른다.

  20. 정확히는 PPU가 RGB가 아니라 NTSC 방송 신호를 직접 합성하기 때문에, "64색의 정확한 RGB 값"이라는 것은 존재하지 않는다. 에뮬레이터마다 조금씩 다른 변환 테이블을 쓰고, 우리도 커뮤니티 표준에 가까운 테이블 하나를 골라 썼다. 참고로 64칸 중 일부는 같은 색이라 실질적으로는 54색 남짓이다.

  21. 카트리지가 자체 VRAM을 싣고 와서 진짜 4장을 제공하는 4화면 방식도 있고, MMC1 같은 매퍼는 미러링 방향을 소프트웨어로 전환할 수도 있다.

  22. 대표적인 것이 스캔라인 중간에 스크롤 값을 바꿔 화면 일부만 다르게 움직이게 하는 기법이다. 이런 기법은 도트 단위 타이밍이 맞아야 재현된다.

  23. 1999년 loopy라는 닉네임의 해커가 PPU의 스크롤 동작을 리버스 엔지니어링해 문서로 정리했고, 그 문서의 표기(v, t, x, w)가 그대로 업계 표준 용어가 됐다. NesDev 위키의 "PPU scrolling" 문서가 그 계보다.

  24. 삼각파 채널에는 볼륨 조절이 없다. 항상 최대 음량이거나 꺼져 있거나 둘 중 하나다. 대신 다른 채널보다 한 옥타브 낮게 발진할 수 있어서 베이스에 어울린다.

  25. DMC(Delta Modulation Channel)는 유일하게 "녹음된 소리"를 다루는 채널로, 1비트 델타 인코딩된 샘플을 PRG-ROM에서 직접 읽어 재생한다. 「펀치아웃」의 심판 목소리, 「슈퍼 마리오 3」의 팀파니 드럼이 이 채널이다.

  26. 정확히는 CPU 클럭을 분주해 약 240Hz의 quarter frame과 120Hz의 half frame 신호를 만든다. 4스텝/5스텝 모드가 있는데 차이는 미묘해서 본문에서는 생략한다.

  27. 표본화 주파수의 절반(나이퀴스트 주파수, 여기서는 22.05kHz)보다 높은 성분은 표본화 과정에서 낮은 주파수로 "접혀" 들어온다. 구간 평균은 고주파를 뭉개는 간이 저역 통과 필터 역할을 해서 이 접힘을 줄여준다.

  28. 다만 requestAnimationFrame은 모니터 주사율을 따라가므로 120Hz 화면에서는 초당 120번 불린다. 브라우저 루프는 타임스탬프를 누적해 1/60초당 한 프레임만 돌도록 보정해야 한다. 이 보정이 없으면 게임이 2배속이 된다.

  29. OAM 0번 스프라이트의 불투명 픽셀이 배경의 불투명 픽셀과 처음 겹치는 순간 PPU가 상태 레지스터에 비트를 올려주는 기능이다. 초대 슈퍼 마리오는 고정된 상태 바와 스크롤되는 필드를 한 화면에 섞기 위해 이 신호로 "몇 번째 줄까지 그려졌는지"를 감지한다.

  30. 더 깊이 가고 싶다면: NesDev 위키가 모든 것의 근원이고, Rust로 NES 에뮬레이터를 만드는 과정을 차근차근 안내하는 bugzmanov의 온라인 북, 6502 어셈블리를 브라우저에서 배우는 easy6502도 훌륭하다. 이 글도 세 자료에 크게 빚졌다.