슈퍼 패미컴
5장 어셈블리 기초
LDA·STA·분기·JSR을 하나씩 읽고, 패치 노트에 나온 코드 조각을 직접 해석합니다.
어셈블리는 바이트에 붙인 이름 쉽게
롬 안의 프로그램은 바이트일 뿐입니다. A9 34 12를 보고 바로 뜻을 알기는 어렵습니다. 그래서 바이트마다 사람이 읽을 이름을 붙여 LDA #$1234처럼 적습니다. 이것이 어셈블리입니다. 바이트를 어셈블리로 바꾸는 도구가 역어셈블러, 그 반대가 어셈블러입니다.
3장에서 본 것처럼 피연산자 길이는 M·X 설정에 따라 달라지고, 주소는 작은 자리부터 적힙니다(2장). 이 두 가지만 기억하면 바이트를 손으로도 어셈블리로 바꿀 수 있습니다.
자주 쓰는 명령 열두 개 쉽게
65c816 명령은 90여 개지만, 패치 노트에 나오는 코드 대부분은 아래 열두 개로 읽을 수 있습니다.
LDA A에 넣기
LDA #$05
A에 값을 넣습니다. LDX·LDY는 X·Y에 넣습니다.
STA 메모리에 쓰기
STA $0A00
A의 값을 메모리에 적습니다. STX·STY도 있습니다.
ADC 더하기
ADC #$05
A에 더합니다. 올림(C)까지 더하므로 앞에 CLC를 씁니다.
INX 1 늘리기
INX
X를 1 늘립니다. DEX는 1 줄입니다.
CMP 비교하기
CMP #$10
A에서 빼 보기만 하고 결과는 버립니다. 플래그만 바뀝니다.
BEQ 같으면 이동
BEQ $8011
Z가 1이면(0이었거나 같았으면) 그 주소로 갑니다.
BNE 다르면 이동
BNE $8006
Z가 0이면 그 주소로 갑니다. 반복에 씁니다.
BRA 무조건 이동
BRA $8005
플래그와 상관없이 갑니다.
JSR 서브루틴 부르기
JSR $8012
돌아올 곳을 스택에 적고 서브루틴으로 갑니다.
RTS 서브루틴에서 돌아오기
RTS
스택에 적어 둔 곳으로 돌아갑니다.
REP 16비트로 바꾸기
REP #$30
P의 칸을 끕니다. #$20은 A, #$10은 X·Y.
SEP 8비트로 바꾸기
SEP #$30
P의 칸을 켭니다.
값과 주소 구별하기 쉽게
같은 LDA라도 뒤에 무엇이 붙느냐에 따라 뜻이 완전히 다릅니다. 이 차이를 주소 지정 방식이라고 하고, 옵코드 바이트가 방식마다 다릅니다.
# 하나 차이로 "숫자 $12"와 "$12번 칸에 든 것"이 갈립니다. 어셈블리를 읽을 때 가장 먼저 확인할 것이 이 #입니다. ,X는 표의 몇 번째 칸을 읽을 때 씁니다. X를 늘려 가며 같은 명령을 되풀이하면 표를 차례로 훑을 수 있습니다.
분기와 반복 쉽게
CPU는 "만약"을 직접 묻지 못합니다. 대신 명령을 실행할 때마다 결과를 P의 플래그에 남기고, 분기 명령이 그 플래그를 보고 갈 곳을 정합니다. DEX가 X를 0으로 만들면 Z 플래그가 켜지고, 바로 뒤의 BNE는 Z가 꺼져 있을 때만 앞쪽 명령으로 되돌아갑니다. 이렇게 "세 번 반복"이 만들어집니다.
서브루틴: JSR과 RTS 쉽게
여러 곳에서 쓰는 코드는 한 군데에 두고 불러 씁니다. 이것이 서브루틴입니다. JSR은 "다녀올 테니 돌아올 곳을 적어 둬"라며 PC 값을 스택에 쌓고 서브루틴으로 갑니다. 서브루틴 끝의 RTS는 스택 맨 위에 적힌 곳을 꺼내 돌아갑니다.
스택은 주소가 큰 쪽에서 작은 쪽으로 쌓이고, 쌓을 때마다 S가 줄어듭니다. 돌아올 곳으로 JSR 명령 자신의 마지막 바이트 주소($8010)를 적어 두고, RTS가 1을 더해 다음 명령($8011)으로 가는 것이 65c816의 방식입니다. 아래 「명령 한 줄씩 실행하기」에서 JSR 전후로 메모리 $7E:1FFE·$7E:1FFF와 S가 바뀌는 것을 확인해 보세요.
패치에 들어간 코드 실행해 보기 쉽게
이제 실제 패치에 들어간 코드를 읽을 수 있습니다. 돌격편 v1.2는 엔딩 화면에 글자를 띄우려고 "롬의 문자열을 0이 나올 때까지 WRAM으로 복사하는" 짧은 루프를 넣었습니다. 아래는 그 루프를 줄인 것입니다. 16비트 모드라 한 번에 글자 하나(2바이트)를 옮기고, X를 두 번 늘려 다음 글자로 갑니다.
더 깊이주소 지정 방식 전부, 스택과 인터럽트, 사이클
주소 지정 방식
데이터시트는 24가지로 나누지만, 실제로 자주 만나는 것은 아래와 같습니다. 바이트는 16비트 A가 아닌 경우의 예입니다.
| 방식 | 표기 | 바이트 | 읽는 곳 |
|---|---|---|---|
| 즉시값 | LDA #$12 | A9 12 | 값 $12 자체 |
| 절대 | LDA $1234 | AD 34 12 | DB:$1234에 있는 값 |
| 절대 긴 | LDA $7E1234 | AF 34 12 7E | $7E:1234에 있는 값 |
| 다이렉트 페이지 | LDA $12 | A5 12 | $00:(D+$12)에 있는 값 |
| 절대 + X | LDA $1234,X | BD 34 12 | DB:$1234 + X |
| 긴 주소 + X | LDA $7E1234,X | BF 34 12 7E | $7E:1234 + X |
| 간접 | LDA ($12) | B2 12 | $00:(D+$12)에 적힌 2바이트 주소(뱅크는 DB)의 값 |
| 긴 간접 | LDA [$12] | A7 12 | $00:(D+$12)에 적힌 3바이트 주소의 값 |
| 간접 + Y | LDA ($12),Y | B1 12 | $00:(D+$12)의 2바이트 주소(뱅크는 DB) + Y |
| 스택 상대 | LDA $03,S | A3 03 | S + 3에 있는 값 |
| 상대 | BNE $8006 | D0 FA | 다음 명령 위치 + 부호 있는 1바이트 |
| 블록 이동 | MVN $80,$7E | 54 7E 80 | 뱅크 $80의 X 위치에서 뱅크 $7E의 Y 위치로 C+1바이트 복사 |
"긴"(long) 방식은 3바이트 주소라 DB와 상관없이 어느 뱅크든 가리킵니다. 분기 명령의 상대 주소는 1바이트 2의 보수라 −128–+127바이트까지만 갈 수 있고, BRL은 2바이트로 뱅크 안 어디든 갑니다.
간접 주소: 메모리에 적힌 주소 따라가기
LDA ($12)는 다이렉트 페이지 $00:(D+$12)와 그다음 칸에서 2바이트 주소를 꺼내고, 그 주소에 DB를 뱅크로 붙인 곳의 값을 읽습니다. 대사 포인터처럼 "표에서 꺼낸 주소의 내용"을 읽을 때 씁니다. [$12]는 뱅크까지 3바이트를 꺼내고, ($12),Y는 꺼낸 주소에 Y를 더합니다.
블록 이동 MVN·MVP
MVN은 X가 가리키는 곳에서 Y가 가리키는 곳으로 C(16비트 A) + 1바이트를 한꺼번에 복사합니다. 어셈블리로는 MVN $80,$7E처럼 "보내는 뱅크, 받는 뱅크" 순서로 적지만, 바이트로는 54 7E 80처럼 받는 뱅크가 먼저 옵니다. 끝나면 DB가 받는 뱅크로 바뀌어 있으니 뒤 코드가 DB에 기대고 있다면 조심해야 합니다. MVP는 뒤에서부터 복사합니다.
BCC·BCS는 부호 없는 비교
CMP 뒤의 BCC("작으면")·BCS("크거나 같으면")는 값을 0–255(16비트면 0–65535)의 부호 없는 수로 비교합니다. $FF는 −1이 아니라 255로 취급되어 $01보다 큽니다. 부호 있는 수로 비교하려면 N·V 플래그를 함께 봐야 합니다.
스택 더 보기
PHA/PLA,PHX/PLX는 레지스터를 잠깐 맡기고 되찾습니다.PHP/PLP는 P를 맡깁니다. 서브루틴이 REP·SEP로 폭을 바꾼 뒤 원래대로 돌려놓을 때 씁니다.JSL/RTL은 다른 뱅크로 다녀오는 쌍입니다. PB까지 3바이트를 쌓습니다. JSR로 간 곳에서 RTL로 돌아오면 스택이 어긋나 게임이 멈춥니다.PHK/PLB는 "지금 코드 뱅크를 데이터 뱅크로" 맞출 때 자주 짝지어 나옵니다.
인터럽트와 NMI
인터럽트는 CPU가 하던 일을 잠깐 멈추고 정해진 루틴을 실행하게 하는 신호입니다. 슈퍼 패미컴에서 가장 중요한 것은 화면을 다 그리고 다음 화면을 그리기 전(수직 귀선 기간)에 오는 NMI입니다. CPU는 PB, PC, P를 스택에 쌓고 네이티브 모드 벡터 $00:FFEA에 적힌 주소로 갑니다. 단, 게임이 레지스터 $4200의 비트 7을 켜 두었을 때만 NMI가 옵니다. 루틴은 RTI로 끝나 원래 자리로 돌아갑니다. 화면 데이터는 이 짧은 시간에만 안전하게 바꿀 수 있어서, 글자 그림을 VRAM에 올리는 코드를 여기에 끼워 넣는 일이 많습니다(6·7장).
사이클
명령마다 걸리는 사이클 수가 다릅니다. 예를 들어 LDA #는 2사이클이고 16비트 A면 1사이클이 더 듭니다. 다만 슈퍼 패미컴에서는 한 사이클의 길이가 읽는 곳에 따라 기준 클록(21.477MHz)의 6·8·12배로 달라서, 사이클 수만으로 시간이 정해지지는 않습니다. 같은 코드도 FastROM 영역에서 더 빨리 돕니다. NMI 안처럼 시간이 빠듯한 곳에 코드를 더하면 화면이 깨질 수 있으니, 데이터시트의 사이클 표와 영역별 속도로 대략의 비용을 셈해 둡니다.
실제 패치에서 실제 패치에서
두 바이트로 디버그 메뉴 열기(돌격편)
돌격편은 전원을 켜면 도는 루프 안에 개발자용 디버그 메뉴를 부르는 코드가 남아 있고, 바로 앞의 BRA가 늘 그것을 건너뜁니다.
$80:8061 80 04 BRA $8067 ; 다음 명령에서 4바이트 앞으로 건너뜀
$80:8063 22 B0 86 82 JSL $8286B0 ; 디버그 메뉴 (늘 건너뜀)
$80:8067 22 00 80 82 JSL $828000 ; 제품판이 가는 곳80 04를 아무것도 안 하는 명령 EA EA(NOP NOP)로 바꾸면 건너뛰지 않고 메뉴가 열립니다. BRA의 04는 "다음 명령 위치($8063)에서 4바이트 더"라서 $8067입니다. 이 메뉴의 시나리오 선택 기능이 패치를 확인하는 바탕이 됐습니다.
한 바이트로 전투 디버그 메뉴 열기(슈퍼 마리오 RPG)
$C1:06A9 AF AE FF 00 LDA $00FFAE ; 어떤 값을 읽어
$C1:06AD 05 80 ORA $80 ; $80 칸과 합친 결과가
$C1:06AF F0 01 BEQ $C106B2 ; 0이면 RTS를 건너뛰어 메뉴로
$C1:06B1 60 RTS ; 아니면 그냥 돌아감제품판에서는 결과가 0이 아니라 늘 RTS로 돌아갑니다. F0(BEQ)을 80(BRA)으로 바꾸면 조건 없이 RTS를 건너뛰어 슈퍼 마리오 RPG의 전투 중 Start로 메뉴가 열립니다. 롬 해킹의 많은 일이 이렇게 "분기 하나 바꾸기"입니다.
줄바꿈 판단(돌격편)
$B7:9846 CLC / ADC $0208 ; 이번 글자 폭 + 지금 x 위치
$B7:984A CMP $020A ; 창 폭과 비교
$B7:984D BEQ / BCC $B79890 ; 같거나 작으면 그대로 그림
$B7:9851 INC $32 ×2 ; 크면 다음 줄로CMP 뒤의 BCC는 "C가 0이면", 곧 "A가 비교한 값보다 작으면" 갑니다. 그래서 글자 폭 합이 창 폭과 같은 줄까지는 한 줄에 들어갑니다. 한글 번역은 이 규칙대로 모든 줄의 폭을 미리 계산해 넘치는 줄이 없게 했습니다.
직접 해 보기 직접 해 보기
위의 두 코드는 툴킷 역어셈블러로 그대로 볼 수 있습니다. 분기 주소는 뱅크까지 붙여 24비트로 나옵니다.
$ krpatch snes dis totsugeki.sfc 808061 3 --mapper lorom
80:8061 80 04 BRA $808067
80:8063 22 b0 86 82 JSL $8286B0
80:8067 22 00 80 82 JSL $828000
$ krpatch snes dis smrpg.sfc C106A9 4 --mapper sa1
C1:06A9 af ae ff 00 LDA $00FFAE
C1:06AD 05 80 ORA $80
C1:06AF f0 01 BEQ $C106B2
C1:06B1 60 RTS아는 명령이 나오는 곳에서 시작해 몇십 줄씩 읽어 보세요. 모르는 옵코드가 연달아 나오면 폭(M·X)이 틀렸거나 코드가 아닌 데이터를 읽고 있는 것입니다.
실제 패치에서 이 개념이 쓰인 롬 분석 노트
노트의 자세한 절은 「자세히 보기」를 펼친 안쪽에 있습니다.
직접 해 보기 kr-patch-toolkit 명령 설명
참고 자료
- WDC, W65C816S 데이터시트 — 명령표, 주소 지정 방식 24가지, 사이클 수, 인터럽트 순서와 벡터
- fullsnes, CPU Memory Addressing — 주소 지정 방식과 어셈블러 표기
- SNESdev Wiki, 65C816 — 명령 요약
- SNESdev Wiki, CPU vectors — NMI·IRQ·리셋 벡터 위치
- SFC Development Wiki, 65816 Reference — 명령별 바이트 수와 플래그 변화
댓글