PSP 코드 패치 및 테스트 샘플 한국어화

한참 중요한 시기라 취미 생활을 거의 못 하고 있지만, 이번 주말에 틈을 내서 적당히 짧게 할 수 있는 작업을 하나 하기로 했다. 한국어화 할 예정인 작품들은 많이 쌓아뒀지만 당장 시간이 걸리는 작업은 하기 그렇고, 테스트 삼아 그 중에서 개인적으로 꼭 한국어로 플레이해보고 싶었고 분량도 매우 작은 작품을 하나 하기로 했다. 이미 발매 당시에 정식 한국어화로 출시된, PSP 의 명작 텍스트 어드벤처인 총성과 다이아몬드체험판이다.




Background


이 게임은 이미 2009년에 소니에서 정식 한국어화로 발매한 게임이다. 따라서 본편을 한국어화할 필요성은 전혀 없다. 개인적으로 정말 재미있게 했고 지금도 종종 생각나면 한 번씩 플레이하고있다.

그런데 이 게임은 출시 직전에 체험판을 PSP 또는 공식 홈페이지에서 플레이할 수 있도록 무료로 공개했었는데, 특이하게도 이 체험판은 통상의 체험판들과 달리 본편의 초반부를 잘라서 짧게 만들어놓았거나 그런 것이 아니라 본편에는 나오지 않는 아예 새로운 에피소드 몇 가지를 짧게 보여준다. 내용은 본편의 프리퀄에 가까운 내용으로, 여기서 나온 내용이 본편에서 짧게 언급되기도 한다. 그래서 체험판을 플레이한 후에 본편을 진행하면 좀 더 재미있게 즐길 수도 있다.

그러나 안타깝게도 이 체험판은 한국어화가 되지 않았기 때문에, 총성과 다이아몬드를 플레이한 대부분의 한국 사람들은 이 체험판의 존재조차 모르는 경우가 많다. 실제로 나도 최근에야 체험판의 존재를 알았다.

어떤 사람이 유튜브에 자막을 달아서 올려놓기도 했기 때문에 물론 굳이 플레이하지 않아도 내용 자체는 알 수 있지만, 그래도 직접 플레이하는 것만큼 좋은 경험이 되지는 않을 것이다. 분량도 매우 작기 때문에 작업에 시간을 거의 들이지 않을 수 있어서 부담도 없으므로 한 번 한국어화를 시도해보기로 했다.

사실 이 게임은 작업에 굳이 코드 패치가 필요하지 않고, 단순 작업으로 쉽게 한국어화를 할 수 있다. 하지만 그러면 그냥 재미없는 노가다에 스크립팅으로 끝날 뿐이므로 반쯤 억지로 코드 패치를 필요로 하는 상황을 만들어서 작업을 해보기로 했다. 물론, 굳이 필요하지 않기는 해도 일단 코드를 삽입할 수 있게 되면 나름의 소소한 장점은 충분히 만들어낼 수 있으므로 아예 무의미한 작업은 아니다.

그래서 이 글에서는 사소한 작업들은 굳이 나중에 기억하기 위해 정리할 필요도 없고 하니 초반부에 짧게 쓰고 대부분 생략하고, 주로 코드 패치와 관련된 작업과 이슈를 중점적으로 서술하기로 한다. 그리고 아무래도 글 하나하나를 너무 자세하게 쓰려고 하다보니 오히려 귀찮아져서 글을 많이 못 쓰게 되는 것 같아서, 이제부터는 가능하면 핵심만 짧게 서술해서 정리하는 방향으로 가려고 한다. 기록 용도이니만큼 내가 나중에 보고 기억해낼 수 있을 정도의 큰 줄기만 적으면 될 것 같다. 코드에 대한 세세한 설명 등은 대부분 생략하였다.


Trivia

일단 이 게임의 내부 파일 구조는 USRDIR/pack 디렉토리에 .dat 확장자로 핵심적인 파일이 모두 들어있고, 이 파일들은 모두 각각 심플한 아카이브 구조로 되어있으며 여러 파일들을 모아서 담고있는 tar 같은 파일이다. 각 헤더는 단순히 파일명과 다음 파일의 시작 오프셋, 압축 여부 등만 담고있는 간단한 구조이므로 파싱하는 데에는 전혀 문제가 없다.

