본문으로 건너뛰기

슈퍼 패미컴모르는 용어는 용어집에서

12장 에뮬레이터로 확인

중단점으로 "어디서 읽나"를 잡고, 입력 스크립트로 같은 장면을 되풀이해 찍고, 정확도가 다른 여러 에뮬레이터에서 같은 결과가 나오는지 봅니다.

왜 에뮬레이터로 확인하나 쉽게

지금까지는 롬 파일을 읽어서 알아냈습니다. 하지만 롬만 봐서는 "이 바이트를 언제, 어느 코드가 읽는가"를 알기 어렵습니다. 같은 바이트가 대사일 수도, 그림일 수도, 쓰지 않는 자리일 수도 있습니다. 에뮬레이터는 게임을 실제로 돌리면서 CPU가 읽고 쓰는 모든 순간을 보여 줄 수 있어서, 롬 해킹의 절반은 에뮬레이터 안에서 일어납니다.

에뮬레이터를 쓰는 일은 크게 세 가지입니다.

  • 찾기: 디버거로 "이 글자를 그리는 코드는 어디인가", "이 값을 바꾸는 코드는 누구인가"를 잡습니다.
  • 되풀이해서 확인하기: 버튼 입력과 스크린샷을 스크립트로 적어 두고, 패치를 고칠 때마다 같은 장면을 다시 찍어 비교합니다. 대사 수천 줄을 사람이 다 띄워 볼 수는 없기 때문입니다.
  • 여러 에뮬레이터에서 돌려 보기: 에뮬레이터마다 실제 기계를 흉내 내는 정확도가 달라서, 한 에뮬레이터에서 되는 패치가 다른 데서 멈추기도 합니다.

이 장은 네 한글패치에서 주로 쓴 Mesen 2를 예로 듭니다. 디버거와 Lua 스크립트가 함께 들어 있는 에뮬레이터입니다. 원래 저장소는 2.1.1(2025년 7월)이 마지막 판이고 2026년 6월 보관되었으며, 지금은 MesenCE라는 이름으로 이어서 개발되고 있습니다. 이 장의 메뉴 이름과 Lua 함수는 패치를 만들 때 쓴 2.1.1 기준입니다.

중단점으로 "어디서 읽나" 잡기 쉽게

중단점(breakpoint)은 "이 주소에 이런 일이 생기면 게임을 멈춰라"라는 표시입니다. 멈추면 디버거가 그 순간 실행 중인 명령과 레지스터를 보여 줍니다. 중단점이 걸리는 일은 세 가지입니다.

  • 실행: CPU가 그 주소의 명령을 실행하려 할 때. "이 코드가 정말 불리나", "언제 불리나"를 볼 때 씁니다.
  • 읽기: 그 주소의 값을 읽을 때. 대사나 그림이 롬의 어디에 있는지 알 때 "이걸 읽는 코드"를 찾습니다.
  • 쓰기: 그 주소에 값을 쓸 때. 화면이나 WRAM의 값이 엉뚱하게 바뀔 때 "누가 바꿨나"를 찾습니다.

Mesen 2에서는 주소 하나뿐 아니라 범위를 걸 수 있고, CPU 주소 말고도 롬(파일 오프셋), WRAM, VRAM, CGRAM처럼 메모리 종류를 골라 걸 수 있습니다. 예를 들어 "VRAM의 이 글자 칸에 쓸 때"에 거는 것이 가능합니다. 이 기능 덕분에 화면에 보이는 것에서 시작해 롬까지 거꾸로 따라갈 수 있습니다.

화면에서 롬까지 거꾸로 따라가기1. 타일 보기로 화면의 글자가 VRAM의 어느 칸인지 찾는다. 2. 그 VRAM 칸에 쓰기 중단점을 건다. 멈춘 곳은 보통 DMA를 시작하는 코드이고, DMA 설정에서 보내는 쪽 주소를 읽는다. 3. 그 주소, 흔히 WRAM의 글자 버퍼에 쓰기 중단점을 건다. 멈춘 곳이 글자를 그리는 코드다. 4. 그 코드가 읽는 롬의 대사 바이트에 읽기 중단점을 걸어 대사를 읽는 코드와 포인터 표를 찾는다.1 화면의 글자타일 보기: 이 글자는 VRAM 몇 번 칸인가2 VRAM 그 칸에 쓰기 중단점멈춘 곳 = DMA. 보내는 쪽 주소를 읽는다3 WRAM 글자 버퍼에 쓰기 중단점멈춘 곳 = 글자를 그리는 코드4 롬의 대사 바이트에 읽기 중단점멈춘 곳 = 대사를 읽는 코드, 포인터 표
화면의 글자에서 롬의 대사까지 거꾸로 따라가는 순서. 단계마다 쓰는 중단점이 다릅니다.

글자를 그대로 VRAM에 올리는 게임이라면 3단계가 없고, 대사가 압축되어 있다면 4단계에서 압축을 푸는 코드를 먼저 만나게 됩니다(9장). 어느 경우든 "결과에서 한 단계씩 원인으로" 거슬러 오른다는 점은 같습니다. 단계마다 찾은 주소는 4장의 방법으로 파일 오프셋으로 바꾸고, 5장처럼 역어셈블해 읽습니다.

