본문으로 건너뛰기

슈퍼 패미컴모르는 용어는 용어집에서

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와 롬이 위에 있다. S-CPU는 포트 $2140–$2143에 쓰고 읽는다. 포트의 다른 쪽은 SPC700에서 $F4–$F7로 보인다. SPC700은 64KB ARAM에 있는 사운드 드라이버를 실행하고 S-DSP 레지스터를 바꾼다. S-DSP는 ARAM의 샘플과 에코 버퍼를 읽고 써서 32000Hz 스테레오 소리를 낸다.롬(카트리지)드라이버·곡·샘플 원본S-CPU(65c816)게임 코드포트 4개S-CPU 쪽 $2140–$2143 = SPC700 쪽 $F4–$F7SPC700(S-SMP)ARAM에 올라온 사운드 드라이버를 실행ARAM 64KB드라이버, 곡 데이터,샘플, 에코 버퍼S-DSP음성 8개 + 에코→ 32000Hz 스테레오
S-CPU는 포트 네 개로만 소리 쪽과 이어집니다. ARAM과 S-DSP는 소리 쪽 안에 있습니다.

롬 해킹에서 소리를 건드릴 일은 생각보다 많습니다. 원하는 곡이 나오게 하거나 효과음을 끄려면 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를 보내기를 기다립니다. 그다음 순서는 이렇습니다.

  1. S-CPU는 받을 주소를 $2142(아래 바이트)와 $2143(위 바이트)에 씁니다.
  2. $2141에 0이 아닌 값을 쓰고 $2140에 CC를 씁니다. IPL이 CC를 되돌려 주면 시작입니다.
  3. 한 바이트마다 데이터를 $2141에, 그 바이트의 번호(0부터, 아래 8비트만)를 $2140에 씁니다. IPL은 번호가 바뀐 것을 보고 데이터를 저장한 뒤 같은 번호를 되돌려 줍니다.
  4. 블록이 끝나면 다음 받을 주소를 쓰고, $2140에 마지막 번호보다 2 이상 큰 값(0이면 안 됩니다)을 씁니다. $2141이 0이 아니면 다음 블록을 받고, 0이면 그 주소로 건너뛰어 실행합니다.

이렇게 올라간 프로그램이 사운드 드라이버입니다. 드라이버가 돌기 시작하면 포트의 뜻은 드라이버가 정합니다. 아래는 각성편이 실제로 이 순서를 밟은 기록입니다. 화면의 표시 번호, 바이트 값, 기다리는 값이 모두 기록 그대로입니다.

사운드 포트 기록 따라가 보기

각성편을 Mesen 2에서 켜고 $2140–$2143에 값을 쓸 때마다 남긴 기록(96,421줄)에서 고른 줄입니다. 한 줄은 "프레임, 포트, 쓴 값, PC"이고, 값이 바뀐 쓰기만 남았습니다. 앞쪽은 사운드 드라이버를 올리는 과정, 뒤쪽은 곡을 바꾸고 효과음을 보내는 명령입니다. 「다음 줄」을 누를 때마다 한 줄씩 나아가며, 네 포트에 마지막으로 쓴 값과 S-CPU가 다음에 기다리는 것을 보여 줍니다. 같은 쓰기가 길게 되풀이되는 곳은 한 단계로 건너뜁니다.

기록 시작 전: IPL이 AA BB를 띄우고 시작 신호를 기다립니다.

  • $2140--신호·번호SPC700 쪽 $F4방금 씀
  • $2141--데이터·명령SPC700 쪽 $F5방금 씀
  • $2142--주소 아래 바이트SPC700 쪽 $F6방금 씀
  • $2143--주소 위 바이트SPC700 쪽 $F7방금 씀
$2142–$2143에 적힌 주소
—
S-CPU가 다음에 기다리는 것
—

