놈2 에뮬레이터 버그 고치는 삽질기

오랜만에 글을 올리는군요!

며칠전에 문득 피처폰에서 재밌게 하던 게임인 놈2가 생각나서 PC에서도 할 방법이 있을까, 하고 찾아보았습니다. 결론적으로는 KEmulator (= Java Emulator) 라는 프로그램과, 놈2 게임파일이 있으면 손쉽게 가능하다는 동영상을 봤는데요,

기대하는 마음으로 jar 게임 파일과 (자바였다니!) 에뮬레이터를 받아서 플레이해보았습니다. 그런데 뭔가 이상하더군요…


이쯤에서 게임을 간략히 소개해드리자면, 놈2는 화면을 360도로 쉴새없이 돌려가며 장애물을 피해 무작정 달리는 원버튼 게임인데요, 재밌읍니다. 한 가지 특이한 점은, 스테이지의 1/4를 지날때마다 “Break Down!” 이라 하면서 화면을 돌리고, 브금이 바뀝니다. 보스전을 제외하면 각도에 따라 (0, 90, 180, 270) 반복되지요.

그런데, 한 바퀴를 돌고 스테이지 2로 오면서… 브금이 더 이상 안 나오는 것이였습니다!

발견하게 된 버그

전에 할 때는 브금이 반복되는 줄도 몰랐었는데… :thinking: 각 스테이지, 그러니까 0도마다 반복이 되더군요. ppsspp같이 다른 에뮬레이터를 시도해볼까도 했지만, 다른 심각한 버그가 있어서 넘어갔습니다.

image

버그의 동작에 대한 확신을 갖기 위해, 메인 메뉴에서 반복적으로 들리는 효과음을 찾았습니다. 설정 창에서 소리를 키고 끄는 옵션이 있는데, 껐다 키니까 더 이상 메인화면의 소리가 들리지 않더군요. "한 번 정지된 BGM은 들리지 않는구나"라 확신했습니다.

다른 방법들을 시도해볼 수도 있었지만 흥미가 생겨서, 어딘가에 있을 버그를 고쳐보기로 결심했읍니다.

분석할 대상: 놈2, 에뮬레이터

아직 버그가 어디서, 어떤 이유로 발생하는지는 찾지 못했기 때문에, 에뮬레이터와 게임이 거시적으로 어떻게 구동되는지 파악해보고 싶었습니다. 에뮬레이터에는 아래와 같은 파일들이 있더군요.

jre/
- bin/
-   java.exe
-   javaw.exe
-   ...
- lib/
-   rt.jar
-   ...
language/
libs/
- jsr{75,82}.jar
- third-party.jar
rms/
- SonyEricssonK800_240x320/
-   ...
KEmulator.jar
KEmulator.exe
amrdecoder.dll
emulator.dll
...

아직은 뭐가 뭔지 모르겠지만, '자바를 쓴다’는 점과, 에뮬레이터가 자바 런타임, UI, 그리고 여러 개의 자바 라이브러리(jar)로 구성된다는 것은 알 수 있었습니다. 이미 컴파일된 자바 프로그램을 분석하려면 자바 디컴파일러 프로그램이 있으면 됩니다.

자바 디컴파일러

이미 컴파일된 프로그램을 다시 소스 코드로 바꿔주는 프로그램이 있습니다. 디-컴파일러 (decompiler) 라고 부르는 이 프로그램들로는 jd-gui, jadx, cfr 등이 있습니다. 보통 사람 손으로 짠거라, 버전마다 지원하는 기계어 패턴들이 달라서 다 써보곤 하는데요.

WIPI

게임, 에뮬레이터에 해당하는 jar 파일들을 디컴파일러로 분석하기 전에 문득 “아, WIPI라는게 있었지!” 하고 생각이 나서 구글에 검색을 해봤습니다. 다름이 아니라, 뭔가 국내 모바일로 배포되는 프로그램들은 WIPI라는 표준 규격을 따른다는 이야기를 어깨너머로 들었었거든요.

찾아보니 아래와 같은 문장이 있었습니다.

WIPI는 Java (MIDP 2.0)와 C 언어를 모두 지원하며 이용한 언어에 따라서 자바로 생성한 콘텐츠를 Jlet, C로 생성한 콘텐츠를 Clet이라고 한다. 바이너리 형식에 대한 호환성은 제공하고 있지 않으며, 프로그래밍 모델과 API에 대한 표준만을 정의하고 있다. WIPI 1판에서는 별도의 자바 규격을 채택하였으나, 현재 사용되는 2판에서는 MIDP2.0 규격을 채택하고 있다.

출처: 위키피디아