더 깊이조건식, 이벤트 뷰어 표시, DMA로 쓴 값 따라가기

중단점에는 조건식을 붙일 수 있습니다. "A 레지스터가 이 대사 번호일 때만", "이 값을 쓸 때만"처럼 좁히면 같은 코드가 프레임마다 수십 번 불려도 원하는 한 번에서만 멈춥니다. 조건식이 없으면 대사를 그리는 코드처럼 자주 도는 곳에 건 중단점은 매번 멈춰서 쓸 수가 없습니다.

멈추지 않고 표시만 하고 싶을 때는 중단점에 「이벤트 뷰어에 표시」를 켭니다. 그러면 게임은 그대로 돌고, 걸린 순간이 아래에서 볼 이벤트 뷰어의 그 프레임 위치에 찍힙니다. 한 프레임 안에서 몇 번, 언제 걸리는지 볼 때 편합니다.

DMA가 쓰는 것도 쓰기 중단점에 걸립니다. 다만 DMA가 도는 동안 CPU는 멈춰 있으므로, 디버거가 보여 주는 위치는 DMA를 시작한 명령(보통 $420B에 쓰는 명령) 바로 다음입니다. 무엇을 보냈는지는 그 직전에 설정한 DMA 레지스터($43x2–$43x4의 보내는 쪽 주소)에서 읽습니다.

메모리·그림 보기와 추적 기록 쉽게

중단점 말고도 디버거에는 게임 안을 들여다보는 창이 여럿 있습니다. 롬 해킹에서 자주 쓰는 것만 추립니다.

  • 메모리 보기(Memory Viewer)

    롬·WRAM·VRAM·CGRAM·세이브 RAM을 헥스 에디터처럼 봅니다. 방금 읽거나 쓴 바이트가 잠깐 색으로 표시되어, 대사를 띄우는 순간 어느 WRAM이 바뀌는지 바로 보입니다.

  • 타일·타일맵·팔레트 보기

    VRAM의 타일을 bpp와 팔레트를 골라 그림으로 보고, BG 타일맵이 어느 타일을 쓰는지, CGRAM의 색이 무엇인지 봅니다(7장).

  • 추적 기록(Trace Logger)

    실행한 명령을 한 줄씩, 그때의 레지스터와 함께 남깁니다. 중단점에서 멈춘 곳까지 어떤 길로 왔는지 거꾸로 따라갈 때 씁니다.

  • 이벤트 뷰어(Event Viewer)

    한 프레임을 줄(스캔라인)과 시간으로 펼친 판 위에 레지스터 읽기·쓰기, NMI, DMA가 일어난 자리를 찍습니다. 화면 밖에서 VRAM에 쓰는지, NMI가 늦게 끝나는지가 한눈에 보입니다.

  • 감시 창(Watch)

    주소나 식을 적어 두면 멈출 때마다 값을 보여 줍니다. 회차 수, 대사 번호처럼 계속 볼 값을 올려 둡니다.

VRAM 보기에서 주소를 읽을 때는 7장에서 본 것처럼 VRAM 주소가 워드 단위인지 바이트 단위인지 먼저 확인합니다. 게임이 레지스터에 적는 값은 워드 주소이고, 메모리 보기는 보통 바이트 위치로 보여 줍니다.

Lua로 확인을 자동으로 쉽게

디버거는 사람이 한 장면씩 볼 때 좋지만, 같은 확인을 패치를 고칠 때마다 되풀이하려면 자동으로 돌려야 합니다. Mesen 2는 작은 프로그래밍 언어 Lua로 쓴 스크립트를 게임과 함께 돌릴 수 있습니다. 스크립트는 "이런 일이 생기면 이 함수를 불러 달라"고 등록해 두는 방식으로 씁니다. 이렇게 등록한 함수를 콜백이라고 합니다.

  • 이벤트 콜백(emu.addEventCallback): 프레임이 끝날 때(endFrame), NMI가 올 때, 게임이 버튼을 읽을 때(inputPolled) 같은 순간에 불립니다. 프레임을 세고, 버튼을 누르고, 스크린샷을 찍는 일은 여기서 합니다.
  • 메모리 콜백(emu.addMemoryCallback): 주소 범위를 실행·읽기·쓰기할 때 불립니다. 중단점을 멈추지 않고 기록만 하는 판이라고 보면 됩니다. 쓰기 콜백에서 값을 돌려주면 그 값이 대신 기록됩니다.

여기에 메모리 읽기·쓰기(emu.read, emu.write), 스크린샷(emu.takeScreenshot), 세이브스테이트(emu.createSavestate, emu.loadSavestate), 레지스터 읽기(emu.getState), 끝내기(emu.stop)를 엮으면 "세이브스테이트에서 시작해 버튼을 이렇게 누르고, 이 주소를 읽을 때마다 기록하고, 이 프레임에서 화면을 찍는다"가 스크립트 하나가 됩니다.

