본문으로 건너뛰기

슈퍼 패미컴

8장 대사는 어떻게 저장되나

글자표는 숫자와 글자의 사전, 포인터 표는 목차입니다. 제어 코드와 이어진 대본, 번역이 길어졌을 때 옮길 자리를 봅니다.

글자표: 바이트 값과 글자 대응표 쉽게

롬 안의 대사는 글자가 아니라 숫자입니다. 게임은 숫자를 받아 폰트의 그 번호 그림을 화면에 찍을 뿐이고, 어느 번호가 어떤 글자인지는 게임마다 제멋대로 정합니다. 그래서 대사를 읽거나 고치려면 "이 번호는 이 글자"를 적은 사전이 먼저 있어야 합니다. 이것을 글자표라고 하고, 롬 해킹 도구들은 흔히 .tbl 파일로 적습니다.

21=!
76=를
8F=을
1D07=획
1D22=득

슈퍼 마리오 RPG 한글판 v1.0의 대사 글자표(korTBL.utf8.tbl)에서 고른 줄입니다.

줄마다 왼쪽이 롬의 바이트(16진수), 오른쪽이 글자입니다. 이 표가 있으면 바이트를 글자로 바꿔 읽고(덤프), 번역한 문장을 다시 바이트로 바꿔 넣을(삽입) 수 있습니다. 원래 게임의 글자표는 없으니, 롬 해커가 화면에 나온 글자와 롬의 바이트를 맞춰 보며 직접 만듭니다. 한글패치를 이어서 고칠 때도 앞선 패치의 표가 남아 있지 않으면 폰트 그림을 읽어 표를 다시 만들어야 합니다.

바이트를 글자표로 읽기바이트 1B 07, 20, 1D 07, 1D 22, 21, 00을 차례로 읽는다. 1B 07은 이름을 넣는 제어 코드, 20은 빈칸, 1D 07은 획, 1D 22는 득, 21은 느낌표, 00은 대사 끝이다.1B 07이름 넣기20빈칸1D 07획1D 22득21!00끝롬 바이트($E2:041A, 파일 0x22041A)화면 예: 「버섯」을 획득! (이름은 실행 중에 채움)
슈퍼 마리오 RPG 대사 0x204의 바이트를 글자표로 읽으면 「<아이템 이름>」을 획득!

1바이트 글자, 2바이트 글자 쉽게

1바이트로는 256가지밖에 적지 못해서, 한자나 한글을 쓰는 게임은 대개 글자 하나에 2바이트를 씁니다. 같은 2바이트라도 어느 쪽 바이트를 먼저 적는지(2장의 엔디안), 1바이트 글자와 섞어 쓰는지가 게임마다 다릅니다.

돌격편 글자마다 2바이트, 작은 자리 먼저

  • F3 08$08F3 어
  • B5 06$06B5 떻
  • 27 05$0527 게

제절초 한글판 글자마다 2바이트, 큰 자리 먼저(코드는 10진수로 적은 표)

  • 01 D8472 여
  • 04 851157 름
  • 03 C0960 날

슈퍼 마리오 RPG 자주 쓰는 글자는 1바이트, 나머지는 1D-1F로 시작하는 2바이트

  • 21$21 !
  • 1D 07$1D07 획
  • 1D 22$1D22 득

슈퍼 마리오 RPG처럼 섞어 쓰는 게임은 첫 바이트를 보고 길이를 압니다. 이 게임에서 $20–$FF는 그대로 글자 하나이고, $1D–$1F가 나오면 "다음 바이트와 합쳐 한 글자"라는 뜻입니다. 첫 바이트가 묶음 번호, 둘째 바이트가 묶음 안 번호인 셈입니다.

  • 1D묶음
  • 07묶음 안 번호
  • 획글리프 $E7

폰트에서 찾을 때는 1바이트 글자 c가 c − $20번 그림이고, 2바이트 글자는 $E0 + (첫 바이트 − $1D) × $100 + 둘째 바이트번 그림입니다. 1D 07이면 $E7번, 1바이트 글자 224개 바로 뒤입니다.

제어 코드: 글자가 아닌 명령 쉽게

