ZZT in Go (using a Pascal-to-Go converter)

Ben Hoyt

Go로 만든 ZZT (Pascal-to-Go 변환기를 이용해서)

원문은 Ben Hoyt님이 에 게재했습니다. 이 블로그 구독하기

요약: Adrian Siekierka의 “Reconstruction of ZZT”를 보고, 그의 Turbo Pascal 코드를 Go로 번역하는 프로그램을 만들었다. 이 글에서는 내가 만든 Pascal-to-Go 변환기와 (완벽하지는 않지만) Go로 포팅한 ZZT에 대해 설명한다.

나는 ‘게이머’라고 할 정도는 아니지만, 십 대 시절 즐겨 했던 게임 중 하나는 Epic MegaGames의 ZZT였다 — 1991년에 나온 오래된 텍스트 모드 DOS 게임이다. 당시 기준으로도 그래픽이 특별히 혁신적이지는 않았지만, ZZT가 크게 성공하고 지금까지도 컬트적인 인기를 유지하는 이유는 월드 에디터(심지어 쉐어웨어 버전에도 무료로 포함되어 있었다) 덕분이었다.

원본 쉐어웨어 버전인 “Town of ZZT”의 타이틀 화면은 이렇게 생겼다:

Town of ZZT 타이틀 화면

월드 에디터에는 ZZT-OOP라는 스크립트 언어가 있었는데, 여기서 OOP의 ‘O’는 ZZT의 ‘오브젝트’, 즉 프로그래밍 가능한 게임 캐릭터나 로봇을 의미한다. 그래서 사람들은 수천 개의 자체 월드를 만들어 공유했고, 그중 수백 개는 ZZT 월드의 archive.org라 할 수 있는 Museum of ZZT에서 다운로드하거나 온라인으로 직접 플레이해 볼 수 있다.

ZZT 복원

ZZT의 원본 소스 코드는 컴퓨터 고장으로 소실되었지만, 그동안 다른 언어로 게임을 재현하려는 다양한 시도가 있었다. C++로 만든 버전, JavaScript로 만든 부분 구현, 심지어 Rust로 만든 정확한 재구현도 있었다. 그리고 Adrian Siekierka는 ZZT 실행만을 위한 특화 DOS 에뮬레이터인 Zeta의 저자이기도 하다.

2020년 3월, Adrian은 Reconstruction of ZZT를 공개했다. ZZT에 사용된 원래 Turbo Pascal 5.5로 컴파일하면 바이트 단위까지 동일한 .EXE 파일이 생성되는, 리버스 엔지니어링으로 복원한 Pascal 소스 코드다. 개인적으로 정말 놀라운 업적이라고 생각한다! 원본 Hacker News 토론과 Adrian이 나중에 자신이 어떻게 작업했는지 쓴 도 참고해 보라.

그는 이후 한 걸음 더 나아가, 다른 ZZT 포트에서 사용할 수 있도록 허용적인 라이선스로 제공되는 이식 가능한 C 재구현인 libzoo도 만들었다.

Pascal-to-Go 변환기

얼마 전 Go로 ZZT 버전을 직접 만들어 보려다 아주 조금 진행한 뒤 포기한 적이 있다. 사이드 프로젝트로 하기엔 너무 큰 작업처럼 보였기 때문이다. 하지만 Pascal 복원판이 나오자 다시 한번 (어흠) go에 도전해 보기로 했다.

나는 즐겨 다루던 인터프리터와 컴파일러를 만지작거리는 걸 좋아해서, Adrian의 Pascal 복원 코드를 반자동으로 Go로 변환하는 프로그램을 만들 수 있을지 시험해 보고 싶었다. 그래서 그다지 완전하지 않은 Turbo Pascal 파서를 만들고, Pascal 구문 트리를 받아 Go 타입과 문법으로 출력하려는 변환기를 작성했다.

Go의 구조와 선언 문법은 사실 Pascal과 상당히 비슷한 부분이 많아서, 변환기 전체 구조를 꽤 단순하게 만들 수 있었다. 예를 들어 OOP.PAS에 있는 작은 Pascal 함수를 보자:

function WorldGetFlagPosition(name: TString50): integer;
    var
        i: integer;
    begin
        WorldGetFlagPosition := -1;
        for i := 1 to 10 do begin
            if World.Info.Flags[i] = name then
                WorldGetFlagPosition := i;
        end;
    end;

변환된 Go 코드는 다음과 같다:

func WorldGetFlagPosition(name string) (WorldGetFlagPosition int16) {
    var i int16
    WorldGetFlagPosition = -1
    for i = 1; i <= 10; i++ {
        if World.Info.Flags[i-1] == name {
            WorldGetFlagPosition = i
        }
    }
    return
}

거의 동일하다. 변환기를 단순하게 유지하기 위해 관용적인 Go 코드를 만들려 하기보다는 Pascal을 좀 더 문자 그대로 번역하는 방식을 택했다. 관용적인 Go로 만드는 건 프로그램으로 자동화하기엔 너무 어렵고, 나중에 직접 다듬으면 된다고 생각했기 때문이다.

Pascal에서 함수 이름 자체를 반환값으로 사용하는 방식은 Go의 명명된 반환값(named return value)을 이용해 대응했음을 주목하라. 버그를 피하기 위해 정수 크기도 그대로 변환했기 때문에, 이 함수는 일반 int가 아니라 int16을 반환한다.

Pascal에서는 배열을 0이 아닌 임의의 인덱스에서 시작하도록 선언할 수 있으며, 실제로는 종종 1부터 시작한다. 그래서 Pascal 버전에서는 Flags[i]였던 것이 Go 버전에서는 Flags[i-1]이 된다.

가장 어려웠던 부분 — 그리고 거의 자동화하지 못한 부분 — 은 Turbo Pascal의 포인터 및 문자열과 Go의 그것 사이의 차이였다. 변환기에서는 모든 문자열 타입을 그냥 string으로 변환한 뒤, 깨지는 부분은 수동으로 고쳤다.

Pascal의 var 매개변수(참조 전달 방식)는 Go에서는 포인터가 되지만, 물론 Go에서는 값을 할당할 때 명시적으로 역참조해야 한다. 그래서 다음 Pascal 함수는:

procedure ElementSpinningGunDraw(x, y: integer; var ch: byte);
    begin
        case CurrentTick mod 8 of
            0, 1: ch := 24;
            2, 3: ch := 26;
            4, 5: ch := 25;
        else ch := 27 end;
    end;

Go에서는 이렇게 된다:

func ElementSpinningGunDraw(x, y int16, ch *byte) {
    switch CurrentTick % 8 {
    case 0, 1:
        *ch = 24
    case 2, 3:
        *ch = 26
    case 4, 5:
        *ch = 25
    default:
        *ch = 27
    }
}

변환된 Go를 go build -gcflags="-e"로 빌드해서, 처음 몇 개의 에러만 보여주는 대신 모든 에러를 출력하고 그 결과를 errors.txt 파일로 뽑아냈다. 처음에 이 파일에는 800개가 넘는 에러가 있었다. 자동 변환 작업이 끝날 무렵에는 33개만 남았고, 나머지는 수동으로 고쳤다.

내 Pascal-to-Go 변환기의 소스 코드는 github.com/benhoyt/pas2go에 있다. 먼저 lexerparser를 만들고, 파싱된 원본 소스(orig 디렉터리)를 pretty-print된 Pascal로 parsed 디렉터리에 출력했다. 두 디렉터리 사이를 diff 해 내 Turbo Pascal 파서가 정확한지 확인했다.

파서를 완성한 뒤 converter 작업으로 넘어갔다. 내가 써 본 중 가장 우아한 코드는 아니다. 그냥 일을 끝내는 데 집중했다. 특히 타입을 파악하려는 로직과 Go 소스 코드를 출력하는 로직이 뒤섞여 있다. 제대로 된 트랜스파일러를 만든다면 이 두 단계를 분리해 먼저 ‘타입’ 자료구조를 만들 것이다.

Pascal의 정수 데이터 타입 처리 방식은 Go와 꽤 다르다. Go는 매우 엄격해서 자동 숫자 타입 변환을 전혀 하지 않는다. Turbo Pascal이 실제로 어떤 변환을 하는지에 대한 좋은 온라인 레퍼런스를 찾지 못해서, Free Pascal 문서와 DOSBox에서 Turbo Pascal 5.5를 직접 돌려보며 규칙을 대부분 파악했다고 생각한다.

Pascal은 수학 연산을 할 때 integer(int16)로 승격하는 것처럼 보이고, 필요하면 할당 시 자동으로 승격되거나 잘린다. 그래서 Go 버전에는 ELEMENTS.PAS의 다음 코드처럼 훨씬 더 많은 명시적 변환이 들어가 있다:

if Difference(Y, Board.Stats[0].Y) <= 2 then begin
    shot := BoardShoot(element, X, Y,
                       Signum(Board.Stats[0].X - X),
                       0, SHOT_SOURCE_ENEMY);