기록에서 고른 줄

  1. 올리기11 2142 00 pc=8380C0

    $2142 ← 00받을 주소 아래 바이트쓴 명령 다음 주소 $83:80C0

    전원을 켠 뒤 11프레임째, 올리기 함수 $83:808C가 롬의 블록 목록에서 받을 주소 $0800을 읽어 $2142–$2143에 한 번에 씁니다. 아래 바이트 00이 $2142로 갑니다. 그 전에 $2140–$2141을 읽어 IPL이 띄운 AA BB(준비됨)를 확인했지만, 읽기는 기록에 남지 않습니다.

  2. 올리기11 2143 08 pc=8380C0

    $2143 ← 08받을 주소 위 바이트쓴 명령 다음 주소 $83:80C0

    위 바이트 08이 $2143으로 갑니다. 받을 주소는 $0800입니다.

  3. 올리기11 2141 01 pc=8380CB

    $2141 ← 01데이터를 보낸다는 표시쓴 명령 다음 주소 $83:80CB

    $2141이 0이 아니면 "이 주소부터 데이터를 받아라", 0이면 "이 주소에서 실행하라"는 뜻입니다.

  4. 올리기11 2140 CC pc=8380D4

    $2140 ← CC시작 신호 CC쓴 명령 다음 주소 $83:80D4

    IPL은 $2140(SPC700 쪽 $F4)에 CC가 오기를 기다리고 있었습니다. 받으면 같은 CC를 되돌려 줍니다.

    기다림: $2140을 읽어 CC가 돌아올 때까지

  5. 올리기11 2141 20 pc=8380F1

    $2141 ← 20데이터 첫 바이트쓴 명령 다음 주소 $83:80F1

    보낼 바이트 20을 $2141에 둡니다. SPC700 명령으로 읽으면 CLRP입니다. 사운드 드라이버의 첫 명령입니다.

  6. 올리기11 2140 00 pc=8380F5

    $2140 ← 00번호 00쓴 명령 다음 주소 $83:80F5

    $2140에 이 바이트의 번호 00을 써서 "새 바이트가 있다"고 알립니다. IPL은 $2141을 읽어 $0800에 저장하고 번호 00을 되돌려 줍니다.

    기다림: $2140을 읽어 00이 돌아올 때까지

  7. 올리기11 2141 CD pc=8380F1

    $2141 ← CD둘째 바이트쓴 명령 다음 주소 $83:80F1

    다음 바이트 CD를 $2141에 둡니다.

  8. 올리기11 2140 01 pc=8380F5

    $2140 ← 01번호 01쓴 명령 다음 주소 $83:80F5

    번호가 하나 늘었으니 새 바이트입니다. 이 바이트는 $0801에 들어갑니다.

    기다림: $2140을 읽어 01이 돌아올 때까지

  9. 올리기11 2141 7F pc=8380F1

    $2141 ← 7F셋째 바이트쓴 명령 다음 주소 $83:80F1

    CD 7F는 SPC700의 MOV X,#$7F입니다.

  10. 올리기11 2140 02 pc=8380F5

    $2140 ← 02번호 02쓴 명령 다음 주소 $83:80F5

    이렇게 한 바이트에 두 번 쓰고 한 번 기다리기를 되풀이합니다.

    기다림: $2140을 읽어 02가 돌아올 때까지

  11. 올리기건너뜀 8,718줄

    같은 모양으로 4,381바이트 더

    11프레임부터 18프레임까지 같은 쓰기가 8,718줄 이어집니다. 번호는 FF 다음에 00으로 돌아갑니다. 같은 바이트가 연달아 오면 $2141 값이 바뀌지 않으므로 그 줄은 기록에 없습니다.

  12. 올리기18 2141 6F pc=8380F1

    $2141 ← 6F마지막 바이트쓴 명령 다음 주소 $83:80F1

    마지막 바이트 6F는 SPC700의 RET입니다.

  13. 올리기18 2140 20 pc=8380F5

    $2140 ← 20번호 20: 4,385번째 바이트쓴 명령 다음 주소 $83:80F5

    번호가 17바퀴 돌고 20에서 끝났습니다. 4,385바이트(0x1121)가 $0800–$1920에 들어갔습니다.

    기다림: $2140을 읽어 20이 돌아올 때까지

  14. 올리기18 2143 1E pc=8380C0

    $2143 ← 1E다음 블록 주소쓴 명령 다음 주소 $83:80C0

    다음 블록은 $1E00으로 갑니다. 16비트로 $2142–$2143에 썼지만 $2142는 00 그대로라 기록에 없습니다. 값이 바뀐 포트만 남기는 기록이기 때문입니다.

  15. 올리기18 2141 01 pc=8380CB

    $2141 ← 01또 데이터를 보낸다는 표시쓴 명령 다음 주소 $83:80CB

    이번에도 0이 아니므로 데이터를 보냅니다.

  16. 올리기18 2140 23 pc=8380D4

    $2140 ← 23다음 블록 신호 23쓴 명령 다음 주소 $83:80D4

    두 번째 블록부터는 CC 대신 마지막 번호보다 2 이상 큰 값을 씁니다. 이 게임은 마지막 번호 20에 3을 더한 23을 씁니다. IPL은 번호가 건너뛴 것을 보고 블록이 끝났음을 압니다.

    기다림: $2140을 읽어 23이 돌아올 때까지

  17. 올리기건너뜀 16줄

    $1E00에 8바이트

    같은 방법으로 8바이트(00 20 00 20 00 20 00 20)를 보냅니다. 16줄입니다.

  18. 올리기18 2143 08 pc=8380C0

    $2143 ← 08실행할 주소쓴 명령 다음 주소 $83:80C0

    $2142–$2143에 실행할 주소 $0800을 씁니다.

  19. 올리기18 2141 00 pc=8380CB

    $2141 ← 00실행하라는 표시쓴 명령 다음 주소 $83:80CB

    $2141이 0이면 데이터가 아니라 실행입니다.

  20. 올리기18 2140 0A pc=8380D4

    $2140 ← 0A실행 신호 0A쓴 명령 다음 주소 $83:80D4

    마지막 번호 07보다 3 큰 0A를 씁니다. IPL은 0A를 되돌려 주고 $0800으로 건너뜁니다. 이제부터 포트의 뜻은 IPL이 아니라 방금 올린 사운드 드라이버가 정합니다.

    기다림: $2140을 읽어 0A가 돌아올 때까지

  21. 올리기건너뜀 57,900줄

    19–64프레임: 블록 다섯 개 더

    드라이버가 IPL로 돌아가게 한 뒤 $1E02, $1F00, $1B00, $4000, $1C00에 모두 29,221바이트를 더 올리고 다시 $0800에서 실행합니다. 57,900줄입니다.

  22. 명령149 2140 80 pc=83800E

    $2140 ← 80곡을 바꾸기 전 신호쓴 명령 다음 주소 $83:800E

    149프레임, 곡 재생 함수 $83:8200이 맨 먼저 부르는 $83:8000입니다. $2143이 0이 될 때까지 기다린 뒤 $2140에 80을 씁니다.

    기다림: $2142를 읽어 80이 돌아올 때까지

  23. 명령150 2141 80 pc=838018

    $2141 ← 80한 번 더 80쓴 명령 다음 주소 $83:8018

    $2141에도 80을 쓰고, 이번에는 $2141에서 80이 돌아오기를 기다립니다. 이 주고받기가 끝나면 곧바로 새 곡을 올리기 시작합니다.

    기다림: $2141을 읽어 80이 돌아올 때까지

  24. 올리기150 2140 FF pc=8380A4

    $2140 ← FFIPL로 돌아가라는 신호쓴 명령 다음 주소 $83:80A4

    올리기 함수 $83:808C는 $2140–$2141이 AA BB가 아니면 $2140에 FF를 쓰고 다시 확인합니다. FF를 받은 드라이버가 IPL로 돌아가면 IPL이 AA BB를 띄웁니다.

    기다림: $2140–$2141을 읽어 AA BB가 보일 때까지

  25. 올리기150 2143 1E pc=8380C0

    $2143 ← 1E곡 데이터 블록 주소쓴 명령 다음 주소 $83:80C0

    곡 표에서 찾은 블록 목록의 첫 블록을 $1E00에 보냅니다.

  26. 올리기150 2141 01 pc=8380CB

    $2141 ← 01데이터를 보낸다는 표시쓴 명령 다음 주소 $83:80CB

    처음처럼 0이 아닌 값입니다.

  27. 올리기150 2140 CC pc=8380D4

    $2140 ← CC시작 신호 CC쓴 명령 다음 주소 $83:80D4

    IPL로 막 돌아왔으므로 다시 CC로 시작합니다.

    기다림: $2140을 읽어 CC가 돌아올 때까지

  28. 올리기건너뜀 29,757줄

    150–186프레임: 곡 데이터와 악기 샘플

    곡 데이터 2,194바이트를 $3000에 올린 뒤, 악기 샘플 여섯 개를 하나씩 올립니다. 샘플마다 샘플 목록 자리로 보이는 $1B50, $1B54, …에 4바이트를 쓰고, $A000부터 BRR 데이터(모두 12,933바이트, 9바이트 블록 1,437개)를 이어 씁니다. 블록마다 FF로 IPL을 깨우고 $0800에서 실행하기를 되풀이합니다. 29,757줄입니다.

  29. 명령187 2143 D7 pc=838056

    $2143 ← D7명령에 딸린 값쓴 명령 다음 주소 $83:8056

    명령 함수 $83:8038은 $2142–$2143에 딸린 값을 먼저 씁니다. D7은 곡 표 항목의 여섯째 바이트입니다. $2142는 00 그대로라 기록에 없습니다.

  30. 명령187 2140 FD pc=83805C

    $2140 ← FD명령 FD쓴 명령 다음 주소 $83:805C

    $2140에 명령 번호 FD를 씁니다. 곡 재생 함수가 곡을 다 올린 뒤 마지막으로 보내는 명령입니다.

    기다림: $2142를 읽어 FD가 돌아올 때까지

  31. 명령187 2140 01 pc=83805C

    $2140 ← 01명령 01쓴 명령 다음 주소 $83:805C

    같은 함수로 01을 보냅니다. 이 함수는 다음 명령을 보내기 전에 $2142로 앞 명령이 돌아왔는지 확인합니다. 01이 드라이버 안에서 무슨 일을 하는지는 이 기록만으로는 알 수 없습니다.

  32. 명령1635 2141 0B pc=838031

    $2141 ← 0B효과음 0B쓴 명령 다음 주소 $83:8031

    효과음 함수 $83:8023은 $2143이 0이 될 때까지 기다린 뒤 효과음 번호를 $2141에 씁니다. 곡 명령을 보내는 $2140과 다른 포트입니다.

  33. 명령3161 2141 0A pc=838031

    $2141 ← 0A효과음 0A쓴 명령 다음 주소 $83:8031

    다음 효과음입니다. 이 함수는 되돌아오는 값을 기다리지 않습니다.