Mesen 2를 --testrunner로 실행하면 창 없이 최고 속도로 롬과 스크립트를 돌리고, 스크립트가 emu.stop(코드)를 부르면 그 값을 종료 코드로 남기고 끝납니다. 기본 제한 시간은 100초이고 --timeout=초로 바꿉니다. 스크립트가 파일을 쓰려면 Mesen 2 설정에서 Lua의 파일 접근(AllowIoOsAccess)을 켜 두어야 합니다.

kr-patch-toolkit에는 네 패치에서 공통으로 쓴 Lua 스크립트가 들어 있습니다. krpatch snes lua가 그 폴더를 알려 주고, 스크립트는 환경 변수로 설정을 받습니다.

툴킷의 Mesen 2 스크립트(일부)
파일하는 일
lib.lua스크린샷, 세이브스테이트 저장·불러오기, 메모리 덤프, 버튼 입력을 돕는 공통 함수
script.luakrpatch snes run과 같은 입력 스크립트를 같은 규칙으로 Mesen 2에서 재생
boot.lua전원을 켜고 정한 간격으로 버튼을 누르며 일정 프레임마다 스크린샷
probe/trace.lua정한 주소가 실행될 때마다 횟수와 레지스터, 원하는 메모리 값을 기록
probe/wtrace.lua정한 WRAM 주소에 쓰는 순간의 프레임, 쓴 코드 주소(PC), 값을 기록
probe/wrange.lua한 번이라도 쓰인 WRAM 256바이트 단위 영역을 모아 빈 곳 찾기를 도움
probe/cgram.lua정한 프레임의 CGRAM 512바이트와 화면을 저장
더 깊이Mesen 2 Lua에서 걸려 넘어지기 쉬운 곳
  • 버튼은 inputPolled에서 넣습니다. 프레임 끝에서 emu.setInput을 불러도 게임이 읽는 순간과 어긋나면 입력이 들어가지 않습니다. 툴킷의 lib.lua는 누를 버튼을 기억해 두었다가 게임이 버튼을 읽는 이벤트에서 넣습니다.
  • 세이브스테이트는 실행 콜백 안에서만 됩니다. 다른 곳에서 emu.createSavestate를 부르면 "주 CPU의 실행 콜백 안에서 불러야 한다"는 오류가 납니다. lib.lua는 할 일을 줄 세워 두고, 다음 명령이 실행될 때 한 번만 불리는 실행 콜백을 걸어 처리합니다.
  • 콜백 안의 오류는 조용히 지나갈 수 있습니다. 각성편 분석 문서는 pcall로 감싸고 메모리 종류(emu.memType) 인자를 빠뜨리지 말라고 적어 두었습니다.
  • Lua로 쓴 세이브 RAM은 저장되지 않습니다. 이어하기 시험은 세이브 파일을 Mesen 2의 Saves 폴더에 직접 복사해 두고 시작했습니다.
  • zsh에서 WATCH라는 환경 변수는 피합니다. zsh가 같은 이름의 셸 변수를 따로 써서, script.lua는 WATCH_WRAM을 씁니다.

화면 없이 돌리기: 입력 스크립트 쉽게

Lua 스크립트는 Mesen 2에서만 돕니다. 같은 확인을 다른 에뮬레이터에서도 하려고 툴킷에는 krpatch snes run을 두었습니다. libretro는 여러 에뮬레이터를 같은 함수 모양(한 프레임 돌리기, 메모리 내주기, 치트 넣기 등)으로 감싼 규격이고, 그 규격으로 만든 에뮬레이터 파일을 코어라고 합니다. krpatch snes run은 코어 파일을 직접 불러 화면 없이 한 프레임씩 돌리면서, 입력 스크립트대로 버튼을 누르고 스크린샷과 메모리 덤프를 남깁니다.

입력 스크립트는 한 줄에 명령 하나이고, 맨 앞 숫자는 전원을 켠 뒤 몇 번째 프레임인지입니다. 슈퍼 패미컴은 1초에 약 60프레임을 그리므로 600은 약 10초 뒤입니다. 툴킷의 Lua 스크립트 script.lua도 같은 파일을 같은 규칙으로 읽기 때문에, 스크립트 하나로 모든 에뮬레이터에 같은 입력을 넣을 수 있습니다.

입력 스크립트의 명령
줄하는 일
100 a 30100프레임부터 A를 30프레임 동안 누릅니다. 버튼 여럿은 a+b처럼 잇습니다.
900 tap a 40 20002000프레임 전까지 40프레임마다 A를 4프레임씩 누릅니다. 대사를 계속 넘길 때 씁니다.
110 shot110프레임을 마친 화면을 f00110.png로 저장합니다.
240 dumpWRAM과 세이브 RAM을 wram_240.bin, sram_240.bin으로 저장합니다.
200 poke 0012 001F200프레임 뒤 WRAM $7E:0012부터 2바이트에 16진수 값을 씁니다(리틀 엔디안).
300 end300프레임을 마치면 멈추고 end.png를 남깁니다. 꼭 있어야 합니다.