그리고 풀어서 내부 파일 구조를 보면, 여러가지 파일들이 있지만 대부분 PSP 에서 많이 써서 잘 알려진 포맷들인데 이미지와 사운드 역시 잘 알려진 포맷이다. 사운드를 수정할 일은 없으므로 이미지만 알면 되는데, 이미지는 .gim2 라는 확장자를 가지고 있는데 이것 자체는 이미지가 아니라 이미지들을 모아놓은 아카이브 파일이다. 이 아카이브 포맷 역시 위에서 말한 아카이브 포맷과 거의 유사한데 더 간단하다. 이미지명과 이미지 파일 사이즈 등의 간단한 헤더와 데이터가 연속적으로 배치되어 있으므로 이것 역시 파싱은 쉽게 할 수 있다.

그렇게 .gim2 파일을 풀어보면 내부에 있는 이미지들은 이제 잘 알려진 GIM 포맷이므로 공개된 여러 툴을 사용해서 변환 및 수정 작업을 할 수 있다.

또한 static.dat 라는 파일 내에는 “DfpHsGothicW5Src9_5.pgf” 라는 이름의 폰트 파일이 들어있다. pgf 폰트에 대한 정보는 많이 알려져있고, 이 파일을 다른 폰트 파일로 교체만 하면 한글 폰트도 간단히 넣을 수 있다.

그리고 .dat 아카이브 내에 있는 파일 중에서는 .hyb 라는 확장자를 가진 파일들을 찾아볼 수 있는데, 이 파일 내에 대사가 UTF-8 로 들어있다. 한글 폰트로 교체한 다음 아무 한국어 문장을 UTF-8 인코딩으로 넣어주면 한글이 바로 출력되는 것을 확인할 수 있다.

다만 대사가 널을 기준으로 나뉘어져 있는데, 대사 포인터는 별개로 저장이 되어 있는 것으로 보이지만 헥스 데이터에서 직관적으로 바로 찾아질 정도로 쉽게 들어있지는 않았다. 계속 찾아볼 수도 있었겠지만 여기서 이제 이번 글에서 서술할 작업의 핵심적인 내용이 관련되어 있으므로 뒤에서 마저 서술하도록 한다.

마지막으로 바이너리는 PSP 게임들이 다 그렇듯이 SYSDIR/EBOOT.BIN 로 존재하고, 이는 암호화된 파일이므로 ppsspp 에뮬레이터의 개발자 도구에서 복호화된 BOOT.BIN 파일을 출력하도록 하는 옵션을 체크하고 실행해서 BOOT.BIN 파일을 얻을 수 있다.

작업하면서 가장 문제가 되었던 것은 다름 아닌 PSP 실기와 에뮬레이터의 간극이었는데, 왜 그랬는지는 뒤에서 여러 번 언급할 것이다. PSP 에뮬레이터는 HLE 이고 실기의 정확한 재현을 목적으로 하는 것이 아니기 때문에, 실기보다 훨씬 더 동작에 관대하다. 따라서 에뮬레이터에서는 되지만 실기에서는 안 되는 상황을 매우 많이 만날 수 있다. 다만 미리 말해두자면 일단 테스트한 건 내가 가지고 있는 기기뿐이므로, 모든 종류에서 동일한 결과를 얻는지는 알 수 없다. 아무튼 이제 본격적으로 작업 과정을 서술해보자.


Inject code

우선 BOOT.BIN 파일의 구조는 특별할 거 없는 그냥 ELF 파일이다. 늘 접하는 익숙한 포맷이므로 쉽게 파싱하고 조작할 수 있다. 그리고 당연히 IDA 에서 아주 편하게 볼 수 있으므로 분석이 굉장히 수월하다.

일단 코드 패치를 하기 위해서는 먼저 코드를 삽입할 공간을 확보해야 한다. 그러나 여기서부터 일반적인 PC 리눅스에서 이런 작업을 할 때는 전혀 겪어보지 않은 첫 번째 문제가 발생하는데, 우선 총성과 다이아몬드 체험판의 BOOT.BIN 바이너리의 Section Header 구조를 보자.