기록의 PC는 포트에 쓴 명령의 다음 명령 주소입니다. 예를 들어 pc=8380F5는 $83:80F2의 STA $2140 다음입니다. 이 값으로 롬에서 올리기 함수를 찾아 5장처럼 역어셈블하면, 포트를 읽고 비교하며 기다리는 반복이 보입니다.

더 깊이IPL 롬 64바이트와 각성편의 블록 목록

IPL 롬은 SPC700 주소 $FFC0–$FFFF에 있습니다. fullsnes의 역어셈블을 따라가면 하는 일은 네 가지입니다.

  1. $0001–$00EF를 0으로 지우고 스택을 정합니다.
  2. $F4에 AA, $F5에 BB를 쓰고 $F4가 CC가 될 때까지 기다립니다.
  3. $F6–$F7(S-CPU의 $2142–$2143)에서 주소를, $F4–$F5에서 신호와 명령을 읽고 신호를 되돌려 줍니다. 명령($2141)이 0이면 그 주소로 건너뜁니다.
  4. 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 12
  • 0필터 0 · 끝 아님
  • 7첫 샘플 +7
  • F둘째 샘플 −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번과 같은 곡을 올린 뒤). 기록에는 올린 자리만 있으므로, 표에 없는 자리를 드라이버가 무엇에 쓰는지는 이 기록으로 알 수 없습니다.