#으로 시작하는 줄은 주석이고, 버튼 이름은 대소문자를 가리지 않습니다. end보다 뒤의 프레임에 둔 명령은 실행될 수 없으므로 오류입니다. 아래 도구는 툴킷과 같은 규칙으로 스크립트를 읽어 시간표로 펼칩니다.

입력 스크립트 읽어 보기

krpatch snes run이 읽는 입력 스크립트를 툴킷과 같은 규칙으로 읽어, 어느 프레임에 무엇을 하는지 보여 줍니다. 예를 고르거나 스크립트를 직접 고쳐 보세요. 틀린 줄이 있으면 툴킷이 내는 오류를 그대로 보여 줍니다. 아래 슬라이더로 프레임을 옮기면 그 프레임에 눌린 버튼이 나옵니다.

예

「직접 해 보기」에서 여러 에뮬레이터에 넣은 스크립트입니다.

# 시험 롬: 배경색은 $7E:0012, A를 누르는 동안은 초록
60 shot
100 a 30
110 shot
200 poke 0012 001F
202 shot
240 dump
300 end

300프레임까지 실행합니다(60프레임이 약 1초). 누르기 1줄, 스크린샷 3줄, 덤프 1줄, poke 1줄입니다.

줄마다 하는 일

  1. 2줄60 shot스크린샷60프레임을 마친 화면을 f00060.png로 저장합니다.
  2. 3줄100 a 30누르기100프레임부터 A 버튼을 30프레임 누릅니다(100–129).
  3. 4줄110 shot스크린샷110프레임을 마친 화면을 f00110.png로 저장합니다.
  4. 5줄200 poke 0012 001Fpoke200프레임을 마친 뒤 $7E:0012부터 2바이트를 고쳐 씁니다: 값 $001F, 메모리 순서 1F 00.
  5. 6줄202 shot스크린샷202프레임을 마친 화면을 f00202.png로 저장합니다.
  6. 7줄240 dump덤프240프레임을 마친 WRAM과 세이브 RAM을 wram_240.bin, sram_240.bin으로 저장합니다.
  7. 8줄300 endend300프레임을 마치면 멈추고 end.png를 저장합니다.

--watch 7E0010:2를 주면 그 주소의 값이 바뀔 때마다 wram.log에 "프레임 값" 한 줄을 남깁니다. 대사 번호나 장면 번호를 감시해 두면 화면을 일일이 보지 않고도 "어느 프레임에 어떤 대사가 떴나"를 글로 비교할 수 있습니다. 찍은 스크린샷은 krpatch shotcmp로 비교합니다. 두 폴더를 주면 같은 이름의 PNG끼리 짝지어 same이나 다른 픽셀 수와 범위를 출력하고, 하나라도 다르면 1로 끝납니다.

더 깊이프레임을 세는 방법과 WRAM을 내주지 않는 코어

krpatch snes run은 프레임 0에서 시작해 "그 프레임에 누를 버튼을 정하고, 한 프레임 돌리고, 프레임 수를 1 올린 뒤, 그 프레임 번호의 poke·덤프·스크린샷을 처리"하기를 end까지 되풀이합니다. 그래서 100 a 30의 A는 101번째로 돌리는 프레임부터 게임에 보이고, shot·dump·poke를 0이나 음수 프레임에 두면 실행되지 않습니다.

poke는 코어가 WRAM 포인터(retro_get_memory_data)를 내주면 그 자리에 바로 씁니다. bsnes와 Supafaust 코어는 WRAM을 내주지 않으므로, 툴킷은 같은 값을 Pro Action Replay 치트 코드(예: 7E00121F, 7E001300)로 바꿔 --cheat-hold 프레임(기본 40) 동안 걸어 둡니다. bsnes 코어는 걸어 둔 치트 값을 프레임마다 그 주소에 써 넣습니다. 그래서 치트를 푼 뒤에도 게임이 덮어쓰기 전까지 값이 남습니다. 시험 롬의 300프레임 화면(end.png)이 bsnes에서도 빨강인 이유입니다. WRAM을 내주지 않는 코어에서는 --watch와 덤프도 남지 않습니다.

코어는 프로세스 안에 전역 상태를 갖고 있어서 한 프로세스에서 스크립트 하나만 돌립니다. 여러 시나리오는 셸 스크립트로 하나씩 돌리고, 실행마다 결과를 한 줄씩 모읍니다. 툴킷의 examples/snes/run_all.sh와 각 패치의 compat/run_all.sh가 그 모양입니다.

여러 에뮬레이터에서 같은 입력 돌리기 쉽게

에뮬레이터는 실제 기계를 얼마나 꼼꼼히 흉내 내느냐가 저마다 다릅니다. bsnes는 libretro 문서가 실제 기계를 정확하고 충실하게 흉내 내는 것을 목표로 한다고 소개하는 에뮬레이터이고, Snes9x는 오래전부터 많은 기기와 프런트엔드에 들어가 널리 쓰이는 에뮬레이터입니다. Mesen 2는 정확도와 함께 디버거가 강점입니다. 네 패치는 이 셋에 bsnes-hd beta, snes9x2010, Beetle Supafaust 코어를 더해 같은 입력을 넣었습니다. 사람들이 실제로 패치를 돌릴 에뮬레이터를 고루 덮으려는 것입니다.

