롬 분석 노트 · 슈퍼 패미컴
롬 분석 노트: 제절초
제절초 한글패치 v1.2을 만들며 남긴 기록입니다. 개념은 각 절의 「가이드」 링크에서 처음부터 풀어 설명합니다.
이 롬을 다시 분석하거나 한글판을 이어서 고칠 사람을 위해 남기는 기록입니다. 2016년에 낸 v1.0은 2회차부터 멈추거나, A 버튼을 누르지 않았는데 화면이 넘어가거나, 단어 중간에서 줄이 끊기는 문제가 있었습니다. v1.1은 이 문제를 롬에서 원인을 찾아 고치고 번역을 원문과 대조해 다시 다듬었습니다. v1.2는 한 걸음 더 나아가 원작에 없던 플로차트, 장 목록, 엔딩 목록을 넣었습니다. 게임 엔진에 새 화면과 새 동작을 끼워 넣은 것이라 번역 패치라기보다 개조 롬에 가깝습니다.
1. 배경지식
게임기 안의 메모리
| 이름 | 크기 | 비유 | 하는 일 |
|---|---|---|---|
| 롬 | 1MB → 2MB | 책 | 카트리지에 든 프로그램·대사·그림·폰트. 읽기만 합니다. 일본판은 1MB, 한글판은 2MB입니다. |
| WRAM | 128KB | 작업 책상 | CPU가 계산하며 값을 적어 두는 곳. 지금 읽는 위치, 선택지 기록이 여기 있습니다. |
| SRAM | 8KB | 책갈피 | 전원을 꺼도 남는 세이브 메모리. 책갈피 슬롯 4개가 들어 있습니다. |
| VRAM | 64KB | 스티커판 | 화면에 붙일 그림 조각을 올려 두는 곳. CPU가 아무 때나 쓸 수 없습니다. |
주소 읽는 법
$15:E401 같은 SNES 주소는 CPU가 보는 뱅크:주소이고, 0xAE401 같은 파일 오프셋은 롬 파일을 처음부터 센 위치입니다. 이 게임(LoROM)은 뱅크 하나가 롬의 32KB를 비춥니다.
파일 오프셋 = (뱅크 & 0x7F) × 0x8000 + (주소 & 0x7FFF)
$15:E401 → 0x15 × 0x8000 + 0x6401 = 0xAE401대본은 글자와 명령의 줄
사운드 노벨의 대본은 글자 사이사이에 명령(제어 코드)이 섞인 하나의 긴 줄입니다. 게임은 이 줄을 앞에서부터 읽으며 글자는 화면에 찍고 명령은 실행합니다. 아래 표기는 이 글에서 쓰는 형식입니다.
{00} | 줄바꿈 |
{03:@주소} | 대본의 다른 곳으로 점프 |
{16} | A 버튼을 기다림 |
{19} {1A} | 선택지. 보기마다 갈 곳(점프 주소)이 붙어 있음 |
{1C} | 선택지 보기 하나의 끝 |
{20} | 주인공 이름 |
{25} | 화면을 지우고 다음 페이지로 |
{2E}…{37} | 배경 그림 바꾸기 |
{39}…{3D} | 음악·효과음 재생과 정지 |
후킹
원래 코드 사이에는 새 기능을 넣을 자리가 없습니다. 그래서 원래 코드의 한 지점을 "새 코드로 가라" 는 명령으로 덮어쓰고, 새 코드는 롬의 빈 공간에 둡니다. v1.1과 v1.2는 이런 지점을 스무 곳 넘게 바꿨습니다. 어셈블리 소스는 asar로 롬에 덮어씁니다.
org $009CF9 ; 입력 대기 중 X(되감기)를 확인하던 자리를
JSL SelCheck ; Select도 확인하는 새 코드로 보낸다2. 대사가 들어 있는 방식
일본판은 대사 전체를 허프만 부호로 압축해 비트 단위로 이어 붙였습니다. 자주 나오는 글자일수록 짧은 비트로 적는 방식이라, 점프 주소도 바이트가 아니라 "기준점에서 몇 번째 비트" 로 적혀 있습니다. v1.0은 압축을 버리고 글자마다 2바이트로 적은 대사를 롬 뒤쪽에 새로 늘린 1MB에 넣었습니다. 대신 일본판 대사가 있던 자리는 0으로 비었는데, 이 자리가 나중에 v1.1과 v1.2의 코드가 들어갈 곳이 됐습니다.
대본의 첫 문장을 두 롬에서 그대로 꺼내 보면 차이가 보입니다. 일본판은 기준점($15:E401)에서 126번째 비트부터 「夏の夕暮。」와 줄바꿈이 61비트에 들어 있습니다. 비트를 하나씩 읽어 허프만 트리를 따라 내려가다가 잎에 닿으면 글자 하나가 나옵니다. v1.0의 「여름의 해질녘.」은 $20:8018(파일 0x100018)부터 2바이트씩 18바이트입니다.
글자마다 길이가 다르니 일본판에서는 "몇 번째 글자" 를 바이트로 셀 수 없습니다. 그래서 점프 주소도 비트 번호입니다. 첫 선택지의 첫 보기는 일본판에서 3,109번째 비트, v1.0에서는 $20:8349로 갑니다. 대본 전체는 일본판이 약 30만 바이트, v1.0이 694,995바이트입니다.
먼저 두 형식을 모두 읽는 파서를 만들고 대본을 6,730줄의 텍스트로 꺼냈습니다. 점프 대상이 되는 곳에서 줄을 나눴더니 일본어 대본과 한국어 대본이 줄 번호까지 똑같이 맞았습니다. 그다음 꺼낸 대본을 고치지 않고 다시 조립했을 때 v1.0 롬과 바이트 단위로 같아지는 것을 확인했습니다. 이게 맞아야 이후의 수정이 다른 곳을 망가뜨리지 않았다고 믿을 수 있습니다.
3. 2회차에 멈추던 이유
게임은 새 회차를 시작할 때 회차 수와 난수로 세 시나리오 중 하나를 고르고, 시작 주소 표($02:A627)에서 그 시나리오의 첫 위치를 읽습니다. 2번 시나리오는 2회차부터 뽑힙니다. v1.0은 표의 0번, 1번만 새 대사 위치로 바꾸고 2번은 일본판 값을 그대로 둬서, 2번이 뽑히면 엉뚱한 데이터를 대사로 읽었습니다.
번역문은 v1.0에도 들어 있었습니다. 0번 시나리오 끝 바로 다음이 2번 시나리오의 시작이라, 3바이트만 고쳐 넣으면 됐습니다. 회차 수를 메모리에서 직접 1, 2, 3으로 바꿔 세 시나리오가 모두 정상으로 시작하는 것을 확인했습니다.
4. A 버튼 없이 넘어가던 화면
엔진은 「」』.?! 같은 문장 끝 글자를 찍으면 알아서 A 버튼을 기다리고, {16}을 만나도 기다립니다. v1.0은 일본어의 「。」를 「…」나 띄어쓰기로 옮기면서 기다림이 빠진 곳과, 자동 대기 바로 뒤에 {16}이 또 붙은 곳이 생겼습니다. 앞의 경우는 버튼을 누르지 않아도 페이지가 지워졌고, 뒤의 경우는 두 번 눌러야 넘어갔습니다.
한국어 대본과 일본어 대본이 줄 단위로 맞으니, 줄마다 "어디서 기다리는가" 를 두 대본에서 계산해 비교할 수 있습니다. 일본판이 기다리는 곳에만 {16}을 90곳 남짓 넣고, 겹친 대기 30곳 남짓을 지웠습니다. 첫 자동 수정은 기준을 잘못 잡아 270곳 넘게 고쳐 버려서, 비교 결과를 줄마다 다시 보며 범위를 좁혔습니다. 지금은 빌드할 때마다 이 비교 결과를 파일로 남기고, 0건이어야 통과입니다.
5. 글자표를 눈으로 읽게 하기
대사를 고치려면 롬 속 글자 번호가 어떤 한글인지 적은 글자표가 있어야 하는데, v1.0의 글자표는 남아 있지 않았습니다. 그래서 폰트 1,282자를 그림으로 그려 macOS의 문자 인식(Vision)에 읽혔습니다. 한 글자씩 넣으면 잘 못 읽어서 여러 글자를 한 줄로 이어 붙였고, 조건을 바꿔 9번 읽힌 뒤 다수결로 정했습니다. 이 폰트의 ㅇ은 모서리만 빈 사각형이라 문자 인식이 ㅁ으로 자주 읽었습니다. 받침과 초성의 모서리 픽셀을 보고 가리는 규칙을 더했습니다. ㅗ·ㅜ의 꼭지는 1픽셀이라 작게 줄인 그림에서는 ㅡ와 구별되지 않아서, 남은 의심 글자는 픽셀을 직접 보며 대사 문맥과 맞춰 검수했습니다. 잋→잊, 꿀→훌, 뒸→뒀처럼 30자를 바로잡았습니다.
재번역으로 새로 필요해진 글자 75자는 v1.0 글자의 부분을 오려 붙이거나 직접 그려서 같은 모양으로 맞췄습니다. 예를 들어 「갹」은 「같」의 ㄱ, 「걷」의 ㅓ, 「밑」의 받침 ㅌ을 이어 붙였습니다. 처음에는 자모를 기계적으로 합성해 봤지만, 이미 있는 글자로 시험하니 크게 틀린 것이 300자가 넘었습니다. 이 폰트는 글자마다 자모 위치가 조금씩 다르기 때문입니다. Galmuri 폰트로 전부 새로 그리는 안도 나란히 찍어 비교했는데, v1.0 글자가 더 보기 좋아서 그대로 두었습니다. 글자를 넣을 자리도 문제였습니다. 폰트는 크기가 같은 글자끼리 묶은 그룹 표로 찾는데, 표 바로 뒤에 다른 데이터가 있어 그룹은 58개가 한계이고 v1.0이 58개를 다 썼습니다. 아무 데서도 쓰지 않는 채움 글자를 이웃 그룹과 같은 크기의 빈 글자로 바꿔 그룹을 합치니 45개로 줄었고, 새 글자 75자를 3개 그룹으로 더해도 48개라 자리가 남았습니다.
엔진은 표 끝 표시를 따로 보지 않습니다. 글자를 찾는 코드($00:A4F4–$00:A51C)는 첫 칸부터 칸마다 글자 수를 더해 가다가, 찾는 코드가 들어가는 칸에서 멈춥니다. 그래서 마지막 그룹보다 큰 코드를 대본에 쓰면 표 뒤의 $00:F5CE 데이터를 그룹 칸으로 읽습니다.
빈 글자로 바꾼 코드는 168개입니다. 대사, 이름 입력 화면의 글자 표, 문자 목록 두 곳 어디에서도 쓰지 않는 코드만 골랐습니다. 이름 입력 화면이 대사와 같은 코드를 쓰기 때문에, 어디서든 쓰이는 글자는 코드와 모양을 그대로 두었습니다.
6. 단어 단위 줄바꿈
일본어는 띄어쓰기가 없어 엔진이 글자 단위로 줄을 바꿔도 자연스럽습니다. 한국어는 「유명/했는데」처럼 단어 중간에서 끊기고, 줄 끝 공백이 다음 줄 첫머리로 넘어가 한 칸 들여 쓴 것처럼 보였습니다.
글자를 찍는 루틴($00:A777)을 후킹해, 공백이나 쉼표·마침표 뒤에서 대본의 다음 단어 폭을 미리 재고 줄 끝을 넘으면 먼저 줄을 바꾸게 했습니다. 주인공 이름처럼 길이가 그때그때 다른 것도 실행 중에 재므로 맞게 동작합니다. 대신 줄이 늘어나 한 화면 9줄을 넘는 페이지가 생깁니다. 대본의 모든 갈래를 따라가며 페이지마다 줄 수를 계산하는 시뮬레이터를 만들어 넘치는 54페이지를 찾았고, 기다림이 있는 문장 끝에서 페이지를 나눴습니다.
시뮬레이터와 실제 게임이 어긋난 적도 있습니다. 시뮬레이터로는 맞았는데 비교 화면에서 「그/럴」처럼 끊긴 줄이 보였습니다. 마침표·느낌표는 찍히는 순간 엔진이 입력 대기 명령을 버퍼에 끼워 넣는데, 후킹 코드가 그 상태를 "이름을 찍는 중" 으로 착각해 단어 폭 재기를 건너뛰고 있었습니다. 또 「!?」가 「! ?」로 벌어지는 곳이 143곳 있었는데, 넓은 「!」 글자에 뒤 간격이 들어 있어서였습니다. 이 경우만 좁은 「!」를 쓰게 했습니다.
7. 원문 대조 재번역
6,730줄을 일본어 원문과 나란히 놓고 다시 다듬었습니다. 새로 번역하지 않고 v1.0 문장을 바탕으로 오역과 누락을 고쳤습니다. 뱀 여자가 「뱀파이어」가 된 곳, 고사포가 「고주파」가 된 곳, 「나라 미인이라 나미」 대목이 통째로 빠진 곳 같은 것들입니다. 「번번번…」으로 깨진 대사 8곳(옛 도구가 「………」를 잘못 바꾼 흔적), 효과 명령의 인자 자리에 글자가 들어가 버린 줄 같은 데이터 오류도 이때 찾았습니다.
- 대본을 24묶음으로 나눠 여럿이 나란히 고쳤더니 묶음마다 용어가 갈렸습니다(피아노 방/홀, 칼/나이프 등). 갈린 용어를 한곳에 모아 한꺼번에 통일했습니다.
- 검수는 두 관점이 토론하는 방식으로 했습니다. 공포 사운드 노벨 팬과 국어 교정자가 서로의 수정안을 반박하고, 반박을 이겨 낸 수정만 남겼습니다. 이렇게 약 300건을 고쳤습니다. v1.0의 말장난 중 분위기를 깨지 않는 것은 살렸습니다.
- 제어 코드는 한 줄에서 하나라도 어긋나면 게임이 엉뚱하게 흘러가므로, 고친 문장은 제어 코드 배열이 원래와 같을 때만 반영되게 했습니다. 바꿀 구절이 그 줄에 정확히 한 번 있을 때만 바꾸는 것도 같은 이유입니다.
8. 옛 세이브로 이어하기
세이브에는 "대본의 어디까지 읽었나" 가 롬 주소로 들어 있습니다. 대사를 다시 쓰면 같은 장면의 주소가 모두 달라지므로, v1.0이나 일본판 세이브를 그대로 불러오면 엉뚱한 곳을 읽습니다. 그래서 줄마다 옛 주소와 새 주소를 짝지은 변환표(각 4,854곳)를 롬에 넣고, 세이브를 불러올 때 표에서 찾아 바꾼 뒤 "변환함" 표식을 남기게 했습니다. 일본판 세이브의 이름은 한글 롬에서 다른 글자로 보이므로 기본 이름 코헤이로 바꿉니다.
9. v1.2 편의 기능: 빈 버튼 찾기
여기부터는 원작에 없는 기능을 엔진에 더한 이야기입니다. 엔딩이 18개인 게임을 처음부터 다시 읽어 가며 모으는 것은 고역이라, 같은 춘소프트의 「카마이타치의 밤」에 있는 장 선택을 옮겨 오는 것에서 시작했습니다. 카마이타치의 밤 SFC판을 에뮬레이터로 직접 돌려 보고 설명서를 읽어 동작을 맞췄습니다. 처음에는 R로 장 화면을 여는 줄 알았는데, 설명서에 R은 쓰지 않는다고 적혀 있었습니다. 앞서 누른 Select가 늦게 반영된 것을 잘못 본 것이었습니다. 나중에 GBA판의 플로차트까지 더했습니다.
먼저 기능을 열 버튼이 필요했습니다. 게임이 패드 값을 받아 쓰는 루틴($00:8B9A)을 읽어 보니 Select와 R은 어디서도 쓰지 않았습니다. A 버튼 입력 대기 중 X를 누르면 되감기로 가는 자리($00:9CF9)가 있어서, 그 바로 앞을 후킹해 Select를 먼저 보게 했습니다.
10. 대본을 읽는 손이 셋
장을 고르면 "지금 읽는 위치" 를 그 장의 시작 주소로 바꾸면 될 것 같았습니다. 결과는 무시되거나, 검은 화면에서 영원히 멈추는 것이었습니다. 멈춘 화면에서 거꾸로 따라갔습니다. 대본이 메뉴 버퍼의 한 곳에서 서 있었고, 그 자리는 장면 번호가 준비되기를 기다리는 배경 명령이었습니다. 그 장면 번호를 쓰는 코드를 감시하니 태스크를 시작하는 루틴이 나왔고, 그 태스크를 다시 시작하는 곳이 점프 명령 처리 뒤에 있었습니다. 대본을 읽는 손이 하나가 아니었던 것입니다.
- 엔진은 태스크(번갈아 도는 작은 프로그램)를 여러 개 돌립니다. 본문 말고도 줄 나눔을 미리 재는 태스크와, 다음 장면 그림을 미리 올려 두는 태스크가 각자의 위치에서 대본을 읽습니다.
- 위치는 네 벌 있고, 엔진은 그중 하나에서 본문 위치를 다시 읽어 옵니다. 하나만 바꾸면 다음 순간 원래 값으로 돌아갑니다. 세이브를 불러올 때처럼 네 벌을 모두 써야 합니다.
- 그림에 없는 넷째 사본 $1989는 태스크 1의 위치입니다. 태스크 1은 $00:9B53에서 진입점 $00:CB99로 시작해 대본을 앞서 읽습니다. 소리 명령({39} {3D} {45} 등)을 만나면 $00:CCAC가 $01:8007 표의 목록을 사운드 명령 버퍼($10A5/$10A6, 잠금 $10A3)로 보냅니다. 선택지·점프·엔딩 같은 코드({19} {1A} {04} {05} {1D} {23} {24} {43} {44})에 닿으면 위치를 $1989–$198C에 남기고 끝나고, 다음에 시작할 때 $00:CC96이 거기서 다시 읽습니다. 소리를 미리 준비해 두는 태스크로 보는데, 이 해석은 추정입니다.
- 본문이 배경 명령에 닿으면 장면 미리 읽기가 그 그림을 준비할 때까지 기다립니다. 장면 미리 읽기가 옛 자리를 읽고 있으면 기다리는 그림이 영영 오지 않습니다. 게임은 점프 명령을 실행한 뒤 미리 읽기를 새 위치에서 다시 시작하는 루틴($00:9A5A)을 부르는데, 위치를 직접 바꿀 때도 이걸 불러야 했습니다.
- 줄 나눔 태스크는 앞서 읽으면서 $28보다 작은 번호의 명령은 실제로 실행합니다. 처음에 메뉴 동작을 안 쓰는
{23}에 달았더니 메뉴를 고르기도 전에 미리 실행됐습니다. 그래서 새 명령은 $28 이상인{44}로 옮겼고, 점프를 가로채는 후킹은 본문이 읽을 때만 동작하게 했습니다. - 쓰지 않는
{23}($00:E13F)과{24}($00:E1DD)는 장면 번호를 골라 그리로 가는 디버그 기능입니다.{23}은 $7F:FFF0의 번호 × 14로 표를 읽어 $5F 버퍼에 기호와{24}를 넣고, 상태를 $1A03 등에 보관하고 $1BB1을 3으로 둡니다.{24}는 $1BAD를 $FE로 두고, 다음 프레임부터 메인 루프의 $00:E1E4가 십자 키로 번호를 바꿉니다(위아래 ±1, 좌우 ±20). A나 B로 나갑니다. 다만 개수 표와 장면 표를 가리키는 주소가 0으로 지워져 있어 $00:0000을 읽습니다. 일본판, v1.0, v1.1 대본 어디에도 이 두 코드는 나오지 않습니다.
11. 메뉴는 그리지 않고 대본으로 쓴다
장 목록을 화면에 새로 그리려면 커서, 소리, 입력 처리를 모두 새로 만들어야 합니다. 그 대신 메뉴를 대본으로 써서 게임에게 읽혔습니다. 대본에 선택지 명령을 쓰면 게임이 원래 선택지 화면 그대로 보여 주고, 커서와 효과음도 원래 것이 나옵니다.
- 대본에서 쓰지 않는 명령 번호
{44}를 "메뉴 동작" 으로 새로 달았습니다. 보기마다 이 명령이 붙어 있어, 고르면 장 번호, 다음 쪽, 엔딩 목록, 돌아가기 중 하나를 실행합니다. - 메뉴 대본은 롬이 아니라 WRAM의 빈 곳에 실행 중에 씁니다. 이번 회차에 읽은 장만 보여야 하기 때문입니다. "다음 쪽" 을 고르면 다른 버퍼에 다음 쪽을 쓰고 그쪽으로 넘어갑니다. 지금 읽고 있는 버퍼를 덮어쓰지 않으려고 두 벌을 번갈아 씁니다.
- 게임 입장에서는 메뉴도 선택지라서, 고를 때마다 "골라 본 선택지 수" 가 늘고 되감기 기록이 지워지고 자동 저장이 일어납니다. 메뉴를 열 때 이 값들을 저장해 두고, 메뉴 밖으로 점프하는 순간 되돌리게 했습니다. 메뉴 안에서는 자동 저장도 건너뜁니다.
- 고르자마자 화면 지우기
{25}를 쓰면, 선택지가 아직 보기를 지우는 중이라 다음 페이지가 멈추는 경우가 있었습니다. 앞에 잠깐 멈춤{17:5}을 넣었습니다. - 한 번에 보이는 보기는 5개까지입니다. 6개를 넣으면 화면이 깨져서, 장 목록은 한 쪽에 3개씩 나눴습니다.
- 장 목록은 원작 선택지처럼 지금 장면의 배경 위에 뜹니다. 그래서 장면에 따라 메뉴 위로 눈알 같은 그림이 보이는데, 이것은 스프라이트가 아니라 장면 배경(BG2)입니다. 그때 화면의 스프라이트(OAM)는 0개이고, 메인 화면 레이어 값 $0A0E를 바꿔 보면 BG2가 켜져 있을 때만 보입니다. 원작의 선택지 화면과 같은 동작이라 그대로 두었습니다.
12. 장으로 옮길 때 배경과 음악
장 첫 페이지가 배경을 정하지 않는 경우가 많았습니다. 원래 흐름에서는 바로 앞 장면의 배경이 이어지기 때문입니다. 빌드할 때 대본을 따라가며 "이 장 시작 직전에 마지막으로 적용된 배경 명령" 을 찾아, 장으로 점프하기 직전에 그 명령을 넣었습니다. 시나리오 첫머리의 배경 명령은 아주 천천히 밝아져 10초 가까이 검은 화면이 이어져서, 점프용으로 넣을 때는 속도를 바꿔 3초쯤으로 줄였습니다.
사실 대본 점프는 두 번째 방식입니다. 처음에는 세이브를 고쳐 쓰고 게임을 다시 시작하는 방식(웜 재시작)으로 장을 옮겼습니다. 그런데 사운드 칩 프로그램이 돌고 있으면 다시 올리는 단계에서 멈췄고, 올리기를 건너뛰면 화면에 줄무늬가 생기거나 에뮬레이터마다 다른 곳에서 멈췄습니다. 고치고 고쳐 코어 4개는 통과했지만 snes9x와 Mesen은 끝내 검은 화면에서 멈췄습니다. 그래서 이 방식을 버리고 모든 이동을 게임 자신의 대본 점프로 바꿨습니다. 게임이 원래 하는 동작이라 에뮬레이터를 가리지 않습니다.
대가로 음악은 직접 챙겨야 했습니다. 게임의 사운드 명령 다섯 개를 후킹해 지금 반복 재생 중인 음악·환경음(57가지)을 비트맵으로 기억하게 했습니다. 빌드 때 계산한 "그 장에서 울려야 할 반복음" 과 비교해, 꺼야 할 것은 끄고 켜야 할 것은 페이드 인하는 명령을 점프 앞에 넣습니다.
13. 읽은 장과 본 엔딩 기록
기록은 SRAM에서 책갈피 슬롯 4개 뒤의 빈 곳($70:1800)에 둡니다. 맨 앞 2바이트 표식이 없으면 처음 켤 때 초기화합니다. 그래서 v1.1, v1.0, 일본판 세이브를 그대로 불러올 수 있습니다.
+0 | 4바이트 | 이번 회차에 읽은 장 (장 하나가 1비트) |
+4 | 4바이트 | 지금까지 읽은 장 |
+8 | 4바이트 | 본 엔딩 |
+12 | 1바이트 | 마지막으로 읽은 장 (책갈피에 표시) |
+13 | 2바이트 | 장에 들어갈 때의 갈래 변수와 그 유무 |
- 이야기를 30장으로 나누고 장마다 대본의 줄 범위를 정했습니다. 자동 저장이 일어날 때 읽는 위치가 어느 장 범위에 들어가는지 보고 그 장을 기록합니다. 여러 갈래를 오가도 들어간 장은 빠짐없이 남습니다.
- 엔딩 끝의
{1D}는 명령처럼 생겼지만 실행되지 않고, 새 회차 처리 코드가 이 값을 직접 찾습니다. 그래서{1D}에 기록 코드를 다는 대신, 새 회차가 시나리오를 고르는 순간을 후킹해 그때의 위치로 어느 엔딩이었는지 판단합니다. - 몇몇 장은 첫머리에서 변수 하나로 갈래를 정합니다. 다른 곳에서 바로 그 장으로 옮기면 변수 값이 달라 엉뚱한 갈래로 가므로, 장에 들어갈 때의 값을 남겨 뒀다가 옮길 때 되돌립니다.
14. 플로차트
선택지 382개와 엔딩 18개가 노드입니다. 보기들이 가는 곳을 이어 보면 나무가 아니라 그물입니다. 대부분의 선택지가 나란히 흐르는 여러 갈래 사이를 오가고, 두 길 이상에서 들어오는 노드가 305개나 됩니다. 아래는 모든 노드를 다 봤을 때의 지도로, 흰 상자가 한 화면에 들어가는 양입니다.
첫 시안은 선택지를 대본 순서대로 한 줄에 꿰어 그렸는데, 분기가 하나도 안 보이는 사슬이 됐습니다. 보기마다 가는 곳을 세어 보니 3~4곳으로 갈리는 선택지가 292개, 2곳이 46개, 말투만 다르고 같은 곳으로 가는 것이 44개였습니다. 한 화면에 다 그리면 선이 엉키므로, GBA판처럼 본 노드만 그리고 장 단위로 나무를 나눴습니다. 번호는 시나리오 시작 세 곳부터 너비 우선으로 매겨 형제 노드가 이어진 번호를 갖게 했습니다. 깊이 우선으로 매기면 첫 보기만 따라가도 그물 전체를 돈 뒤에야 형제로 돌아와서, 1번의 자식이 2번, 378번, 381번처럼 흩어집니다. 다른 장으로 가는 길은 "→ 장 번호" 상자로 다음 줄에 둡니다.
- "어느 보기를 골랐나" 는 새로 저장하지 않았습니다. 게임이 선택지마다 고른 보기를 이미 세이브 변수의 상위 비트에 남기고 있었습니다. 선택지 382개가 변수 380개를 쓰고, 둘이 같이 쓰는 변수는 2개뿐이라 그대로 쓸 수 있었습니다.
- 화면은 배경 레이어를 끄고 글자 레이어만 남긴 뒤, 그 레이어의 비트맵(WRAM)에 상자와 선을 픽셀 단위로 직접 찍습니다. 번호와 A–E는 게임 폰트가 너무 커서 4×6 픽셀 글자 17개를 새로 그렸고, 장 제목만 게임 폰트로 씁니다.
- 플로차트가 떠 있는 동안은 입력 대기 후킹 안에서 자체 루프로 돕니다. 매 프레임 글자 레이어를 VRAM에 올리고 화면 갱신을 기다립니다. 닫을 때는 버튼을 뗄 때까지 기다리는데, 안 그러면 게임이 그 버튼을 새 입력으로 받아 페이지가 한 장 넘어갔습니다.
- A를 누르면 그 선택지가 있는 페이지의 처음으로 갑니다. 메뉴와 같은 방식으로 화면 지우기, 배경, 음악, 점프 명령을 대본으로 써서 게임에게 읽힙니다.
- 스포일러를 줄이려고 아직 지나지 않은 노드는 그리지도, 커서로 가지도, 미니맵에 점을 찍지도 않습니다. 다만 지나간 선택지는 고르지 않은 보기 글자도 보여 주고 고른 보기만 노랗게 칠해서, "여기서 다른 걸 골라 볼 수 있다" 는 것까지만 알게 했습니다.
- 상자가 6개 이하인 줄은 가운데에 놓고, 그보다 긴 줄만 왼쪽부터 놓고 좌우로 스크롤합니다. 위아래로 움직이면 화면에서 가로 위치가 가장 가까운 상자로 갑니다.
15. 책갈피와 노란 글씨
X로 되감아 본 페이지를 노란 글씨로 보이게 하는 것은 의외로 간단했습니다. 글자는 팔레트의 한 색으로 찍히므로, 되감기에 들어갈 때 그 색을 노랑으로 바꾸고 나올 때 되돌리면 됩니다. 팔레트는 화면 갱신 틈에만 바꿀 수 있어 게임의 팔레트 사본을 고친 뒤 "다음 틈에 올려라" 표시를 켭니다. 책갈피 화면은 슬롯을 그리는 루틴을 후킹해, 장 기록이 있는 슬롯은 둘째 줄에 페이지 수 대신 마지막으로 읽은 장 제목을 게임 폰트로 그립니다.
여기서도 한 번 검은 화면에 멈췄습니다. 65c816은 돌아오는 명령이 두 가지(RTS, RTL)라 부를 때와 같은 짝으로 돌아가야 하는데, 먼 곳에서 불린 코드(JSL)가 가까운 호출용 루틴(RTS로 끝남)으로 그냥 뛰어가 버려 엉뚱한 주소로 돌아갔습니다. 짝을 맞추는 중간 단계를 두어 고쳤습니다.
16. 코드를 둔 자리
v1.1과 v1.2의 코드와 표는 모두 v1.0이 일본판 대사를 0으로 지운 자리에 들어갔습니다. 롬을 더 늘리지 않아도 됐습니다. WRAM은 6,000프레임을 플레이하며 게임이 쓰는 곳을 모두 기록한 뒤, 한 번도 쓰이지 않은 곳만 골라 메뉴 버퍼와 플로차트 작업 공간으로 썼습니다.
v1.0도 같은 자리에 자기 것을 넣었습니다. 0으로 비운 자리 가운데 $16:806A–$16:AE4E만 다른 바이트로 채워져 있는데, v1.0이 넣은 그래픽과 코드입니다. v1.0은 일본판 코드 7곳($00:B472, $00:C436, $00:C4EC, $00:D585, $02:B521, $02:B54B, $02:BCF9)을 이 근처로 가는 점프(JML)로 바꿨고, $15:E401–$15:E52A 부근도 v1.0 코드입니다. 이 후킹들이 $0100에 1~3을 적으면, 화면 갱신 후킹($00:C4EC → $15:E499)이 그 번호의 그래픽을 VRAM으로 보냅니다.
| $0100 | 롬 | 크기 | VRAM | 때 |
|---|---|---|---|---|
| 1 | $16:8000 | 0x1200 | $5700 | 켠 직후 |
| 2 | $16:A67B | 0x580 | $4840 | 책갈피 화면 앞 |
| 3 | $16:AC7B | 0x1C0 | $4330 | 이름 입력 화면 앞 |
v1.2가 쓰는 WRAM은 최종 롬으로 다시 확인했습니다. 장 시작 30곳, 엔딩 직전 18곳에서 이어지는 세이브와 시나리오 3개의 새 게임, 모두 51번을 1만 2천 프레임씩 돌렸습니다. 장 시작 세이브에서는 Select, 십자 키, X·Y, A를 섞어 플로차트와 메뉴를 오가게 했고, 나머지는 A를 연타했습니다. 세이브는 しおり 단계를 7로 맞춰 숨은 보기까지 모두 보이게 했습니다. 패치가 쓰는 구간($7F:C000–$7F:D8FF, $7F:FFE0–$7F:FFEF, $7E:1FF2–$7E:1FF3)에 패치 코드가 아닌 곳에서 쓴 것은 켤 때 $0000–$1FFF를 0으로 지우는 루프($00:8030)뿐이었습니다. WRAM 포트($2181–$2183)로 주소를 정하는 쓰기도 없었고, 51번 모두 $00:0000–$00:08FF로 뛰는 폭주가 없었습니다.
17. 확인은 에뮬레이터가
Mesen 2의 테스트 모드와 Lua 스크립트로 버튼 입력을 재생하고, 원하는 순간의 화면과 메모리를 저장했습니다. 멈춤을 찾을 때는 이 순서가 가장 빨랐습니다.
- 프레임마다 CPU가 실행 중인 주소(PC)를 기록해 어디서 멈췄는지 찾습니다.
- 멈춘 곳이 기다리는 메모리 값에 쓰기 감시를 걸어, 그 값을 바꾸는 코드의 주소와 호출 순서를 봅니다.
- 정상으로 진행하는 경우에도 같은 감시를 걸어 두 기록을 나란히 비교합니다.
- 회귀 검사: 같은 세이브에서 A를 3만 프레임 연타했을 때 자동 저장 위치의 순서가 v1.1과 같은지 비교합니다.
- 장 이동: 30장을 하나씩 메뉴로 옮겨 첫 화면을 찍고 배경과 첫 문장을 확인합니다.
- 깊은 장면은 그 장면 직전에서 이어지는 세이브 파일을 직접 만들어 찍습니다. 비교 화면은 일본판 기본 이름 코헤이로 찍었습니다.
- libretro 코어를 직접 불러 쓰는 작은 프로그램을 만들어, 같은 입력을 여러 에뮬레이터에 넣고 새 게임, 이어하기, 장 목록, 플로차트를 비교했습니다.
- 아래 표는 최종 v1.2(CRC32 2C31A0B2)로 다시 돌린 결과입니다. 코어마다 7가지를 넣었습니다. 새 게임(헤더 없는 롬, 헤더 있는 롬), v1.1·v1.0·일본판 세이브로 이어하기, 장 목록, 플로차트입니다. 정상인 코어는 끝까지 같은 장면과 선택지로 갔고, 차이는 글자가 찍히는 타이밍 몇 프레임뿐이었습니다. Mesen 2 줄은 그 전에 Mesen 2로 직접 확인한 결과입니다.
| 에뮬레이터 | 결과 |
|---|---|
| Mesen 2 | 정상 (1~4회차, 모든 세이브 종류 이어하기) |
| bsnes | 정상 (7가지 모두) |
| bsnes-hd beta | 정상 (7가지 모두). 화면을 224줄이 아닌 216줄로 내보냄 |
| snes9x, snes9x2010 | 정상 (7가지 모두) |
| Beetle Supafaust | 정상 (7가지 모두) |
| Mesen-S 코어 | 판정 불가. 7가지 모두 테스트 프로그램이 바로 종료(exit 139)하고, 일본판 원본도 같음 |
18. 다음 사람에게
롬 주소와 형식은 패치 소스의 docs/ANALYSIS.md에 모았습니다. 헤더, 메모리 지도, 대본 형식, 제어 코드, 폰트 그룹 표, 후킹 지점 전체(원래 바이트와 바꾼 바이트), 세이브 형식, 쓴 공간이 들어 있고, 직접 확인한 것은 [실측], 짐작만 한 것은 [추정]으로 나눠 적었습니다. 여기에는 확인하지 못한 것과 남은 자리만 적습니다.
확인하지 못한 것
- 실기(실제 슈퍼 패미컴, 플래시 카트리지)에서 돌려 본 기록은 작업 노트에 없습니다. 위 에뮬레이터 결과가 전부입니다.
- 줄 수, 입력 대기, 점프는 도구로 모든 갈래를 계산했습니다. 화면으로 확인한 것은 장 30개의 첫 화면, 시나리오 3개를 3만 프레임씩 진행한 결과, 1~4회차 시작입니다.
- WRAM 빈 곳은 쓰기 감시를 돌린 장면에서만 확인했습니다(16절). 감시에 걸리지 않은 장면이 그곳을 쓸 가능성은 남아 있습니다.
- 태스크 1($1989)이 소리를 미리 준비하는 태스크라는 것은 동작을 보고 한 해석입니다. 원작 개발 의도는 확인할 수 없습니다.
- {23}·{24}가 읽던 장면 표와 개수 표는 가리키는 주소가 0으로 지워져 있어, 원래 어떤 장면을 고를 수 있었는지는 알 수 없습니다.
남은 자리
v1.2 롬에서 0이 길게 이어진 곳 중 큰 것입니다. 0이 이어졌다고 비어 있다는 뜻은 아닙니다. 표의 일부가 0일 수도 있으니, 쓰기 전에 패치 소스가 그 주소를 쓰는지 확인하세요. 폰트 그룹 표에는 빈 칸이 10개 남아 있습니다.
$1A:96D1–$1A:FFFF | 편의 기능 코드 뒤 |
$1C:E628–$1D:FFFF | 플로차트 데이터 뒤부터 뱅크 하나 넘게 |
$18:81D0–$18:FFFF | 세이브 호환 코드 뒤 |
$35:AF67–$3F:FFFF | 대본 뒤. 대본이 늘어날 자리 |
쓴 도구
| asar | 1.91 |
|---|---|
| Mesen 2 | 2.1.1 |
| Python | 3.9.6 (Pillow 11.3.0) |
글자표를 읽을 때 쓴 문자 인식은 macOS의 Vision이라, krpatch ocr은 macOS에서만 돌아갑니다.
19. 패치 소스 파일
| 하는 일 | 파일 | 설명 |
|---|---|---|
| 대사 꺼내기·다시 넣기 | tools/script.pytools/dump.pytools/assemble.py | 2. 대사 |
| 롬 코드 읽기 | krpatch snes dis | 2. 대사 |
| 2회차 멈춤 수정 | tools/fixes.pylua/lap.lua | 3. 2회차 멈춤 |
| A 버튼 대기 맞추기 | tools/waits.pytools/fixwaits.pylua/waitdemo.lua | 4. A 버튼 |
| 글자표·새 글자 | tools/font.pytools/fontjson.pykrpatch ocrtools/mkfont.pytools/glyph_workbench.py | 5. 글자표 |
| 단어 단위 줄바꿈 | asm/wordwrap.asmtools/layout.pytools/splitpages.py | 6. 줄바꿈 |
| 재번역 반영 | tools/trcheck.pytools/trmerge.pytools/rvapply.py | 7. 재번역 |
| 옛 세이브 이어하기 | tools/savemap.pyasm/savecompat.asm | 8. 옛 세이브 |
| 장·엔딩·메뉴 데이터 | tools/qol.pytables/qol-chapters.tsvtables/qol-endings.tsv | 11. 메뉴 |
| 편의 기능 코드 | asm/qol.asm | 11. 메뉴 |
| 플로차트 | tools/flow.pyasm/flow.asm | 14. 플로차트 |
| 화면 찍기·비교 | lua/pages.lualua/name.lualua/jumpsave.pytools/patchnote.py | 17. 확인 |
| 엔진 추적 | lua/qol/pcs.luaprobe/wtrace.lua (krpatch snes lua)probe/trace.lua (krpatch snes lua)probe/watch.lua (krpatch snes lua) | 10. 대본 읽는 손 |
| 장 이동 회귀 | lua/qol/menujump.shlua/qol/chjump.sh | 12. 배경과 음악 |
| 에뮬레이터 호환성 | krpatch snes runcompat/run_all.sh | 17. 확인 |
| 롬 분석 문서(주소·형식 전체) | docs/ANALYSIS.md | 18. 다음 사람에게 |
이 노트의 개념을 설명하는 장
직접 해 보기 소스와 도구
- 패치 소스 — 이 노트가 가리키는 파일과 빌드 순서(한국어 안내)
- 롬 분석 문서 v1.2 — 주소와 형식을 [실측]·[추정]으로 나눠 적은 한국어 문서
- kr-patch-toolkit v0.2.0 — 공통 명령 krpatch 한국어 설명
- 제절초 한글패치 페이지 — 패치 내려받기와 롬에 바로 적용
댓글