세상에, WIPI가 자바 표준이였군요. 자바와 네이티브(C)를 지원한다라… 놈2는 피처폰 끝자락에 가서도 있었으니, 규격중에서는 비교적 최신의 것도 지원하겠다 싶어서, 위에 나온 MIDP 2.0이라는 것에 대해서 검색해보았습니다. 오라클 공식 홈페이지에서 문서를 배포하고 있었는데요,

image

‘Version 2.0, for Korean’ 한글 문서라니! 없을 이유도 없지만서도, 인상깊었읍니다. 열어보니 javax.microedition.*의 각 클래스와 함수들의 동작이 쓰여있더군요.

WIPI가 자바 프로그램에 관한 표준이라는 것과, API 문서를 가지고 본격적으로 게임과 에뮬레이터를 분석해보기로 했습니다.

게임 까보기

물론 에뮬레이터의 소리 재생부를 바로 분석해볼 수도 있었지만, 소리를 재생하는 윈도 API로부터 콜스택 따라 에뮬레이터, 게임의 구현까지 타고 올라가는 방식은 시간이 더 걸리겠다 싶어서 (중간에 자바 구현체도 있을 것 같더군요) 반대방향으로 해보기로 했습니다.

  1. 게임에서 소리를 어떻게 재생하는지 (게임 -> WIPI API)
  2. 에뮬레이터에서 어떻게 실제로 소리를 재생하는지 (WIPI API -> java runtime, WINAPI)

이렇게 해보는 거죠. 더 나아가면 "그 당시의 자바에서 소리 재생 관련 버그는 없었는지"까지 조사하게 될 수도 있지만, 일단 1번이나 2번 중에 문제가 있다면, 운이 좋은 것이겠죠! 단계별로 유기성을 파악하되, 쉬운 방법을 택하면 될 거라는 생각이였습니다.

WIPI 프로그램에서 소리를 재생하는 방법

일단 놈2가 소스코드로 변환이 되는지부터 봐야겠군요. jadx를 돌려봤습니다.

자라란, 잘 되는군요! javax.microedition으로 시작하는 API들을 보고, 어느정도 맞구나 하는 확신이 들었습니다. 소리를 재생하는 부분을 찾기 위해서 “play”, “wav”, "midi"같은 키워드를 전체 자바 파일에 검색해보았더니, 다행히 바로 나오더군요.

  • 프로그램 로딩 시 22개의 midi/wav 파일들을 전부 로딩하여 ‘Player’ 인스턴스를 만든다.
    private void c() {
        this.h = new Player[21];
        for (int i = 0; i < 21; i++) {
            this.h[i] = a(i);
        }
    }

    private Player a(int i) { // ...
        try {
            return Manager.createPlayer(
                new ByteArrayInputStream(d.a(stringBuffer, 0x1000)), this.e[this.f]);
        } catch (Exception e2) {
            return null;
        }
    }
  • 요청이 들어오면, 해당 Player의 start 메서드로 음악을 재생한다.
    public final void a(int i, boolean z) {
        this.a = i;
        try {
            this.h[this.a].getControl("VolumeControl").setLevel(this.d);
            this.h[this.a].start();
        } catch (Exception e7) {
        }
    }
  • 현재 실행하는 음악의 번호는 this.a에 저장되는데, 이를 stop해주는 함수도 있었습니다.
    public final void a(boolean z) {
        this.h[this.a].stop();
    }

뭔가 딱히 문제는 없어보였습니다. 그리고 이 부분이 실제로 BGM을 관리한다는 것이 실감이 안 나더군요. 다른 위치에 비슷한 코드가 존재할 수 있으니까요. 그래서 확인해 볼 겸, 또한 버그를 고치려면 실제로 자바 프로그램을 수정 후 실행도 해봐야 하니까 간단한 실습을 해보기로 했습니다.

예를 들어 위의 stop()함수가 실행되지 않도록 게임을 고친다면 어떤 BGM이 정지된 후 다른 음악이 나올 때, 서로 겹쳐진 상태로 들리겠지요.

자바 코드 (소스코드 없이) 수정하기

디컴파일러가 아무리 발달한다 해도, 여기서 생성된 소스 코드를 다시 컴파일했을 때 문제가 생기지 않을 거라는 것은 꽤나 보장하기 어렵습니다. 읽을만한 소스코드로 변환하는 과정에서 많은 정보들이 생략되기 때문인데요.

가령 지역 변수를 여러 개 선언했을 때 각각 정확히 어느 위치에 할당되는지는 보통 컴파일러 마음대로입니다. 하지만 컴파일러는 버전마다 바뀌고, 해당 정보들을 javac 컴파일러에 다시 넘겨줄 방법이 아는 범위내에선 없었습니다. (바이트코드에 StackMap이라는 필드가 있었습니다)