대사에는 글자만 있지 않습니다. 줄을 바꾸거나, 버튼을 누를 때까지 기다리거나, 주인공 이름을 끼워 넣거나, 음악을 트는 명령도 섞여 있습니다. 이런 바이트를 제어 코드라고 합니다. 이 가이드의 도구는 덤프한 대본에 제어 코드를 {16} 같은 중괄호 표시로 적어 글자와 구별합니다. 제어 코드 뒤에 인자가 몇 바이트 붙는지도 코드마다 정해져 있어서, 이것을 모르면 인자를 글자로 읽어 그 뒤가 전부 어긋납니다.

제어 코드와 인자파일 0x100000부터 10 3A 00 0F 00 27은 소리 페이드 인 코드 3A와 인자 15, 39다. 10 32 00 10 00 01은 장면 코드 32와 인자 16, 1, 10 22 00 00 00 07과 10 22 00 01 00 01은 변수를 정하는 코드 22다. 그 뒤 01 D8은 여, 04 85는 름이다.10 3A00 0F 00 27{3A:15,39}소리 페이드 인10 3200 10 00 01{32:16,1}장면(배경)10 2200 00 00 07{22:0,7}변수 0을 7로10 2200 01 00 01{22:1,1}변수 1을 1로01 D804 85… 여름날 해 질 녘.10으로 시작하는 2바이트 = 제어 코드, 그 뒤 = 인자
제절초 한글판 대본의 첫머리. 제어 코드 넷 다음에 첫 문장이 나옵니다.

제절초는 기호 값 $1000–$1046을 제어 코드로 씁니다. {3A:15,39}는 코드 $3A에 인자 둘이 붙은 것이고, 인자도 대개 2바이트씩입니다. 점프할 곳만 3바이트 주소입니다. 슈퍼 마리오 RPG는 00이 대사 끝, 01이 줄바꿈, 02–04가 창 넘김, 1A가 아이템 이름 넣기입니다. 돌격편은 $000A가 줄바꿈, $0000이 끝입니다.

한국어 번역에 특히 중요한 것은 이름 넣기 코드입니다. 아이템 이름이 들어갈 자리 뒤에 붙을 조사가 이름의 받침에 따라 「을」·「를」로 바뀌어야 하기 때문입니다. 슈퍼 마리오 RPG 한글판은 쓰이지 않던 코드 $1B를 이름을 넣는 코드로 바꾸고, 뒤의 플래그 바이트에 따라 「」, 가운데 정렬, 받침에 맞는 을/를을 붙이게 했습니다(위 그림의 1B 07은 셋 다 켠 것).

포인터 표: 대사 번호로 위치 찾기 쉽게

대사는 길이가 제각각이라 "6번 대사는 몇 번째 바이트부터"를 계산으로 알 수 없습니다. 그래서 대사마다 시작 주소를 적어 둔 포인터 표를 따로 둡니다. 책의 목차처럼, 대사 번호로 표를 찾아 주소를 읽고 그 주소로 갑니다.

포인터 표 따라가기대사 0x006은 0x000-0x25B 범위라 뱅크 $A8의 표 $A8:8000을 쓴다. $A8:8000 + 0x006 × 2 = $A8:800C에 FE 85가 있어 주소 $85FE다. 같은 뱅크의 $A8:85FE부터 F3 08 어, B5 06 떻, 27 05 게, 0A 00 줄바꿈, … 00 00 끝이 이어진다.대사 번호0x0060x000-0x25B는 뱅크 $A8표 $A8:8000$A8:8000 + 0x006 × 2 = $A8:800C8000…8002…8004…8006…8008…800A…800CFE 85FE 85 = $85FE → 같은 뱅크의 $A8:85FE (파일 0x1405FE)F3 08어B5 06떻27 05게0A 00줄바꿈A1 09잡C6 08아어떻게 / 잡아 볼까‥ (00 00에서 끝)
돌격편 대사 0x006: 번호 → 뱅크 → 포인터 → 문자열

포인터 FE 85는 작은 자리가 먼저라 $85FE입니다. 포인터가 2바이트뿐이라 뱅크 번호가 없고, 표가 있는 뱅크 $A8을 그대로 붙여 읽습니다. 그래서 이 대사는 뱅크 $A8 밖으로 옮길 수 없습니다. 이 한계가 번역문을 넣을 자리를 정합니다(아래 「번역이 길어지면」).