Section Headers:
  [Nr] Name              Type            Addr     Off    Size   ES Flg Lk Inf Al
  [ 0]                   NULL            00000000 000000 000000 00      0   0  0
  [ 1] .init             PROGBITS        00000000 0000c0 000024 00  AX  0   0  1
  [ 2] .rel.init         LOPROC+0xa0     00000000 13b920 000010 08      0   1  4
  [ 3] .text             PROGBITS        00000030 0000f0 0f6c3c 00  AX  0   0 16
  [ 4] .rel.text         LOPROC+0xa0     00000000 13b930 048ee0 08      0   3  4
  [ 5] .fini             PROGBITS        000f6c6c 0f6d2c 00001c 00  AX  0   0  1
  [ 6] .rel.fini         LOPROC+0xa0     00000000 184810 000008 08      0   5  4
  [ 7] .sceStub.text     PROGBITS        000f6c88 0f6d48 0005d0 00  AX  0   0  4
  [ 8] .lib.ent.top      PROGBITS        000f7260 0f7320 000004 00   A  0   0  4
  [ 9] .lib.ent          PROGBITS        000f7264 0f7324 000010 00   A  0   0  4
  [10] .rel.lib.ent      LOPROC+0xa0     00000000 184818 000008 08      0   9  4
  [11] .lib.ent.btm      PROGBITS        000f7274 0f7334 000004 00   A  0   0  4
  [12] .lib.stub.top     PROGBITS        000f7278 0f7338 000004 00   A  0   0  4
  [13] .lib.stub         PROGBITS        000f727c 0f733c 0001cc 00   A  0   0  4
  [14] .rel.lib.stub     LOPROC+0xa0     00000000 184820 000228 08      0  13  4
  [15] .lib.stub.btm     PROGBITS        000f7448 0f7508 000004 00   A  0   0  4
  [16] .rodata.sceM[...] PROGBITS        000f744c 0f750c 000034 00   A  0   0  4
  [17] .rel.rodata.[...] LOPROC+0xa0     00000000 184a48 000028 08      0  16  4
  [18] .rodata.sceR[...] PROGBITS        000f7480 0f7540 0001cc 00   A  0   0  4
  [19] .rel.rodata.[...] LOPROC+0xa0     00000000 184a70 000030 08      0  18  4
  [20] .rodata.sceNid    PROGBITS        000f764c 0f770c 0002e8 00   A  0   0  4
  [21] .ctors            PROGBITS        000f7934 0f79f4 000054 00  WA  0   0  4
  [22] .rel.ctors        LOPROC+0xa0     00000000 184aa0 000098 08      0  21  4
  [23] .dtors            PROGBITS        000f7988 0f7a48 000034 00  WA  0   0  4
  [24] .rel.dtors        LOPROC+0xa0     00000000 184b38 000058 08      0  23  4
  [25] .jcr              PROGBITS        000f79bc 0f7a7c 000004 00  WA  0   0  4
  [26] .eh_frame         PROGBITS        000f79c0 0f7a80 02f8a0 00  WA  0   0  4
  [27] .rel.eh_frame     LOPROC+0xa0     00000000 184b90 009830 08      0  26  4
  [28] .gcc_except_table PROGBITS        00127260 127320 00333c 00  WA  0   0  4
  [29] .rel.gcc_exc[...] LOPROC+0xa0     00000000 18e3c0 000010 08      0  28  4
  [30] .rodata           PROGBITS        0012a5c0 12a680 00ce5c 00 AMS  0   0 64
  [31] .rel.rodata       LOPROC+0xa0     00000000 18e3d0 0045d8 08      0  30  4
  [32] .rodata.0001      PROGBITS        00137420 1374e0 000404 00 AMS  0   0 16
  [33] .rodata.0002      PROGBITS        00137830 1378f0 00004c 00 AMS  0   0 16
  [34] .rodata.0003      PROGBITS        00137880 137940 00103c 00 AMS  0   0  8
  [35] .rodata.0004      PROGBITS        001388c0 138980 0000ac 00 AMS  0   0  8
  [36] .rodata.0005      PROGBITS        00138970 138a30 000074 00 AMS  0   0  8
  [37] .rodata.0006      PROGBITS        001389e8 138aa8 000054 00 AMS  0   0  8
  [38] .rodata.0007      PROGBITS        00138a40 138b00 000154 00 AMS  0   0  8
  [39] .rodata.0008      PROGBITS        00138b98 138c58 000174 00 AMS  0   0  8
  [40] .rodata.0009      PROGBITS        00138d10 138dd0 000018 00 AMS  0   0  8
  [41] .rel.rodata.0001  LOPROC+0xa0     00000000 1929a8 000008 08      0  32  4
  [42] .rel.rodata.0003  LOPROC+0xa0     00000000 1929b0 0014f8 08      0  34  4
  [43] .rel.rodata.0004  LOPROC+0xa0     00000000 193ea8 0000a8 08      0  35  4
  [44] .rel.rodata.0005  LOPROC+0xa0     00000000 193f50 000070 08      0  36  4
  [45] .rel.rodata.0006  LOPROC+0xa0     00000000 193fc0 000050 08      0  37  4
  [46] .rel.rodata.0007  LOPROC+0xa0     00000000 194010 000100 08      0  38  4
  [47] .rel.rodata.0008  LOPROC+0xa0     00000000 194110 0001e8 08      0  39  4
  [48] .rel.rodata.0009  LOPROC+0xa0     00000000 1942f8 000020 08      0  40  4
  [49] .data             PROGBITS        00138d40 138e00 0028fc 00  WA  0   0 64
  [50] .rel.data         LOPROC+0xa0     00000000 194318 002108 08      0  49  4
  [51] .data.0001        PROGBITS        0013b640 13b700 0001b8 00  WA  0   0 16
  [52] .data.0002        PROGBITS        0013b800 13b8c0 000060 00  WA  0   0 16
  [53] .sbss             NOBITS          0013b860 13b920 000000 00  WA  0   0  4
  [54] .bss              NOBITS          0013b880 13b920 1356348 00  WA  0   0 64
  [55] .bss.0001         NOBITS          01491bd0 13b920 001d58 00  WA  0   0 16
  [56] .bss.0002         NOBITS          01493940 13b920 000fe8 00  WA  0   0 64
  [57] .comment          PROGBITS        00000000 196420 00005f 00      0   0  1
  [58] .shstrtab         STRTAB          00000000 19647f 0002f5 00      0   0  1
  [59] .gcc_compile[...] PROGBITS        00000000 1970d4 000000 00      0   0  1