또한 이런 ‘컴파일하면서 생긴’ 정보들이 실제로 자바 런타임에서 어떻게 쓰이는지라던지, 프로그램이 깨지는 것을 막기 위해서 어떤 정보까지 유지시켜줘야하는지 가늠을 하기 어렵습니다. (실제로 java 파일을 컴파일해서 생긴 class 파일을 다시 jar로 압축하여 넣어봤을 때 제대로 동작하지 않는 것을 겪은 이후, 이런 부분을 신경쓰게 되었습니다)

따라서 수정을 한다면 최소한으로, 이미 있는 정보를 최대한 바꾸지 않는 편으로 하는 것을 선호하게 됩니다. 이를 위해 중간에 많은 복잡한 과정이 있는 jar -> 소스 코드 -> jar이 아닌, jar -> 어셈블리어 코드 -> jar로 방향을 바꾸기로 했습니다.

자바 바이트코드, 어셈블리어

어셈블리어란 기계어 자체를 단순히 사람이 읽을 수 있는 형태로 바꾼 언어인데요, 가령 기계에서 실제로 a, b를 더해서 c라는 공간에 넣는다는 것을 어셈블리어에서는 아래와 같이 보여줍니다.

add c, a, b

기계어랑 1:1 대응이 되는, 사람이 짤 수 있는 언어를 보통 어셈블리 언어라고 합니다. 1:1 대응이 되니 둘 다 편의상 기계어, 즉 '바이트코드’라 부르겠습니다. 자바의 경우에는 JVM Bytecode list라는 키워드로 검색하면 어셈블리어와 이에 대응되는 기계어 바이트 배열이 나옵니다.

보통 컴파일러는 편의성을 위해 어셈블리어를 생성하고, 이를 다시 기계어로 변환해주는 작업을 하게 됩니다 (물론 바로 기계어를 생성할 수도 있습니다). 이는 디컴파일러도 마찬가지인데요, 기계어를 디컴파일러 개발자가 처리할 수 있는 어셈블리어로 바꾼 뒤, 이를 재구성해서 소스 코드를 만들어주곤 합니다.

또한, 보통 한 줄의 소스코드는 여러 개의 바이트코드와 대응되는 경우가 많습니다.

따라서 1. 보다 예상 가능한 범위의, 2. 정밀한 수정을 위해 바이트코드 단위로 동작하는 디스어셈블러 + 어셈블러 조합으로 jar파일을 수정하기로 했습니다.

(이렇게 소스코드 없이 프로그램을 수정하는 것을 바이너리 패치라고도 합니다.)

Krakatau

Krakatau는 파이썬으로 된 자바 디컴파일러 + 디스어셈블러 + 어셈블러입니다. 스크립트 언어로 되있어서 버그가 발생하면 수정하기도 괜찮겠군요!

cd Krakatau
python disassembler.py h.class
# h.j 파일이 생성됨

python assembler.py h.j
# h.j -> h.class

일단 수정 없이 위의 코드가 담긴 class 파일인 h.class를 바이트 코드 파일인 h.j로 변환하고, 다시 어셈블러를 돌렸습니다. 그 후 반디집으로 jar파일을 연 후 해당 class파일을 드래그해서 바꿔치기해주면 됩니다. (현재 파일에 추가하기 메뉴)

파일의 크기가 아주 약간 변하긴 했지만 잘 되더군요! 이제 수정을 해볼 차례입니다. stop으로 검색해보니 해당되는 바이트코드는 아래와 같았습니다.