정확도 차이가 실제로 어떻게 드러나는지는 이 장을 쓰며 만든 작은 시험 롬에서 바로 나왔습니다. 이 롬은 NMI에서 컨트롤러 값을 읽어, A가 눌렸으면 배경을 초록으로, 아니면 WRAM $7E:0012의 색으로 칠합니다. 처음 만든 판은 snes9x와 bsnes 코어에서 A를 누르는 동안 초록이 나왔지만, Mesen 2에서는 끝까지 파랑이었습니다.

원인은 롬 쪽에 있었습니다. 슈퍼 패미컴은 화면을 다 그리고 수직 귀선 기간이 시작되면 컨트롤러 16비트를 하드웨어가 자동으로 읽어(자동 컨트롤러 읽기) $4218–$4219에 넣는데, 이 읽기는 수직 귀선이 시작되고 조금 뒤에 시작해 4224마스터 사이클 동안 이어집니다. 읽는 동안은 $4212의 비트 0이 1입니다. 시험 롬은 NMI 첫머리에서 "비트 0이 0이 될 때까지"만 기다렸는데, NMI가 너무 일러서 자동 읽기가 아직 시작도 안 한 순간이었습니다. 그래서 곧바로 $4218을 읽었고, 그때는 하드웨어가 레지스터를 막 비우고 B 버튼부터 한 비트씩 채우는 중이라 아홉째로 들어오는 A는 아직 없었습니다. snes9x 코어는 수직 귀선이 시작되자마자 16비트를 한꺼번에 채우고, bsnes 115 코어는 한 비트씩 채우지만 '읽는 중' 표시($4212 비트 0)를 수직 귀선 시작과 함께 켭니다. 두 코어 모두 '아직 시작 전'인 짧은 틈이 없어 문제가 드러나지 않았습니다. Mesen 2는 실제 기계처럼 수직 귀선 첫 줄에서 조금 지난 뒤에 읽기를 시작해, 그 틈이 그대로 생깁니다. "비트 0이 1이 될 때까지 기다렸다가, 다시 0이 될 때까지 기다린다"로 고치자 Mesen 2에서도 초록이 나왔습니다.

이처럼 에뮬레이터 하나에서만 시험하면, 그 에뮬레이터가 흉내 내지 않는 틈에 숨은 버그를 놓칩니다. 반대로 정확한 에뮬레이터 하나만 보면 많은 사람이 쓰는 에뮬레이터에서만 생기는 문제를 놓칩니다. 그래서 정확도가 다른 여러 에뮬레이터에 같은 입력을 넣고 결과를 나란히 놓습니다. 고친 시험 롬에 같은 스크립트를 넣은 결과입니다.

고친 시험 롬, 같은 입력 스크립트
에뮬레이터결과
snes9x정상. WRAM을 내주므로 wram.log와 덤프가 남습니다.
snes9x2010snes9x와 같은 화면, 같은 wram.log.
bsnes같은 화면. WRAM을 내주지 않아 wram.log가 비고 덤프가 없습니다. poke는 치트로 들어가 빨강이 나옵니다.
bsnes-hd beta화면이 224줄이 아니라 216줄이라 크기부터 다릅니다. 치트도 먹지 않아 202프레임에도 파랑입니다.
Beetle Supafaustpoke 치트를 Game Genie 코드로 읽다가 「incorrect length」 오류로 코어가 종료합니다(종료 코드 134).
Mesen-S 코어켜자마자 종료합니다(종료 코드 139). 네 패치의 일본판 원본에서도 같았습니다.
Mesen 2(script.lua)같은 결과지만 A와 poke가 화면에 2–3프레임 늦게 나와 202프레임 화면만 다릅니다.

표의 차이는 대부분 롬과 상관없는 에뮬레이터 쪽 사정입니다. 이런 결과를 롬의 문제와 가르려면 원본 롬도 같은 입력으로 돌려 보는 것이 가장 확실합니다. 네 패치의 분석 문서가 "일본판 원본도 같음"을 꼭 적어 둔 이유입니다. 또 에뮬레이터마다 같은 일이 몇 프레임씩 어긋나므로, 프레임 번호까지 맞추기보다 나온 장면과 대사의 순서를 비교합니다.

실기에서만 알 수 있는 것 쉽게