.bss 가 다른 게임들에 비해 쓸데없이 크게 잡혀있는데, 어쨌거나 일단 vaddr 기준으로 가장 마지막 섹션은 .bss.0002 이다. 물론 섹션은 실제 주소 공간의 배치를 결정하는 것은 아니므로 굳이 수정할 필요는 없으나, 수정한 바이너리를 IDA 에서 분석하거나 할 때는 의미가 있으므로 일단 적절하게 수정은 해주기로 한다.

실제로 런타임에 적용하기 위해 수정해야하는 것은 Program Header 로, 이 게임에서는 아래와 같다.

There are 3 program headers, starting at offset 52

Program Headers:
  Type           Offset   VirtAddr   PhysAddr   FileSiz MemSiz  Flg Align
  LOAD           0x0000c0 0x00000000 0x000f750c 0x138d28 0x138d28 RWE 0x40
  LOAD           0x138e00 0x00138d40 0x00000000 0x02b20 0x135bbe8 RW  0x40
  LOPROC+0xa0    0x13b920 0x00000000 0x00000000 0x5ab00 0x00000     0x10

실질적으로 메모리가 배치되는 정보는 이 프로그램 헤더이므로, 여기에 LOAD 타입 세그먼트를 하나 더 추가해서 2번째 세그먼트가 끝나는 주소를 VirtAddr 로 잡고 ELF 파일 마지막 오프셋을 Offset 에 넣고 실제로 파일 뒤에 원하는 데이터를 덧붙이고 적절한 size 로 잡아주기만 하면 .bss 영역 뒤쪽에 원하는 데이터를 삽입할 수 있다.

이 바이너리에서 Program Header 는 위에 나와있다시피 파일에서 오프셋 52(0x34) 에 위치해있고, Section Header 는 파일의 거의 마지막 부분에 위치하고 있다. 이 두 헤더의 시작 오프셋은 ELF 헤더에 설정되어 있으므로, 이들을 변경해서 파일 끝에 헤더를 새로 붙여주면 원하는 항목이 추가된 헤더를 쉽게 만들 수 있다.

실제로 그렇게 변경해서 에뮬레이터에서 테스트한 결과 정상적으로 실행되는 모습을 볼 수 있다. 그런데, 왜인지 PSP 실기에서 테스트를 해본 결과 오류로 실행이 되지 않는다. 원인을 파악해보니 Program Header 의 시작 오프셋을 다른 값으로 변경해서 위치를 옮기면 어떻게 해도 오류가 발생한다. 이유는 알 수 없으나 PSP 에서는 Program Header 를 다른 곳으로 이동시키는 것에 대해 로더가 적절히 파싱을 하지 못하는 것으로 생각된다.

