슈퍼 패미컴
6장 후킹
원래 코드 몇 바이트를 점프 명령으로 바꿔 새 코드로 건너갔다 돌아오게 합니다. 덮어쓴 명령을 다시 실행하는 법과 빈 공간 찾기까지 다룹니다.
코드 사이에 끼워 넣을 수는 없다 쉽게
한글패치를 만들다 보면 게임에 없던 동작이 필요해집니다. 한국어는 띄어쓰기가 있어서 단어 중간에서 줄이 끊기면 읽기 어렵고, 일본어 글자 수에 맞춰 만든 폰트 칸은 한글을 다 담지 못합니다. 이럴 때는 게임 코드에 새 코드를 더해야 합니다.
그런데 롬 안의 코드는 빈틈없이 붙어 있어서 중간에 바이트를 끼워 넣을 수 없습니다. 바이트 하나를 끼우면 그 뒤의 모든 명령과 데이터가 한 칸씩 밀리고, 그 주소를 가리키던 점프와 포인터가 전부 엉뚱한 곳을 가리키게 됩니다. 그래서 원래 코드 몇 바이트를 점프 명령으로 바꿔 쓰고, 빈 곳에 둔 새 코드로 건너갔다가 일이 끝나면 돌아오게 합니다. 이것을 후킹(hooking)이라고 합니다. 원래 길 중간에 "여기서 잠깐 옆길로" 표지판을 세우는 셈입니다.
다른 뱅크의 코드를 불렀다 돌아오기: JSL과 RTL 쉽게
5장에서 본 JSR·RTS는 같은 뱅크 안의 서브루틴을 다녀오는 쌍입니다. 후킹할 새 코드는 대개 다른 뱅크의 빈 곳에 두므로, 뱅크까지 3바이트로 적는 JSL(옵코드 22)과 그 짝인 RTL(6B)을 씁니다. JSL은 4바이트라서 원래 코드 4바이트 이상을 덮어써야 합니다.
제절초 한글판은 글자를 찍는 루틴의 $00:A777에서 이렇게 바꿨습니다. 원래 여섯 바이트 자리에 "$1E:8000으로 다녀오라"는 JSL과, 남는 두 칸을 채우는 아무 일도 안 하는 명령 NOP(EA) 둘을 넣었습니다.
원래 바이트 ($00:A777, 파일 0x2777)
C2REP30#$3018CLCADLDA83주소 아래1A주소 위
바꾼 바이트
22JSL00주소 아래80주소 위1E뱅크EANOPEANOP
주소 $1E8000이 00 80 1E로 적힌 것은 2장의 리틀 엔디안입니다. 새 코드가 끝나면 RTL이 스택에 적어 둔 곳으로 돌려보냅니다. 그 자리는 JSL 바로 다음 바이트인 $A77B라서 NOP 두 개를 지나 원래 다음 명령인 $A77D의 ADC에 닿습니다.
덮어쓴 명령 다시 실행하기 쉽게
JSL을 쓰면 그 자리에 있던 명령은 사라집니다. 원래 게임이 하던 일을 빠뜨리지 않으려면, 덮어쓴 명령을 새 코드 안에서 그대로 다시 실행해야 합니다. 제절초의 새 코드도 마지막에 REP #$30, CLC, LDA $1A83을 실행한 뒤 RTL로 돌아갑니다.
덮어쓸 범위는 명령 단위로 잘라야 합니다. JSL은 4바이트인데 앞의 두 명령은 3바이트뿐이라 네 번째 바이트가 LDA의 첫 바이트를 먹습니다. LDA의 나머지 두 바이트(83 1A)가 남으면 CPU가 그것을 명령으로 읽어 엉뚱한 일을 합니다. 그래서 LDA까지 통째로 여섯 바이트를 가져가고, 남는 두 바이트를 NOP으로 채웠습니다.
덮어쓸 자리를 고를 때는 그 안으로 점프해 들어오는 코드가 없는지도 봅니다. 다른 곳에서 $A779(원래 CLC)로 바로 분기하는 명령이 있다면, 그 분기는 이제 JSL의 셋째 바이트 80에 떨어지고, CPU는 그것을 BRA 명령으로 읽어 엉뚱한 곳으로 갑니다. 분기 대상이 되는 자리는 피하고, 함수 첫머리처럼 늘 위에서부터 들어오는 곳을 고르는 것이 안전합니다.
레지스터와 플래그를 그대로 돌려주기 쉽게
새 코드가 끝나고 돌아갔을 때, 원래 코드는 아무 일도 없었던 것처럼 이어서 실행됩니다. 그러니 원래 코드가 기대하는 상태를 그대로 맞춰 돌려줘야 합니다.
- A·X·Y: 새 코드가 쓴 레지스터는 들어올 때 스택에 맡겼다가(
PHX·PHY) 나갈 때 되찾습니다(PLY·PLX). 꺼내는 순서는 맡긴 순서의 반대입니다. - 폭(M·X 플래그): 원래 코드가 16비트 A를 기대하는데 8비트로 돌아가면 다음 명령의 바이트 길이부터 달라집니다(3장). 제절초는 덮어쓴
REP #$30을 끝에서 다시 실행해 폭을 원래대로 맞춥니다. - 플래그: 바로 뒤가
ADC라서 올림(C)이 0이어야 합니다. 마지막에 다시 실행하는CLC가 그 일을 합니다. 분기가 기다리고 있다면 Z·N 같은 플래그도 맞춰야 합니다. - 스택: 쌓은 만큼 꺼내야
RTL이 제자리로 돌아갑니다. 하나라도 남으면 엉뚱한 주소로 돌아가 게임이 멈춥니다.
새 코드를 둘 빈 공간 쉽게
새 코드는 롬에서 아무도 쓰지 않는 곳에 둡니다. 롬을 만들 때 남은 칸은 보통 00이나 FF로 채워져 있어서, 같은 바이트가 길게 이어진 곳이 후보가 됩니다. 롬을 늘려 새 뱅크를 붙이는 방법도 있습니다(11장).
그러나 같은 바이트가 이어진다고 빈 곳은 아닙니다. 각성편 v1.1은 FF가 이어진 것처럼 보이는 $88:FFAE에 긴 지명 문자열을 넣었습니다. 그런데 그 자리는 앞 이동 경로 레코드의 끝 표시 FF FF였고, 바로 뒤 $88:FFB0에는 레코드 하나가 더 있었습니다. 끝 표시와 다음 레코드를 잃은 이동 루틴이 문자열 바이트를 경로로 읽어, 우주선이 맵 밖으로 날아갔습니다. 경로 포인터 표($88:FC80)를 따라가 봤다면 $88:FFB0도 쓰이는 데이터라는 것을 알 수 있었습니다.
빈 곳이라고 판단하기 전에 확인할 것은 이렇습니다.
- 원본 롬에서도 같은 값인가: 다른 패치가 이미 쓴 곳일 수 있으니 일본판 원본과 비교합니다.
- 앞 데이터의 끝 표시가 아닌가: 바로 앞 데이터의 끝 표시가
FF나00일 수 있습니다. 그 데이터를 가리키는 포인터 표를 따라가 마지막 항목이 어디서 끝나는지 봅니다. 몇 바이트 여유를 두는 것만으로는 각성편처럼 뒤에 이어진 레코드를 지키지 못합니다. - 읽는 코드가 없는가: 에뮬레이터 디버거로 그 범위의 읽기를 감시하면서 게임을 돌려 봅니다. 어떤 루틴이 정해진 길이만큼 넘겨 읽는지(폰트 디코더처럼)도 봅니다.
- RAM이라면 쓰는 코드가 없는가: 새 코드가 쓸 변수 자리는 쓰기 감시로 확인합니다. 평소에 0이던 곳도 특정 화면에서만 쓰일 수 있습니다.
더 깊이JML과 JMP, 폭과 P 지키기, 뱅크 한계, 표 바꾸기, NMI, 빌드에서 확인하기
돌아오지 않는 점프: JML과 JMP
JSL은 스택에 돌아올 곳을 적고 가지만, JML(5C, 4바이트)과 JMP(4C, 3바이트)는 적지 않고 그냥 갑니다. 새 코드가 끝에서 정해진 주소로 다시 JML하면 스택을 건드리지 않고 후킹할 수 있습니다. 돌아갈 곳이 덮어쓴 자리 바로 뒤가 아니거나, 스택에 아무것도 남기지 않아야 할 때 씁니다.
돌격편 v1.1은 폰트를 고르는 갈래의 첫머리 $B7:97BC를 JML $B7FA00으로 바꿨습니다. 원래 SEC·SBC #$07F0 4바이트 자리입니다. 새 코드는 글자 코드가 $0C00보다 작으면 원래 갈래를 통째로 다시 실행하고, 크면 새 글자 블록을 고른 뒤, 어느 쪽이든 모든 갈래가 만나는 $B7:97D6으로 JML합니다.
B7:97BC 5C 00 FA B7 JML $B7FA00 ; 원래 38 E9 F0 07 (SEC / SBC #$07F0)
B7:FA00 C9 00 0C CMP #$0C00 ; 새 글자 블록 코드인가
B7:FA03 B0 1E BCS $B7FA23
B7:FA05 38 SEC ; 아니면 덮어쓴 두 명령부터
B7:FA06 E9 F0 07 SBC #$07F0 ; 원래 갈래를 그대로 실행하고
…
B7:FA1F 5C D6 97 B7 JML $B797D6 ; 모든 갈래가 만나는 곳으로슈퍼 마리오 RPG의 필드 메뉴는 새 코드를 같은 뱅크 $C3의 빈 곳(원본 $C3:F000–$C3:F3FF, 전부 FF)에 두었습니다. 같은 뱅크라서 3바이트 JMP와 JSR로 충분합니다. 아이템 이름을 그리는 $C3:7990은 SEP #$20·PHX 3바이트를 JMP $F007로 바꿨습니다. 새 루틴이 원래 루틴을 통째로 대신하고 끝의 RTS로 원래 루틴을 부른 곳에 바로 돌아가므로, 덮어쓴 명령을 다시 실행할 필요가 없습니다. 메뉴 NMI의 JSR $013E는 JSR $F169로 바꿨습니다. 이쪽 새 루틴은 첫 줄에서 덮어쓴 JSR $013E를 먼저 실행한 다음 글자를 VRAM에 올립니다.
폭과 P, D 지키기
새 코드는 자기를 부른 코드가 8비트였는지 16비트였는지 모릅니다. 그래서 즉시값을 쓰기 전에 REP·SEP로 폭을 직접 정하고, 어셈블러에도 그 폭을 알려 줍니다. 제절초는 첫 줄에서 REP #$30을 실행하고 CMP.w #!MAGIC처럼 .w를 붙여 2바이트 즉시값으로 조립하게 했습니다. 폭이 틀리면 CPU와 어셈블러가 명령 길이를 다르게 세어 그다음 바이트부터 엉뚱한 명령이 됩니다.
PHX로 맡길 때와PLX로 꺼낼 때의 X 폭이 같아야 합니다. 16비트로 2바이트를 쌓고 8비트로 1바이트만 꺼내면 스택이 한 바이트 어긋나RTL이 엉뚱한 곳으로 갑니다.- 원래 폭과 플래그를 통째로 지키려면 처음에
PHP로 P를 맡기고, 끝에서PLP로 되찾습니다. 제절초처럼 덮어쓴REP를 다시 실행해 폭을 맞추는 방법도 있습니다. JSL은 D(다이렉트 페이지)도 바꾸지 않습니다. 제절초의LDA [$52],Y는 원래 코드와 같은 D를 쓴다는 것을 믿고 대본 위치를 읽습니다. 새 코드가 D를 바꾸면 끝에서 되돌려야 합니다.
뱅크 한계: 2바이트 주소는 뱅크를 넘지 못한다
JSR·RTS·JMP·분기 명령은 PB(코드 뱅크)를 바꾸지 않습니다. 게임이 2바이트 주소로 부르는 루틴을 바꾸고 싶다면, 그 루틴은 반드시 같은 뱅크에 있어야 합니다. 같은 뱅크에 빈 곳이 모자라면 같은 뱅크에 몇 바이트짜리 중계 코드만 두고, 거기서 JSL로 다른 뱅크의 본체를 부릅니다.
JSL은 PB만 바꾸고 DB(데이터 뱅크)는 그대로 둡니다. 그래서 새 코드에서 LDA $1A7D처럼 적으면 여전히 원래 코드의 데이터 뱅크를 읽습니다. 새 뱅크에 둔 표를 읽을 때는 제절초의 LDA.l $1EF000,X처럼 뱅크까지 적는 긴 주소를 쓰거나, PHB·PLB로 DB를 바꿨다가 되돌립니다.
코드 대신 표를 바꾸는 후킹
위의 제어 코드 표처럼, 게임이 주소를 표에서 꺼내 점프한다면 그 칸만 바꿔도 새 코드로 갑니다. 원래 코드를 한 바이트도 덮어쓰지 않아 덮어쓴 명령을 다시 실행할 필요가 없습니다. 슈퍼 마리오 RPG는 쓰이지 않던 대사 제어 코드 $1B의 처리 주소($C0:6896, 원래 4E 62)를 아이템 이름 처리 루틴 $60A6으로 바꿔, 새 제어 코드를 하나 얻었습니다(8장).
변수를 둘 RAM
WRAM은 전원을 켰을 때 값이 정해져 있지 않습니다. 제절초는 "다음 글자 앞에서 줄 바꿈" 표시를 0/1이 아니라 16비트 값 $5A3C와 같은지로 판단합니다. 우연히 그 값이 들어 있을 확률이 낮기 때문입니다. 표시를 둔 $7F:FFE0 둘레는 Mesen 2의 쓰기 감시로 51번 × 12,000프레임을 돌려, 패치 코드 말고 쓰는 곳이 없는 것을 확인했습니다. 감시하지 않은 장면까지 보장하지는 못하므로 분석 노트에도 그렇게 적어 두었습니다.
NMI 안에 끼워 넣을 때
NMI는 화면을 그리지 않는 짧은 시간에 VRAM을 고칠 수 있는 곳이라(5장), 글자 그림을 올리는 코드를 자주 끼워 넣습니다. 이 시간은 짧아서, NMI에 더하는 일은 미리 준비해 두고 옮기기만 하게 만듭니다. 슈퍼 마리오 RPG의 필드 메뉴는 그릴 글자를 WRAM에 12자까지 모아 두었다가 NMI에서 DMA(CPU 대신 하드웨어가 메모리 사이에 바이트를 한꺼번에 옮기는 기능)로 올립니다.
- NMI는 메인 루프의 아무 명령 사이에서나 끼어듭니다. NMI 안에 넣은 코드가 바꾼 레지스터·DB·D는, 게임의 NMI 입구가 이미 맡겨 두지 않았다면 직접 맡겼다가 되돌려야 합니다.
- 반대로 메인 루프 쪽 후킹은 언제든 NMI에 끊길 수 있습니다. 두 쪽이 같은 WRAM 버퍼를 쓰면 반쯤 채운 데이터를 NMI가 올려 버릴 수 있습니다. 슈퍼 마리오 RPG는 버퍼를 채우는 동안 잠금 값(
!lock)을 켜 두고, NMI 쪽 코드는 잠금이 켜져 있으면 그 프레임을 건너뜁니다. - 게임의 NMI가 이미 쓰는 DMA 채널을 새 코드가 함께 쓰면 설정이 섞입니다. 비어 있는 채널을 확인하고 씁니다.
빌드에서 확인하기
- asar의
assert pc() == $C16AD7처럼 덮어쓴 코드가 정해진 자리에서 끝나는지 빌드 때 확인합니다. 넘치면 빌드가 멈춰, 뒤 명령을 몰래 덮어쓰는 일이 없습니다. - 분기 명령은 −128–+127바이트까지만 갑니다(
BRL은 2바이트 거리라 ±32KB까지). 새 코드가 길어져 범위를 넘으면 asar가 빌드를 멈춥니다. 제절초는 이때 반대 조건 분기와JMP로 바꿨습니다. - 빌드한 롬을 원본과 비교해(
krpatch diff) 바뀐 범위가 계획한 후킹 지점과 새 코드 자리뿐인지 봅니다.
실제 패치에서 실제 패치에서
제절초: 단어 단위 줄바꿈
제절초 엔진은 글자를 하나 찍을 때마다 줄 끝을 넘는지만 봅니다. 한국어는 단어 중간에서 끊겨 읽기 어려워서, 위에서 본 $00:A777 후킹으로 공백이나 쉼표·마침표·느낌표를 찍은 순간 대본에서 다음 단어를 미리 읽어 폭을 잽니다. 줄 끝을 넘을 것 같으면 그 단어 앞에서 줄을 바꿉니다. 새 코드는 $1E:8000–$1E:80C9, 글자 폭 표는 $1E:F000에 있고, 둘 다 v1.0이 일본판 대본을 비우면서 0으로 남은 곳입니다.
org $00A777 ; REP #$30 / CLC / LDA $1A83 at the head of the wrap check
JSL WordWrap
NOP #2
org $1E8000
WordWrap:
REP #$30
…
.fits:
PLY
PLX
.done:
REP #$30
CLC
LDA $1A83
RTLkr-patches의 asm/wordwrap.asm에서 처음과 끝만 옮겼습니다.
돌격편: 글자 코드에 따라 새 글자 블록으로 보내기
돌격편 v1.1은 앞의 JML 후킹으로 글자 코드 $0C00–$0FFF를 새로 열었습니다. 새 코드 120바이트($B7:FA00–$B7:FA77)와 4KB짜리 글자 블록 여덟 곳은 모두 일본판과 v1.0에서 전부 FF였던 곳입니다. 글자 칸을 어떻게 나눴는지는 7장에서 이어집니다.
슈퍼 마리오 RPG: 같은 뱅크 안의 빈 곳
필드 메뉴 코드는 뱅크 $C3의 $C3:F000–$C3:F3FF, 전투 메뉴와 결과 창 코드는 $C3:FC40–$C3:FFFF에 넣었습니다. 둘 다 원본에서 FF였던 곳입니다. 처음에 변수 자리로 잡았던 WRAM $7E:F580–$F8FF는 평소에 0이었지만 아이템을 들고 가는 화면의 스프라이트 버퍼였습니다. 그래서 메뉴·전투·대사 어디에서도 쓰지 않는 $7E:0C00–$17FF로 옮겼습니다.
각성편: FF가 이어진 곳을 빈 곳으로 잘못 본 버그
각성편 v1.1의 진행 버그는 「새 코드를 둘 빈 공간」 그림의 상황입니다. $88:FFAE에 지명을 넣으면서 $88:FFAA 이동 경로 레코드의 끝 표시와 $88:FFB0 레코드를 덮었습니다. v1.2는 원본 바이트를 되돌리고, 문자열을 뒤로 밀고, 그 문자열을 가리키는 포인터를 같은 만큼 고쳤습니다.
직접 해 보기 직접 해 보기
일본판과 한글판을 같은 주소에서 역어셈블해 비교하면 후킹 지점이 바로 보입니다. 아래는 제절초 일본판과 한글판 v1.2를 툴킷으로 읽은 결과입니다.
$ krpatch snes dis otogirisou.sfc 00A777 4 --mapper lorom --m16 --x16
00:A777 c2 30 REP #$30
00:A779 18 CLC
00:A77A ad 83 1a LDA $1A83
00:A77D 6d 7f 1a ADC $1A7F
$ krpatch snes dis otogirisou-k.sfc 00A777 4 --mapper lorom --m16 --x16
00:A777 22 00 80 1e JSL $1E8000
00:A77B ea NOP
00:A77C ea NOP
00:A77D 6d 7f 1a ADC $1A7F
$ krpatch snes dis otogirisou-k.sfc 1E80B2 4 --mapper lorom --m16 --x16
1E:80B2 c2 30 REP #$30
1E:80B4 18 CLC
1E:80B5 ad 83 1a LDA $1A83
1E:80B8 6b RTL세 번째 명령이 새 코드의 끝입니다. 덮어쓴 세 명령이 그대로 다시 나오고 RTL로 끝나는 것을 확인할 수 있습니다. 직접 후킹을 써 볼 때는 asar 같은 어셈블러로 org에 덮어쓸 주소를 적고, 새 코드는 확인한 빈 공간에 둡니다. 빌드한 뒤에는 에뮬레이터 디버거에서 후킹 지점에 실행 중단점을 걸고 한 줄씩 따라가 봅니다.
실제 패치에서 이 개념이 쓰인 롬 분석 노트
노트의 자세한 절은 「자세히 보기」를 펼친 안쪽에 있습니다.
직접 해 보기 kr-patch-toolkit 명령 설명
참고 자료
- WDC, W65C816S 데이터시트 — JSL·RTL·JML·JSR·RTS가 스택에 쌓는 바이트와 돌아오는 주소, NOP
- fullsnes, CPU Memory Addressing — 같은 뱅크 안 주소와 긴 주소(뱅크 포함) 표기
- SNESdev Wiki, 65C816 — 명령 요약과 레지스터 폭(M·X)
- asar — 네 패치가 쓴 65c816 어셈블러. org로 덮어쓸 주소를 정하고 assert로 범위를 확인합니다
- kr-patches, otogirisou/asm/wordwrap.asm — 이 장의 예제인 제절초 단어 단위 줄바꿈 후킹 소스
댓글