에뮬레이터를 여럿 돌려도 실제 슈퍼 패미컴(실기)과 같다는 보장은 없습니다. 특히 아래는 에뮬레이터마다 다르게 처리하거나 실기에서도 정해지지 않은 부분입니다.

  • 켤 때 RAM 값: 실기의 WRAM은 켤 때 정해진 값이 없습니다. 시험 롬의 덤프를 보면 snes9x는 쓰지 않은 자리가 모두 55였고, Mesen 2는 무작위 값이었습니다(Mesen 2의 기본 설정이 무작위입니다). 초기화하지 않은 WRAM을 읽는 코드는 에뮬레이터에 따라 다르게 돌 수 있습니다. 전원을 넣을 때 디버거를 열어 두었다면, Mesen 2는 초기화하지 않은 메모리를 읽을 때 로그에 경고를 남기고, 그런 읽기에서 멈추게 할 수도 있습니다.
  • 오픈 버스: 아무것도 연결되지 않은 주소를 읽으면 실기에서는 직전에 버스에 실렸던 값이 나옵니다. 에뮬레이터가 이것까지 흉내 내는지는 저마다 다릅니다.
  • 타이밍: 수직 귀선 안에 VRAM 쓰기가 끝나는지, 자동 컨트롤러 읽기와 겹치지 않는지는 사이클 단위의 문제입니다. 위 시험 롬처럼 정확한 에뮬레이터는 잡아 주지만, 모든 경우를 실기와 똑같이 맞춘다는 보장은 없습니다.
  • 플래시 카트리지: 롬 파일을 SD 카드에서 읽어 실기에서 돌리는 카트리지는 헤더와 파일 크기로 매핑과 세이브 RAM 크기를 정합니다(11장). 늘린 롬이나 세이브 크기를 바꾼 패치는 여기서 따로 확인해야 합니다.

네 패치는 모두 실기에서 시험하지 않았고, 분석 문서에 그렇게 적어 두었습니다. 확인한 범위와 확인하지 못한 범위를 나란히 적어 두면, 실기를 가진 사람이 무엇을 보면 되는지 알 수 있습니다.

실제 패치에서 실제 패치에서

제절초: 2회차에서 멈추는 버그를 다시 일으키기

제절초 v1.0은 2회차부터 뽑힐 수 있는 2번 시나리오에서 화면이 글자로 가득 찬 채 멈췄습니다. 2회차까지 직접 플레이하지 않고 버그를 다시 일으키려고, 시나리오를 고르는 코드($02:A569)가 실행되는 순간 세 세이브 슬롯의 회차 수를 원하는 값으로 바꿔 쓰는 Lua 스크립트(lua/lap.lua)를 만들었습니다. 같은 스크립트가 $02:A5EF에서 A 레지스터를 읽어 어느 시나리오가 뽑혔는지 기록합니다. 회차를 1, 2, 3으로 바꿔 가며 세 시나리오가 모두 정상으로 시작하는 것을 확인했습니다.

멈춘 원인을 찾는 순서도 분석 문서에 남아 있습니다. 프레임마다 CPU가 실행 중인 주소(PC)를 기록해 어디서 멈췄는지 찾고, 멈춘 코드가 기다리는 메모리 값에 쓰기 감시를 걸어 누가 그 값을 바꾸는지 보고, 정상으로 진행하는 경우의 같은 기록과 나란히 비교합니다. v1.2에서는 쓰기 감시를 51번, 12,000프레임씩 돌려 패치가 새로 쓰는 WRAM 자리를 게임이 쓰지 않는지도 확인했습니다. 감시에 걸리지 않은 장면이 그 자리를 쓸 가능성은 남아 있다고 함께 적어 두었습니다.

슈퍼 마리오 RPG: 보고 싶은 화면을 바로 만들기

슈퍼 마리오 RPG는 바뀐 아이템 이름과 메뉴를 확인하려면 그 장면까지 가야 합니다. 그래서 인벤토리($7F:F882)에 아이템을 직접 넣고, 경험치를 레벨업 직전으로 바꾸고, 방 번호($00:3030)와 전투 편성($7E:FA0E)은 게임이 쓰려는 순간 Mesen 2의 쓰기 콜백에서 값을 바꿔치기했습니다. 미리 써 두면 게임이 덮어쓰기 때문입니다. 원하는 대사를 상점 주인 자리에 띄우는 시험용 롬도 따로 만들었습니다. 이름 표기를 「셸」에서 「쉘」로, 메뉴 글자 「힐」·「휠」을 고친 뒤에는 bsnes와 Snes9x에서 새 게임과 이어하기를 다시 돌려 화면 84장이 앞선 결과와 같은 것을 확인했습니다.

돌격편: 대사 번호를 읽는 순간을 감시하기, 창 폭 시험

돌격편은 대사를 띄울 때마다 대사 번호로 창 정보($8E:C9BA + 번호×2)를 읽습니다. 이 범위에 읽기 콜백을 걸면 어떤 대사가 몇 번째로 나왔는지 기록됩니다. 시나리오 256개를 8000프레임씩 돌려 일본판과 한글판의 대사 번호 순서를 비교했고, 순서가 다르거나 먼저 멈춘 시나리오는 없었습니다. 한 줄에 들어가는 글자 수는 lua/wintest.lua로 시험했습니다. 시험용 줄을 넣은 롬을 돌리면서, 창 크기를 정한 직후($81:ADDD), 글자 오른쪽 끝을 비교하는 코드($B7:984A), 줄을 넘기는 코드($B7:9851)가 실행될 때마다 값을 기록해 어느 글자에서 줄이 넘어가는지 봅니다.

각성편: 디버그 메뉴로 장면 376개 돌리기