포인터 표를 찾는 가장 확실한 방법은 대사를 읽는 코드를 찾는 것입니다. 돌격편은 $81:AC4C가 대사 번호를 CMP #$14AD, CMP #$121F… 차례로 비교해 뱅크를 고르고, LDA $A88000,X처럼 그 뱅크의 표를 읽습니다. 코드를 모를 때는 2바이트마다 점점 커지는 값이 이어진 곳(문자열 시작 주소들을 리틀 엔디안으로 적은 표)을 롬에서 찾아봅니다.

번호 없이 한 줄로 이어진 대사 쉽게

모든 게임이 대사를 번호로 부르지는 않습니다. 사운드 노벨인 제절초는 게임 전체가 하나로 이어진 대본입니다. 엔진은 지금 읽는 위치(WRAM $52–$54, 24비트)부터 글자를 하나씩 읽어 찍고, 선택지나 조건 점프 코드를 만나면 대본 안의 다른 위치로 건너뜁니다. 점프할 곳은 제어 코드의 인자로 대본 안에 3바이트 주소로 들어 있습니다.

이어진 대본과 점프시작 주소 표 $02:A627에 00 80 20, 95 D3 2E, A8 EA 25가 있어 시나리오 0, 1, 2가 $20:8000, $2E:D395, $25:EAA8에서 시작한다. 대본은 $20:8000부터 뱅크를 넘어 이어지고, 선택지 코드의 인자가 대본 안의 다른 위치로 점프한다.시나리오 0·1·2 시작 주소 표 $02:A627 (작은 자리 먼저)00 80 200번 → $20:800095 D3 2E1번 → $2E:D395A8 EA 252번 → $25:EAA8대본글자 … 글자선택지 코드+ 점프 인자보기 문구갈래 A …인자가 가리키는 곳으로 점프뱅크 끝 $XX:FFFF를 지나면 다음 뱅크 $8000으로 이어 읽음
제절초 한글판 대본. 시작 주소 표와 점프 인자가 대본 안 위치를 직접 가리킵니다.

이런 대본은 줄 단위 포인터 표가 없는 대신, 대본 안의 점프 인자와 시작 주소 표, 그리고 세이브 파일에 적힌 "어디까지 읽었나" 위치가 모두 대본 안 주소입니다. 번역문 길이가 바뀌면 이 주소들을 전부 다시 계산해야 합니다. 제절초 한글판은 대본을 텍스트로 꺼내는 도구와 다시 조립하는 도구를 만들어, 조립할 때 모든 점프 인자를 새 주소로 채웠습니다. 옛 버전 세이브의 위치는 변환표로 새 대본 위치에 맞춥니다.

번역이 길어지면 어디에 두나 쉽게

한국어 번역이 일본어 원문보다 짧으면 제자리에 덮어쓰면 됩니다. 길어지면 남는 곳을 찾아야 하는데, 2바이트 포인터는 같은 뱅크 안만 가리킨다는 점이 가장 큰 제약입니다. 네 패치는 이렇게 풀었습니다.

  • 제자리에서 고치기

    슈퍼 마리오 RPG 전투 메시지

    문자열 뒤에 남는 0 바이트 안에서만 늘립니다. 옮길 빈 곳이 없을 때 씁니다.

  • 뱅크를 처음부터 다시 채우기

    돌격편, 슈퍼 마리오 RPG 대사

    한 뱅크의 문자열을 모두 새로 쓰고, 같은 문자열은 하나만 둔 뒤 포인터 표를 새로 만듭니다.

  • 같은 뱅크의 빈 곳으로 옮기기

    각성편

    안 들어가는 줄만 같은 뱅크 끝의 빈 곳(FF)으로 옮기고 그 포인터만 고칩니다.

  • 늘린 롬으로 통째로 옮기기

    제절초

    롬을 늘려 새 뱅크에 대본 전체를 두고, 대본을 읽는 코드를 바꿉니다.

같은 뱅크 안에서 다시 배치각성편 뱅크 $99는 앞에 포인터 표, 그 뒤에 문자열, 끝에 FF로 남은 곳이 있다. 길어진 대사 B는 제자리에 안 들어가 같은 뱅크 끝의 빈 곳으로 옮기고 포인터만 고친다. 뱅크 $9A로는 옮길 수 없다.각성편 뱅크 $99 ($8000-$FFFF, 32KB)포인터표AB 자리C길어진 B원래 FF$9AB의 포인터만 새 주소로 고침2바이트 포인터에는 뱅크 번호가 없어, 오른쪽 선너머(뱅크 $9A)로는 옮길 수 없습니다.
뱅크 하나 안에서 다시 배치하기. 포인터는 뱅크 경계를 넘지 못합니다.