end;

Go에서는 결국 다음과 같이 int16() 변환이 잔뜩 붙게 된다:

if Difference(int16(stat.Y), int16(Board.Stats[0].Y)) <= 2 {
    shot = BoardShoot(element, int16(stat.X), int16(stat.Y),
                      Signum(int16(Board.Stats[0].X)-int16(stat.X)),
                      0, SHOT_SOURCE_ENEMY)
}

Go 포팅

반자동 변환이 끝난 뒤에도 수십 개의 Go 컴파일 에러가 남아 있었다. 첫 번째 단계는 에러 없이 컴파일되도록 만드는 것이었다. Go 코드를 별도의 github.com/benhoyt/zztgo 리포지토리로 옮기고 errors.txt 파일을 따라가며 문제를 하나씩 고치고, 덜 중요한 소스 코드(사운드 함수 등)는 제거했다.

컴파일이 되게 만든 뒤에는 비디오 기능을 추가해야 했다. 현재는 tcell을 이용해 터미널 ‘그래픽’을 사용한다. 진짜 ZZT처럼 보이게 하려면 폰트와 터미널 색상을 예전 DOS 시절과 맞춰 조정해야 한다. zztgo 버전의 모습은 다음과 같다(꽤 비슷하지 않은가!):

zztgo 타이틀 화면

ZZT 월드 직렬화 루틴도 직접 작성해야 했다 — Turbo Pascal 버전은 바이너리 구조체를 그냥 메모리에 직접 로드했는데(옛날에는 당연히 리틀 엔디안과 packed 구조체를 가정했다). 이 루틴은 serialize.go에 있다.

큰 버그들을 없애고 나니 놀랍게도 게임 플레이 대부분이 그냥 동작했다. 점점 자리를 잡아가고 있었다. 특히 기억에 남는 커밋 두 개가 있는데, 게임 플레이 대부분을 동작하게 만든 이 커밋과 인게임 월드 에디터를 거의 완전히 동작하게 만든 이 커밋이다. 자동 코드 변환 만세!

zztgo 직접 실행하기

직접 실행해 보려면: Go를 설치하고, 저장소를 클론한 뒤 go build를 입력하고 ./zztgo를 실행하면 된다. 좀 더 그럴듯하게 보이게 하려면 IBM EGA 폰트를 설치하고 줄 간격을 0으로 조정하면 된다. macOS에서는 이 Terminal 설정 파일을 사용할 수 있다.

앞으로는?

아쉽게도 이 프로젝트에 더 쓸 시간이 없다. 게임 플레이 대부분이 구현되어 있고 꽤 플레이할 만한 수준이지만, 포크해서 더 발전시키고 싶다면 얼마든지 해도 좋다! 내가 파악한, 아직 없거나 고장 난 부분은 다음과 같다:

  • 타이밍 코드가 이상적이지 않다. 현재는 다음 틱 시간까지 정확히 기다리는 대신 고정된 시간만큼 time.Sleep()을 호출한다. 고치는 건 어렵지 않을 것이다.
  • 사운드가 전혀 동작하지 않는다. sounds.go에 있는 유용한 코드들은 모두 주석 처리되어 있다. 이를 고치려면 꽤 많은 작업이 필요하고 사운드/게임 라이브러리를 가져와야 한다.
  • 게임 라이브러리 얘기가 나와서 말인데, 렌더링에는 (DOS 폰트를 설치하고 터미널 줄 간격을 조정해야 하는 대신) 출력에 대한 제어를 더 잘 할 수 있도록 제대로 된 그래픽 라이브러리를 사용하는 편이 나을 수도 있다.
  • 대부분의 ZZT-OOP 오브젝트는 동작하는 것 같지만, Preposterous Machines 타이틀 화면이 깨지는 것에서 알 수 있듯 분명 몇 가지 버그가 있다.
  • EditorTransferBoard는 주석 처리되어 있다.
  • 마지막으로, 분명 관용적인 Go가 아니다! 코드는 어쩐지 ‘Pascal에서 자동으로 변환된’ 느낌이 강하게 난다(왜 그런지는 모르겠지만).

하지만 일단 지금 상태 그대로 공개해 본다 — 즐겨 주시길! Pascal-to-Go 변환기를 구현하고 게임 플레이가 살아나는 걸 보는 과정이 정말 재미있었다.

이 글은 muse-spark-1.2-contributor 모델을 사용해 번역했습니다.

댓글