각성편 ARAM: 올린 블록과 정해진 자리
SPC700 주소크기무엇
$0000–$00EF240바이트제로 페이지: 드라이버의 변수
$00F0–$00FF16바이트SPC700 레지스터: 포트 $F4–$F7, DSP 창구 $F2·$F3, 타이머
$0100–$01FF256바이트스택
$0800–$19204,385바이트사운드 드라이버(11프레임에 올림)
$1B00–$1BFF256바이트26프레임에 올림. 샘플 목록으로 보이는 자리로, 곡을 바꿀 때 $1B50부터 4바이트씩 고쳐 씀
$1C00–$1D7F384바이트63프레임에 올린 블록
$1E00–$1E5D94바이트18프레임에 $1E00부터 8바이트, 19프레임에 $1E02부터 92바이트를 겹쳐 올림. 곡을 바꿀 때 $1E00–$1E01을 다시 씀
$1F00–$2F264,135바이트19프레임에 올린 블록
$3000–$38912,194바이트곡 데이터(곡을 바꿀 때마다 새로 올림)
$4000–$9F2124,354바이트26–62프레임에 올린 큰 블록
$A000–$D28412,933바이트이 곡의 악기 샘플 여섯 개(BRR)
$FFC0–$FFFF64바이트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)으로, 곡 번호의 위 바이트를 지웁니다.