여러 대사 번호가 같은 문자열 하나를 가리키는 경우도 흔합니다. 이런 문자열을 제자리에서 고치면 다른 번호의 대사까지 같이 바뀝니다. 다시 배치할 때는 같은 문자열을 하나로 모아 공간을 아끼되, 고칠 때는 어느 번호들이 함께 쓰는지 먼저 확인합니다.

더 깊이그룹 오프셋 포인터, 코드 속 포인터, 끝 코드 뒤의 바이트, 창 폭

뱅크 머리말 + 오프셋 포인터(슈퍼 마리오 RPG)

슈퍼 마리오 RPG의 포인터는 주소가 아니라 "그룹 시작에서 몇 바이트"입니다. 대사 번호를 512개씩 묶은 그룹이 여덟 개 있고(번호를 오른쪽으로 9비트 민 값), 그룹 시작 주소는 대사 뱅크 첫머리의 머리말에 적혀 있습니다. 포인터 표는 따로 $F7:E000에 번호마다 2바이트입니다. 머리말은 대사 뱅크마다 있고, 그룹 0–3은 $E2, 4–6은 $E3, 7은 $E4에 있습니다. 한 뱅크의 머리말에 다 들어가지 않는 그룹은 다음 뱅크로 넘어갑니다.

대사 0x204 → 그룹 0x204 >> 9 = 1
$E2:0000  08 00 F5 03 15 33 6D 67   머리말: 크기 $0008(그룹 4개), 그룹 1 시작 $03F5
$F7:E408  25 00                     포인터 $F7:E000 + 0x204 × 2 → $0025
$E2:03F5 + $0025 = $E2:041A         문자열 1B 07 20 1D 07 1D 22 21 00

그룹 순서와 뱅크는 바꿀 수 없지만, 그룹 안의 문자열은 머리말과 포인터 표를 새로 만들면 자유롭게 다시 채울 수 있습니다. 한글판 v1.1은 이렇게 다시 채워 v1.0보다 뱅크 $E2에서 7,584바이트, $E3에서 9,136바이트를 줄였습니다.

포인터가 코드 안에 있을 때

표가 아니라 코드의 LDA #$xxxx 피연산자가 곧 문자열 주소인 경우도 있습니다. 돌격편의 세이브 장면 대사 열두 개와 전투 외침 일곱 개가 그렇습니다. 문자열을 옮기면 그 명령의 둘째·셋째 바이트를 고쳐야 하므로, 덤프 도구에 피연산자 위치(파일 0xD85A 등)를 목록으로 적어 둡니다.

표 사이에 섞인 데이터, 끝 코드 뒤의 바이트

  • 슈퍼 마리오 RPG의 메뉴 문구 영역에는 포인터가 가리키지 않는 데이터가 섞여 있어, 문자열을 옮기지 않고 제자리에서 고쳤습니다. 포인터가 가리키는 곳만 문자열이라고 단정하면 이런 데이터를 덮어씁니다.
  • 같은 게임 v1.0은 원문 위에 덮어쓰면서 끝 코드(00) 뒤에 원문 조각을 남겼습니다. 화면에는 안 나오지만 다시 배치할 때 끝 코드 뒤를 버려야 공간이 생깁니다. 반대로 끝 코드가 잘못 들어가 대사가 잘린 곳(ID 498, 8B4, E85)도 있었습니다. 길이가 0이거나 원문보다 눈에 띄게 짧은 항목은 원문과 대조해 볼 만합니다.

압축된 대본

제절초 일본판의 대본은 허프만 부호로 압축된 비트열(약 246만 비트)이었습니다. 자주 나오는 글자일수록 짧은 비트로 적혀 있어 바이트 단위로는 읽을 수 없습니다. 한글판 v1.0은 대본을 2바이트 글자로 풀어 늘린 롬에 두고 읽는 루틴을 바꿨습니다. 허프만 부호는 10장에서 다룹니다.

창 폭과 줄 수