각성편은 숨겨진 디버그 메뉴를 켠 롬에서 430프레임째 장면 번호 칸 $7E:029C에 번호를 써 넣어 원하는 장면을 바로 시작했습니다. 위 도구의 「각성편 나레이션 장면」이 그 스크립트입니다. 장면 376개를 12,000프레임씩 돌려 대사 번호가 뜬 순서 전체를 일본판·이전 판과 비교했고, 달라진 장면은 하나하나 원인을 적었습니다. 대부분 대사 길이가 달라 버튼이 눌리는 순간이 바뀌고, 그 때문에 전투 결과가 갈린 경우였습니다. 같은 입력 스크립트를 Mesen 2와 libretro 코어 여섯에 넣었을 때, WRAM을 내주지 않는 bsnes는 치트로 장면 번호를 넣어 판정했고, 치트도 먹지 않는 bsnes-hd beta와 치트를 넣으면 종료하는 Beetle Supafaust는 디버그 장면을 판정하지 못했다고 적었습니다. 위 시험 롬에서 본 것과 같은 현상입니다.

직접 해 보기 직접 해 보기

롬 파일 없이 따라 할 수 있도록, 위에서 쓴 시험 롬을 만드는 Python 스크립트를 그대로 싣습니다. 128KB LoROM이고, 코드는 5장의 명령만 씁니다. 리셋에서 화면을 켜고 NMI와 자동 컨트롤러 읽기를 켠 뒤 기다리고, NMI마다 $7E:0010의 프레임 수를 1 올리고 배경색을 칠합니다.

# mkrom.py: 128KB LoROM 시험 롬. 배경색은 $7E:0012, A를 누르는 동안은 초록, $7E:0010은 NMI 수
import sys

rom = bytearray(b"\xff" * 0x20000)
reset = bytes.fromhex(
    "78 18 FB"          # SEI / CLC / XCE (네이티브 모드)
    "C2 30"             # REP #$30
    "A2 FF 1F 9A"       # LDX #$1FFF / TXS
    "64 10"             # STZ $10
    "A9 00 7C 85 12"    # LDA #$7C00 / STA $12 (파랑)
    "E2 20"             # SEP #$20
    "A9 80 8D 00 21"    # LDA #$80 / STA $2100 (화면 끄기)
    "9C 21 21"          # STZ $2121
    "A9 00 8D 22 21"    # LDA #$00 / STA $2122
    "A9 7C 8D 22 21"    # LDA #$7C / STA $2122
    "A9 0F 8D 00 21"    # LDA #$0F / STA $2100 (화면 켜기)
    "A9 81 8D 00 42"    # LDA #$81 / STA $4200 (NMI, 자동 컨트롤러 읽기)
    "CB 80 FD"          # loop: WAI / BRA loop
)
nmi = bytes.fromhex(
    "C2 30 48"          # REP #$30 / PHA
    "E6 10"             # INC $10
    "E2 20"             # SEP #$20
    "AD 12 42 29 01 F0 F9"  # 자동 읽기가 시작될 때까지 기다림
    "AD 12 42 29 01 D0 F9"  # 자동 읽기가 끝날 때까지 기다림
    "C2 20"             # REP #$20
    "AD 18 42 29 80 00" # LDA $4218 / AND #$0080 (A 버튼)
    "F0 05"             # BEQ noA
    "A9 E0 03 80 02"    # LDA #$03E0 (초록) / BRA set
    "A5 12"             # noA: LDA $12
    "E2 20"             # set: SEP #$20
    "9C 21 21 8D 22 21 EB 8D 22 21"  # STZ $2121 / STA $2122 / XBA / STA $2122
    "AD 10 42"          # LDA $4210
    "C2 20 68 40"       # REP #$20 / PLA / RTI
)
rom[0:len(reset)] = reset
rom[0x40:0x40 + len(nmi)] = nmi
rom[0x7F] = 0x40        # 쓰지 않는 벡터가 가리킬 RTI
rom[0x7FC0:0x7FD5] = b"KRPATCH GUIDE TEST   "
rom[0x7FD5:0x7FE0] = bytes([0x20, 0x00, 0x07, 0x00, 0x00, 0x33, 0x00, 0xFF, 0xFF, 0x00, 0x00])
w = lambda off, v: rom.__setitem__(slice(off, off + 2), v.to_bytes(2, "little"))
for off in (0x7FE4, 0x7FE6, 0x7FE8, 0x7FEE, 0x7FF4, 0x7FF8, 0x7FFA, 0x7FFE):
    w(off, 0x807F)
w(0x7FEA, 0x8040)       # 네이티브 모드 NMI
w(0x7FFC, 0x8000)       # 리셋
open(sys.argv[1], "wb").write(rom)

롬을 만들고 체크섬을 맞춘 뒤, 위 도구의 「이 장의 시험 롬」 스크립트를 demo.txt로 저장해 snes9x 코어에서 돌립니다. 코어 파일은 RetroArch 등에서 받은 libretro 코어입니다(macOS는 .dylib, Linux는 .so, Windows는 .dll).