곡 표 7번 항목 C1 AF BA BC 82 D7 00 A0
바이트값쓰는 곳
0–2$BA:AFC1곡 데이터 블록 목록. $83:808C가 올립니다(곡 7은 $1E00에 2바이트, $3000에 1,232바이트)
3–4$83:82BC쓸 샘플 번호 목록(15 16 1F 26 27, FF로 끝). $83:8105가 하나씩 올립니다
5D7마지막에 명령 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=D4FB

352프레임의 첫 줄이 타이틀 음악입니다. 돌아갈 주소 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.luaS-CPU가 $2140–$2143에 쓴 값이 바뀔 때마다 프레임, 주소, 값, 프로그램 뱅크(PB)와 PC를 한 줄씩APUF, APUPORTS, MAXFapuports.txt
probe/voices.luaVSTEP프레임(기본 6)마다 S-DSP 음성 여덟 개의 엔벨로프 크기VSTEP, MAXFvoices.txt
probe/calls.luaCALLS에 적은 주소가 실행될 때마다 A, X, Y와 돌아갈 주소CALLS, LIMIT, MAXFcalls.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.txt

EVERY=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.lua

calls.txt의 ret=은 스택에 있는 돌아갈 주소입니다. JSL로 들어왔을 때만 뱅크 바이트가 의미가 있습니다(6장). voices.lua는 Mesen 2가 상태에 담는 이름(spc.dsp.voices[0].envVolume)을 읽으므로, 이름이 다른 판에서는 그 사실을 알리며 멈춥니다. 스크립트마다 받는 환경 변수는 파일 첫 줄들에 적혀 있고, 전체 목록은 툴킷 README의 snes lua 절에 있습니다.

이 장의 기록은 Mesen 2.1.1에서 툴킷 스크립트와 같은 방식의 이전 스크립트로 남긴 것입니다. 롬 주소는 각성편·제절초 일본판 기준이고, 주소를 CPU 주소로 바꾸는 법은 4장에 있습니다.