슈퍼 패미컴모르는 용어는 용어집에서
13장 소리 ① 슈퍼 패미컴 사운드 구조
본체 CPU와 따로 도는 소리 전용 CPU(SPC700)와 음원 칩(S-DSP), 둘이 값을 주고받는 포트 $2140–$2143을 봅니다.
소리는 따로 도는 작은 컴퓨터가 냅니다 쉽게
지금까지 본 65c816(S-CPU)은 게임 코드를 돌리고 화면을 그리게 하지만, 소리를 직접 내지는 못합니다. 슈퍼 패미컴 안에는 소리만 맡는 작은 컴퓨터가 따로 있습니다. 이 묶음을 흔히 APU(오디오 처리 장치)라고 부르고, 안에는 셋이 들어 있습니다.
- SPC700: 소리 쪽 CPU입니다. 칩 이름은 S-SMP이고, 1.024MHz로 돕니다. 6502를 닮은 8비트 CPU이지만 명령의 바이트 값은 65c816과 다릅니다.
- ARAM(오디오 RAM) 64KB: SPC700의 메모리 전부입니다. 소리 프로그램, 곡 데이터, 악기 소리(샘플)가 모두 여기 들어가야 합니다. S-CPU는 이 메모리를 직접 읽거나 쓸 수 없습니다.
- S-DSP: 실제로 소리를 만드는 음원 칩입니다. ARAM에 있는 샘플을 정한 높이로 읽어, 음성 여덟 개를 섞고 메아리(에코)를 더해 1초에 32,000번 스테레오 값을 내보냅니다.
그래서 게임은 소리를 이렇게 냅니다. 먼저 SPC700에서 돌 사운드 드라이버(음악을 연주하는 프로그램)와 곡 데이터, 샘플을 롬에서 꺼내 ARAM으로 보냅니다. 그다음부터는 "3번 곡", "11번 효과음"처럼 번호만 보냅니다. 악보를 읽고 S-DSP의 레지스터를 매 순간 바꾸는 일은 드라이버가 혼자 합니다. 오케스트라에 비유하면 S-CPU는 무대 밖에서 쪽지로 곡 번호를 넣어 주는 사람이고, 지휘는 SPC700이 합니다.
롬 해킹에서 소리를 건드릴 일은 생각보다 많습니다. 원하는 곡이 나오게 하거나 효과음을 끄려면 S-CPU가 곡 번호를 보내는 곳을 찾아야 하고(14장), 화면을 고치다 소리 쪽과 주고받는 순서를 깨뜨리면 게임이 멈춥니다. 그 주고받는 길이 다음 절의 포트입니다.
포트 네 개로 주고받기 쉽게
S-CPU와 SPC700 사이에는 1바이트짜리 칸이 네 개 있습니다. S-CPU에게는 $2140–$2143이고, SPC700에게는 $F4–$F7입니다. 칸마다 방향이 둘이라서, 실제로는 쪽지함이 여덟 개라고 보면 됩니다.
- S-CPU가
$2140에 쓰면, SPC700이$F4를 읽을 때 그 값이 보입니다. - SPC700이
$F4에 쓰면, S-CPU가$2140을 읽을 때 그 값이 보입니다. - 내가 쓴 값을 내가 다시 읽을 수는 없습니다.
$2140을 읽으면 언제나 상대가 쓴 값이 나옵니다.
fullsnes는 두 CPU가 주고받는 길이 이 네 칸뿐이고 인터럽트 같은 다른 방법은 없다고 적습니다. 그래서 상대가 값을 읽었는지 알려면 "받았으면 같은 값을 되돌려 줘"라고 약속하고, 그 값이 돌아올 때까지 포트를 계속 읽으며 기다립니다. 이렇게 값을 주고받으며 맞추는 것을 핸드셰이크라고 합니다. 소리 쪽은 S-CPU와 따로 돌기 때문에, 이 기다림이 없으면 상대가 읽기 전에 다음 값으로 덮어써 버립니다.
어떤 포트에 무엇을 쓰고 무엇을 기다리는지는 게임마다 다릅니다. 정해진 것은 전원을 켠 직후, 드라이버를 올리기 전의 약속 하나뿐입니다.
사운드 드라이버 올리기 쉽게
전원을 켜면 SPC700은 칩 안에 들어 있는 64바이트짜리 프로그램, IPL 롬을 실행합니다. IPL은 ARAM의 앞쪽을 지우고 $F4–$F5에 AA BB를 써서 "준비됐다"고 알린 뒤, S-CPU가 시작 신호 CC를 보내기를 기다립니다. 그다음 순서는 이렇습니다.
- S-CPU는 받을 주소를
$2142(아래 바이트)와$2143(위 바이트)에 씁니다. $2141에 0이 아닌 값을 쓰고$2140에CC를 씁니다. IPL이CC를 되돌려 주면 시작입니다.- 한 바이트마다 데이터를
$2141에, 그 바이트의 번호(0부터, 아래 8비트만)를$2140에 씁니다. IPL은 번호가 바뀐 것을 보고 데이터를 저장한 뒤 같은 번호를 되돌려 줍니다. - 블록이 끝나면 다음 받을 주소를 쓰고,
$2140에 마지막 번호보다 2 이상 큰 값(0이면 안 됩니다)을 씁니다.$2141이 0이 아니면 다음 블록을 받고, 0이면 그 주소로 건너뛰어 실행합니다.
이렇게 올라간 프로그램이 사운드 드라이버입니다. 드라이버가 돌기 시작하면 포트의 뜻은 드라이버가 정합니다. 아래는 각성편이 실제로 이 순서를 밟은 기록입니다. 화면의 표시 번호, 바이트 값, 기다리는 값이 모두 기록 그대로입니다.
기록의 PC는 포트에 쓴 명령의 다음 명령 주소입니다. 예를 들어 pc=8380F5는 $83:80F2의 STA $2140 다음입니다. 이 값으로 롬에서 올리기 함수를 찾아 5장처럼 역어셈블하면, 포트를 읽고 비교하며 기다리는 반복이 보입니다.
더 깊이IPL 롬 64바이트와 각성편의 블록 목록
IPL 롬은 SPC700 주소 $FFC0–$FFFF에 있습니다. fullsnes의 역어셈블을 따라가면 하는 일은 네 가지입니다.
$0001–$00EF를 0으로 지우고 스택을 정합니다.$F4에AA,$F5에BB를 쓰고$F4가CC가 될 때까지 기다립니다.$F6–$F7(S-CPU의$2142–$2143)에서 주소를,$F4–$F5에서 신호와 명령을 읽고 신호를 되돌려 줍니다. 명령($2141)이 0이면 그 주소로 건너뜁니다.- 0이 아니면 바이트를 받습니다.
$F4가 기다리던 번호가 되면$F5를 읽어 저장하고 번호를 되돌려 줍니다. 번호가 기다리던 값을 지나치면 블록이 끝난 것으로 보고 3으로 돌아갑니다.
마지막 바이트의 되돌려 주기는 아주 짧은 순간만 보입니다. fullsnes는 올리는 쪽이 느리면 놓칠 수 있으니 업로드 동안 IRQ와 NMI를 끄라고 권합니다. 각성편의 올리기 함수 $83:808C도 시작할 때 STZ $4200으로 NMI를 끄고, 끝나면 되살립니다. 다음 블록 신호는 마지막 번호에 3을 더한 값입니다(INC A를 블록 안에서 한 번, 끝에서 두 번 하고, 0이 되면 한 번 더).
이 함수가 읽는 블록 목록은 롬에 "길이 2바이트, 받을 주소 2바이트, 데이터"가 이어진 모양이고, 길이가 0이면 그 주소에서 실행하라는 뜻입니다. 파일 오프셋 0x1C0000에 있는 첫 목록은 이렇게 시작합니다. 2바이트 값은 2장의 리틀 엔디안입니다.
21길이 아래11길이 위00주소 아래08주소 위20 CD 7F BD …데이터 0x1121바이트
0x1121은 4,385바이트, 받을 주소는 $0800으로 위 기록과 같습니다. 데이터 뒤에는 08 00 00 1E와 8바이트, 그리고 00 00 00 08(길이 0, $0800에서 실행)이 옵니다. 드라이버의 첫 바이트 20 CD 7F BD는 SPC700 명령으로 CLRP, MOV X,#$7F, MOV SP,X입니다. 같은 바이트도 65c816과 SPC700에서 전혀 다른 명령이라, SPC700 코드를 65c816 역어셈블러로 읽으면 뜻 없는 결과가 나옵니다.
곡 번호와 효과음 번호 보내기 쉽게
드라이버가 돌기 시작하면 S-CPU는 짧은 명령만 보냅니다. 위 기록의 뒤쪽이 그 모습입니다. 각성편에서는 이렇게 나뉩니다.
- 곡 바꾸기: 곡 재생 함수
$83:8200이 곡 번호로 곡 표를 찾아, 드라이버를 IPL로 돌려보내고 그 곡의 데이터와 샘플을 새로 올린 뒤 명령FD를 보냅니다. 곡 하나를 바꾸는 데 149프레임부터 187프레임까지 38프레임, 0.6초쯤 걸렸습니다. - 효과음: 효과음 함수
$83:8023이$2141에 효과음 번호 하나를 씁니다. 곡 명령을 보내는$2140과 다른 포트입니다. - 바쁜지 확인: 두 함수 모두 쓰기 전에
$2143이 0이 될 때까지 기다립니다. 드라이버가 이 포트로 "지금은 받을 수 없다"를 알리는 것으로 보입니다.
다른 게임은 포트를 다르게 나눠 씁니다. 그래도 찾는 방법은 같습니다. $2140–$2143에 쓰는 순간을 기록해(12장), 곡이 바뀌는 장면에서 어떤 값이 어느 포트로 갔는지 보고, 그 값을 쓴 PC에서 거꾸로 올라가 곡 번호를 넘겨받는 함수를 찾습니다. 그 함수를 찾으면 곡 번호를 바꾸거나, 효과음 함수 첫 바이트를 RTL로 바꿔 효과음을 막을 수 있습니다(6장). 14장에서 실제로 그렇게 합니다.
S-DSP: 음성 여덟 개 쉽게
S-DSP는 동시에 소리 여덟 개(음성)를 냅니다. 음성마다 "어느 샘플을, 어떤 높이로, 얼마나 크게, 왼쪽·오른쪽에 얼마씩"을 정하는 레지스터가 있고, 드라이버가 악보를 따라 이 값을 바꿉니다. 음성 하나가 피아노 건반 하나를 누르는 손가락이라고 보면, 화음과 드럼과 효과음을 합쳐 손가락 여덟 개로 연주하는 셈입니다. 그래서 효과음이 나는 동안 곡의 음성 하나가 잠깐 끊기는 게임도 있습니다.
- 샘플: ARAM에 있는 악기 소리 한 토막입니다. 압축된 BRR 형식입니다.
- 피치: 샘플을 읽는 빠르기입니다. 14비트 값이고
$1000이면 1초에 32,000샘플, 곧 녹음한 그대로의 높이입니다.$2000이면 두 배 빠르게 읽어 한 옥타브 높아집니다. - 엔벨로프: 소리가 시작해서 사라질 때까지의 크기 변화입니다. 건반을 누르면 커졌다가(어택) 조금 줄고(디케이) 유지되다가(서스테인) 떼면 사라집니다(릴리스). 이 네 단계를 정하는 방식을 ADSR이라고 합니다.
- 에코: 소리를 ARAM의 에코 버퍼에 써 두었다가 조금 늦게 다시 섞어 울림을 만듭니다. 음성마다 에코에 보낼지 정합니다.
S-DSP의 레지스터는 128개이고, SPC700은 $F2에 레지스터 번호를, $F3에 값을 써서 바꿉니다. S-CPU는 이 레지스터에 손댈 수 없습니다. 소리를 바꾸려면 언제나 드라이버를 거쳐야 하는 이유입니다.
더 깊이피치, ADSR과 GAIN, 건반 누르기와 떼기
음성 x의 레지스터는 x0–x9입니다(x는 0–7). 왼쪽·오른쪽 볼륨(x0, x1), 피치(x2–x3, 14비트), 샘플 번호(x4), ADSR 두 바이트(x5–x6), GAIN(x7), 그리고 읽기만 하는 현재 엔벨로프(x8 ENVX)와 현재 출력(x9 OUTX)입니다. 여덟 음성을 한꺼번에 다루는 레지스터로는 건반 누르기 KON($4C), 떼기 KOFF($5C), 에코 켜기 EON($4D), 샘플 목록 위치 DIR($5D)이 있습니다.
KON의 비트를 켜면 그 음성은 엔벨로프를 0으로 하고 어택부터 다시 시작합니다. 이미 울리던 음성이어도 처음부터 다시 시작해서 딸깍 소리가 날 수 있습니다. 엔벨로프는 11비트(0–$7FF)이고 ENVX로는 위 7비트만 읽힙니다.
ADSR 대신 GAIN을 쓰면 엔벨로프를 드라이버가 직접 정합니다. 크기를 고정하거나, 일정하게 또는 지수적으로 늘리고 줄이는 모드가 있습니다. 엔벨로프가 한 단계씩 바뀌는 간격은 32,000Hz 샘플 단위의 표로 정해져 있습니다(가장 빠르면 매 샘플, 가장 느리면 2,048샘플마다).
피치가 $1000일 때 32kHz라는 것은, 샘플을 32kHz로 녹음해 두면 원래 높이가 난다는 뜻입니다. 실제 게임은 롬을 아끼려고 샘플을 낮은 빠르기로 줄여 두고 피치로 높이를 맞추는 일이 많습니다. S-DSP는 최근 네 샘플을 가우스 보간으로 이어 부드럽게 만들지만, 출력이 32kHz라 16kHz보다 높은 소리는 낼 수 없습니다.
64KB 안에 모두 들어가야 합니다 쉽게
ARAM 64KB에는 드라이버, 곡 데이터, 그 곡이 쓰는 모든 악기 샘플, 에코 버퍼가 함께 들어가야 합니다. 1초짜리 소리를 16비트 그대로 두면 32kHz에서 64,000바이트라, 악기 하나로 ARAM이 거의 찹니다. 그래서 샘플은 BRR이라는 방식으로 줄여 넣습니다. 16샘플(16비트로 두면 32바이트)을 9바이트에 담아, 크기가 3.5분의 1쯤으로 줄어듭니다.
에코도 자리를 먹습니다. 에코는 지나간 소리를 ARAM에 적어 두었다가 다시 읽는 것이라, 울림이 길수록 버퍼가 커집니다. 가장 길게 잡으면 30KB, 64KB의 절반 가까이입니다. 곡마다 샘플을 바꿔 올리는 게임이 많은 이유가 이 좁은 공간입니다. 각성편도 곡을 바꿀 때마다 그 곡의 샘플만 $A000부터 새로 올렸습니다.
더 깊이BRR 블록 하나 풀어 보기
BRR 블록은 9바이트입니다. 첫 바이트(머리)의 위 4비트는 shift(얼마나 키울지), 그다음 2비트는 필터 번호, 아래 2비트는 끝·반복 표시입니다. 나머지 8바이트에는 4비트 샘플이 두 개씩, 위 4비트가 먼저 들어 있습니다. 4비트 값은 −8–+7로 읽습니다(1장의 2의 보수).
Cshift 120필터 0 · 끝 아님7첫 샘플 +7F둘째 샘플 −1+14,336첫 샘플 값−2,048둘째 샘플 값
위는 머리 C0, 첫 데이터 바이트 7F인 예입니다. 값은 (4비트 값 << shift) >> 1이라서 +7은 7 × 4096 ÷ 2 = 14,336, −1은 −2,048이 됩니다. 필터 0은 이 값을 그대로 씁니다. 필터 1–3은 바로 앞의 한두 샘플에 정해진 비율을 곱해 더합니다. 소리는 앞 샘플과 비슷하게 이어지기 마련이라, "앞에서 예상한 값과의 차이"만 4비트로 적으면 더 작은 shift로 정밀하게 담을 수 있습니다. 9장의 LZSS가 "앞에 나온 것을 다시 쓰는" 압축이었다면(9장), BRR은 "앞 값으로 다음 값을 예측하는" 손실 압축입니다.
끝 비트가 켜진 블록을 다 읽으면, 반복 비트도 켜져 있으면 샘플의 반복 주소로 돌아가 계속 울리고 아니면 소리를 멈춥니다. 반복 주소는 블록 경계, 곧 16샘플 단위에만 둘 수 있습니다. 샘플의 시작 주소와 반복 주소는 DIR 레지스터가 가리키는 샘플 목록에 샘플마다 4바이트로 적혀 있습니다. 각성편이 곡을 바꿀 때 $1B00 + 샘플 번호×4 자리에 4바이트씩 쓰는 것이 이 목록으로 보입니다.
더 깊이에코 버퍼의 크기와 각성편 ARAM의 쓰임새
에코 버퍼의 시작은 ESA 레지스터(256바이트 단위), 길이는 EDL 레지스터로 정합니다. EDL이 0이면 4바이트, 1–15이면 2KB씩이라 최대 30KB이고, 시간으로는 16ms 단위로 최대 240ms입니다. 버퍼 안은 32,000Hz마다 왼쪽·오른쪽 한 쌍(4바이트)이 돌며 덮어써지고, 꺼낸 값은 8탭 FIR 필터와 피드백(EFB)을 거쳐 다시 섞입니다. 에코 쓰기를 켜 두면 S-DSP가 그 자리를 계속 덮어쓰므로, 드라이버나 샘플이 겹치면 망가집니다. 에코 크기를 바꾸면 새 크기가 적용되기까지 240ms쯤 걸릴 수 있다는 점도 주의할 곳입니다.
아래는 각성편 기록에서 S-CPU가 데이터를 올린 자리를 모은 것입니다(곡 표 3번과 같은 곡을 올린 뒤). 기록에는 올린 자리만 있으므로, 표에 없는 자리를 드라이버가 무엇에 쓰는지는 이 기록으로 알 수 없습니다.
| SPC700 주소 | 크기 | 무엇 |
|---|---|---|
$0000–$00EF | 240바이트 | 제로 페이지: 드라이버의 변수 |
$00F0–$00FF | 16바이트 | SPC700 레지스터: 포트 $F4–$F7, DSP 창구 $F2·$F3, 타이머 |
$0100–$01FF | 256바이트 | 스택 |
$0800–$1920 | 4,385바이트 | 사운드 드라이버(11프레임에 올림) |
$1B00–$1BFF | 256바이트 | 26프레임에 올림. 샘플 목록으로 보이는 자리로, 곡을 바꿀 때 $1B50부터 4바이트씩 고쳐 씀 |
$1C00–$1D7F | 384바이트 | 63프레임에 올린 블록 |
$1E00–$1E5D | 94바이트 | 18프레임에 $1E00부터 8바이트, 19프레임에 $1E02부터 92바이트를 겹쳐 올림. 곡을 바꿀 때 $1E00–$1E01을 다시 씀 |
$1F00–$2F26 | 4,135바이트 | 19프레임에 올린 블록 |
$3000–$3891 | 2,194바이트 | 곡 데이터(곡을 바꿀 때마다 새로 올림) |
$4000–$9F21 | 24,354바이트 | 26–62프레임에 올린 큰 블록 |
$A000–$D284 | 12,933바이트 | 이 곡의 악기 샘플 여섯 개(BRR) |
$FFC0–$FFFF | 64바이트 | IPL 롬(켜 두면 이 자리에서는 RAM 대신 롬이 읽힘) |
곡 표 항목의 여섯째 바이트(이 곡은 D7)는 곡을 다 올린 뒤 명령 FD와 함께 보내는 값입니다. 곡마다 AF–FF 사이로 다르고, 샘플이 끝난 $D284보다 위인 $D700을 가리키는 페이지 번호로 읽으면 에코 버퍼 자리와 들어맞습니다. 하지만 드라이버 코드에서 확인하지는 않았으니 추정으로만 적어 둡니다.
SPC 파일 쉽게
게임 음악을 모아 듣는 사람들은 SPC 파일을 씁니다. 곡이 연주되는 한순간에 소리 쪽 전체를 그대로 떠 둔 파일입니다. ARAM 64KB, S-DSP 레지스터 128바이트, SPC700 레지스터, 그리고 곡 이름 같은 꼬리표가 들어 있습니다. 플레이어는 SPC700과 S-DSP만 흉내 내어 그 순간부터 드라이버를 계속 돌리므로, 게임 없이 곡이 끝없이 연주됩니다. 소리 쪽이 S-CPU와 포트로만 이어진 덕분에 이렇게 떼어 낼 수 있습니다.
이 블로그의 배경음악은 SPC 파일로 만들지 않았습니다. 처음에는 Mesen 2의 Lua로 소리 쪽 상태를 떠서 SPC 파일을 만들려 했지만, emu.getState()가 내주는 S-DSP 상태에는 음성마다의 엔벨로프·샘플 주소·피치와 에코 길이 같은 값만 있고 DSP 레지스터 128바이트를 그대로 담은 항목이 없었습니다. 그래서 에뮬레이터가 내는 소리를 그대로 녹음하는 길로 바꿨습니다(14장).
더 깊이SPC 파일의 배치
- 0x00–0x24: 글자 머리
SNES-SPC700 Sound File Data v0.30, 꼬리표가 있는지 표시, 판 번호 - 0x25–0x2B: SPC700 레지스터 PC(2바이트), A, X, Y, PSW, SP
- 0x2E–0xFF: ID666 꼬리표(곡 이름, 게임 이름, 뜬 사람, 재생 길이와 페이드 길이 등)
- 0x100–0x100FF: ARAM 64KB
- 0x10100–0x1017F: DSP 레지스터 128바이트
- 0x101C0–0x101FF: IPL 롬 자리에 깔린 RAM 64바이트
ARAM의 $FFC0–$FFFF는 IPL 롬이 켜져 있으면 롬이 보이지만, 밑에 깔린 RAM에도 값이 있을 수 있어 따로 담습니다. DSP 레지스터가 빠지면 어느 음성이 어느 샘플을 어떤 높이로 내고 있었는지, 에코가 어디에 있는지를 되살릴 수 없습니다.
실제 패치에서 실제 패치에서
각성편: 곡 표와 곡 재생 함수
각성편의 곡 재생 함수 $83:8200은 A에 든 곡 번호를 8배 해 곡 표 $83:83DE에서 항목을 찾습니다. 곡은 20개이고 항목 하나는 8바이트입니다. 곡 번호를 받는 $83:8203의 명령은 29 FF 00(AND #$00FF)으로, 곡 번호의 위 바이트를 지웁니다.
| 바이트 | 값 | 쓰는 곳 |
|---|---|---|
| 0–2 | $BA:AFC1 | 곡 데이터 블록 목록. $83:808C가 올립니다(곡 7은 $1E00에 2바이트, $3000에 1,232바이트) |
| 3–4 | $83:82BC | 쓸 샘플 번호 목록(15 16 1F 26 27, FF로 끝). $83:8105가 하나씩 올립니다 |
| 5 | D7 | 마지막에 명령 FD와 함께 $2143으로 보내는 값 |
| 6–7 | $A000 | 샘플을 올리기 시작할 ARAM 주소 |
위 도구의 기록에서 150–186프레임에 올라간 곡은 데이터 2,194바이트와 샘플 14 15 16 18 19 1A로, 곡 표 3번 항목과 크기·샘플 번호가 모두 맞습니다. 효과음 함수 $83:8023은 첫 명령이 STA $B3(85 B3)이고, 이 기록에서 $2141에 0B, 0A, 80을 보냈습니다. 14장은 이 두 함수의 첫 바이트를 고쳐 원하는 곡만 나오는 롬을 만듭니다.
제절초: 대본의 소리 코드에서 부르는 함수
제절초는 대본 속 제어 코드로 음악과 효과음을 냅니다. 소리 코드 {39}를 처리하는 게임 코드가 A에 소리 번호를 넣고 $00:CD60을 부릅니다. 9바이트마다 놓인 $00:CD69, $00:CD72, $00:CD7B, $00:CD84도 같은 모양(PHP, REP #$30, PHA, LDA #값, BRA)의 입구로, 각자 다른 16비트 값을 A에 넣고 뒤쪽 코드로 건너뜁니다. 멈추는 코드 {3B}·{3C}는 $00:CD45·$00:CD4E를 부릅니다.
$00:CD60과 $00:CD45가 실행될 때마다 A와 돌아갈 주소를 적으며 타이틀까지 돌린 기록은 네 줄이었습니다. 툴킷 이전 스크립트로 남긴 것이라, X·Y와 뱅크까지 적는 calls.lua와는 형식이 조금 다릅니다.
352 00CD60 A=0001 ret=D58C
425 00CD60 A=0092 ret=D4B3
583 00CD45 A=0092 ret=D4F5
583 00CD60 A=0092 ret=D4FB352프레임의 첫 줄이 타이틀 음악입니다. 돌아갈 주소 D58C는 JSR이 스택에 넣은 값(명령의 마지막 바이트 주소)이라, 부른 곳은 $00:D58A의 JSR $CD60이고 그 바로 앞 $00:D587이 LDA #$0001입니다. 곡 번호 1이 롬의 $00:D588(파일 0x5588)에 그대로 적혀 있다는 뜻입니다. 583프레임에서는 같은 번호 $92를 멈췄다가 다시 냅니다.
한글판은 이 소리 코드들을 후킹해 지금 반복 중인 음악·환경음을 기억합니다. 장으로 바로 옮기는 기능을 처음에는 게임을 다시 시작하는 방식으로 만들었는데, 사운드 드라이버가 이미 돌고 있으면 다시 올리는 단계에서 멈췄습니다. 포트를 IPL이 아니라 드라이버가 맡고 있으니, 켤 때처럼 AA BB를 기다리는 올리기 코드가 답을 받지 못했을 것으로 봅니다. 그래서 게임 자신의 대본 점프로 옮기고, 음악은 기억해 둔 상태로 다시 켜도록 바꿨습니다.
슈퍼 마리오 RPG: 음성 여덟 개가 쉬는 시간
슈퍼 마리오 RPG의 오프닝 데모를 녹음하다, 두 번 녹음한 소리가 조금씩 달랐습니다. 효과음이 섞였는지 보려고 voices.lua와 같은 방식으로 6프레임마다 음성 여덟 개의 엔벨로프를 15,096프레임 동안 적었습니다.
1800 1190 1190 1190 1190 1190 1602 1749 1190
1806 1185 1185 1185 1185 1185 0 1742 1185
1812 1175 1175 1175 1175 1175 1270 1728 1175한 줄은 프레임과 음성 0–7의 엔벨로프(0–2,047)입니다. 기록 전체에서 엔벨로프가 0이 아닌 칸은 음성 0–4가 60–65%, 5와 6이 53–56%, 7이 33%였습니다. 다른 음성보다 훨씬 적게 울리는 음성 7, 짧게 켜졌다 꺼지는 5·6은 효과음일 수 있다는 단서입니다. 다만 곡 자체가 그 음성을 드물게 쓸 수도 있어, 이것만으로 효과음이라고 단정하지는 않았습니다. 포트 기록에서 효과음 명령이 몇 번 갔는지와 함께 보는 것이 순서입니다.
직접 해 보기 직접 해 보기
kr-patch-toolkit 0.2.0에는 이 장의 기록을 남기는 Mesen 2 Lua 스크립트 세 개가 들어 있습니다. 모두 버튼 입력은 boot.lua로 처리하므로, 게임을 켜고 정한 프레임(MAXF, 기본 1500)까지 돌린 뒤 SHOT_DIR에 결과를 씁니다.
| 파일 | 하는 일 | 환경 변수 | 결과 |
|---|---|---|---|
probe/apuports.lua | S-CPU가 $2140–$2143에 쓴 값이 바뀔 때마다 프레임, 주소, 값, 프로그램 뱅크(PB)와 PC를 한 줄씩 | APUF, APUPORTS, MAXF | apuports.txt |
probe/voices.lua | VSTEP프레임(기본 6)마다 S-DSP 음성 여덟 개의 엔벨로프 크기 | VSTEP, MAXF | voices.txt |
probe/calls.lua | CALLS에 적은 주소가 실행될 때마다 A, X, Y와 돌아갈 주소 | CALLS, LIMIT, MAXF | calls.txt |
$ L=$(krpatch snes lua)
$ mkdir -p out/apu
$ LIBDIR=$L SHOT_DIR=$PWD/out/apu MAXF=600 EVERY=0 \
Mesen --testrunner $PWD/game.sfc $L/probe/apuports.lua
$ head -4 out/apu/apuports.txtEVERY=0은 스크린샷을 끕니다. 올리기가 수만 줄을 남기므로, 명령만 보고 싶으면 APUPORTS="1,3"처럼 포트를 고르거나 APUF로 기록을 시작할 프레임을 늦춥니다. 곡이 바뀌는 장면의 프레임은 EVERY=60으로 1초마다 찍은 화면에서 찾습니다. 줄 형식은 프레임 주소 값 PB:PC이고, PC는 위 도구에서 본 것처럼 쓴 명령의 다음 주소입니다. 그 주소로 롬을 역어셈블합니다.
$ krpatch snes dis game.sfc 838000 20곡 번호를 넘겨받는 함수의 주소를 찾았다면 calls.lua로 불릴 때마다 A를 적어 봅니다. 여러 주소는 쉼표로 잇습니다.
$ LIBDIR=$L SHOT_DIR=$PWD/out/calls MAXF=3000 EVERY=0 CALLS=838200,838023 \
Mesen --testrunner $PWD/game.sfc $L/probe/calls.lua
$ LIBDIR=$L SHOT_DIR=$PWD/out/voices MAXF=3000 EVERY=0 VSTEP=6 \
Mesen --testrunner $PWD/game.sfc $L/probe/voices.luacalls.txt의 ret=은 스택에 있는 돌아갈 주소입니다. JSL로 들어왔을 때만 뱅크 바이트가 의미가 있습니다(6장). voices.lua는 Mesen 2가 상태에 담는 이름(spc.dsp.voices[0].envVolume)을 읽으므로, 이름이 다른 판에서는 그 사실을 알리며 멈춥니다. 스크립트마다 받는 환경 변수는 파일 첫 줄들에 적혀 있고, 전체 목록은 툴킷 README의 snes lua 절에 있습니다.
이 장의 기록은 Mesen 2.1.1에서 툴킷 스크립트와 같은 방식의 이전 스크립트로 남긴 것입니다. 롬 주소는 각성편·제절초 일본판 기준이고, 주소를 CPU 주소로 바꾸는 법은 4장에 있습니다.
실제 패치에서 이 개념이 쓰인 롬 분석 노트
직접 해 보기 kr-patch-toolkit 명령 설명
참고 자료
- fullsnes, SNES APU Memory and I/O Map — SPC700 메모리 지도($0000–$00EF, $00F0–$00FF 레지스터, $0100–$01FF 스택, $FFC0–$FFFF IPL 롬), 포트 $2140–$2143이 SPC700 쪽 $F4–$F7과 이어진다는 것, 포트 말고는 주고받을 길이 없다는 것, DSP 레지스터 128개 목록
- fullsnes, SNES APU Main CPU Communication Port — 올리는 쪽 절차(BBAA 기다리기, CC, 데이터와 번호, 다음 블록 신호는 마지막 번호+1보다 크고 0이 아닐 것, $2141=0이면 실행), IPL 롬 64바이트 역어셈블, 많은 게임이 $FFC0으로 뛰어 IPL로 돌아간다는 설명, 업로드 중 인터럽트를 끄라는 주의
- fullsnes, SNES APU SPC700 CPU Overview, Load/Store Commands, Jump/Control Commands — SPC700 레지스터(A, X, Y, SP, PSW)와 이 장에서 읽은 명령(CLRP 20, MOV X,# CD, MOV SP,X BD, RET 6F)
- fullsnes, SNES APU DSP BRR Samples, BRR Pitch — 9바이트 블록, 머리 바이트의 shift·필터·끝/반복 비트, sample = (nibble SHL shift) SAR 1, 필터 공식, DIR 표의 시작·반복 주소, 피치 $1000 = 32000Hz
- fullsnes, SNES APU DSP ADSR/Gain Envelope — ADSR과 GAIN, 엔벨로프는 11비트(최대 $7FF)이고 ENVX는 그 위 7비트라는 것
- fullsnes, SNES APU DSP Echo Registers — ESA(256바이트 단위 시작 주소), EDL(0이면 4바이트, 1–15는 2KB 단위, 최대 30KB·240ms), EFB, EON, FIR 8탭
- fullsnes, SNES Timing Oscillators — APU 발진기 24.576MHz, SPC700 1.024MHz, DSP 출력 32000Hz
- SNESdev Wiki, S-SMP — S-SMP·S-DSP·64KB ARAM·DAC로 이루어진 사운드 모듈, DSP와 SPC700이 ARAM을 나눠 쓴다는 것, 실제 출력은 기기마다 32000–32160Hz
- SNESdev Wiki, Booting the SPC700 — $2140–$2143에 쓰고 읽으며 프로그램을 올리는 순서를 65c816 코드로 보인 설명
- SNESdev Wiki, BRR samples — 16샘플을 9바이트에, 반복 지점은 16샘플 경계, 첫 블록은 필터 0
- Super Famicom Development Wiki, SPC and RSN File Format — SPC 파일: 0x100바이트 머리(SPC700 레지스터, ID666 태그), 0x100부터 64KB RAM, 0x10100부터 DSP 레지스터 128바이트, 0x101C0부터 IPL 자리 RAM 64바이트
- kr-patch-toolkit 0.2.0, snes/lua/probe의 apuports.lua, voices.lua, calls.lua — 이 장의 기록을 남긴 방법. 이 장의 각성편 기록은 같은 방식의 이전 스크립트로 남긴 것입니다
- kr-patches, 제절초 asm/qol.asm — 대본의 소리 코드({39}–{3D})를 처리하는 게임 코드가 $00:CD60·$00:CD69·$00:CD45·$00:CD4E를 부르는 모양
댓글