문자열이 들어갈 공간과 별개로, 화면 창에 들어가는지도 따져야 합니다. 돌격편은 대사마다 창 정보 2바이트가 $8E:C9BA + 번호 × 2에 있고, 첫 바이트가 FF면 나레이션 창(176픽셀), FE면 얼굴 그림 없는 128픽셀 창, FD면 창을 열지 않고, 그 밖은 얼굴 창(128픽셀)입니다(FE는 코드로만 확인했습니다). 글자 폭의 합이 1픽셀이라도 넘으면 마지막 글자가 다음 줄로 밀립니다. 한글판은 빌드할 때 모든 줄의 폭을 계산해 넘치는 줄이 없게 했습니다.

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

돌격편: 뱅크 아홉 개, 뱅크마다 다시 채우기

돌격편 대사 5,370줄은 번호 범위에 따라 뱅크 아홉 곳에 나뉘어 있고, 뱅크마다 2바이트 포인터 표가 있습니다. v1.1은 뱅크마다 일본어 문자열 영역 전체를 처음부터 다시 채우고, 똑같은 문자열은 하나만 두었습니다. 뱅크 $A8은 일본판 문자열 영역 31,520바이트 가운데 27,164바이트를 씁니다.

각성편: 안 들어가는 줄만 같은 뱅크 끝으로

각성편은 같은 엔진이라 글자 하나가 2바이트, 포인터도 2바이트입니다. v1.3은 고친 782줄 가운데 제자리에 들어가지 않는 432줄을 같은 뱅크 끝의 빈 곳(FF)으로 옮기고 포인터만 고쳤습니다. 빌드 뒤에도 뱅크마다 2,500–4,800바이트가 남습니다.

제절초: 1MB 롬을 2MB로 늘려 대본을 통째로

제절초 일본판 대본은 압축된 비트열이라 한국어를 끼워 넣을 수 없었습니다. 한글판 v1.0은 롬을 2MB로 늘려 기호마다 2바이트인 대본을 $20:8000부터 새로 두고, 읽는 루틴($02:C156 등)을 2바이트 읽기로 바꿨습니다. v1.1 대본은 $20:8000–$35:AF66입니다.

슈퍼 마리오 RPG: 이름을 실행 중에 채우기

슈퍼 마리오 RPG v1.0은 아이템 이름을 대사 본문에 영문으로 직접 적었습니다. v1.1은 본문에서 이름을 빼고 이름 넣기 코드 $1B로 바꿨습니다. 이름과 조사는 실행 중에 채워지므로, 이름 길이에 따른 가운데 정렬도 실행 중에 계산합니다.

직접 해 보기 직접 해 보기

대사 번호로 뱅크를 고르는 돌격편 루틴을 툴킷으로 역어셈블하면 위 표의 경계값이 그대로 보입니다.

$ krpatch snes dis totsugeki-k.sfc 81AC4C 6 --mapper lorom --m16 --x16
81:AC4C  a5 4d        LDA $4D
81:AC4E  c9 ad 14     CMP #$14AD
81:AC51  b0 43        BCS $81AC96
81:AC53  c9 1f 12     CMP #$121F
81:AC56  b0 56        BCS $81ACAE
81:AC58  c9 58 0f     CMP #$0F58

Python에서는 툴킷의 매핑 객체로 포인터를 따라가 문자열 바이트까지 읽을 수 있습니다.

>>> from kr_patch.snes import mapper
>>> lo = mapper.LoROM()
>>> rom = open('totsugeki-k.sfc', 'rb').read()
>>> p = lo.to_offset(0xA88000 + 0x006 * 2)
>>> hex(p), rom[p:p+2].hex(' ')
('0x14000c', 'fe 85')
>>> s = lo.to_offset(0xA80000 | int.from_bytes(rom[p:p+2], 'little'))
>>> hex(s), rom[s:s+22].hex(' ')
('0x1405fe', 'f3 08 b5 06 27 05 0a 00 a1 09 c6 08 c5 02 e5 07 85 05 6b 01 00 00')

나온 바이트를 글자표와 맞춰 보면 「어떻게/잡아 볼까‥」가 됩니다. 다른 게임이라면 화면에 나온 짧은 대사를 하나 골라, 그 글자들이 폰트의 몇 번 그림인지부터 알아낸 다음 롬에서 그 번호 줄을 찾아보세요. 찾은 문자열의 시작 주소가 들어 있는 곳이 포인터 표입니다.