또한 이 게임의 경우 ELF 헤더 쪽에 프로그램 헤더 뒤에 약 0x2C 정도의 패딩이 존재하는데(그 뒤는 .init 섹션), Program Header 의 엔트리 하나의 size 는 이 바이너리에서 0x20 이므로 이를 이용해서 시작 오프셋은 그대로 두고 뒤에 추가 세그먼트를 그대로 써넣는 방법이 있는데, 이것조차도 에뮬레이터에서는 잘 되지만 실기에서는 오류로 실행이 되지 않는다.

그래서 Program Header 에 세그먼트를 하나 더 추가하는 방법은 일단 보류하고, 위의 3개 헤더만으로 값만 수정해서 적절히 코드를 삽입하기로 했다.

우선, 문제는 어찌됐든 .bss 영역이 크게 잡혀있고 .bss 영역은 프로그램 내에서 언제 어느 부분을 사용할지 알 수 없으니 웬만하면 그대로 놔두어야 하므로 코드 삽입을 해야할 주소는 최소한 .bss 섹션들이 끝나는 0x1494928 정도부터가 좋은데, Program Header 의 2번째 엔트리를 보면 MemSiz 가 0x135bbe8 인데, FileSiz 는 0x2b20 밖에 되지 않는다. 이는 .bss 영역은 uninitialized 영역이기 때문에 파일에 데이터로 존재하지는 않기 때문이다. 0x2b20 은 .data 섹션들의 size 이다.

Program Header 세그먼트는 연속적인 공간을 표현하기 때문에, 만약 위의 값에서 FileSiz 와 MemSiz 를 각각 0x1000 씩 늘린다고 해도 그건 그냥 .bss 영역의 시작 vaddr 인 0x138d40 + 0x2b20 에 ELF 파일의 0x138e00 + 0x2b20 부터 존재하는 데이터(이 ELF 파일에선 Relocation 관련 데이터가 존재)가 0x1000 만큼 배치될 뿐이지 .bss 영역 뒤에 데이터가 추가되지 않는다.

그래서 .bss 영역 뒤에 원하는 데이터를 배치시키려면, Program Header 의 2번째 세그먼트의 FileSiz 를 실제로 MemSiz 만큼 크게 만드는 방법을 생각해볼 수 있다. 즉 .bss 영역만큼의 size 를 실제로 파일에 적당히 0 으로 채우고 붙여서 vaddr 뿐 아니라 파일 내에서도 실제로 연속적인 구조가 되도록 만드는 것이다. 그렇게 한 뒤에 .bss 영역 데이터 뒤쪽에 원하는 코드 등의 데이터를 붙여서 Program Header 에도 그 추가된 값으로 변경을 하면 된다.

말이 복잡하지만 실제로 그렇게 수정한 파일의 Program Header 부터 보자.

Program Headers:
  Type           Offset   VirtAddr   PhysAddr   FileSiz MemSiz  Flg Align
  LOAD           0x0000c0 0x00000000 0x000f750c 0x138d28 0x138d28 RWE 0x40
  LOAD           0x199598 0x00138d40 0x00000000 0x137cbe8 0x137cbe8 RWE 0x40
  LOPROC+0xa0    0x13b920 0x00000000 0x00000000 0x5ab00 0x00000     0x10

우선 2번째 세그먼트의 파일 내에서의 시작 오프셋을 뒤쪽으로 수정했다. 물론 당연히 원래 앞쪽에 있던 .data 섹션의 데이터는 그대로 복사해서 옮겨주어야 한다. 그리고 FileSiz 가 MemSiz 와 같은 값으로 크게 증가했다. 그리고 실제로 ELF 파일에서 0x199598 오프셋부터 0x137cbe8 의 size 를 가지는 데이터가 존재한다. 물론 0 으로 채워진 값이다. 여기서는 0x20000 정도를 뒤에 여유 공간으로 잡았는데, 여기에 내가 삽입할 코드나 데이터가 들어가게 되고 실제 주소 공간에서 위치하는 곳은 BASE_ADDRESS + 0x138d40 + 0x135cbe8 주소가 될 것이다.

이렇게 하면 ELF 파일의 크기가 매우 뻥튀기되지만, 어찌됐든 에뮬레이터는 물론이고 실기에서도 정상적으로 실행되는 것을 확인할 수 있다. 사실 이 게임이 특이하게 .bss 영역을 크게 잡고있는 것이지 다른 게임들은 대체로 저 정도로 큰 경우는 그다지 많지않으므로 크기가 이 정도로 커지지는 않을 것이다.


