我沒有用 REA:原版 DLL 直接跑,Unity 遊戲移植只剩引擎要自己重做
10/19 早上 8 點 3 分,我在待寫清單裡加了一個題目,要用 gh 搜一下 REA,後面接了一句:「但是我不用 REA 也能做啊。」REA 全名 Reverse Engineer Anything,是這陣子 GitHub 上很紅的逆向工程專案,10/20 下午一點多 GitHub 顯示 339,241 顆星、85,626 個 fork。它的 README 寫得很清楚:一個 MCP,把檢查原生程式、JavaScript 和 Electron app、.NET 組件、網站的工具接給 agent,終端機也能用;分析在本機跑,每個結論都附上證據和限制。支援清單裡還有 Unity 的 IL2CPP 中繼資料。它也寫明只用於合法研究、教育、互通性,以及分析自己擁有或有權限的軟體。
那時候,這條新路線的第一個 commit 才進去 20 分鐘:10/19 早上 7 點 43 分,第一款遊戲在 Mac 上開機,從 start 場景跑到 intro_start。這次遊戲程式一行都沒有轉成 TypeScript,跑的是原版的 DLL,也就是 REA 清單上 .NET 組件那一類。隔天上午 11 點 43 分,第二款遊戲用同一個引擎,在桌面和手機上都從主選單一路跑到戰鬥出牌,例外 0。兩個 commit 前後大約 28 小時。這中間我沒有裝 REA,用的是 AssetRipper、Mono.Cecil、一支自己寫的掃描器、一個替身產生器,還有前兩週做的網頁版。
10/16 我寫〈卡牌 roguelike 明明最適合手機,官方手機版卻總要等一兩年以上〉,講官方手機版為什麼拖這麼久,裡面有一句:「Unity 遊戲沒辦法這樣直接搬:遊戲邏輯要轉成另一種語言,引擎那一層要自己重做。」三天後,這句話只剩一半是對的。
10/20 我問 Claude Code:跟之前失敗的幾次比,為什麼這次一下子跳這麼多?它對照了 10/10 寫的路線檢討、前一天的效能實驗結論和這條新路線的提交紀錄,下面的分析大多是它查出來的,數字我再回頭對過原始紀錄。跟上一篇一樣,這兩款 Unity 卡牌遊戲都是我自己買的,移植給自己玩,不散布。
今年 1 到 3 月我試過三條路。1/2 到 1/4,用 Swift 把第二款遊戲整個重寫,當時的檢討記錄寫了 3.8 萬行,能編譯,1/4 整個刪掉。1/4 到 1/7,用 AssetRipper(一個把 Unity 遊戲的場景和素材抽出來、還原成 Unity 專案的開放原始碼工具)把同一款遊戲還原,再 build 到 iOS,戰鬥場景沒做完。2/5 到 3/18 換第一款遊戲,反編譯成原始碼、還原成 Unity 專案、目標一樣是 iOS,留下 41 個 commit。
10/5 到 10/18 是第四條,也就是上一篇提到的網頁版:把反編譯出來的 C# 轉成 TypeScript,畫面、字型、shader 讀原版的資料。這條沒有失敗,但 10/19 之後,它變成了新路線的對照組。
動刀的位置換了
前三次改的都是遊戲。反編譯成原始碼,還原成 Unity 專案,再讓真的 Unity 重新編譯一次。整個遊戲要經過兩道會出錯的工具,壞掉的地方散在各處:m_Script 格式錯了 32,498 個,同名的資產造成 106 個斷掉的參照,Timeline 在反編譯後的專案裡失效,只好改用 DOTween 頂替。這種錯列不出總數,修完一個,不知道還剩幾個。
這次原版的 DLL 照原樣跑在 .NET 上。Unity 遊戲的程式編譯完是 IL,一種 .NET 執行環境讀得懂的中間碼,存在 DLL 裡。遊戲本身的 Assembly-CSharp,還有 uGUI、TextMeshPro、DOTween、Spine、Timeline,全都是原本的 IL。換掉的只有 Unity 引擎最底下那層 C++。UnityEngine 的 DLL 裡有很多方法只寫了宣告,實際的程式在 Unity 的 C++ 引擎裡,這種方法叫 internal call。我用 Mono.Cecil(一個可以讀取、改寫 .NET DLL 的函式庫)把 UnityEngine 的 DLL 改過,讓這些 internal call 改呼叫自己用 C# 寫的引擎。
這些 internal call 可以列成一份清單。掃描器從遊戲的每個方法出發,順著 IL 裡的呼叫一路追進 UnityEngine,碰到 internal call 就記下來:引擎全部 8875 個,第一款遊戲碰得到的 1543 個。沒手寫的,產生器自動做一個替身,記下傳進來的值、回傳預設值,被呼叫到就留紀錄。以前 Timeline 只能找東西頂替,現在第一款遊戲對話框的進場和退場,照原版的 Timeline 播(commit 396944e)。
遊戲的程式碼原則上不改,所以出現例外,該查的就是引擎,修在引擎裡,下一款遊戲也一起受益。10/19 晚上手機上戰鬥打完要回地圖時,CanvasScaler 丟了 NullReferenceException。查下去,原因是換場景時攝影機比 Canvas 先被摧毀,Canvas 還把它當成有效的攝影機。修的地方是 Canvas 判斷攝影機的邏輯,遊戲裡沒有加任何 null guard(commit 6b793e4)。以前的做法剛好相反,在遊戲裡塞 null guard、用替代品,錯誤看起來消失了,行為也跟著偏離原版。
另一個差別是修正留不留得住。2 到 3 月那次,大約 52 個修正寫在一支 post_export_fix.sh 裡。3/16 第一次打完戰鬥,同一天我換成 DLC 版重新抽檔,rsync 一次 55,694 個變更,把之前手動修好的東西全部蓋掉。當天又補了一輪,3/18 之後就沒有 commit 了。
這次的修正都放在 patcher、引擎和每款遊戲自己的轉接設定裡,重新抽檔、重跑一次,修正就回來。第二款遊戲就是直接拿第一款用的同一個引擎,加上它自己的設定,網頁版做過的繁體中文、手機寬版和觸控也一起帶過去(commit 09eea31)。
那 28 小時,站在網頁版的兩週上面
10/5 到 10/18 做網頁版時,Unity 原生那層沒有原始碼的行為,我已經摸過、偵錯過一遍。新引擎很多地方是照網頁版搬過來的:粒子系統、TextMeshPro 的動態字型、混音器,檔頭直接寫「照 TS 版」;Playables 的檔頭寫的是照文件和 TS 版推定。場景載入時 Awake 照 fileID 排序、prefab 上的 GetComponentInChildren 怎麼算啟用、GrabPass、滾輪,commit 訊息一再寫「同 TS 版」。
讀的資料也是同一份。場景、素材和 shader 都是網頁版的匯入器轉好的,shader 當初就從 DX11 bytecode 轉成 GLSL 了。10/19 下午我要兩個引擎同時跑、以 TS 版為準來比對,新引擎哪裡畫錯、哪裡排版跑掉,等於手上有一份標準答案。Claude Code 的判斷是,如果 10/5 一開始就走原生,不會是 28 小時。
回饋的速度也差很多。1 月那次要等 iOS build,還被快取騙過好幾次,對著舊的場景在偵錯。現在 Mac 上的桌面版幾秒就開起來,載入最佳化之後只要 1 秒。另外有一條控制管道,不開畫面也能點擊、拖曳、截圖,邊跑邊玩。
所以我說不用 REA 也能做,指的是這次需要的那幾件事。工具都很普通,剩下的是 10/10 檢討之後留下的幾條規則:驗收看的是跟原版一不一樣,不是能不能跑;修正要放在重跑得回來的地方;看不到原始碼的東西,要指得出依據,不能用猜的。REA 那種一個 MCP 接二十幾種目標的平台,我沒有,這次也沒用到。
連網頁版也被拉開了
會走這條路,是因為 10/19 我發現 WebView 開不了平行的 worker,所有東西都塞在主執行緒,就問能不能自己做一個通用引擎,而且要快的做法。那天先做了一個小實驗,拿同一段 Spine 動畫程式來量:原版 DLL 直接跑,每格 0.43 到 0.45 ms;轉成 TypeScript 的版本,每格 6.06 到 6.28 ms,差了約 14 倍。TypeScript 那邊大約 45% 的時間,花在轉換器自己的分派上。
搬到手機(POCO F8 Ultra)之後,引擎把每格的動態緩衝改成輪流使用的緩衝池,戰鬥每格從 15.8 ms 降到 10.6 ms(commit 09eea31)。開機時間從 12.6 秒降到 3.8 秒;之後 Android 改用 CoreCLR(.NET 現在主要的執行環境,原本用的是另一個叫 Mono 的),再降到 2.1 秒。
現在要防的
10/10 那份檢討,有一條根因是「不當機不代表跟原版一樣」。在這條路線上,它換了個樣子出現。替身會安靜地回傳預設值,所以「例外 0」不等於行為跟原版相同。10/19 就碰過一次:找材質主貼圖的一個 internal call 還是替身,一律回 0,地圖上的戰鬥按鈕就畫成一團方塊光(commit 82ed416)。替身本來就不丟例外,這種錯不會算進例外的數字裡。
目前擋這個風險的,是網頁版的對照和一份跟原版的差異清單。要是哪天網頁版不再維護,替身的呼叫紀錄就是唯一看得到偏差的地方。

COMMENTS 留言討論
載入留言系統中...
無法載入留言系統。您可以前往 GitHub Discussions 參與討論。