L20:    aload_0 
L21:    getfield Field h h [Ljavax/microedition/media/Player; 
L24:    aload_0 
L25:    getfield Field h a I 
L28:    aaload 
- L29:    invokeinterface InterfaceMethod javax/microedition/media/Player stop ()V 1 
+ L29:    pop
L34:    iload_1 
...
L44:    pop 
L45:    return 

“invokeinterface”, 뭔가 함수를 부를 것 같이 생긴 명령어로군요. 아예 지울까 생각해보다가, stop()은 this까지 포함하면 인자를 하나 받으니까, 스택을 복구해주는 겸 pop으로 바꾸면 어떨까 싶었습니다. 바꾼 후 적용시키니까 더 이상 음악이 종료되지 않더군요!

문득 생각이 나서 다시 설정창을 들어가보니, 이제는 효과음을 여러번 트리거할 때 잘 재생이 되더군요. 하지만 아직 stop() 후에 같은 음악을 재생했을 때 왜 재생이 안되는지는 파악하지 못했습니다. 게임 파일은 다시 원본으로 돌렸습니다.


위의 놈2 코드를 다시 생각해 보면, 모든 음악은 앱이 시작될 때 같이 로딩됩니다. 각 Player 인스턴스가 하나의 음악을 담고, 이후 stop/start가 반복적으로 호출될 수 있는 구조였습니다.

image
stop() 후에 다시 start()를 하는 것은 그래프에 정의된 경로니까 문제가 없어보였고, 이쯤에서 에뮬레이터는 밑단의 javax.microedition.* API를 어떻게 구현하는지 궁금해졌습니다.

게임에서 에뮬레이터로

에뮬레이터 폴더 안에 java.exe가 있었고, 게임 파일도 자바 API를 쓰는데, 에뮬레이터의 WIPI 구현 클래스들이 자바 런타임이라는 토대를 쓰지 않을 이유가 딱히 없어보였습니다. 그래서 혹시 jar파일 안에 구현이 있지 않을까 하는 가정을 세웠습니다. 위에서 본 파일명들 중에 rt.jar, 즉 런타임(runtime)에 쓰이는 것처럼 보이는 무언가도 있겠다 싶어서, 그냥 grep을 에뮬레이터 디렉토리에서 돌려보았습니다.

$ cd KEmulator
$ grep -r Player .
Binary file KEmulator.jar matches

오 세상에, :smile: 에뮬레이터 자체에서 밑단의 자바 클래스들을 전부 구현하는걸까요? 이 또한 jadx로 분석해보았습니다. jar 파일의 javax/microedition/media/PlayerImpl.class를 소스 코드로 변환해본 뒤 midi 파일 처리부를 보면,

    public PlayerImpl(InputStream inputStream, String fileType) throws IOException {
...
        } else if (fileType.equalsIgnoreCase(Manager.CONTENT_TYPE_XMIDI) ||
                   fileType.equalsIgnoreCase(Manager.CONTENT_TYPE_MIDI)) {
            this.playInterface = MidiSystem.getSequence(inputStream);
            this.sequencer = MidiSystem.getSequencer();
        }
}

그리고 별도로 클래스 내에서 생성하는 스레드 함수에 대해서는, stop() 이후 midi 파일에 대해서만

    public void run() {
        /* loop until stop() or finished playing a file ... */

        // Object playInterface = Sequence(midi) | Clip(wave)
        if (this.playInterface instanceof Sequence) {
            this.sequencer.close();
        }

요런걸 호출하고 있더군요. this.sequencer는 PlayerImpl의 초기 호출에서만 초기화되는데, 혹시… 한 번이라도 close()가 호출된 sequencer는 더 이상 재생을 못하지 않을까요?

잘 모르겠어서 this.sequencer.close()를, 이번에는 그냥 if의 true 분기에 있는 코드를 전부 없애는 방법으로 패치 후, jar파일에 넣어주었습니다.

L217:   getfield Field javax/microedition/media/PlayerImpl a Ljava/lang/Object; 
L220:   instanceof javax/sound/midi/Sequence 
L223:   ifeq L235 
- L226:   aload_0 
- L227:   getfield Field javax/microedition/media/PlayerImpl a Ljavax/sound/midi/Sequencer; 
- L230:   invokeinterface InterfaceMethod javax/sound/midi/MidiDevice close ()V 1 
L235:   return 

그리고 대망의 테스트시간…

테스트

잘되는군요! 보람찼습니다.

맺음말

'놈2’라는 피처폰 게임을 PC로 돌리기 위해 게임 jar파일와 에뮬레이터를 받았지만, midi 파일에 한해 한 번 정지된 BGM이 다시 재생되지 않는 문제가 있었습니다. 이를 위해

  1. WIPI가 뭔지 간략히 찾아보고,
  2. 게임에서 WIPI의 API를 이용해서 BGM을 어떻게 로딩하고 관리하는지 살펴보고,
  3. 에뮬레이터의 해당 API 구현에 문제가 없는지 살펴봤습니다.

에뮬레이터에 리소스 관리 문제가 있음을 파악했고, 자바의 바이트코드 단위에서 코드를 패치해보았습니다. 실행하니 잘 되는군요!

피처폰 게임들의 저작권법이 허락한다면, 더 이상 판매되지 않는 여러 게임들을 전시해놓고 플레이 해볼 수 있는 박물관이 만들어졌으면 좋겠다는 생각을 해보면서 글을 맺어보아야겠습니다.

8 Likes

이건… 너무 좋은 것 같아요.
그전에 불법으로라도 해보고 싶은 추억의 피처폰 게임들이 잔뜩…읍읍