UTF-8 → UTF-16

이제 원하는 코드를 삽입할 수 있으니, 무엇을 패치할지를 정해보자. 일단 위에서 잠시 언급했지만 이 게임의 대사는 아카이브 파일 내의 .hyb 파일들에 존재하는데, 실제로 ep07_101.dat 파일 내에 있는 ep07_101.hyb 파일의 데이터를 보면 아래와 같다.

UTF-8 인코딩으로 일본어 문장들이 삽입되어 있는 것을 볼 수 있다. 각 대사들은 널로 구분되어 있다.

기본적으로 UTF-8 에서 가나 문자와 한자 및 한글은 모두 3bytes 를 차지하므로, 한자가 포함된 일본어 문장을 한국어로 번역할 경우 띄어쓰기의 영향까지 포함해서 원래 문장보다 더 길어지는 경우가 대부분이다. 만약 저 공간에 그대로 한국어를 넣으려고 할 경우 대부분의 대사에 말줄임을 심하게 동원해야 하고, 심지어 한자 2글자로 된 일본어 이름의 경우 한글로는 거의 항상 3글자 이상이 되므로 이 경우 말줄임을 할 수도 없으니 아예 넣을수가 없다.

그래서 필연적으로 대사 포인터를 찾아서 수정하고 대사를 뒤로 옮기거나 하는 작업이 필요한데, 포인터가 직관적으로 있는 경우라면 큰 문제가 되지 않으나 이 경우 바로 눈에 띄지는 않는다. 그래서 코드 분석과 디버깅 등을 동원해서 찾아야 하는 경우도 생기는데, 이런 작업은 굉장히 귀찮은 것이 사실이다.

따라서 이번에는 코드 패치를 동원해서, 대사 포인터를 수정하지 않고도 한국어 문장을 거의 말줄임 없이 넣을 수 있도록 하는 방법을 고안해보았다. 그 방법은 바로 이 게임의 대사가 UTF-8 인코딩이 되어 있다는 점을 이용하는 것이다.

위에서 말했듯이 UTF-8 인코딩에서는 가나 문자와 한자 및 한글이 모두 3bytes 를 차지하는데, 이들은 알다시피 UTF-16 인코딩에서는 2bytes 를 차지하는 문자들이다. 따라서 대사를 UTF-16 으로 넣을 수 있으면 상당한 공간을 확보할 수 있게 된다. 이는 원본 대사가 길수록 더욱 많은 공간이 확보되지만, 원본 대사가 2글자 정도로 짧으면 그리 많은 공간을 확보할 수 없기는 하다. 하지만 대부분의 대사는 그렇게 짧은 경우가 없으므로 거의 대부분 말줄임 없이 대사를 온전히 다 삽입할 수 있다.

우선 먼저 알아두어야 할 것은, 이 게임에서 비록 대사를 UTF-8 로 저장하기는 하지만 실제로 폰트 파일에서 원하는 글자를 찾기 위한 코드는 내부적으로도 UTF-16 을 사용한다는 점이 중요하다. 이를 알아보기 위해 우선 아래의 시스템 콜을 알아야한다.

int sceFontGetCharGlyphImage_Clip (SceFontHandle fontHandle, unsigned int charCode, SceFontGlyphImage *glyphImage, int clipXPos, int clipYPos, int clipWidth, int clipHeight)

이는 이름 그대로, 폰트에서 문자 코드를 기반으로 원하는 글자를 찾아서 래스터화하기 위한 시스템 콜이다. 여기에서 2번째 인자인 charCode 가 있는데, 이를 디버거에서 직접 어떤 값이 들어가는지 확인해보면 아래와 같다.

위와 같이 UTF-16 코드가 들어가는 것을 확인할 수 있다. 그렇다는 것은 파일에 저장된 UTF-8 대사 코드를 UTF-16 로 변환하는 루틴이 반드시 존재한다는 것인데, 위 시스템 콜 호출부터 거꾸로 콜스택 타고 올라가다보면 아래의 함수를 찾을 수 있다.

변수 이름은 의미에 맞게 대충 붙였다. 이 함수에서 UTF-16 으로 변환된 데이터가 들어갈 메모리 공간을 할당한 뒤, 0x6E408 함수에서 변환 작업을 하게 된다. 따라서 이 부분이 코드 패치를 해야할 핵심적인 부분이다.