$ python3 mkrom.py test.sfc
$ krpatch snes checksum test.sfc --fix
checksum 9D17
$ krpatch snes run cores/snes9x_libretro.dylib test.sfc out/snes9x demo.txt --watch 7E0010:2
OK 300
Map_LoROMMap
$ ls out/snes9x
end.png  f00060.png  f00110.png  f00202.png  sram_240.bin  wram_240.bin  wram.log
$ head -3 out/snes9x/wram.log
1 0000
2 0001
3 0002
$ tail -1 out/snes9x/wram.log
300 012B

Map_LoROMMap은 snes9x 코어가 스스로 찍는 줄입니다. wram.log는 NMI 수가 프레임마다 1씩 늘어나는 것을 보여 줍니다. 같은 스크립트를 bsnes 코어와 Mesen 2에도 넣고 스크린샷을 비교합니다. Mesen 2에는 툴킷의 script.lua를 씁니다. 경로는 절대 경로로 줍니다.

$ krpatch snes run cores/bsnes_libretro.dylib test.sfc out/bsnes demo.txt --watch 7E0010:2
OK 300
$ krpatch shotcmp out/snes9x out/bsnes
end.png: same
f00060.png: same
f00110.png: same
f00202.png: same
$ mkdir -p out/mesen2
$ LIBDIR=$(krpatch snes lua) SCRIPT=$PWD/demo.txt SHOT_DIR=$PWD/out/mesen2 WATCH_WRAM=7E0010:2 \
    Mesen --testrunner $PWD/test.sfc $(krpatch snes lua)/script.lua
$ krpatch shotcmp out/snes9x out/mesen2
end.png: same
f00060.png: same
f00110.png: same
f00202.png: diff px=57344 bbox=0,0,256,224

bsnes 코어의 wram.log는 비어 있고 덤프도 없지만, poke는 치트로 들어가 화면은 snes9x와 같습니다. Mesen 2에서는 202프레임 화면 전체(256×224 = 57,344픽셀)가 다릅니다. 앞에서 본 대로 poke가 화면에 늦게 나와 아직 파랑이기 때문이고, 끝 화면은 같습니다. 화면 높이가 다른 bsnes-hd beta는 --box로 같은 범위만 비교합니다.

$ krpatch snes run cores/bsnes_hd_beta_libretro.dylib test.sfc out/bsnes-hd demo.txt
OK 300
$ krpatch shotcmp out/snes9x out/bsnes-hd
end.png: size (256, 224) != (256, 216)
f00060.png: size (256, 224) != (256, 216)
f00110.png: size (256, 224) != (256, 216)
f00202.png: size (256, 224) != (256, 216)
$ krpatch shotcmp out/snes9x/f00110.png out/bsnes-hd/f00110.png --box 0,0,256,216
f00110.png: same
$ krpatch shotcmp out/snes9x/f00202.png out/bsnes-hd/f00202.png --box 0,0,256,216
f00202.png: diff px=55296 bbox=0,0,256,216

A를 누른 110프레임은 같고, poke가 들어가야 할 202프레임만 다릅니다. bsnes-hd beta가 치트를 받지 않아 파랑으로 남은 것입니다. 덤프끼리는 krpatch diff로 비교합니다.

$ krpatch diff out/snes9x/wram_240.bin out/mesen2/wram_240.bin
000000-01FFFF 131072
$ xxd -s 0x10 -l 4 out/snes9x/wram_240.bin
00000010: ef00 1f00                                ....
$ xxd -s 0x10 -l 4 out/mesen2/wram_240.bin
00000010: ef00 1f00                                ....

다른 범위가 WRAM 128KB 전체로 나오지만, 롬이 쓰는 4바이트($0010–$0013)는 둘 다 EF 00 1F 00(NMI 239번, 색 $001F)으로 같습니다. 나머지는 켤 때의 RAM 값 차이입니다. 스크립트를 틀리게 적으면 툴킷은 실행 전에 거부하고 2로 끝납니다. 위 도구의 「틀린 예: end 뒤의 명령」을 bad.txt로 저장해 넣은 결과입니다.

$ krpatch snes run cores/snes9x_libretro.dylib test.sfc out/x bad.txt
krpatch: error: line 2: frame is after 'end' at frame 300

명령마다 자세한 설명은 툴킷 README의 snes run 절에 있습니다. 여기서 만든 롬에 일부러 버그를 다시 넣어 보는 것도 좋은 연습입니다. 첫째 대기 줄(F0 F9로 끝나는 줄)을 지우고 다시 만들면, snes9x에서는 초록이 나오고 Mesen 2에서는 나오지 않는 것을 직접 볼 수 있습니다. 디버거의 이벤트 뷰어에서 $4218을 읽는 순간과 자동 읽기 구간을 견주어 보세요.

이 장의 출력은 kr-patch-toolkit 0.2.0, Mesen 2.1.1, 네 패치의 compat/cores/에 있던 libretro 코어로 macOS에서 돌린 결과입니다. 끝으로 되돌아보면 8장의 대사 구조도, 6장의 후킹도 이 장의 도구로 확인한 것입니다.