如何讓 Agent 玩仙劍奇俠傳
心血來潮想玩仙劍奇俠傳,看到別人在 macOS 上成功支援紅色警戒 2 之後,我就想,也許我也能在 macOS 上原生支援仙劍奇俠傳?
挑選遊戲
第一步是找到能在 macOS 上跑仙劍的方法。
很快就找到一個 DOSBox 的專案,也能在 macOS 上跑 DOS 遊戲。照著說明把仙劍奇俠傳的 DOS 版放進去,就可以啟動、能玩。(附帶一提,原本那個 dosbox.exe 是 Windows 執行檔,在 macOS 上跑不動,只是順便一提。)
https://github.com/handoing/Legend-of-Sword-and-Fairy
但 DOSBox 對外就是一個黑盒子。agent 除了截圖解析畫面,也沒有地方伸手進去。
這種方式雖然可以動,但非常耗費 Token,因為每個指令都要截圖、OCR、解析、再下下一個指令。更糟的是,畫面上的字是像素,不是字串,OCR 會有誤判,agent 也沒辦法知道自己到底看到什麼。
後來找到 SDLPAL,這是一個社群用原生 C 耗費十多年逆向出來的仙劍重製遊戲。
https://github.com/sdlpal/sdlpal
有了原始程式碼之後,就可以開始在遊戲裡面插入不影響遊戲流程的程式碼,目的是讓 agent 可以直接拿到遊戲的內部狀態,也能直接注入按鍵。這樣就不需要截圖、OCR、解析了。
agent 的 Input:怎麼讓 agent 看見這個世界?
要觀測的東西分四類,各自的拿法不一樣。
劇情對話。遊戲真的準備要把一句話畫出來的時候會走 PAL_ShowDialogText(),我在這個函式開頭把手上的寬字元轉成 UTF-8,塞進一個 ring buffer,配一個單調遞增的序號。上層只要記住自己讀到第幾號,下次就能增量拿新的台詞,不會漏也不會重複。
能走到哪裡。場景編號、角色座標、tile 座標,還有這張地圖上有哪些 event。event 就是可互動的那些點,NPC、出口、可搜的物件都算。agent 決定下一步去哪,看的是這份清單而不是畫面。
數值狀態。HP、MP、等級、背包,這些遊戲本來就攤在全域結構 gpGlobals 裡,直接序列化成 JSON 就好,連攔都不用攔。
選單內容。PAL_ReadMenu() 的 callback 存一份快照:有哪些選項、預設值是什麼、游標現在停在第幾項,全都是遊戲自己給的答案。
然後還有一類訊號,描述的是遊戲程式本身:busy_ms、frame_ms、keys_queued。這三個要一起看,用來判斷主執行緒是不是卡住了。
agent 不只要看得到遊戲,還要看得出來「遊戲現在有沒有在動」。少了這層,它會對著一個當掉的畫面一直按鍵,還把每一次沒反應都解釋成「大概是這個選項選錯了」。
截圖指令我還是留著,只拿來排查問題。
agent 的 Output:怎麼讓 agent 操作?
最底層當然是注入按鍵:方向鍵、確認、取消、搜尋,還有戰鬥用的那幾顆。這層一定要有,但只有這層遠遠不夠。
動作原語的抽象層級,要跟決策的層級對齊。
如果 agent 能下的指令只有「按左鍵一次」,那它每走一步都得自己算目標在左邊幾格、方向鍵要按幾次、中間有沒有牆。這些算完,它就沒有餘力去想劇情了。所以往上疊了幾層:
- 走位:
goto <x> <y>或goto event <i>。路徑交給遊戲內建的 BFS 算。 - 戰鬥:戰鬥是完全另一套 UI,拿方向鍵去戳選單既脆弱又難讀。所以給語意層的指令:攻擊、施法、用道具、丟道具、防禦、逃跑,外加指定目標。
- 對話推進:
advance,一路連按到對話框結束。 - 快轉:這是為自動化準備的功能,玩家其實用不到;一趟路線有大量時間是花在等動畫演完。
快轉是整件事裡投資報酬率最高的一項。遊戲所有的等待都走 PAL_DelayUntil 這個巨集,我只在巨集裡加了一個旗標條件,所有呼叫點自動受益,一個呼叫點都不用改。
一個掉進去才懂的坑
有個 event 怎麼樣都觸發不了。agent 一直走近、一直按搜尋,就是沒反應。
後來是我提示「對著嬸嬸使用紫金丹」才解開:PAL_Search 要求 event 落在角色正前方那一格,而且遊戲的方向鍵是斜的,螢幕上的「上」並不是座標系的「上」。站對位置還不夠,得面向它。
動作原語必須對應遊戲自己的規則,而不是對應鍵盤。「走到旁邊按搜尋」聽起來像一個動作,實際上是「走到某一鄰格、轉到某個朝向、再按搜尋」三件事,中間任何一段沒對上,回傳的都是一樣的沉默。
這兩件事都得伸手進遊戲裡
flowchart LR
E["SDLPAL 遊戲"] -->|"input"| O["觀測<br/>對話 / 座標與 event / HP 與背包 / 選單"]
O --> A["agent 決策"]
A --> C["動作原語<br/>按鍵 / 走位 / 戰鬥指令 / 快轉"]
C -->|"output"| E到這裡才輪到講遊戲本身。上面兩條軸,input 要讀遊戲的內部狀態,output 要在遊戲的輸入處理裡插隊,兩邊都得伸手進遊戲程式裡才做得到:DOSBox 做不到,SDLPAL 做得到。
DOSBox 把 DOS 程式當黑盒子跑,這兩邊它都給不了:讀不到 gpGlobals,也插不進按鍵佇列。
介面本身沒什麼好講的:一條 unix domain socket,一行純文字指令進去,一行 JSON 回來,一次只服務一個 client。
要接上這兩條軸,只需要六個掛鉤點
新增的檔案只有兩個,是 bridge 自己的 .c 和 .h。至於既有檔案,從第一次提交到現在,總共只動了 7 個檔、共 +20/-1 行。首次提交時我算過一次帳,那時掛鉤只有 14 行。七個檔裡的 sdl_compat/sdl_compat.h 是順手修 C++ 編譯問題的 4 行,跟掛鉤無關,所以下表只列六個。
| 檔案 | 掛在哪 |
|---|---|
text.c | input:PAL_ShowDialogText() 開頭攔對話文字 |
ui.c | input:PAL_ReadMenu() 的進入、每輪、兩條離開路徑 |
input.c | output:PAL_ClearKeyState() 尾端注入排隊按鍵、PAL_ProcessEvent() 內輪詢 |
palcommon.h | output:PAL_DelayUntil 巨集加上快轉旗標條件 |
main.c | PAL_Init() 尾端起 socket 執行緒 |
main.h | 一行 include |
數值狀態沒出現在表上,因為 gpGlobals 是直接讀的,一個掛鉤都不用加。
結論
補完這兩樣之後,這款 1995 年的遊戲就成功變成一個程式看得懂、也可以操作的東西。剩下的像是繁體字型、建置工具,這篇就不寫了。
後面那些路線怎麼走、戰鬥怎麼打,是另一個故事。
跑出來的結果我整理成了另一篇:仙劍奇俠傳 DOS 版 AI 攻略。