원래 문자열의 끝을 00 하나로 인식할 수 있는 UTF-8 인코딩과 달리, UTF-16 의 경우 가령 00 00 으로 2bytes 를 뒤에 붙이고 인식하는 등의 추가적인 작업이 필요하다. 그렇게 해도 상관없고, 또는 각 대사의 앞에 문자 길이를 1byte 로 붙여주는 방법도 있다. 둘 다 결국 원래의 대사 공간에서 1byte 를 더 점유해야 한다는 것은 동일하다. 만약 1byte 로 인해 대사 공간이 부족한 경우가 많이 생길 경우 별도의 대사 길이 테이블을 따로 두거나 하는 방법을 이용하면 이 1byte 도 여유 공간으로 이용할 수 있지만, 굳이 그렇게 하지 않아도 공간은 충분한 것으로 판단해서 나는 후자의 방법으로 구현했다.

대사의 시작 부분에 [0xC0 + text count] 의 값으로 1byte 를 붙여주는데, 이 때 text count 는 인코딩된 대사 길이가 아니라 순수 문자 개수를 의미한다. 코드에서 대사 시작 부분 바이트가 0xC0 ~ 0xDF 인 경우 한국어 번역된 대사로 인식해서, 시작 바이트에서 0xC0 을 빼서 문자 개수를 구할 수 있다. UTF-16 은 일반적인 문자들에 한해서는 문자 개수에 2 를 곱하면 그대로 인코딩된 문자열 길이가 되므로 간단하게 전체 길이도 구할 수 있다.

실제 예를 하나 들어보자.

グレン
E3 82 B0 E3 83 AC E3 83 B3 00

글렌
C2 AE 00 B8 0C

위에서 보다시피, 2글자인 경우 앞에 C2 를 붙여준다. 그렇게 넣어도 필요한 대사 공간이 1/2 로 줄어든 것을 볼 수 있다. 이렇게 하면 대사 포인터를 찾아서 수정하지 않고도 한국어화를 할 수 있게 된다. 물론 실제 작업에서는 위에서 예로 든 이름 등 몇 가지 부분이 공간이 부족해서, 이는 몇 개 되지는 않아서 별도로 처리해주었다.


Patch code

이제 실제로 어떤 식으로 코드 패치를 하는지 간단하게 확인해보자. 우선 x86 과 달리 MIPS 는 절대주소 점프만 지원하는데(브랜치로 짧은 점프는 가능), 위에서 봤듯이 ELF 내부적으로는 주소 지정이 0x0 부터 시작된다. 그러나 실제 실행시에는 당연히 이 주소가 사용되는 것이 아니라 재배치되어 Base Address 가 더해진 값으로 바뀌게 된다. ELF 내에 Relocation Table 이 존재하고, 여기에는 프로그램 내에서 사용되는 점프 명령이라든지 기타 재배치가 필요한 주소가 포함되는 모든 명령들의 위치를 담고있다. 바이너리가 로드될 때 특정 Base Address 주소에 배치되면 이 주소를 기준으로 해서 점프 명령 등의 모든 명령 위치의 값을 전부 이 Base Address 가 더해진 값으로 변경해서 정상적으로 동작할 수 있도록 하는 것이다.

즉 내가 삽입할 코드에 점프 명령 등 절대 주소가 필요한 명령이 포함된다면, 이를 정상적으로 실행할 수 있도록 하기 위해서는 2가지 방법이 있다.

  1. 내가 삽입한 코드의 점프 명령 등이 정상적으로 재배치될 수 있도록 Relocation Table 을 수정
  2. 바이너리가 실행되는 Base Address 가 특정 값으로 고정되어 있기를 바라며 그 값을 미리 더해서 어셈블하는 기도메타

1번 방법이 좀 더 바람직한 방법이기는 하나, 이는 그리 간단하지 않다. 2번의 경우 실제로 Base Address 가 고정된다는 근거가 없으면 애초에 사용할 수도 없다.

다행히도, PSP 는 실행 파일을 항상 0x8804000 에 올리는 것으로 추측된다. 실제로 ppsspp 등의 에뮬레이터는 고정적으로 0x8804000 에 배치시키도록 구현되어 있는데, PSP 실기도 정말 이렇게 항상 0x8804000 에 고정적으로 배치한다는 확실한 근거가 포함된 정보를 인터넷에서 찾아보았으나 아쉽게도 찾지 못했다. 다만 어쨌든 실기 테스트를 수십 수백번을 해봐도 전혀 문제가 없는 것으로 보아 그렇다고 생각해도 될 것이다.

