롬 분석 노트 · 슈퍼 패미콤
롬 분석 노트: 제절초
제절초 한글패치 v1.3을 만들며 남긴 기록입니다. 개념은 각 절의 「가이드」 링크에서 처음부터 풀어 설명합니다.
이 롬을 다시 분석하거나 한글패치를 이어서 고칠 사람을 위해 남기는 기록입니다. 2016년에 낸 v1.0은 2회차부터 멈추거나, A 버튼을 누르지 않았는데 화면이 넘어가거나, 단어 중간에서 줄이 끊기는 문제가 있었습니다. v1.1은 이 문제를 롬에서 원인을 찾아 고치고 번역을 원문과 대조해 다시 다듬었습니다. v1.2는 한 걸음 더 나아가 원작에 없던 플로차트, 장 목록, 엔딩 목록을 넣었습니다. 게임 엔진에 새 화면과 새 동작을 끼워 넣은 것이라 번역 패치라기보다 개조 롬에 가깝습니다. v1.3은 이름 입력을 자음과 모음으로 조합하는 화면으로 바꿨습니다.
1. 이 노트에서 쉽게
제절초는 글과 그림, 소리로 이야기를 읽어 가는 사운드 노벨입니다. 한글패치는 네 번 나왔고, 버전마다 롬의 다른 곳을 고쳤습니다.
- v1.0 (2016): 압축된 일본어 대본을 버리고 롬을 1MB에서 2MB로 늘려 한국어 대본을 넣었습니다(2절, 3절).
- v1.1: v1.0의 버그를 롬에서 원인을 찾아 고쳤습니다. 2회차 멈춤, A 버튼 없이 넘어가는 화면, 단어 중간에서 끊기는 줄이고, 대본을 원문과 대조해 다시 다듬고새 글자 75자를 그려 넣었습니다. 옛 세이브도 이어서 읽을 수 있습니다.
- v1.2: 원작에 없던 장 목록, 엔딩 목록, 플로차트, 되감은 페이지의 노란 글씨를 엔진에 끼워 넣었습니다(8절).
- v1.3: 이름 입력의 한글 쪽을 자음과 모음을 골라 조합하는 한 쪽으로 바꾸고, 폰트에 없던 음절 1,034자와 자모 51자를 더해 완성형 2,350자로 이름을 지을 수 있게 했습니다(6절).
처음 읽는다면 가이드의 0장 롬과 패치, 4장 주소와 뱅크, 8장 대사는 어떻게 저장되나를 먼저 보면 이 노트의 주소와 바이트가 읽힙니다. 일본판 대본의 압축은 10장 허프만이, v1.2 기능을 끼워 넣은 방법은 6장 후킹이 처음부터 설명합니다.
배경지식
게임기 안의 메모리
| 이름 | 크기 | 비유 | 하는 일 |
|---|---|---|---|
| 롬 | 1MB → 2MB | 책 | 카트리지에 든 프로그램·대사·그림·폰트. 읽기만 합니다. 일본판은 1MB, 한글패치 롬은 2MB입니다. |
| WRAM | 128KB | 작업 책상 | CPU가 계산하며 값을 적어 두는 곳. 지금 읽는 위치, 선택지 기록이 여기 있습니다. |
| SRAM | 8KB | 책갈피 | 전원을 꺼도 남는 세이브 메모리. 책갈피 슬롯 4개가 들어 있습니다. |
| VRAM | 64KB | 스티커판 | 화면에 붙일 그림 조각을 올려 두는 곳. CPU가 아무 때나 쓸 수 없습니다. |
주소 읽는 법
$15:E401 같은 슈퍼 패미콤 주소는 CPU가 보는 뱅크:주소이고, 0xAE401 같은 파일 오프셋은 롬 파일을 처음부터 센 위치입니다. 이 게임(LoROM)은 뱅크 하나가 롬의 32KB를 비춥니다. 계산은 4장 「CPU 주소와 파일 오프셋은 다르다」의 위젯으로 직접 해 볼 수 있고, 예에 이 주소가 들어 있습니다.
파일 오프셋 = (뱅크 & 0x7F) × 0x8000 + (주소 & 0x7FFF)
$15:E401 → 0x15 × 0x8000 + 0x6401 = 0xAE401후킹
원래 코드 사이에는 새 기능을 넣을 자리가 없습니다. 그래서 원래 코드의 한 지점을 "새 코드로 가라" 는 명령으로 덮어쓰고, 새 코드는 롬의 빈 공간에 둡니다. v1.1과 v1.2는 이런 지점을 스무 곳 넘게 바꿨습니다. 어셈블리 소스는 asar로 롬에 덮어씁니다. 6장 「다른 뱅크의 코드를 불렀다 돌아오기」가 이 게임의 줄바꿈 후킹을 예로 설명합니다.
org $009CF9 ; 입력 대기 중 X(되감기)를 확인하던 자리를
JSL SelCheck ; Select도 확인하는 새 코드로 보낸다2. 롬 지도 쉽게
롬 파일을 32KB씩 잘라 늘어놓으면 어디에 무엇이 있는지 한눈에 보입니다. 일본판은 1MB, 뱅크 32개입니다. 뒤쪽 열 개 뱅크에 압축된 대본이, 마지막 뱅크 $1F에 대사 폰트가 있습니다. v1.0은 압축을 풀어 크기가 두 배를 넘는 대본을 넣으려고 롬을 2MB로 늘리고, 늘린 뒤쪽 1MB에 한국어 대본을 두었습니다. 일본판 대본 자리는 0으로 비었고, v1.1·v1.2는 그 빈자리에 새 코드와 표를 넣었습니다.
롬을 늘리면 헤더의 크기 바이트도 고쳐야 합니다. 헤더($00:FFC0, 파일 0x7FC0)의 0x7FD7은 롬 크기를 적는 자리인데, v1.0은 롬을 2MB로 늘리고도 1MB를 뜻하는 0A를 그대로 두었습니다. v1.1이 2MB를 뜻하는 0B로 고치고 체크섬을 다시 계산했습니다. 크기 바이트와 미러, 체크섬이 왜 함께 바뀌는지는 11장 「헤더의 크기 바이트」에 있고, 그 장의 위젯이 처음부터 이 게임의 1MB → 2MB를 보여 줍니다.
코드를 둔 자리
v1.1과 v1.2의 코드와 표는 모두 v1.0이 일본판 대사를 0으로 지운 자리에 들어갔습니다. 롬을 더 늘리지 않아도 됐습니다. v1.3의 새 글자 약 16KB와 이름 입력 코드는 그 자리에 다 들어가지 않아, v1.0이 늘린 1MB 가운데 대본 뒤의 빈 뱅크 $36에 두었습니다(6절). WRAM은 6,000프레임을 플레이하며 게임이 쓰는 곳을 모두 기록한 뒤, 한 번도 쓰이지 않은 곳만 골라 메뉴 버퍼와 플로차트 작업 공간으로 썼습니다. 빈 공간을 찾는 일반적인 방법은 6장 「새 코드를 둘 빈 공간」에 있습니다.
v1.0도 같은 자리에 자기 것을 넣었습니다. 0으로 비운 자리 가운데 $16:806A–$16:AE4E만 다른 바이트로 채워져 있는데, v1.0이 넣은 그래픽과 코드입니다. 그래픽은 5절에서 봅니다.
더 깊이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로 뛰는 폭주가 없었습니다. WRAM은 켤 때 값이 정해져 있지 않아서, 패치의 표시는 0/1이 아니라 16비트 값(예: 줄바꿈 표시 $5A3C)으로 판정합니다.
남은 자리
v1.2 롬에서 0이 길게 이어진 곳 중 큰 것입니다. 0이 이어졌다고 비어 있다는 뜻은 아닙니다. 표의 일부가 0일 수도 있으니, 쓰기 전에 패치 소스가 그 주소를 쓰는지 확인하세요.
$1A:96D1–$1A:FFFF | 편의 기능 코드 뒤 |
$1C:E628–$1D:FFFF | 플로차트 데이터 뒤부터 뱅크 하나 넘게 |
$18:81D0–$18:FFFF | 세이브 호환 코드 뒤 |
$35:AF67–$35:FFFF | 대본 뒤. 대본이 늘어날 자리 |
$36:FA80–$3F:FFFF | v1.3 이름 글자와 코드 뒤 |
3. 대사 쉽게
사운드 노벨의 대본은 글자 사이사이에 명령(제어 코드)이 섞인 하나의 긴 줄입니다. 대사마다 번호를 붙여 포인터 표로 찾는 RPG와 달리, 게임은 이 줄을 앞에서부터 읽으며 글자는 화면에 찍고 명령은 실행합니다. 이런 대본의 모양은 8장 「번호 없이 한 줄로 이어진 대사」가 이 게임을 예로 설명합니다.
한글패치 대본의 첫 문장을 롬에서 그대로 꺼내 보면 이렇습니다. $20:8018(파일 0x100018)부터 2바이트가 기호 하나입니다. 큰 자리 바이트가 먼저 오므로(2장 엔디안)01 D8은 폰트의 472번($1D8) 글자 「여」이고, $1000 이상은 제어 코드입니다.
01 D8여04 85름01 CF의04 DF공백04 95해01 BA질01 B9녘05 C7.10 00{00}
「여름의 해질녘.」과 줄바꿈이 기호 9개, 18바이트입니다. 마지막 10 00이 줄바꿈 {00}이고, 그 앞 24바이트(파일 0x100000–0x100017)는 음악 페이드 인, 배경, 대본 변수를 정하는 제어 코드 넷입니다. 이 글에서 쓰는 제어 코드 표기는 이렇습니다.
{00} | 줄바꿈 |
{03:@주소} | 대본의 다른 곳으로 점프 |
{16} | A 버튼을 기다림 |
{19} {1A} | 선택지. 보기마다 갈 곳(점프 주소)이 붙어 있음 |
{1C} | 선택지 보기 하나의 끝 |
{20} | 주인공 이름 |
{25} | 화면을 지우고 다음 페이지로 |
{2E}…{37} | 배경 그림 바꾸기 |
{39}…{3D} | 음악·효과음 재생과 정지 |
제어 코드가 무엇이고 인자를 어떻게 읽는지는 8장 「제어 코드: 글자가 아닌 명령」에서 이 대본의 첫머리로 풀어 봅니다.
일본판은 비트 단위로 압축했다
일본판은 대사 전체를 허프만 부호로 압축해 비트 단위로 이어 붙였습니다. 자주 나오는 글자일수록 짧은 비트로 적는 방식이라, 점프 주소도 바이트가 아니라 "기준점에서 몇 번째 비트" 로 적혀 있습니다. v1.0은 압축을 버리고 글자마다 2바이트로 적은 대사를 롬 뒤쪽에 새로 늘린 1MB에 넣었습니다.
같은 첫 문장을 일본판에서 꺼내면, 기준점($15:E401)에서 126번째 비트부터 「夏の夕暮。」와 줄바꿈이 61비트에 들어 있습니다. 비트를 하나씩 읽어 허프만 트리를 따라 내려가다가 잎에 닿으면 글자 하나가 나옵니다. 트리를 만드는 법부터 이 비트열을 한 비트씩 읽는 과정까지 10장 「실제 패치에서」에 있습니다.
글자마다 길이가 다르니 일본판에서는 "몇 번째 글자" 를 바이트로 셀 수 없습니다. 그래서 점프 주소도 비트 번호입니다. 첫 선택지의 첫 보기는 일본판에서 3,109번째 비트, v1.0에서는 $20:8349로 갑니다. v1.0의 점프 인자는 작은 자리부터 적은 3바이트 LoROM 주소라 롬에는 49 83 20으로 들어 있습니다. 대본 전체는 일본판이 약 30만 바이트, v1.0이 694,995바이트입니다.
더 깊이두 형식을 읽는 파서와 바이트 단위 왕복 확인
먼저 두 형식을 모두 읽는 파서를 만들고 대본을 6,730줄의 텍스트로 꺼냈습니다. 점프 대상이 되는 곳에서 줄을 나눴더니 일본어 대본과 한국어 대본이 줄 번호까지 똑같이 맞았습니다. 6,730줄 모두 제어 코드 열도 같습니다. 그다음 꺼낸 대본을 고치지 않고 다시 조립했을 때 v1.0 롬과 바이트 단위로 같아지는 것을 확인했습니다. 이게 맞아야 이후의 수정이 다른 곳을 망가뜨리지 않았다고 믿을 수 있습니다.
선택지 코드는 인자 수 자리에 0x63이라는 특별한 값이 들어 있습니다. 처리 루틴($00:9DAE)은 기호 3개를 읽은 뒤 (첫 기호 & 7) − 1개의 점프 인자를 읽는데, 건너뛰기 루틴($00:A99F)은 기호를 2개만 읽습니다. 파서는 처리 루틴 쪽을 따라야 줄이 어긋나지 않습니다. 제어 코드의 분기표는 $00:F390, 인자 표는 $00:F41E에 2바이트씩 0x47개 있습니다.
2회차에 멈추던 이유
이 게임에는 대사마다의 포인터 표가 없지만, 대본으로 들어가는 입구를 적은 표가 하나 있습니다. 새 회차를 시작할 때 회차 수와 난수로 세 시나리오 중 하나를 고르고, 시작 주소 표($02:A627, 파일 0x12627)에서 그 시나리오의 첫 위치를 읽습니다. 칸마다 3바이트이고, 작은 자리부터 읽으면 대본 안의 주소입니다. 2번 시나리오는 2회차부터 뽑힙니다.
v1.0은 표의 0번, 1번만 새 대사 위치로 바꾸고 2번은 일본판 값을 그대로 둬서, 2번이 뽑히면 엉뚱한 데이터를 대사로 읽었습니다. 화면이 w로 가득 찬 채 멈춘 것이 그 결과입니다.
번역문은 v1.0에도 들어 있었습니다. 일본판 대사 블록은 0 → 2 → 1 순서라 0번 시나리오 끝 바로 다음이 2번 시나리오의 시작이었고, 3바이트만 고쳐 넣으면 됐습니다. 회차 수를 메모리에서 직접 1, 2, 3으로 바꿔 세 시나리오가 모두 정상으로 시작하는 것을 확인했습니다. 이렇게 버그를 다시 일으키고 확인하는 과정은 12장 「실제 패치에서」에 있습니다.
A 버튼 없이 넘어가던 화면
엔진은 「」』.?! 같은 문장 끝 글자를 찍으면 알아서 A 버튼을 기다리고, {16}을 만나도 기다립니다. v1.0은 일본어의 「。」를 「…」나 띄어쓰기로 옮기면서 기다림이 빠진 곳과, 자동 대기 바로 뒤에 {16}이 또 붙은 곳이 생겼습니다. 앞의 경우는 버튼을 누르지 않아도 페이지가 지워졌고, 뒤의 경우는 두 번 눌러야 넘어갔습니다.
한국어 대본과 일본어 대본이 줄 단위로 맞으니, 줄마다 "어디서 기다리는가" 를 두 대본에서 계산해 비교할 수 있습니다. 일본판이 기다리는 곳에만 {16}을 90곳 남짓 넣고, 겹친 대기 30곳 남짓을 지웠습니다. 첫 자동 수정은 기준을 잘못 잡아 270곳 넘게 고쳐 버려서, 비교 결과를 줄마다 다시 보며 범위를 좁혔습니다. 지금은 빌드할 때마다 이 비교 결과를 파일로 남기고, 0건이어야 통과입니다.
원문 대조 재번역
6,730줄을 일본어 원문과 나란히 놓고 다시 다듬었습니다. 새로 번역하지 않고 v1.0 문장을 바탕으로 오역과 누락을 고쳤습니다. 뱀 여자가 「뱀파이어」가 된 곳, 고사포가 「고주파」가 된 곳, 「나라 미인이라 나미」 대목이 통째로 빠진 곳 같은 것들입니다. 「번번번…」으로 깨진 대사 8곳(옛 도구가 「………」를 잘못 바꾼 흔적), 효과 명령의 인자 자리에 글자가 들어가 버린 줄 같은 데이터 오류도 이때 찾았습니다.
- 대본을 24묶음으로 나눠 여럿이 나란히 고쳤더니 묶음마다 용어가 갈렸습니다(피아노 방/홀, 칼/나이프 등). 갈린 용어를 한곳에 모아 한꺼번에 통일했습니다.
- 검수는 두 관점이 토론하는 방식으로 했습니다. 공포 사운드 노벨 팬과 국어 교정자가 서로의 수정안을 반박하고, 반박을 이겨 낸 수정만 남겼습니다. 이렇게 약 300건을 고쳤습니다. v1.0의 말장난 중 분위기를 깨지 않는 것은 살렸습니다.
- 제어 코드는 한 줄에서 하나라도 어긋나면 게임이 엉뚱하게 흘러가므로, 고친 문장은 제어 코드 배열이 원래와 같을 때만 반영되게 했습니다. 바꿀 구절이 그 줄에 정확히 한 번 있을 때만 바꾸는 것도 같은 이유입니다.
4. 글자 쉽게
대사 폰트는 뱅크 $1F에 1bpp(픽셀 하나가 1비트)로 들어 있습니다. 글자마다 폭과 높이가 다를 수 있어서, 크기가 같은 글자끼리 묶은 그룹 표($00:F4AC)로 글자를 찾습니다. 앞 절의 01 D8, 472번 글자 「여」는 첫 그룹(폭 9, 높이 11)에 있고, 롬에서 꺼내면 9비트짜리 줄 11개입니다. 1이 글자 색입니다.
타일 바이트와 비트평면을 읽는 법은 7장 「픽셀 하나에 비트 몇 개」, 이 게임처럼 크기가 다른 1bpp 글자를 묶어 두는 폰트는 7장 「한글 수천 자를 제한된 폰트 칸에 넣기」와 그 장의 「실제 패치에서」에 있습니다. 글자를 그리면 엔진은 이 비트를 WRAM $7F:0010부터의 2bpp 글자 레이어에 겹쳐 찍습니다. 첫째 평면이 글자, 둘째 평면이 그림자입니다.
더 깊이472번 글자를 롬에서 찾는 계산
그룹 표 한 칸은 5바이트입니다. v1.2의 첫 칸 05 92 00 80 2C는 앞 2바이트 $9205의 아래 12비트가 글자 수 517, 위 4비트가 폭 9, 다음 2바이트가 시작 주소 $8000(뱅크 $1F 안), 마지막 바이트 $2C를 2비트 내리면 높이 11입니다. 코드는 1부터 세므로 첫 그룹이 1–517번입니다.
비트 위치 = (472 − 1) × 폭 9 × 높이 11 = 46,629
바이트 = 46,629 ÷ 8 = 5,828 (0x16C4), 나머지 5
→ $1F:8000 + 0x16C4 = $1F:96C4의 비트 5부터 9비트씩 11줄줄과 줄 사이에 바이트 경계가 없습니다. 99비트가 줄줄이 이어지고, 디코더($00:A57F)는 줄마다 3바이트를 읽어 필요한 9비트를 잘라 냅니다. 그래서 그룹 데이터 뒤에 2바이트 여유가 있어야 합니다.
글자표를 눈으로 읽게 하기
대사를 고치려면 롬 속 글자 번호가 어떤 한글인지 적은 글자표가 있어야 하는데, v1.0의 글자표는 남아 있지 않았습니다. 그래서 폰트 1,282자를 그림으로 그려 macOS의 문자 인식(Vision)에 읽혔습니다. 한 글자씩 넣으면 잘 못 읽어서 여러 글자를 한 줄로 이어 붙였고, 조건을 바꿔 9번 읽힌 뒤 다수결로 정했습니다. 위 「여」처럼 이 폰트의 ㅇ은 모서리만 빈 사각형이라 문자 인식이 ㅁ으로 자주 읽었습니다. 받침과 초성의 모서리 픽셀을 보고 가리는 규칙을 더했습니다. ㅗ·ㅜ의 꼭지는 1픽셀이라 작게 줄인 그림에서는 ㅡ와 구별되지 않아서, 남은 의심 글자는 픽셀을 직접 보며 대사 문맥과 맞춰 검수했습니다. 잋→잊, 꿀→훌, 뒸→뒀처럼 30자를 바로잡았습니다.
새 글자 75자와 그룹 표
재번역으로 새로 필요해진 글자 75자는 v1.0 글자의 부분을 오려 붙이거나 직접 그려서 같은 모양으로 맞췄습니다. 예를 들어 「갹」은 「같」의 ㄱ, 「걷」의 ㅓ, 「밑」의 받침 ㅌ을 이어 붙였습니다. 처음에는 자모를 기계적으로 합성해 봤지만, 이미 있는 글자로 시험하니 크게 틀린 것이 300자가 넘었습니다. 이 폰트는 글자마다 자모 위치가 조금씩 다르기 때문입니다. Galmuri 폰트로 전부 새로 그리는 안도 나란히 찍어 비교했는데, v1.0 글자가 더 보기 좋아서 그대로 두었습니다.
글자를 넣을 자리도 문제였습니다. 그룹 표 바로 뒤에 다른 데이터가 있어 그룹은 58개가 한계이고 v1.0이 58개를 다 썼습니다. 아무 데서도 쓰지 않는 채움 글자를 이웃 그룹과 같은 크기의 빈 글자로 바꿔 그룹을 합치니 45개로 줄었고, 새 글자 75자를 3개 그룹으로 더해도 48개라 자리가 남았습니다. v1.3은 이름에 쓸 글자를 그룹 하나로 더해 49개를 씁니다.
더 깊이표 끝 표시가 없는 그룹 표와 빈 글자로 바꾼 168개
엔진은 표 끝 표시를 따로 보지 않습니다. 글자를 찾는 코드($00:A4F4–$00:A51C)는 첫 칸부터 칸마다 글자 수를 더해 가다가, 찾는 코드가 들어가는 칸에서 멈춥니다. 그래서 마지막 그룹보다 큰 코드를 대본에 쓰면 표 뒤의 $00:F5CE 데이터를 그룹 칸으로 읽습니다. 그 자리는 줄 끝에 매달 수 있는 문자 목록입니다.
빈 글자로 바꾼 코드는 168개입니다. 대사, 이름 입력 화면의 글자 표, 문자 목록 두 곳 어디에서도 쓰지 않는 코드만 골랐습니다. 이름 입력 화면이 대사와 같은 코드를 쓰기 때문에, 어디서든 쓰이는 글자는 코드와 모양을 그대로 두었습니다. 새 글자는 코드 1614–1688에 9×11 33자, 10×11 20자, 11×11 22자로 들어갔습니다.
단어 단위 줄바꿈
일본어는 띄어쓰기가 없어 엔진이 글자 단위로 줄을 바꿔도 자연스럽습니다. 한국어는 「유명/했는데」처럼 단어 중간에서 끊기고, 줄 끝 공백이 다음 줄 첫머리로 넘어가 한 칸 들여 쓴 것처럼 보였습니다.
글자를 찍는 루틴($00:A777)을 후킹해, 공백이나 쉼표·마침표 뒤에서 대본의 다음 단어 폭을 미리 재고 줄 끝을 넘으면 먼저 줄을 바꾸게 했습니다. 폭은 v1.1이 새로 넣은 글자 폭 표($1E:F000, 코드마다 1바이트)에서 읽습니다. 주인공 이름처럼 길이가 그때그때 다른 것도 실행 중에 재므로 맞게 동작합니다. 이 후킹의 코드 한 줄 한 줄은 6장 후킹의 예제입니다.
대신 줄이 늘어나 한 화면 9줄을 넘는 페이지가 생깁니다. 대사창은 왼쪽 여백 12, 줄바꿈 기준 x = 242, 줄 간격 23픽셀이라 한 화면이 9줄이고 10번째 줄부터는 잘립니다. 대본의 모든 갈래를 따라가며 페이지마다 줄 수를 계산하는 시뮬레이터를 만들어 넘치는 54페이지를 찾았고, 기다림이 있는 문장 끝에서 페이지를 나눴습니다.
더 깊이시뮬레이터와 실제 게임이 어긋난 두 곳
시뮬레이터로는 맞았는데 비교 화면에서 「그/럴」처럼 끊긴 줄이 보였습니다. 마침표·느낌표는 찍히는 순간 엔진이 입력 대기 명령을 버퍼에 끼워 넣는데, 후킹 코드가 그 상태를 "이름을 찍는 중" 으로 착각해 단어 폭 재기를 건너뛰고 있었습니다. 이름을 찍는 중인지 보는 검사를 공백일 때만 하도록 고쳤습니다.
또 「!?」가 「! ?」로 벌어지는 곳이 143곳 있었는데, 넓은 「!」 글자에 뒤 간격이 들어 있어서였습니다. 이 경우만 좁은 「!」를 쓰게 했습니다.
5. 그림·압축 쉽게
이 게임에서 압축된 것은 그림이 아니라 일본판 대본입니다(3절). v1.1·v1.2가 롬에 쓴 곳을 모두 적은 롬 분석 문서의 목록에는 압축 그림을 풀거나 다시 넣은 곳이 없습니다. 그림을 압축하는 흔한 방식은 9장 RLE·LZSS에서 다른 게임의 타이틀 그림으로 봅니다.
v1.0이 넣은 그래픽도 압축하지 않았습니다. v1.0은 일본판 코드 7곳($00:B472, $00:C436, $00:C4EC, $00:D585, $02:B521, $02:B54B, $02:BCF9)을 뱅크 $15·$16으로 가는 점프(JML)로 바꿨습니다. 이 후킹들이 $0100에 1~3을 적으면, 화면 갱신 후킹($00:C4EC → $15:E499)이 그 번호의 롬 바이트를 DMA로 VRAM에 그대로 보냅니다. 풀어 주는 단계가 없으니 롬에 든 바이트가 곧 타일입니다. VRAM에 그림을 올리는 순서는 7장 「VRAM과 타일맵, BG 모드」에 있습니다.
| $0100 | 롬 | 크기 | VRAM | 때 |
|---|---|---|---|---|
| 1 | $16:8000 | 0x1200 | $5700 | 켠 직후 |
| 2 | $16:A67B | 0x580 | $4840 | 책갈피 화면 앞 |
| 3 | $16:AC7B | 0x1C0 | $4330 | 이름 입력 화면 앞 |
v1.2는 플로차트에 쓰는 4×6 픽셀 글자 17개를 새로 그렸습니다. 타일로 올리지 않고 글자 레이어의 비트맵에 픽셀을 직접 찍습니다(8절 「플로차트」). 되감은 페이지의 노란 글씨는 그림이 아니라 팔레트 한 색을 바꾼 것입니다(8절 「책갈피와 노란 글씨」, 팔레트는 7장 「색은 팔레트 번호로 고른다」).
6. 메뉴·이름·전투 쉽게
사운드 노벨이라 전투가 없고, 화면은 대본을 읽는 화면이 거의 전부입니다. 대사 밖의 글자는 세 곳에 있습니다.
- 선택지: 보기 문구도 대본 안에 있습니다.
{19}·{1A}뒤에 보기 문구와{1C}가 이어지고, 보기마다 갈 곳이 붙어 있습니다. 대본을 번역하면 선택지도 함께 번역됩니다. 한 번에 보이는 보기는 5개까지입니다. - 이름 입력 화면: 고를 글자를 늘어놓은 표(
$02:A990–$02:B03F)가 대사와 같은 글자 코드를 씁니다. 그래서 v1.1은 v1.0의 코드와 글자 대응을 하나도 바꾸지 않고, 쓰지 않는 코드만 골라 비웠습니다(4절). 대본의{20}을 만나면 엔진이 이 이름을 기호 버퍼($5F)에 끼워 넣어 대사처럼 찍습니다. v1.3은 한글 쪽을 자모 자판으로 바꿨습니다(아래). - 책갈피(しおり) 화면: 세이브 슬롯을 고르는 화면입니다. v1.2는 슬롯을 그리는 루틴을 후킹해 둘째 줄에 마지막으로 읽은 장 제목을 그립니다(8절).
v1.2의 장 목록과 엔딩 목록은 새 화면을 그리지 않고 이 선택지 화면을 그대로 빌려 씁니다. 그 이야기는 8절 「메뉴는 그리지 않고 대본으로 쓴다」에 있습니다. 제어 코드를 하나 새로 달아 메뉴 동작을 만든 방법이라, 8장 「제어 코드: 글자가 아닌 명령」을 먼저 보면 읽기 쉽습니다.
자모로 조합하는 이름 입력 (v1.3)
v1.2까지 이름 입력의 한글 쪽은 음절 50자씩 10쪽이었습니다. 폰트에 있는 음절만 고를 수 있고, 원하는 글자가 몇 쪽에 있는지 넘겨 가며 찾아야 했습니다. v1.3은 한글 쪽을 두벌식 자판 한 쪽으로 바꾸고, 자음과 모음을 고르면 글자가 조합되게 했습니다.
- 글자: 이름은 대사에도 찍히므로 대사 폰트에 글자가 있어야 합니다. 폰트에 없는 완성형 음절 1,034자와 호환 자모 51자를 그룹 하나(코드 1689–2773, 11×11)로 더했습니다. 약 16KB인데 뱅크
$1F에 남은 곳은 약 11KB라, 늘린 쪽의 빈 뱅크$36에 두었습니다. 디코더는 뱅크를$1F로 고정해 두었으므로($00:A56C의LDA #$1F / STA $16) 이 4바이트를JSL로 바꿔, 49번째 그룹부터는 뱅크$36을 고르게 했습니다. 새 글자만 Galmuri11로 그렸습니다. 한글 전부를 Galmuri로 바꾸는 안도 비교했지만, 본문은 v1.0 글자가 더 잘 읽혀서 그대로 두었습니다. - 조합: 이름은 전처럼 대사 폰트 코드의 열이라 세이브 형식이 그대로입니다. 지금 만드는 음절은 늘 이름의 마지막 코드이고, 넣은 자모마다 (초성, 중성, 종성) 상태를 WRAM
$7F:E000에 쌓아 B 버튼이 자모 하나씩 되돌립니다. 음절 번호를 완성형 2,350자 표에서 이진 탐색해 코드로 바꿉니다. 받침 뒤에 모음이 오면 받침이 다음 글자로 넘어가고, 완성형 밖이 되는 모음은 무시하며, 완성형 밖이 되는 받침은 앞 글자를 확정하고 새 글자로 시작합니다. 쓰기는 모두 원래 화면의 루틴($02:B334, 코드를 붙이고 이름 칸에 그림)을 거칩니다. - 화면: 메뉴 번호가 곧 쪽 번호라, 쪽 포인터 표(
$02:B18E,$02:B849)의 0번과 2번을 맞바꿔 「한글」을 맨 위로 올리고 처음부터 자판이 열리게 했습니다. 「한글 ◀ 1 ▶」의 화살표는 글자가 아니라 BG1 책 그림(런 길이 타일맵$07:E4CD)의 타일이라, 그 4바이트를 버튼 면 타일로 바꿨습니다. 커서는 빈칸을 건너뛰고, Start는 바로 끝내지 않고 커서를 END로 옮깁니다. - 확인: 슈퍼 마리오 RPG v1.3에서 파일 선택 화면이 이름 입력 창의 테두리 타일을 덮은 일이 있어, 같은 일이 없는지 따로 봤습니다. 세이브가 있을 때와 없을 때 이름 입력 화면의 VRAM을 비교하니 v1.2와 같은 차이만 있었습니다. v1.3은 VRAM에 새로 올리는 것이 없기 때문입니다. 바꾼 책 그림 타일맵은 이름 입력 화면에서만 읽혔고, 메뉴·플로차트 시나리오의 화면과 대본 위치도 v1.2와 같았습니다.
7. 세이브·암호 쉽게
암호는 없습니다. 카트리지에 배터리가 달린 8KB SRAM이 있어(헤더 0x7FD6 = 02, 롬 + SRAM + 배터리) 책갈피를 그곳에 저장합니다. SRAM은 CPU 주소 $70:0000부터 보입니다.
게임은 불러올 때 고른 슬롯을 0번으로 복사한 뒤 읽고, 자동 저장할 때는 0번에 쓴 뒤 원래 슬롯으로 복사합니다. 그래서 게임 코드가 다루는 주소는 늘 0번 슬롯 기준입니다. 슬롯의 주요 값은 이렇습니다.
$700024 | 회차 |
$700026 | 골라 본 선택지 수 |
$700028 | 페이지 수 |
$70002A–$7003D5 | 되감기용 페이지 시작 위치, 4바이트씩 235개까지 |
$7003D6– | 대본 변수 0x1A4바이트. 아래 3비트가 값, 위 5비트가 고른 보기 |
$7005AC | 이어서 읽을 위치 (24비트). $7005AF는 바이트 안 비트 번호 |
$7005B5 | v1.1 표식 $C311 (게임은 쓰지 않던 2바이트) |
$7005D5 | 주인공 이름 |
슬롯이 깨지지 않았는지는 체크섬으로 봅니다. $700022부터 0x5E1바이트를 모두 더한 값입니다. 체크섬이 무엇인지는 1장 비트·바이트·16진수에, 24비트 위치를 작은 자리부터 적는 이유는 2장 엔디안에 있습니다.
옛 세이브로 이어하기
세이브에는 "대본의 어디까지 읽었나" 가 롬 주소로 들어 있습니다. 대사를 다시 쓰면 같은 장면의 주소가 모두 달라지므로, v1.0이나 일본판 세이브를 그대로 불러오면 엉뚱한 곳을 읽습니다. 그래서 줄마다 옛 주소와 새 주소를 짝지은 변환표(각 4,854곳)를 롬에 넣고, 세이브를 불러올 때 표에서 찾아 바꾼 뒤 "변환함" 표식을 남기게 했습니다. 일본판 세이브의 이름은 한글 롬에서 다른 글자로 보이므로 기본 이름 코헤이로 바꿉니다.
더 깊이세 가지 세이브를 구분하고 바꾸는 법
위치의 뜻이 롬마다 다릅니다. 일본판은 허프만 비트열의 바이트 주소(뱅크 $15–$1D)에 바이트 안 비트 번호를 더한 것이고, 비트 위치로는 (파일 오프셋 − 0xAE401) × 8 + 비트 번호입니다. v1.0·v1.1은 2바이트 대본의 LoROM 주소(뱅크 $20–$35)입니다. 그래서 뱅크만 보고 어느 롬의 세이브인지 가릴 수 있습니다.
변환표는 v1.0용이 $17:8000, 일본판용이 $19:8000입니다. 2바이트 개수(4,854) 뒤에 (옛 24비트, 새 24비트) 짝이 옛 주소 순으로 늘어서 있습니다. 불러올 때 표식이 없으면 이진 탐색으로 "그 위치 이하인 마지막 항목" 을 찾아 바꾸고, 표식 $C311을 쓰고 체크섬을 다시 계산합니다.
읽은 장과 본 엔딩 기록
v1.2의 기록은 SRAM에서 책갈피 슬롯 4개 뒤의 빈 곳($70:1800)에 둡니다. 맨 앞 2바이트 표식이 없으면 처음 켤 때 초기화합니다. 그래서 v1.1, v1.0, 일본판 세이브를 그대로 불러올 수 있습니다.
+0 | 4바이트 | 이번 회차에 읽은 장 (장 하나가 1비트) |
+4 | 4바이트 | 지금까지 읽은 장 |
+8 | 4바이트 | 본 엔딩 |
+12 | 1바이트 | 마지막으로 읽은 장 (책갈피에 표시) |
+13 | 2바이트 | 장에 들어갈 때의 갈래 변수와 그 유무 |
- 이야기를 30장으로 나누고 장마다 대본의 줄 범위를 정했습니다. 자동 저장이 일어날 때 읽는 위치가 어느 장 범위에 들어가는지 보고 그 장을 기록합니다. 여러 갈래를 오가도 들어간 장은 빠짐없이 남습니다.
- 엔딩 끝의
{1D}는 명령처럼 생겼지만 실행되지 않고, 새 회차 처리 코드가 이 값을 직접 찾습니다. 그래서{1D}에 기록 코드를 다는 대신, 새 회차가 시나리오를 고르는 순간을 후킹해 그때의 위치로 어느 엔딩이었는지 판단합니다. - 몇몇 장은 첫머리에서 변수 하나로 갈래를 정합니다. 다른 곳에서 바로 그 장으로 옮기면 변수 값이 달라 엉뚱한 갈래로 가므로, 장에 들어갈 때의 값을 남겨 뒀다가 옮길 때 되돌립니다.
8. 게임 고유 문제 실제 패치에서
여기부터는 원작에 없는 기능을 엔진에 더한 v1.2 이야기입니다. 엔딩이 18개인 게임을 처음부터 다시 읽어 가며 모으는 것은 고역이라, 같은 춘소프트의 「카마이타치의 밤」에 있는 장 선택을 옮겨 오는 것에서 시작했습니다. 기능 하나를 넣을 때마다 새 문제가 나왔고, 아래 절들은 그 흐름을 따라갑니다.
- 기능을 열 버튼을 찾고
- 대본 위치를 바꾸려다 대본을 읽는 손이 셋인 것을 알고
- 메뉴를 대본으로 써서 게임에게 읽히고
- 장으로 옮길 때 배경과 음악을 맞추고
- 플로차트를 글자 레이어에 직접 그리고
- 책갈피 화면과 되감기 글자색을 바꿨습니다
모두 6장 후킹의 방법으로 원래 코드 몇 바이트를 새 코드로 가는 점프로 바꾼 것입니다. 점프 명령과 돌아오는 명령의 짝은 5장 「서브루틴: JSR과 RTS」에 있습니다.
더 깊이v1.1·v1.2의 후킹 지점 전체 (원래 바이트와 바꾼 바이트)
| 주소 | 일본판 | v1.2 | 하는 일 |
|---|---|---|---|
$00:A777 | C2 30 18 AD 83 1A | 22 00 80 1E… | 단어 단위 줄바꿈 |
$02:A54F | A5 13 9F AF 05 70 | 22 00 80 18… | 새 슬롯에 v1.1 표식 |
$02:A83B | AD 5D 00 8F B1 05 | 22 13 80 18… | 자동 저장 때 표식 |
$02:A6D3 | AF AC 05 70 | 22 22 80 18 | 옛 세이브 위치 변환 |
$00:9CF9 | A5 CC 29 00 80 | 22 45 84 1A EA | 입력 대기 중 Select → 플로차트 |
$02:A79C | AD BF 1B 0A 0A | 22 B7 83 1A EA | 읽은 장 기록, 메뉴 안 자동 저장 건너뜀 |
$02:C120 | A5 10 85 52 | 5C D2 83 1A | 점프할 때 메뉴 값 되돌리기 |
$02:A56F | C2 30 29 FF 00 | 22 97 82 1A EA | 새 회차 = 엔딩 기록 |
$02:A4D1 | 29 FF 00 85 30 | 22 65 82 1A EA | 새 슬롯 기록 초기화 |
$01:F3BB | C2 30 AD BF 1B | 22 4C 80 1A EA | 되감기 들어갈 때 노란 글씨 |
$01:F421 | A9 C0 00 8D 2D 0A | 22 5B 80 1A EA EA | 되감기 나올 때 색 되돌리기 |
$02:B955 | 20 6B B9 20 89 B9 … | 22 87 80 1A EA… | 책갈피 화면에 마지막 장 제목 |
$00:CE3F | 22 00 80 02 | 22 14 80 1A | 켤 때 SRAM 기록 초기화 |
22는 JSL(먼 곳 호출), 5C는 JML(먼 곳 점프), EA는 NOP입니다. 덮어쓴 바이트가 점프보다 길면 남는 자리를 NOP으로 채웁니다. 이 밖에 제어 코드 분기표($00:F390 + 코드 × 2)의 칸도 바꿨습니다. {44}는 $00:FAB0의 중계 코드를 거쳐 메뉴 동작으로, {39}–{3D}는 반복음을 기록한 뒤 원래 처리로 갑니다. 분기표는 2바이트 주소라 뱅크 $00 안에 중계 코드를 둬야 합니다(6장 「새 코드를 둘 빈 공간」).
빈 버튼 찾기
카마이타치의 밤 SFC판을 에뮬레이터로 직접 돌려 보고 설명서를 읽어 동작을 맞췄습니다. 처음에는 R로 장 화면을 여는 줄 알았는데, 설명서에 R은 쓰지 않는다고 적혀 있었습니다. 앞서 누른 Select가 늦게 반영된 것을 잘못 본 것이었습니다. 나중에 GBA판의 플로차트까지 더했습니다.
먼저 기능을 열 버튼이 필요했습니다. 게임이 패드 값을 받아 쓰는 루틴($00:8B9A)을 읽어 보니 Select와 R은 어디서도 쓰지 않았습니다. A 버튼 입력 대기 중 X를 누르면 되감기로 가는 자리($00:9CF9)가 있어서, 그 바로 앞을 후킹해 Select를 먼저 보게 했습니다. 원래 바이트 A5 CC 29 00 80(LDA $CC · AND #$8000)을 22 45 84 1A EA(JSL $1A:8445 · NOP)로 바꾼 것입니다. 이 명령들을 읽는 법은 5장 어셈블리 기초에 있습니다.
대본을 읽는 손이 셋
장을 고르면 "지금 읽는 위치" 를 그 장의 시작 주소로 바꾸면 될 것 같았습니다. 결과는 무시되거나, 검은 화면에서 영원히 멈추는 것이었습니다. 멈춘 화면에서 거꾸로 따라갔습니다. 대본이 메뉴 버퍼의 한 곳에서 서 있었고, 그 자리는 장면 번호가 준비되기를 기다리는 배경 명령이었습니다. 그 장면 번호를 쓰는 코드를 감시하니 태스크를 시작하는 루틴이 나왔고, 그 태스크를 다시 시작하는 곳이 점프 명령 처리 뒤에 있었습니다. 대본을 읽는 손이 하나가 아니었던 것입니다.
- 엔진은 태스크(번갈아 도는 작은 프로그램)를 여러 개 돌립니다. 본문 말고도 줄 나눔을 미리 재는 태스크와, 다음 장면 그림을 미리 올려 두는 태스크가 각자의 위치에서 대본을 읽습니다.
- 위치는 네 벌($195B, $1972, $1989, $19A0) 있고, 엔진은 그중 $195B에서 본문 위치를 다시 읽어 옵니다. 하나만 바꾸면 다음 순간 원래 값으로 돌아갑니다. 세이브를 불러올 때처럼 네 벌을 모두 써야 합니다.
- 본문이 배경 명령에 닿으면 장면 미리 읽기가 그 그림을 준비할 때까지 기다립니다. 장면 미리 읽기가 옛 자리를 읽고 있으면 기다리는 그림이 영영 오지 않습니다. 게임은 점프 명령을 실행한 뒤 미리 읽기를 새 위치에서 다시 시작하는 루틴($00:9A5A)을 부르는데, 위치를 직접 바꿀 때도 이걸 불러야 했습니다.
- 줄 나눔 태스크는 앞서 읽으면서 $28보다 작은 번호의 명령은 실제로 실행합니다. 처음에 메뉴 동작을 안 쓰는
{23}에 달았더니 메뉴를 고르기도 전에 미리 실행됐습니다. 그래서 새 명령은 $28 이상인{44}로 옮겼고, 점프를 가로채는 후킹은 본문이 읽을 때만 동작하게 했습니다.
더 깊이넷째 사본을 쓰는 태스크 1과 쓰지 않는 디버그 코드 {23}·{24}
그림에 없는 넷째 사본 $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이 거기서 다시 읽습니다. 소리를 미리 준비해 두는 태스크로 보는데, 이 해석은 추정입니다. 본체 CPU가 소리 칩에 명령을 보내는 구조는 13장 「곡 번호와 효과음 번호 보내기」에 있습니다.
쓰지 않는 {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 대본 어디에도 이 두 코드는 나오지 않습니다.
메뉴는 그리지 않고 대본으로 쓴다
장 목록을 화면에 새로 그리려면 커서, 소리, 입력 처리를 모두 새로 만들어야 합니다. 그 대신 메뉴를 대본으로 써서 게임에게 읽혔습니다. 대본에 선택지 명령을 쓰면 게임이 원래 선택지 화면 그대로 보여 주고, 커서와 효과음도 원래 것이 나옵니다.
- 대본에서 쓰지 않는 명령 번호
{44}를 "메뉴 동작" 으로 새로 달았습니다. 보기마다 이 명령이 붙어 있어, 고르면 장 번호, 다음 쪽, 엔딩 목록, 돌아가기 중 하나를 실행합니다. - 메뉴 대본은 롬이 아니라 WRAM의 빈 곳($7F:C000, $7F:C800)에 실행 중에 씁니다. 이번 회차에 읽은 장만 보여야 하기 때문입니다. "다음 쪽" 을 고르면 다른 버퍼에 다음 쪽을 쓰고 그쪽으로 넘어갑니다. 지금 읽고 있는 버퍼를 덮어쓰지 않으려고 두 벌을 번갈아 씁니다.
- 게임 입장에서는 메뉴도 선택지라서, 고를 때마다 "골라 본 선택지 수" 가 늘고 되감기 기록이 지워지고 자동 저장이 일어납니다. 메뉴를 열 때 이 값들을 저장해 두고, 메뉴 밖으로 점프하는 순간 되돌리게 했습니다. 메뉴 안에서는 자동 저장도 건너뜁니다.
- 고르자마자 화면 지우기
{25}를 쓰면, 선택지가 아직 보기를 지우는 중이라 다음 페이지가 멈추는 경우가 있었습니다. 앞에 잠깐 멈춤{17:5}을 넣었습니다. - 한 번에 보이는 보기는 5개까지입니다. 6개를 넣으면 화면이 깨져서, 장 목록은 한 쪽에 3개씩 나눴습니다.
- 장 목록은 원작 선택지처럼 지금 장면의 배경 위에 뜹니다. 그래서 장면에 따라 메뉴 위로 눈알 같은 그림이 보이는데, 이것은 스프라이트가 아니라 장면 배경(BG2)입니다. 그때 화면의 스프라이트(OAM)는 0개이고, 메인 화면 레이어 값 $0A0E를 바꿔 보면 BG2가 켜져 있을 때만 보입니다. 원작의 선택지 화면과 같은 동작이라 그대로 두었습니다.
장으로 옮길 때 배경과 음악
장 첫 페이지가 배경을 정하지 않는 경우가 많았습니다. 원래 흐름에서는 바로 앞 장면의 배경이 이어지기 때문입니다. 빌드할 때 대본을 따라가며 "이 장 시작 직전에 마지막으로 적용된 배경 명령" 을 찾아, 장으로 점프하기 직전에 그 명령을 넣었습니다. 시나리오 첫머리의 배경 명령은 아주 천천히 밝아져 10초 가까이 검은 화면이 이어져서, 점프용으로 넣을 때는 속도를 바꿔 3초쯤으로 줄였습니다.
대가로 음악은 직접 챙겨야 했습니다. 게임의 사운드 명령 다섯 개를 후킹해 지금 반복 재생 중인 음악·환경음(57가지)을 비트맵으로 기억하게 했습니다. 빌드 때 계산한 "그 장에서 울려야 할 반복음" 과 비교해, 꺼야 할 것은 끄고 켜야 할 것은 페이드 인하는 명령을 점프 앞에 넣습니다.
더 깊이버린 첫 방식: 세이브를 고쳐 쓰고 다시 시작하기
사실 대본 점프는 두 번째 방식입니다. 처음에는 세이브를 고쳐 쓰고 게임을 다시 시작하는 방식(웜 재시작)으로 장을 옮겼습니다. 그런데 사운드 칩 프로그램이 돌고 있으면 리셋 코드($00:8EA0)가 사운드 칩에 프로그램을 다시 올리는 단계에서 멈췄고, 올리기를 건너뛰면 화면에 줄무늬가 생기거나 에뮬레이터마다 다른 곳에서 멈췄습니다. 고치고 고쳐 코어 4개는 통과했지만 snes9x와 Mesen은 끝내 검은 화면에서 멈췄습니다. 그래서 이 방식을 버리고 모든 이동을 게임 자신의 대본 점프로 바꿨습니다. 게임이 원래 하는 동작이라 에뮬레이터를 가리지 않습니다.
사운드 칩에 프로그램을 올리는 과정은 13장 「사운드 드라이버 올리기」에 있습니다.
플로차트
선택지 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개 이하인 줄은 가운데에 놓고, 그보다 긴 줄만 왼쪽부터 놓고 좌우로 스크롤합니다. 위아래로 움직이면 화면에서 가로 위치가 가장 가까운 상자로 갑니다.
책갈피와 노란 글씨
X로 되감아 본 페이지를 노란 글씨로 보이게 하는 것은 의외로 간단했습니다. 글자는 팔레트의 한 색으로 찍히므로, 되감기에 들어갈 때 그 색을 노랑으로 바꾸고 나올 때 되돌리면 됩니다. 팔레트는 화면 갱신 틈에만 바꿀 수 있어 게임의 팔레트 사본(글자색은 $0C9B)을 고친 뒤 "다음 틈에 올려라" 표시를 켭니다. 책갈피 화면은 슬롯을 그리는 루틴을 후킹해, 장 기록이 있는 슬롯은 둘째 줄에 페이지 수 대신 마지막으로 읽은 장 제목을 게임 폰트로 그립니다.
여기서도 한 번 검은 화면에 멈췄습니다. 65c816은 돌아오는 명령이 두 가지(RTS, RTL)라 부를 때와 같은 짝으로 돌아가야 하는데, 먼 곳에서 불린 코드(JSL)가 가까운 호출용 루틴(RTS로 끝남)으로 그냥 뛰어가 버려 엉뚱한 주소로 돌아갔습니다. 짝을 맞추는 중간 단계를 두어 고쳤습니다. 이 짝이 왜 중요한지는 6장 「덮어쓴 명령 다시 실행하기」에 있습니다.
9. 확인 방법 직접 해 보기
Mesen 2의 테스트 모드와 Lua 스크립트로 버튼 입력을 재생하고, 원하는 순간의 화면과 메모리를 저장했습니다. 손으로 플레이하지 않으니 같은 장면을 몇 번이고 똑같이 다시 볼 수 있습니다. 멈춤을 찾을 때는 이 순서가 가장 빨랐습니다. 중단점과 쓰기 감시가 무엇인지는 12장 「중단점으로 "어디서 읽나" 잡기」에 있습니다.
- 프레임마다 CPU가 실행 중인 주소(PC)를 기록해 어디서 멈췄는지 찾습니다.
- 멈춘 곳이 기다리는 메모리 값에 쓰기 감시를 걸어, 그 값을 바꾸는 코드의 주소와 호출 순서를 봅니다.
- 정상으로 진행하는 경우에도 같은 감시를 걸어 두 기록을 나란히 비교합니다.
- 회귀 검사: 같은 세이브에서 A를 3만 프레임 연타했을 때 자동 저장 위치의 순서가 v1.1과 같은지 비교합니다.
- 장 이동: 30장을 하나씩 메뉴로 옮겨 첫 화면을 찍고 배경과 첫 문장을 확인합니다.
- 깊은 장면은 그 장면 직전에서 이어지는 세이브 파일을 직접 만들어 찍습니다. 비교 화면은 일본판 기본 이름 코헤이로 찍었습니다.
- 빌드마다 입력 대기 비교(build/waits.tsv)와 9줄 초과 페이지(build/overflow.tsv)가 0건인지 봅니다.
여러 에뮬레이터에 같은 입력 넣기
libretro 코어를 직접 불러 쓰는 작은 프로그램(krpatch sfc run)으로 같은 입력을 여러 에뮬레이터에 넣고 새 게임, 이어하기, 장 목록, 플로차트를 비교했습니다. 세이브를 불러온 뒤 A를 눌러 가며 화면을 찍는 이어하기 시나리오(compat/s_cont.txt)입니다. 왼쪽 숫자가 프레임이고, 주석은 이 노트에서 붙였습니다.
1000 start 4 ; 1000프레임에 Start를 4프레임 동안
1300 a 4 ; 1300프레임에 A
1500 a 4 ; 1500프레임에 A
1600 tap a 40 5000 ; 1600프레임부터 5000프레임까지 40프레임마다 A
500 shot ; 500프레임에 화면 저장. 5000프레임까지 250프레임마다 같은 줄 19개 …
5000 end실행할 때 --watch 7E0052:3을 붙여 대본 위치($52–$54, 3절)를 프레임마다 기록합니다. 코어마다 이 기록이 같은 순서로 흘러가면 같은 장면과 선택지로 갔다는 뜻입니다. 이 스크립트 형식은 12장 「화면 없이 돌리기: 입력 스크립트」의 위젯에서 「제절초 이어하기」를 골라 한 줄씩 볼 수 있습니다.
아래 표는 최종 v1.2(CRC32 2C31A0B2)로 다시 돌린 결과입니다. 코어마다 7가지를 넣었습니다. 새 게임(헤더 없는 롬, 헤더 있는 롬), v1.1·v1.0·일본판 세이브로 이어하기, 장 목록, 플로차트입니다. 정상인 코어는 끝까지 같은 장면과 선택지로 갔고, 차이는 글자가 찍히는 타이밍 몇 프레임뿐이었습니다. Mesen 2 줄은 그 전에 Mesen 2로 직접 확인한 결과이고, MesenCE 줄은 나중에 Mesen 2와 같은 입력을 나란히 넣어 비교한 결과입니다.
| 에뮬레이터 | 결과 |
|---|---|
| Mesen 2 | 정상 (1~4회차, 모든 세이브 종류 이어하기) |
| MesenCE 2.2.1 | 정상 (회차를 바꿔 시작, 이어하기, 장 목록, 플로차트). 대사 순서가 Mesen 2와 같음 |
| bsnes | 정상 (7가지 모두) |
| bsnes-hd beta | 정상 (7가지 모두). 화면을 224줄이 아닌 216줄로 내보냄 |
| snes9x, snes9x2010 | 정상 (7가지 모두) |
| Beetle Supafaust | 정상 (7가지 모두) |
| Mesen-S 코어 | 판정 불가. 7가지 모두 테스트 프로그램이 바로 종료(exit 139)하고, 일본판 원본도 같음 |
10. 다음 사람에게 실제 패치에서
롬 주소와 형식은 패치 소스의 docs/ANALYSIS.md에 모았습니다. 헤더, 메모리 지도, 대본 형식, 제어 코드, 폰트 그룹 표, 후킹 지점 전체(원래 바이트와 바꾼 바이트), 세이브 형식, 쓴 공간이 들어 있고, 직접 확인한 것은 [실측], 짐작만 한 것은 [추정]으로 나눠 적었습니다. 여기에는 확인하지 못한 것과 알려진 한계, 쓴 도구만 적습니다. 남은 롬 자리는 2절 「남은 자리」에 있습니다.
확인하지 못한 것
- 실기(실제 슈퍼 패미콤, 플래시 카트리지)에서 돌려 본 기록은 작업 노트에 없습니다. 위 에뮬레이터 결과가 전부입니다.
- 줄 수, 입력 대기, 점프는 도구로 모든 갈래를 계산했습니다. 화면으로 확인한 것은 장 30개의 첫 화면, 시나리오 3개를 3만 프레임씩 진행한 결과, 1~4회차 시작입니다.
- WRAM 빈 곳은 쓰기 감시를 돌린 장면에서만 확인했습니다(2절 「코드를 둔 자리」). 감시에 걸리지 않은 장면이 그곳을 쓸 가능성은 남아 있습니다.
- 태스크 1($1989)이 소리를 미리 준비하는 태스크라는 것은 동작을 보고 한 해석입니다. 원작 개발 의도는 확인할 수 없습니다.
- {23}·{24}가 읽던 장면 표와 개수 표는 가리키는 주소가 0으로 지워져 있어, 원래 어떤 장면을 고를 수 있었는지는 알 수 없습니다.
알려진 한계
- 선택지 화면에 한 번에 보이는 보기는 5개까지입니다. 6개를 넣으면 화면이 깨집니다. 장 목록을 한 쪽에 3개씩 나눈 이유입니다.
- 폰트 그룹 표는 58칸이 한계이고 지금 49칸을 씁니다. 새 글자를 넣을 칸은 9개 남았습니다.
- v1.3에서 새로 쓸 수 있게 된 글자(코드 1689 이후)를 넣은 이름을 v1.2 이하 패치로 열면, 그 글자는 그룹 표 뒤의 데이터를 그룹으로 읽어 깨져 보입니다.
- 폰트 디코더는 줄마다 3바이트를 읽고 뱅크 $20으로 넘어가지 못합니다. 그룹 데이터 뒤에 2바이트 여유를 두고, 뱅크 $1F 뒤쪽에는 새 그룹 데이터만 두는 것이 안전해 보입니다(추정).
- 글자표의 ㅗ·ㅜ·ㅡ는 꼭지가 1픽셀이라, 드물게 쓰는 글자에 오독이 남아 있을 수 있습니다(추정).
- しおり 단계가 0인 옛 장 이동 시험용 세이브로 대본 5,904줄 갈래에서 이어하면 그 선택지의 보기가 모두 가려져 $00:0000으로 폭주합니다. v1.1도 같고, 정상 플레이로는 갈 수 없는 상태입니다.
- Mesen-S 코어는 일본판 원본까지 바로 종료해서, 이 패치가 그 코어에서 도는지는 판정하지 못했습니다.
쓴 도구
| asar | 1.91 |
|---|---|
| Mesen 2 | 2.1.1 |
| Python | 3.9.6 (Pillow 11.3.0) |
글자표를 읽을 때 쓴 문자 인식은 macOS의 Vision이라, krpatch ocr은 macOS에서만 돌아갑니다.
11. 패치 소스 파일 직접 해 보기
빌드는 일본판 롬에 v1.0 IPS를 먼저 적용하고, 그 위에 대본·폰트·어셈블리를 다시 씌운 뒤 새 IPS를 만듭니다. IPS가 무엇이고 어떻게 읽는지는 0장 「IPS 패치를 바이트로 읽기」에 있습니다. 하는 일마다 파일과 이 노트의 절을 이었습니다.
이 노트의 개념을 설명하는 장
직접 해 보기 소스와 도구
- 패치 소스 — 이 노트가 가리키는 파일과 빌드 순서(한국어 안내)
- 롬 분석 문서 v1.3 — 주소와 형식을 [실측]·[추정]으로 나눠 적은 한국어 문서
- kr-patch-toolkit v0.4.0 — 공통 명령 krpatch 한국어 설명
- 제절초 한글패치 페이지 — 패치 내려받기와 롬에 바로 적용
댓글