用 Go 重現 ZZT(使用 Pascal 轉 Go 轉換器)
摘要:在看到 Adrian Siekierka 的《Reconstruction of ZZT》之後,我寫了一個程式,將他的 Turbo Pascal 程式碼轉換為 Go。本文將介紹我的 Pascal 轉 Go 轉換器,以及我那個(不算完全完整的)ZZT Go 移植版。
我不是什麼「玩家」,但青少年時期讓我樂在其中的遊戲之一,就是 Epic MegaGames 的 ZZT——一款 1991 年推出的老式文字模式 DOS 遊戲。即使以當時的標準來看,它的畫面也稱不上革新……但 ZZT 之所以大獲成功、至今仍擁有一群死忠粉絲,關鍵在於它的世界編輯器(即使是共享軟體版本也免費附贈)。
以下是原版共享軟體「Town of ZZT」標題畫面的樣子:

世界編輯器內建一種名為 ZZT-OOP 的腳本語言,其中的「O」指的就是 ZZT 的「物件(objects)」,也就是可程式化的遊戲角色或機器人。因此玩家們創造了數以千計的自製世界並互相分享——其中有數百個世界至今仍可在 Museum of ZZT 下載、甚至直接線上遊玩,那裡就像是 ZZT 世界的 archive.org。
ZZT 的重建
ZZT 的原始程式碼已在一場電腦當機中遺失,但多年來人們曾嘗試用其他語言重製這款遊戲,包括 C++ 版本、JavaScript 的部分實作,甚至還有以 Rust 精確重現的版本。而 Adrian Siekierka 則是 Zeta 的作者——一款專門用來執行 ZZT 的 DOS 模擬器。
2020 年 3 月,Adrian 發表了他的 Reconstruction of ZZT,這是一份透過逆向工程重建的 Pascal 原始碼,若使用當年 ZZT 所用的 Turbo Pascal 5.5 來編譯,會產生位元組完全相同的 .EXE 檔案。就我個人而言,這實在是一項驚人的成就!可以參考當時在 Hacker News 上的討論,以及 Adrian 後來撰寫的文章,了解他是如何做到的。
此後他更進一步,打造了 libzoo——一個可攜式的 C 語言 ZZT 遊戲引擎重製版,採用寬鬆授權,供其他 ZZT 移植專案使用。
Pascal 轉 Go 轉換器
一段時間前,我曾嘗試用 Go 寫一個 ZZT 版本,但在取得一點點進展後就放棄了——對一個 side project 來說,這似乎是太龐大的工作。不過,當 Pascal 重建版問世後,我又有了再試一次(ahem)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
}幾乎一模一樣。為了讓轉換器保持簡單,我選擇對 Pascal 做比較直譯式的轉換,而不是試圖產生符合慣用寫法的 Go——我的想法是,要透過程式自動產生道地的 Go 實在太難,之後再慢慢整理就好。
請注意這裡使用了 Go 的具名回傳值,來對應 Pascal 中以函式名稱作為回傳值的做法。我如實轉換了整數的大小以避免錯誤,所以這裡回傳的是 int16 而非一般的 int。
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 build -gcflags="-e" 來建置轉換後的 Go,以便印出所有錯誤而非僅僅前幾個,並將結果輸出到 errors.txt 檔案中。一開始這個檔案裡有超過 800 個錯誤。到了自動化轉換的尾聲,只剩下 33 個——接著我就手動將它們修掉了。
我的 Pascal 轉 Go 轉換器原始碼放在 github.com/benhoyt/pas2go。我先從建置 lexer 和 parser 開始,並將解析後的原始碼(orig 目錄)以排版美化過的 Pascal 形式輸出到 parsed 目錄。我透過比對這兩個目錄的差異,來判斷我的 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 版本的樣子(很接近吧!):

我還得撰寫 ZZT 世界的 序列化常式——Turbo Pascal 版本只是直接將二進位結構載入記憶體(在當年就是預設為 little-endian 且結構是緊密排列的)。
在排除重大錯誤後,令人驚訝的是,有相當大一部分的遊戲玩法就直接能運作了。事情真的開始步上軌道。我特別記得兩個 commit:這個讓大部分遊戲玩法得以運作,以及這個讓遊戲內建的世界編輯器大致可用的提交。自動化程式碼轉換萬歲!
自行執行 zztgo
想自己執行看看:安裝 Go,複製 repo,輸入 go build,然後執行 ./zztgo。如果你想讓它看起來更原汁原味,應該安裝 IBM EGA 字型並將行距調整為零。在 macOS 上你可以使用這個 Terminal 設定檔。
接下來呢?
可惜的是,我已經沒有時間繼續投入這個專案了。大部分的遊戲玩法都已就位,玩起來也算相當順暢,但如果你想 fork 並進一步開發,儘管放手去做!就我所知,目前缺少或損壞的部分有:
- 計時程式碼還遠稱不上理想:目前只是固定呼叫
time.Sleep()一段時間,而不是精準地等待到下一個 tick 時間。應該不難修正。 - 聲音完全無法運作。sounds.go 中所有有用的部分都被註解掉了。要修復這部分需要花不少功夫,還得引入聲音/遊戲函式庫。
- 說到遊戲函式庫,或許使用正規的圖形函式庫來做渲染會更好,這樣才能對輸出有更多掌控(而不是得安裝 DOS 字型、還要去調整終端機的行距)。
- 大多數 ZZT-OOP 物件似乎都能運作,但肯定還有一些錯誤,例如 Preposterous Machines 的標題畫面就顯示錯亂了。
- EditorTransferBoard 被註解掉了。
- 最後但同樣重要的是,它絕對不是道地的 Go!程式碼有種很強的「從 Pascal 自動轉換而來」的感覺(不知為何)。
不過我想還是就這樣釋出吧——請好好享受!實作 Pascal 轉 Go 轉換器並看著遊戲玩法活起來的過程,我確實玩得很開心。
隨機一篇部落格
留言
登入後參與討論