코드 패치(라기보단 인젝션) 방식은 게임보이나 기타 다른 플랫폼들에서 하던 것과 전혀 다르지 않다. 패치를 원하는 위치에 j 또는 jal 명령을 덮어써서 내가 삽입한 코드가 있는 주소로 점프하도록 한다. 이 때, 만약 해당 원본 코드가 점프나 기타 재배치되는 명령이었을 경우에는 그냥 원래대로 0x0 기준 주소로 삽입해야 한다. 그러면 실행시에 알아서 재배치가 되서 0x8804000 이 더해진 명령으로 바뀌게 된다.

그리고 원하는 코드를 실행한 다음, 다시 원래 위치로 리턴하거나 또는 다른 위치로 점프하거나 한다. 새로 삽입한 코드 영역은 재배치되는 영역이 아니므로 여기서 주소를 사용할 때는 전부 0x8804000 을 더한 주소로 어셈블해야한다.

결과적으로 이 게임에서 코드 패치한 부분들은 대략 아래와 같다.

  1. UTF-8 대사를 UTF-16 으로 변환하는 루틴에서 이미 UTF-16 으로 저장되어 있는 대사는 변환하지 않고 그대로 복사하도록 패치
  2. 대사 길이를 구하는 루틴에서 UTF-16 으로 저장된 대사는 앞의 1byte 로 저장한 [0xC0 + text count] 값을 이용해서 구하도록 패치
  3. 기존의 공백(0x20) 코드를 그대로 사용하면 간격이 지나치게 멀어져서 출력되는 문제가 있어서, 번역 대사를 삽입할 때 공백 부분을 공백 대신 “뷁” 으로 바꾸고, 폰트 출력시에 “뷁” 글자가 나오면 출력을 하지 않는 것으로 변경해서 공백이 정확히 한 글자 간격으로 나오도록 패치
  4. 이 게임에서는 폰트를 기본 사이즈 폰트와 large 폰트로 나누어서 사용하는데, 한국어화된 본편에서는 large 폰트를 제거하고 기본 사이즈 폰트만 사용하고 있으므로 그에 맞춰서 이 체험판에서도 전부 기본 폰트만 로드하도록 패치


Miscellaneous

작업을 하다보면 실기에서 별의 별 문제가 다 발생할 수 있는데, 위처럼 ELF 파일 구조상의 오류는 로더의 문제이므로 그나마 원인 파악도 쉽고 해결도 간단하지만 가령 명령어 레벨에서의 오류는 원인 불명의 이해할 수 없는 문제가 많고 해결도 까다로운 경우가 많다.

그런 경우 다양한 방법들을 시도하면서 실기 테스트를 반복하는 지루한 작업이 필요하다. 가령 특정 위치의 특정 명령에서 원인 불명으로 뻗는 문제가 발생하는 것으로 추정되는 경우, 해당 명령을 다른 여러 명령의 조합으로 동일한 동작을 하도록 바꾸면 해결되는 황당한 경우도 존재한다. (lw 나 sw 를 lbu 와 sb 여러 개로 대체한다거나) 또는 단순히 명령어 위치를 다른 곳으로 옮기는 것으로 해결되기도 한다. 물론 더 근본적인 원인이 있을 수 있겠지만 나는 알아내지 못했고 우회적인 방법으로 해결했다.

Python 의 어셈블러 모듈인 Keystone Engine 은 옛날부터 많이 쓰고 있지만, MIPS 의 경우 파이프라인으로 인해 브랜치 명령(j, b 계열) 뒤에 브랜치 직전에 실행되는 명령이 들어가야 하는데 이 모듈은 그 경우 원하는 명령을 넣을 수 있도록 지원하지 않고 무조건 nop 이 들어가도록 해놔서 별로 좋지는 않다. 가능하면 그냥 게임보이 한글패치 하던 때처럼 외부 어셈블러를 연동하는 게 바람직할 것 같지만 어차피 패치할 코드는 짧으니 딱히 상관은 없을 것 같다.

Source code

이전의 게임보이 코난 시리즈를 비롯해서 모든 한국어화 패치 소스 코드는 GitHub 에 업로드하고 있다. 이번 소스 코드도 아래 repository 에 업로드하였다.