Helldivers 2 · 模組原理

Helldivers 2 模組是怎麼運作的

一個不是開發商寫的程式,怎麼會在遊戲裡執行?它怎麼知道遊戲的資料放在哪裡?做模組用的工具又各自是什麼原理?這份文件用從零做出 Hive Lord HUD 的過程當主線,一章講一個原理。每章結尾會告訴你「這時候 HUD 做到哪了」。

A 部分
模組怎麼在遊戲裡跑起來(第 1–7 章)
B 部分
怎麼知道遊戲裡有什麼(第 8–11 章)
C 部分
怎麼做出來、交到玩家手上(第 12–16 章)
第 0 章

地圖:一個模組從想法到你的畫面

先看全貌。一個模組的一生分成兩段:在你的電腦上把它做出來(製作期),以及在遊戲裡被執行(執行期)。圖上每一格標了講它的章節。

製作期 · 你的電腦 想法「我想看到血量」 研究遊戲FileDiver · Recon · 反組譯第 8–11 章 寫 Lua第 12 章 離線測試第 13 章 打包patch 檔 + manifest第 14 章 管理器部署寫進 data/第 3、14 章 執行期 · 遊戲裡 遊戲啟動、讀資源patch 蓋過原版 · 第 1 章 Loader 被執行找到並載入你的 Lua · 第 3 章 你的程式搭上 update()每一幀執行一次 · 第 2、4 章 讀遊戲資料引擎 API · FFI 讀記憶體 · 第 4–7 章 畫到螢幕引擎的 Gui · 第 4 章 範圍與限制讀取要安全(11)· 多人與更新(15)· 反作弊(16) 藍框:在你的電腦上做,可以反覆試、成本低。黃框:發生在遊戲裡,每試一次都要重開遊戲。 整份文件的核心觀念:能在藍框弄清楚的事,就不要拿到黃框去試。

每一章的長相

  • 這一章要回答:一個問題。整章都在回答它。
  • 新概念:這一章第一次出現的詞,最多五、六個,用到之前先說明。
  • 內文:盡量寫成「因為…所以…」,讓概念之間的關係看得見。
  • Hive Lord HUD 此時:這個原理在做 HUD 時實際發生了什麼。
  • 在原始碼裡:真正的模組裡實作這一章的那幾行,並連到有說明的原始碼頁。
  • 重點與自我檢查:讀完先試著回答,再展開看答案。答不出來的,就是還沒建立起來的連結。
Hive Lord HUD 與 Dead Plate Fix 模組本身可以在 GitHub Releases 下載(zip 裡的 Source/ 資料夾就是完整原始碼)。這份文件只講模組的原理。
A 部分:模組怎麼在遊戲裡跑起來遊戲本體、腳本層、Loader、引擎 API、FFI、記憶體、資料結構
第 1 章 · 執行期

遊戲本身是什麼

這一章要回答Helldivers 2 裝在你電腦裡的那些檔案是什麼?哪一個特性讓「模組」有可能存在?
新概念
機器碼、編譯
CPU 能直接執行的 bytes;把人寫的程式翻成機器碼的過程。
.exe、.dll、程序
裝機器碼的檔案(主程式、函式庫);程式執行起來後的那個「活著的實體」。
引擎
做遊戲的共用底層:畫面、物理、讀檔、執行腳本。
資源、封存檔
模型、貼圖、音效、腳本等遊戲用的檔案,以及把它們打包起來的大檔案。
雜湊
把一段文字變成固定長度數字的公式。

程式碼:編譯過的機器碼

Arrowhead 的工程師用 C++ 寫遊戲(叫原始碼),再用編譯器翻成 CPU 能直接執行的機器碼。翻譯的過程中,變數名稱、函式名稱、註解全部消失,只剩一串 bytes。因為我們拿到的只有機器碼,所以想知道遊戲在做什麼,就得從機器碼往回推(第 10 章)。

機器碼裝在兩種檔案裡:.exe 是主程式,.dll 是主程式執行時載入的函式庫。Helldivers 2 的分工是:

檔案是什麼
bin/helldivers2.exe引擎:Autodesk Stingray(前身 Bitsquid)。負責畫面、物理、讀檔、執行 Lua 腳本。很多遊戲共用同一套引擎。
bin/game.dll遊戲邏輯:傷害、AI、網路同步,C++ 寫的,而且被加密保護(第 10 章)。
bin/lua51.dllLuaJIT:執行 Lua 腳本的程式(第 2 章)。
data/資源檔:其他所有東西。

你按下「開始遊戲」,Windows 就把 helldivers2.exe 跑起來,變成一個程序,它再把 game.dll、lua51.dll 載入自己裡面。從這一刻起,引擎、遊戲邏輯、腳本都在同一個程序裡執行。

資源:用雜湊找檔案

data/ 裡是一堆名字像 9ba626afa44a3aa3 的封存檔,每個裡面裝著很多資源。引擎要一個資源時,不是用文字名稱找,而是用兩個數字:型別雜湊和名稱雜湊。

雜湊是一個公式:同一段文字永遠算出同一個數字,但不能從數字反推回文字。HD2 用的公式叫 murmur64,算出 64 bit 的數字。例如 Hive Lord 的模型資源叫 content/fac_bugs/cha_hive_lord/cha_hive_lord,雜湊是 0xD465D9C7F77A07CB;型別「lua」的雜湊是 0xA14E8DFA2CD117E2。因為檔案裡只存數字,所以直接打開封存檔看不到任何名稱,要靠別人整理的名稱清單才能翻回來(第 8 章)。

讓模組成為可能的關鍵:patch 檔會蓋過原版

封存檔裡有一種特別的:檔名結尾是 .patch_0、.patch_1…的 patch 檔。引擎讀資源時,如果 patch 檔裡有一個型別和名稱都一樣的資源,就用 patch 檔裡的版本,蓋過原版。這原本是給遊戲更新用的機制。

因為引擎會乖乖讀任何放在 data/ 裡的 patch 檔、而且不檢查內容有沒有被改過,所以任何人都能做一個 patch 檔,把某個資源換成自己的版本。所有 HD2 模組(換造型的、加功能的)都建立在這一點上。

Hive Lord HUD 此時只是一個想法(10/03):「應該可以做一個 Hive Lord 血條,顯示主血和每塊裝甲的 HP。」遊戲本身不顯示這些,也沒有提供任何「讓你加介面」的功能。所以要做到,得讓我們自己的程式在遊戲裡執行。第一步就是知道:程式要放進 patch 檔,才進得了遊戲。
重點
  1. 遊戲是編譯過的機器碼,原始碼和名稱都不在我們手上。
  2. 資源用「型別 + 名稱」的雜湊來找,檔案裡看不到文字名稱。
  3. patch 檔裡同名的資源會蓋過原版,而且遊戲不檢查,這是模組能存在的根本原因。
自我檢查:為什麼把一個 .dds 貼圖檔直接丟進 data/ 不會有任何效果?

引擎不認得散落的檔案,它只從封存檔裡、用「型別雜湊 + 名稱雜湊」找資源。要讓遊戲用你的貼圖,得把它包進一個 patch 封存檔,並且用跟原版一模一樣的名稱雜湊,才能蓋過原版。

自我檢查:如果遊戲在讀 patch 檔時會檢查數位簽章,模組還能存在嗎?

不能(至少不能用這種方式)。簽章檢查會發現 patch 檔不是官方的而拒絕載入。HD2 沒有做這個檢查,模組才有一扇門。

第 2 章 · 執行期

遊戲怎麼每一幀執行腳本

這一章要回答就算能把檔案放進遊戲,遊戲為什麼會「執行」它?什麼時候執行?
新概念
幀、遊戲迴圈
遊戲不停重複「更新 → 畫面」,每一次叫一幀。
函式
一段有名字、可以被呼叫的程式。
腳本語言、Lua、LuaJIT
以文字形式存在、執行時才被讀懂的程式;HD2 用的那一種;它的高速版本。
全域變數
整個腳本環境都看得到、都能改的變數。
update()
引擎每一幀呼叫的那個 Lua 函式。

遊戲迴圈

遊戲不停地重複:讀你的輸入 → 更新世界(怪物移動、子彈飛行)→ 畫出畫面。每重複一次叫一幀,60 FPS 就是每秒 60 幀。所有會「隨時間發生」的事,都發生在這個迴圈裡。

引擎裡的腳本層

Stingray 引擎內建一個 Lua 執行環境,用的是 LuaJIT 版本(語法等於 Lua 5.1)。Lua 是腳本語言:程式以純文字存在,執行時才由 LuaJIT 讀懂並執行,不必事先編譯。引擎原本的設計是讓遊戲邏輯大多寫在 Lua 裡;但 HD2 把幾乎所有邏輯都放進了 C++ 的 game.dll,Lua 只剩一層很薄的「膠水」:

  • 引擎啟動時會載入幾個 Lua 腳本(它們也是 data/ 裡的資源,型別是 lua)。
  • 引擎每一幀會呼叫一個叫 update 的 Lua 函式,還有 render、shutdown 等幾個固定的進入點。
  • 引擎開放了一組 Lua 可以呼叫的功能(例如列出場景裡的物件、在畫面上畫東西),叫 stingray.* API(第 4 章)。

我們量過:遊戲自己的 Lua update 每幀幾乎不花時間(接近 0 ms)。也就是說,遊戲本身幾乎不用這個 Lua 環境,但它一直開著、每幀都在被呼叫。

為什麼這是模組的入口

Lua 的函式存在全域變數裡,而全域變數是可以被改的。因為引擎每幀是「去全域變數 update 裡拿函式來呼叫」,所以只要有人把 update 換成自己的函式,引擎下一幀就會呼叫新的那個:

local original = update          -- 先把遊戲原本的 update 存起來
update = function(...)            -- 換成我們的函式
    do_my_stuff()                 -- 先做自己的事
    return original(...)          -- 再呼叫原本的,遊戲照常運作
end

這種「把一個呼叫導到自己身上,再轉回原本的地方」的做法叫 hook。模組每一幀能做事,靠的就是這五行。

但這裡有一個前提:這段程式本身得先被執行一次,才能把 update 換掉。遊戲只會執行它自己知道的那幾個 Lua 腳本,不會去執行一個陌生的 Lua 資源。誰來執行第一次?這就是下一章的 Loader。

Hive Lord HUD 此時確定了做法:HUD 要用 Lua 寫,搭在 update 上每幀執行。整個 HUD 最後的入口(H.install())就是本章那五行的加強版:多了錯誤保護(第 4 章)和自我計時。
在原始碼裡
  • hive_lord_hud.lua 第 2564–2619 行 hook:包住遊戲的 update():第 2 章那五行的完整版:先存原本的 update、換成自己的函式、每幀 pcall 執行 HUD、出錯就永久解除武裝,最後一定呼叫原本的 update。
    展開這段程式碼
    function H.install()
        if H.armed then return true end
        local original = rawget(_G, 'update')
        if type(original) ~= 'function' then
            writer('install refused: no global update()')
            return false
        end
        if not (Gui and Vector2 and Vector3 and Color and World and Unit and App) then
            writer('install refused: missing engine API')
            return false
        end
        H.text_ok = attempt(App.can_get, 'font', H.FONT) == true and attempt(App.can_get, 'material', H.FONT) == true
        H.armed = true
        -- Self-timing: how much frame time the HUD costs (QueryPerformanceCounter around H.update).
        pcall(ffi.cdef, 'int QueryPerformanceCounter(int64_t* c); int QueryPerformanceFrequency(int64_t* f);')
        local qpc, qbuf = nil, ffi.new('int64_t[1]')
        pcall(function()
            local k = ffi.load('kernel32')
            k.QueryPerformanceFrequency(qbuf)
            local freq = tonumber(qbuf[0])
            if freq and freq > 0 then
                qpc = function() k.QueryPerformanceCounter(qbuf); return tonumber(qbuf[0]) * 1000 / freq end
            end
        end)
        H.cost = {sum = 0, max = 0, n = 0, scan_sum = 0, scan_n = 0}
        rawset(_G, 'update', function(...)
            if H.armed then
                local on = H.option_enabled()
                if on ~= H.enabled then
                    H.enabled = on
    -- …(共 56 行,完整內容見原始碼頁)
  • plate_fix.lua 第 306–323 行 第二個 hook:Plate Fix 也包住全域 update,跟 HUD 的包裝疊在一起。每個包裝都呼叫前一個,所以兩個模組可以同時存在。
    展開這段程式碼
    function M.install()
        if M.armed then return true end
        local original = rawget(_G, 'update')
        if type(original) ~= 'function' then writer('install refused: no global update()'); return false end
        M.armed = true
        rawset(_G, 'update', function(...)
            if M.armed then
                if M.hello then writer(M.hello); M.hello = nil end
                local ok, why = pcall(function() M.poll_option(); M.update() end)
                if not ok then
                    M.armed = false
                    writer('disarmed after fault: ' .. tostring(why))
                end
            end
            return original(...)
        end)
        return true
    end
重點
  1. 引擎內建 Lua,每一幀都會呼叫全域函式 update。
  2. 全域變數可以被改,所以換掉 update 就能每幀執行自己的程式(hook)。
  3. 但要先有人執行你的程式一次,它才有機會去換 update。
自我檢查:如果 hook 時忘了呼叫 original(...),會發生什麼事?

引擎每幀呼叫的就只剩你的函式,遊戲自己 Lua 層該做的事全部不做了。輕則某些功能失效,重則遊戲壞掉。所以一定要呼叫原本的函式,並把它的回傳值傳回去。

自我檢查:HD2 的遊戲邏輯幾乎都在 C++ 裡,那 Lua 模組還能做什麼?

Lua 層雖然薄,但它在遊戲程序裡、每幀被呼叫,而且能用引擎開放的 API。再加上第 5 章的 FFI,Lua 可以呼叫 Windows 的函式、直接讀遊戲的記憶體,所以能做的事遠超過「遊戲本身用 Lua 做的事」。

第 3 章 · 執行期

誰來執行「別人寫的」Lua:Bingus Shared Loader

這一章要回答遊戲只執行它自己知道的腳本,那我們的 Lua 是怎麼被執行第一次的?
新概念
回呼(callback)
先把一個函式交給別人,讓對方在某個時機呼叫它。
Loader
負責找出所有模組並執行它們的模組。
require
Lua 載入另一個腳本資源並執行它的指令。
模組管理器
Arsenal、HD2MM:把模組寫成 data/ 裡的 patch 檔的工具。
log
程式把發生的事寫成的文字檔。模組在遊戲裡執行時,你只能靠它知道發生了什麼。

關鍵一步:借用一個一定會被執行的原版腳本

把第 1 章和第 2 章接起來就能想到辦法:遊戲啟動時一定會載入某些原版 Lua 腳本,而 patch 檔可以用同名資源蓋過原版。所以只要做一個 patch 檔,裡面放一個跟某個原版腳本同名的 Lua 資源,遊戲就會在啟動時執行我們的版本。

Bingus Shared Loader 選的是 core/wwise/lua/wwise_flow_callbacks:音效系統(Wwise)啟動時一定會載入的回呼腳本。但原版腳本負責音效,被蓋掉了遊戲就沒聲音。所以 Loader 裡面保留了原版腳本的所有功能,再多做「載入模組」這件事。

  • 遊戲的資源放在 data/ 的封存檔裡,用「型別 + 名稱雜湊(murmur64)」定位。Mod 管理器(Arsenal / HD2MM)把模組部署成額外的 9ba626afa44a3aa3.patch_N,裡面同名的資源會蓋過原版。
  • Loader 的封存檔只有一個 lua 資源,名稱雜湊是 0x7251FDD9BB62480A。我們用同一套 murmur64 算過,正好是 core/wwise/lua/wwise_flow_callbacks:遊戲音效系統(Wwise)啟動時會 require 的原版腳本。
  • 所以遊戲一開機載入音效系統,執行的就是 Loader。它裡面保留了原版腳本的所有 Wwise 回呼(wwise_trigger_event、wwise_load_bank…),音效照常運作;README 也特別說明它「保留 Wwise callbacks,不取代 boot」。
  • Loader 本體是編譯過的 LuaJIT bytecode(檔頭 \x1bLJ,約 20 KB)。以下流程是從 bytecode 裡的字串常數、README 和實際 log 整理出來的。

Loader 啟動後做的事

  1. 放大 LuaJIT 的 JIT 快取:所有模組共用遊戲的同一個 LuaJIT,預設快取太小,裝多了會一直被清空(flush)。v18 一開始就調到 16 MB / 8,000 段編譯好的程式(traces),被清空後再長到 64 MB / 16,000 段,並在 log 寫一行 LuaJIT cache:。
  2. 建立共用 log 資料夾 %LOCALAPPDATA%\CowboyBingus\Helldivers2\Logs,開啟 BingusSharedLoader.log。
  3. 建立全域表 CowboyBingusModLoader:放版本、API 等級、模組狀態清單、open_log(檔名)(只接受 ^[%w_-]+%.log$ 這種檔名,避免模組寫到別的路徑)、JIT 快取統計(jit.flushes 等)。
  4. 找到遊戲的 data 資料夾:用 GetModuleFileNameA 取得執行檔路徑,確認結尾是 /bin/helldivers2.exe(不是就停止),推出 /data/。
  5. 掃描所有 patch 檔:FindFirstFileA 列出 9ba626afa44a3aa3.patch_*,逐一打開,檢查封存檔魔數 0xF0000011 和表格,讀每個 lua 資源的開頭,用這個樣式比對第一行:
    ^%-%- HD2%-Addon: (mods/[A-Za-z0-9_]+/[A-Za-z0-9_/]+)
    bytecode 裡還有一份 murmur64 實作(hasher),看起來會核對宣告的名稱和資源的雜湊是否一致(推測,未在原始碼確認)。
  6. 載入:自動發現的 addon,加上一份內建的 CowboyBingus 模組清單,逐一用 Application.can_get('lua', 名稱) 檢查是否存在:不存在記 not installed;存在就 pcall(require, 名稱),成功記 loaded,失敗記 load failed: 原因。

BingusSharedLoader.log 的範例(節錄):

Bingus Shared Loader loader-v18; API 1
Discovery: 8 declared entries
LuaJIT cache: expanded 16384 KB / 8000 traces; flushes 0, growth 0; watcher on
mods/cowboybingus/vanilla_plus_megapack: loaded
mods/cowboybingus/mod_options_menu: loaded
mods/hivelordhud/hive_lord_hud: loaded       ← 我們的 HUD
mods/hivelordhud/plate_fix: loaded           ← Dead Plate Fix
mods/cowboybingus/consistent_vaulting: not installed
…

為什麼要有一個「共用」的 Loader

同一個資源只能有一個 patch 勝出。如果每個模組都自己去蓋 wwise_flow_callbacks,就只有最後一個會生效,其他全部消失。所以社群用一個共用的 Loader 佔住這個入口,再由它依序載入所有模組。你的模組不需要蓋掉任何原版資源,只要放一個新名稱的 Lua 資源,第一行寫上 -- HD2-Addon: … 讓 Loader 找得到。

模組管理器(Arsenal、HD2MM)負責把每個模組寫成 data/ 裡的一個 patch 檔。它每次部署都會重新分配 patch 編號,所以你不能假設自己的模組固定在某個編號(第 14 章會再遇到這件事)。

模組怎麼配合 Loader

用途做法
讓 Loader 找到我們原始碼第一行寫 -- HD2-Addon: mods/hivelordhud/hive_lord_hud。build_hive_lord.py 打包時檢查這一行,並把它改成正確的資源名。
打包格式每個 Arsenal 選項一個 patch 檔,裡面一個 lua 資源:名稱雜湊 = murmur64(資源名),內容 = (長度, 2 = 純文字) + 原始碼。必須是明文、無 BOM(BOM:有些編輯器會在檔案開頭偷偷加上的 3 個 bytes):編譯成 bytecode 會丟掉第一行註解,Loader 就找不到。
開始執行被 require 時,檔案最上層只跑一次:先用全域變數擋重複載入(HD2HiveLordHud / HD2HiveLordPlateFix),再把自己包在全域 update() 外面。之後每幀的工作都在包裝函式裡,跟 Loader 就沒有關係了。
寫 logCowboyBingusModLoader.open_log('HiveLordHud_<時間>.log'),log 和其他 Bingus 模組放在同一個資料夾。沒有 Loader 時退回 %TEMP%。
設定選單Mod Options Menu 也是一個透過 Loader 載入的模組(Megapack 內附),提供全域的 ModOptionsMenu。HUD 在 update 裡才去找它,因為載入順序不保證它比我們早。
除錯模組沒反應時,第一步看 BingusSharedLoader.log:有沒有出現、是 loaded 還是 load failed。

沒有 Loader 還能用嗎?

不能。我們的模組只是一個叫 mods/hivelordhud/hive_lord_hud 的 lua 資源,遊戲本身沒有任何地方會 require 這個名字。沒有 Loader,資源會被部署進遊戲資料夾,但永遠不會被執行:不會報錯,也不會有任何效果。

  • 程式裡唯一對「沒有 Loader」做的備援,是 log 檔改寫到 %TEMP%。但前提還是模組有被載入,所以實際上用不到。
  • 設定選單也會一起消失:Mod Options Menu 本身就是靠 Loader 載入的模組。
  • 理論上可以學 Loader,自己換掉一個原版腳本當入口。但同一個資源只能有一個 patch 勝出,這樣會跟 Loader 和其他模組互相覆蓋,還得自己維護原版腳本的功能,所以不這樣做。這也是 Loader 存在的意義:讓所有模組共用一個入口,不必搶同一個原版腳本。

所以安裝說明把 Bingus Shared Loader 列為必要的相依模組;Mod Options Menu(Megapack)則是選配,沒有它 HUD 會用預設值執行。

容易踩的坑
  • 遊戲是 LuaJIT(Lua 5.1 語法)。// 整除、string.pack、math.type 這類 Lua 5.3 以後才有的東西都不能用;離線測試要用 LuaJIT 版的 lupa(在 Python 裡執行 Lua 的套件),否則測試會通過、進遊戲才 load failed。
  • 所有模組共用同一個 Lua VM 和同一份 FFI 宣告:ffi.cdef 要包 pcall(別的模組可能先宣告過),全域變數名稱要獨特。
  • Arsenal 裡 Loader 要放在清單最下面(預設優先順序),換了順序要 Purge + Deploy。
  • Loader 綁定遊戲版本。遊戲大更新後要等 Loader 更新。
Hive Lord HUD 此時的前 69 個版本,其實是跟我們自己寫的研究用偵察模組 Recon 打包在同一個模組裡(這個工具沒有公開,第 9 章會介紹它的原理),由 Loader 載入。到 v70 才獨立出來,有了自己的資源名稱 mods/hivelordhud/hive_lord_hud。之後的 Dead Plate Fix 則是第二個資源 mods/hivelordhud/plate_fix,在 Arsenal 裡是另一個可以單獨勾選的選項。
在原始碼裡
  • hive_lord_hud.lua 第 1 行 Loader 找的那一行:第一行就是 Loader 用來找到模組的宣告。名稱必須跟打包時的資源名稱完全一樣,而且要是明文,編譯成 bytecode 就會不見。
    展開這段程式碼
    -- HD2-Addon: mods/hivelordhud/hive_lord_hud
  • hive_lord_hud.lua 第 37–49 行 用 Loader 的共用 log 資料夾:優先呼叫 Loader 提供的 open_log,log 就跟其他模組放在一起;沒有 Loader 才退回 %TEMP%。
    展開這段程式碼
    local function open_own_log()
        if own_log ~= nil then return own_log end
        own_log = false
        local name = 'HiveLordHud_' .. ((os and os.date and os.date('%Y%m%d_%H%M%S')) or 'session') .. '.log'
        local loader = rawget(_G, 'CowboyBingusModLoader')
        local ok, f = pcall(function() return loader and loader.open_log and loader.open_log(name) end)
        if not (ok and f) then
            local temp = os and os.getenv and os.getenv('TEMP')
            f = temp and io.open(temp .. '\\' .. name, 'w')
        end
        if f then own_log, H.log = f, name end
        return own_log
    end
  • plate_fix.lua 第 1 行 第二個資源名稱:Dead Plate Fix 是另一個資源,在 Arsenal 裡是另一個選項,所以可以只裝它不裝 HUD。
    展開這段程式碼
    -- HD2-Addon: mods/hivelordhud/plate_fix
重點
  1. Loader 用 patch 檔蓋掉一個「遊戲啟動一定會載入」的原版腳本,並保留它原本的功能。
  2. Loader 再掃描所有 patch 檔,用第一行 -- HD2-Addon: 找出模組,逐一 require。
  3. 共用一個入口,所有模組才不會互相蓋掉。
自我檢查:如果你把模組的 Lua 編譯成 bytecode 再打包,會發生什麼事?

Loader 認不出它:bytecode 會丟掉註解,第一行的 -- HD2-Addon: 就不見了。模組會被部署進遊戲,但永遠不會被執行,也不會出現在 Loader 的 log 裡。

自我檢查:模組「沒反應」時,第一個該看哪個檔案?為什麼?

BingusSharedLoader.log。它記錄了每個模組有沒有被找到、是 loaded 還是 load failed: 原因。如果你的模組根本不在清單上,問題出在打包或部署;如果是 load failed,問題出在程式本身。

第 4 章 · 執行期

模組在遊戲裡能做什麼:引擎 API

這一章要回答程式進到遊戲、每幀被執行之後,它能看到什麼、能做什麼?做錯了會怎樣?
新概念
API
開放給別人呼叫的函式。這裡指引擎開放給 Lua 的 stingray.*。
world、unit、骨頭
場景;場景裡的物件;模型骨架的節點。
handle
代表某個東西的號碼牌,不是東西本身。
Gui、保留模式
引擎畫 2D 介面的功能;「建立一次、之後修改」的畫法。
pcall、assert
Lua 攔住錯誤的呼叫;引擎內部的自我檢查,失敗就直接結束遊戲。

引擎給了什麼

引擎把一部分功能開放給 Lua,叫 API。模組最常用的是這幾組:

  • Application:整個遊戲,例如 Application.worlds() 列出目前所有的 world(場景:船上、任務、選單各是一個)。
  • World / Unit:World.units(world) 列出場景裡所有的 unit(一隻怪、一個玩家、一台攝影機都是 unit)。Unit.resource_name(u) 告訴你它用的是哪個模型資源(回傳雜湊)。Unit.node(u, 'head') 找到名叫 head 的骨頭,再讀它的位置。
  • Camera:Camera.world_to_screen 把 3D 位置換成螢幕上的座標。
  • Gui:在畫面上畫矩形、文字、三角形。

要注意:腳本拿到的 unit 只是一個 handle(號碼牌),不是指向資料的位置。你可以拿它去問引擎問題,但不能從它直接走到怪物的血量資料。引擎也沒有開放「讀某個怪物的血量」這種 API。這件事決定了 HUD 後面的路(第 5 章)。

例子:HUD 怎麼找到 Hive Lord

  • 每 2 秒掃一次 Application.worlds() 裡每個 world 的 World.units(),比對 Unit.resource_name。發行版回傳的是雜湊過的 IdString64,所以比對路徑字串 hive_lord 或雜湊 d465d9c7f77a07cb(= murmur64 of content/fac_bugs/cha_hive_lord/cha_hive_lord)。
  • 剛出現的 world 要等 10 秒穩定才碰。main_world() 大約每 3 秒就換一次,不能拿來判斷。
  • HUD 畫在「單位最少的那個穩定 world」上(UI world;遊戲 world 有上千個單位)。world 一旦消失,它的 Gui 也跟著消失:只遺忘,絕不再呼叫 destroy。
  • 單位值是 handle 不是指標,無法從單位直接走到元件記憶體,所以血量要另外找(下一節)。
  • Hive Lord 死後 2.5 秒淡出、再 1.5 秒就完全不再碰那具屍體,並記下它的位址,之後掃描直接跳過。這是為了避開一個會讓遊戲當掉的情況(見「踩過的坑」)。

畫面:保留模式的 Gui

引擎的 Gui 是保留模式:Gui.rect(...) 建立一個矩形,拿到一個編號;之後用 Gui.update_rect(編號, ...) 改它的位置、大小、顏色;不用了要銷毀。它不是每幀把畫面清空重畫。

  • 每個群組 0.35–0.6 秒淡入淡出,淡出完才銷毀。
  • 圖元只有在顏色、大小、位置或文字改變時才送進 Gui;建立的那一幀不送更新(同一幀建立又更新的文字在遊戲裡不會出現)。
  • 參數必須是真正的引擎物件:Vector3(x, y, layer)、Vector2(w, h)、Color(a, r, g, b)。
  • Low-poly 面板的每個面是一個真正的 Gui.triangle。它的點要寫成 Vector3(x, 0, y)(從 helldivers2.exe 反組譯確認:y 和 z 會互換)。如果建立三角形失敗,會自動退回用 2 px 的矩形條拼。
  • 字型用基礎封存檔裡就有的 core/performance_hud/debug;粗體是同一段文字往右疊 1 px 的副本。

互動示範:顏色校正

螢幕 Gui 在遊戲的曝光 / 色調映射之前畫,顏色實際顯示會亮約 3 倍、暗部被壓掉(送 {96,2,18} 的暗紅,畫面上是 243,0,0;150–240 的灰全變成純白)。HUD 送出前先做反向換算:送出值 = 想要的值 ÷ gain + black,滑桿 100% 時 gain = 3、black = 18。下面用實測的放大模型(顯示 ≈ 3 × (送出 − 18))模擬:

做錯會怎樣:兩種錯誤

  • Lua 層的錯誤(例如對 nil 做加法):可以用 pcall 攔住。所以模組的每幀工作都包在 pcall 裡,出錯就永久關閉自己,遊戲照常。
  • 引擎內部的錯誤:引擎每個 API 裡面都有自我檢查(assert),參數不對、對象已經不存在,就直接結束整個遊戲。pcall 攔不住,因為錯誤根本沒有回到 Lua。

因為第二種錯誤無法挽救,所以模組必須在呼叫之前就確認不會出錯:world 還在不在、unit 還活著沒、參數格式對不對。這些規則幾乎都是當掉之後才學到的(見下面)。

Hive Lord HUD 此時連續當掉了好幾次,每次都教了一條規則:
  • v0:每個 tick 都呼叫 Gui.rect 建立新矩形、從不銷毀,每秒累積約 2,400 個,遊戲 1 分鐘和 3.5 分鐘後硬當。改成保留模式的正確用法。
  • v27:用遊戲的原生字型畫字,第一次繪製就當掉,還留下關不掉的遊戲程序,只能重開機。從此只用基礎資源裡的除錯字型。
  • v42–v64:玩家死亡、Hive Lord 死後屍體被移除時,還在對已經消失的 unit 呼叫 API。改成每次呼叫前先 Unit.alive(u),Hive Lord 死後完全不再碰它。
  • v40:Unit.world_position(u, 0) 對所有單位都回傳原點,距離永遠是 0 m。改用具名骨頭(head、root)。
在原始碼裡
  • hive_lord_hud.lua 第 16–21 行 用當機換來的生命週期規則:包在 update 裡、第一次出錯就解除武裝、每次呼叫前確認 world 還活著、Gui 是保留模式、參數用真正的引擎物件。這五條各自對應第 4 章的一次當機。
    展開這段程式碼
    -- Lifecycle (same as HUD+, which is known to be stable):
    --   * runs inside a wrapper around the engine's global `update` (main thread, once per frame) and disarms itself
    --     permanently on the first Lua fault
    --   * every engine call is gated on the world being live
    --   * Gui is retained-mode: Gui.rect once -> id, then Gui.update_rect(id); World.destroy_gui on teardown
    --   * vectors / colours are real engine objects: Vector3(x, y, layer), Vector2(w, h), Color(a, r, g, b)
  • hive_lord_hud.lua 第 71–79 行 呼叫前先確認 world 還活著:已經消失的 world 連同它的 Gui 一起失效。任何引擎呼叫之前都先查它還在不在清單裡。
    展開這段程式碼
    local function world_is_live(world)
        if world == nil or not App then return false end
        local worlds = attempt(App.worlds)
        if type(worlds) ~= 'table' then return false end
        for i = 1, #worlds do
            if worlds[i] == world then return true end
        end
        return false
    end
  • hive_lord_hud.lua 第 182–217 行 用引擎 API 找到 Hive Lord:World.units 列出所有單位,先 Unit.alive 再 Unit.resource_name,用雜湊比對 Hive Lord;死掉的屍體用位址跳過,連 alive 都不再問。
    展開這段程式碼
    function H.find_hive_lord(worlds, t)
        if not World or type(World.units) ~= 'function' then return nil end
        if H.dead_addr and t - (H.dead_addr_t or t) > 300 then H.dead_addr = nil end
        local total = 0
        for _, rec in ipairs(settled_worlds(worlds, t)) do
            local units = attempt(World.units, rec.w)
            if type(units) == 'table' then
                rec.n = #units
                total = total + #units
                if not rec.logged then describe_world(rec, units) end
                local hl, hl_name, divers, cams = nil, nil, {}, {}
                for i = 1, #units do
                    local u = units[i]
                    -- The game crashed twice ~60 s after a Hive Lord died, while we kept polling its corpse as it was
                    -- despawned (logs 222058 / 233615). Once dead it is never touched again, not even Unit.alive.
                    if H.dead_addr and handle_addr(u) == H.dead_addr then
                        -- skip
                    elseif attempt(Unit.alive, u) then
                        local name = attempt(Unit.resource_name, u)
                        if name ~= nil then
                            local s = tostring(name):lower()
                            if not hl and is_hive_lord(s) then hl, hl_name = u, tostring(name)
                            elseif s:find(AVATAR_HEX, 1, true) then divers[#divers + 1] = u
                            elseif s:find(CAMERA_HEX, 1, true) then cams[#cams + 1] = u end
                        end
                    end
                end
                if hl then
                    H.live.units_seen, H.live.divers, H.live.cams = total, divers, cams
                    return hl, rec.w, hl_name
    -- …(共 36 行,完整內容見原始碼頁)
  • hive_lord_hud.lua 第 219–224 行 Unit.world_position 的坑:節點 0 對所有單位都回傳原點,所以改用具名骨頭(root、head),而且先用 has_node 確認,因為未知名稱會觸發引擎 assert。
    展開這段程式碼
    -- Root node world position. Index 0 is the root (scene node indices are 0-based); guarded by node count.
    -- Vector3 fields are unreliable here, so parse tostring 'Vector3(x, y, z)'.
    -- Node 0 is a static root that reads (0,0,0) for the Hive Lord and every avatar, so named bones are used instead,
    -- resolved once per unit (Unit.has_node first: Unit.node asserts on unknown names).
    local BODY_NODES = {'root', 'j_root', 'hips', 'j_hips', 'pelvis', 'j_pelvis', 'spine', 'j_spine', 'head', 'j_head'}
    local node_cache = setmetatable({}, {__mode = 'k'})
  • hive_lord_hud.lua 第 991–1032 行 保留模式的圖元:每個矩形、文字在建立時拿到一個編號,之後只修改;群組負責淡入淡出與銷毀。
    展開這段程式碼
    local function new_group(name)
        local g = {name = name, a = 0, prims = {}, born = H.frame}
        H.grp[name] = g
        return g
    end
    
    -- Rect prim: c = {a, r, g, b}; w may change later (bars).
    local function add_rect(g, x, y, w, h, c, layer)
        local p = {kind = 'rect', x = x, y = y, w = w, h = h, c = c, layer = layer or LAYER}
        p.id = Gui.rect(H.ui.gui, Vector3(x, y, p.layer), Vector2(w, h), Color(0, c[2], c[3], c[4]))
        g.prims[#g.prims + 1] = p
        return p
    end
    
    -- Text prim, created with its real initial string (updates in the creation frame are dropped by the engine).
    -- align: 'l' (x = left), 'r' (x = right edge), 'c' (x = centre).
    local function text_pos(p)
        if p.align == 'l' then return p.x end
        local w = text_width(p.str, p.size)
        if p.align == 'r' then return p.x - w end
        return p.x - w / 2
    end
    
    -- Bold: the debug font has no bold face, so a text is drawn bold_px(size) + 1 times, each copy 1 px further right (a
    -- solid thicker stroke; a single copy shifted by several px reads as a double image). Every copy sits on whole pixels
    -- at a whole-pixel size: at fractional positions each copy is resampled on its own and the stack smears.
    local function bold_px(size) return math.max(1, math.floor(size / 16 + 0.5)) end
    local function px(v) return math.floor(v + 0.5) end
    H.bold_px = bold_px
    
    -- …(共 42 行,完整內容見原始碼頁)
  • hive_lord_hud.lua 第 1059–1073 行 不用了就銷毀:群組淡出完才把它的每個圖元從 Gui 移除。v0 就是因為從不銷毀而當機。
    展開這段程式碼
    local function destroy_group(name)
        local g = H.grp[name]
        if not g then return end
        if H.ui.gui then
            for _, p in ipairs(g.prims) do
                if p.kind == 'rect' then pcall(Gui.destroy_rect, H.ui.gui, p.id)
                elseif p.kind == 'tri' then tri_free(p)
                else
                    pcall(Gui.destroy_text, H.ui.gui, p.id)
                    for _, id in ipairs(p.copies or {}) do pcall(Gui.destroy_text, H.ui.gui, id) end
                end
            end
        end
        H.grp[name] = nil
    end
  • hive_lord_hud.lua 第 1078–1088 行 色調映射的反向修正:Gui 在遊戲的曝光與色調映射之前畫,顏色會亮約 3 倍。送出前先換算:送出值 = 想要的值 ÷ gain + black。
    展開這段程式碼
    -- Colour correction. The screen gui is drawn before the game's exposure / tone mapping, so a colour comes out roughly
    -- 3x brighter with the darks crushed (a {96,2,18} strip showed as 243,0,0; greys of 150-240 as pure white). Colours
    -- are authored as they should look and mapped back here: sent = shown / gain + black. 'Colour correction' slider:
    -- 100 = gain 3, black 18; 0 = off; 150 = stronger.
    function H.color_out(v)
        local f = (tonumber(H.cfg and H.cfg.color_fix) or 100) / 100
        if f <= 0 then return math.floor(v + 0.5) end
        local gain, black = 1 + 2 * f, 18 * f
        return math.floor(math.min(255, v / gain + black) + 0.5)
    end
    local color_out = H.color_out
  • hive_lord_hud.lua 第 1090–1121 行 只送有變的:每個圖元記住上次送出的顏色、位置、文字,沒有變就不呼叫 Gui,省下每幀的成本。
    展開這段程式碼
    local function draw(p, ga)
        local a = math.floor(p.c[1] * ga + 0.5)
        local k = ga < 1 and (0.15 + 0.85 * ga) or 1
        local r, gg, b = color_out(p.c[2] * k), color_out(p.c[3] * k), color_out(p.c[4] * k)
        if p.kind == 'rect' then
            local w = ga < 0.04 and 0 or p.w
            local key = a * 16777216 + r * 65536 + gg * 256 + b + math.floor(w * 4) * 4294967296
            local pk = math.floor(p.x) * 65536 + math.floor(p.y)
            if key == p.key and pk == p.pk and p.h == p.lh then return end
            p.key, p.pk, p.lh = key, pk, p.h
            Gui.update_rect(H.ui.gui, p.id, Vector3(p.x, p.y, p.layer or LAYER), Vector2(w, p.h), Color(a, r, gg, b))
        elseif p.kind == 'tri' then
            local key = a * 16777216 + r * 65536 + gg * 256 + b
            if key == p.key then return end
            p.key = key
            tri_free(p)
            tri_make(p, a, r, gg, b)
            if H.tri_error and (p.fi or p.tick) then     -- a facet / bar stripe failed to re-create: rects from now on
                H.poly_tri_off, H.tri_error, H.poly_rebuild = H.tri_error, nil, true
            end
        else
            local str = ga < 0.04 and ' ' or p.str
            local key = string.format('%d,%d,%d,%d|%d,%d|%s', a, r, gg, b, math.floor(p.x), math.floor(p.y), str)
            if key == p.key then return end
            p.key = key
            local tx, ty, col = px(text_pos(p)), px(p.y), Color(a, r, gg, b)
            Gui.update_text(H.ui.gui, p.id, str, H.font, p.size, H.font_mat, Vector3(tx, ty, p.layer), col)
            for k, id in ipairs(p.copies or {}) do
                Gui.update_text(H.ui.gui, id, str, H.font, p.size, H.font_mat, Vector3(tx + k, ty, p.layer), col)
            end
    -- …(共 32 行,完整內容見原始碼頁)
  • hive_lord_hud.lua 第 1738–1768 行 3D 位置投影到螢幕:用 Camera.world_to_screen 把 Hive Lord 的位置換成螢幕座標。第一次呼叫前先寫一行 log:萬一當掉,最後一行就是元兇。
    展開這段程式碼
    function H.project(wx, wy, wz)
        local Camera, Quat = api('Camera'), api('Quaternion')
        if not Camera or type(Camera.world_to_screen) ~= 'function' then return nil end
        local pc = frame_camera(Camera, Quat)
        local c = pc.c
        if c == nil then return nil end
        if not H.tag_called then
            H.tag_called = true
            writer('tag: calling Camera.world_to_screen for the first time (if the game crashes now, this API is bad)')
        end
        local ok, v = pcall(Camera.world_to_screen, c, Vector3(wx, wy, wz))
        if not ok or v == nil then
            if not H.tag_err then H.tag_err = true; writer('tag: Camera.world_to_screen failed: ' .. tostring(v)) end
            return nil
        end
        local sx, sy, sz = parse3(v)
        if not (sx and sy) then return nil end
        local behind = false
        if pc.fx and pc.px then
            behind = (wx - pc.px) * pc.fx + (wy - pc.py) * pc.fy + (wz - pc.pz) * pc.fz < 0
        end
        if H.tag_scale == nil then
            local rw = type(Gui.render_resolution) == 'function' and attempt(Gui.render_resolution) or nil
            H.tag_scale = (type(rw) == 'number' and rw > 0) and (H.ui.width / rw) or 1
            writer(string.format('tag: first projection raw=(%s) gui=%dx%d render_w=%s scale=%.3f', tostring(v), H.ui.width,
                H.ui.height, tostring(rw), H.tag_scale))
        end
        sx, sy = sx * H.tag_scale, sy * H.tag_scale
        if H.tag_flip then sy = H.ui.height - sy end
        return sx, sy, behind, sz
    -- …(共 31 行,完整內容見原始碼頁)
  • hive_lord_hud.lua 第 2198–2219 行 Gui 的建立與拆除:在一個 world 上建立螢幕 Gui;拆除時只有 world 還活著才銷毀,已經消失的 world 只遺忘。
    展開這段程式碼
    local function build_gui(world)
        local gui = World.create_screen_gui(world, 'scale', 1, 1)
        if gui == nil then return false end
        H.ui.gui, H.ui.world = gui, world
        H.grp = {}
        local w, h = Gui.resolution()
        if type(w) == 'number' and w > 0 and type(h) == 'number' and h > 0 then
            H.ui.width, H.ui.height = w, h
            H.ui.scale = math.min(w / 1920, h / 1080)
        end
        writer(string.format('overlay up (%dx%d, scale %.2f, text=%s)', H.ui.width, H.ui.height,
            H.ui.scale, tostring(H.text_ok)))
        return true
    end
    
    -- Destroy only if the world is still alive; a dead world already took the gui with it.
    local function teardown()
        if H.ui.gui ~= nil and world_is_live(H.ui.world) then
            pcall(World.destroy_gui, H.ui.world, H.ui.gui)
        end
        H.ui.gui, H.ui.world, H.grp = nil, nil, {}
    end
  • hive_lord_hud.lua 第 2239–2249 行 畫在哪個 world 上:挑單位最少、已經穩定 10 秒的 world(介面用的那個)。main_world 大約每 3 秒就換一次,不能拿來判斷。
    展開這段程式碼
    -- Overlay world: the settled world with the fewest units (the UI world; the game world has thousands).
    -- main_world flips ~every 3s, so it can't be used to tell them apart. Once chosen, kept while it lives.
    local t_now = 0
    local function pick_overlay_world()
        local best
        for _, rec in ipairs(H.worlds_seen) do
            if t_now - rec.since >= 10 and rec.n ~= nil and (best == nil or rec.n < best.n) then best = rec end
        end
        if best then writer(string.format('overlay on world #%d (%d units)', best.idx, best.n)) end
        return best and best.w
    end
重點
  1. 引擎 API 讓模組能看場景、找物件、畫畫面,但沒開放的事(例如讀血量)就做不到。
  2. Lua 錯誤可以攔,引擎內部的錯誤會直接結束遊戲,所以要「呼叫前先確認」。
  3. Gui 是保留模式:建立一次、之後修改、記得銷毀。
自我檢查:為什麼模組已經包了 pcall,HUD 還是會讓遊戲當掉?

當掉的原因在引擎內部(assert 失敗、存取已經不存在的物件),錯誤沒有回到 Lua,pcall 根本沒機會攔。唯一的辦法是不讓這種呼叫發生。

自我檢查:HUD 能用 World.units 找到 Hive Lord,為什麼還讀不到它的血量?

拿到的只是一個 handle,不是資料的位置;引擎也沒有開放讀血量的 API。血量存在 game.dll 管理的記憶體裡,要用第 5 章的方法自己去讀。

第 5 章 · 執行期

引擎沒開放的事:LuaJIT FFI

這一章要回答引擎 API 拿不到的東西(例如血量),模組怎麼拿?
新概念
FFI
LuaJIT 的功能:從 Lua 直接呼叫 C 語言的函式、直接操作記憶體。
Windows API
Windows 開放給所有程式呼叫的函式,例如讀記憶體、開檔案、計時。
記憶體、位址
程式執行時放資料的地方;每個 byte 的編號。(第 6 章詳細說明)

FFI 是什麼

LuaJIT 有一個標準 Lua 沒有的功能:FFI(Foreign Function Interface)。它讓 Lua 直接呼叫 C 語言寫的函式,包括 Windows API,也能直接配置、讀寫一塊記憶體。遊戲帶的 lua51.dll 沒有把這個功能關掉。

local ffi = require('ffi')
pcall(ffi.cdef, 'int QueryPerformanceCounter(int64_t* counter);')  -- 告訴 FFI 函式長什麼樣
local kernel32 = ffi.load('kernel32')                               -- 載入函式所在的 dll
local buf = ffi.new('int64_t[1]')                                   -- 配置一塊 C 記憶體
kernel32.QueryPerformanceCounter(buf)                               -- 呼叫 Windows API
print(tonumber(buf[0]))                                             -- 高精度計時器的值

有了 FFI,模組能做的事一下子變成「一般 Windows 程式能做的事」:

  • 例如呼叫 Windows 的執行緒 API,決定遊戲的每條執行緒跑在哪顆 CPU 核心。
  • HUD 用它呼叫 ReadProcessMemory,讀遊戲程序自己的記憶體。

為什麼 FFI 也很危險

  • 沒有安全網:函式宣告錯、參數給錯、讀了不該讀的位址,遊戲直接當掉,pcall 攔不住(原因在第 6 章)。
  • 所有模組共用一個 Lua 環境:ffi.cdef 重複宣告會出錯,所以每個宣告都包 pcall;ffi.load 拿到的物件要一直留著,否則 dll 可能被卸載、函式失效。
  • 能力等於一般程式:一個惡意的 Lua 模組也能讀你的檔案、連網路。這是第 16 章「只裝可信模組」的原因。
Hive Lord HUD 此時確定了「讀血量」只能走 FFI:用 ReadProcessMemory 讀遊戲記憶體。但問題馬上變成兩個:血量在記憶體的哪裡?那裡的資料長什麼樣子?要回答,得先懂記憶體(第 6 章)和遊戲怎麼組織資料(第 7 章)。
在原始碼裡
  • hive_lord_hud.lua 第 506–515 行 FFI 宣告 Windows API:告訴 FFI ReadProcessMemory、VirtualQuery 長什麼樣,包在 pcall 裡(別的模組可能先宣告過),再載入 kernel32。
    展開這段程式碼
    pcall(ffi.cdef, [[
    unsigned long long VirtualQuery(const void* addr, void* buf, unsigned long long len);
    void* GetCurrentProcess(void);
    int ReadProcessMemory(void* process, const void* addr, void* buf, size_t size, size_t* read);
    typedef struct {
        void* BaseAddress; void* AllocationBase; unsigned long AllocationProtect; unsigned short PartitionId;
        unsigned long long RegionSize; unsigned long State; unsigned long Protect; unsigned long Type;
    } HLH_MBI;
    ]])
    local k32 = ffi.load('kernel32')
重點
  1. FFI 讓 Lua 呼叫 Windows API、直接操作記憶體,是模組能做「引擎沒開放的事」的關鍵。
  2. 它沒有安全網,錯了遊戲直接當掉。
  3. 模組的能力因此等於一般程式,這同時是力量也是風險。
自我檢查:如果遊戲帶的 LuaJIT 把 FFI 關掉了,HUD 還做得出來嗎?

血條和面板做不出來:引擎沒有讀血量的 API,只能靠 FFI 讀記憶體。標記、出土警告這類只用引擎 API 的部分還可以。

第 6 章 · 執行期

記憶體:資料住在哪裡

這一章要回答遊戲執行時,一個數字(例如血量)實際放在哪裡、長什麼樣子?為什麼讀錯位址會當掉?
新概念
byte、十六進位、int32
記憶體的最小單位;以 0x 開頭的寫法;4 bytes 的整數。
虛擬記憶體、分頁
每個程序自己的一套位址;以 4 KB 為單位管理。
存取違規
讀寫不存在或沒權限的位址,程式直接當掉。
程式碼區、資料區、堆積
dll 的機器碼;dll 裡固定位置的變數;執行中才配置的資料。
ASLR、基址、RVA
dll 每次載入位置不同;這次的位置;從 dll 開頭算起的位移。
指標
一個存著另一個位址的值。

一個數字在記憶體裡的樣子

記憶體是一長排格子,每格 1 byte(0–255),每格有一個編號叫位址。位址和內容習慣用十六進位寫(0x 開頭,每位 0–9、A–F)。血量這種整數通常用 4 bytes 存,叫 int32;而且「低位在前」,所以 150,000 = 0x000249F0 在記憶體裡是 F0 49 02 00:

每個程序有自己的一套位址

程式看到的位址不是 RAM 條上的實際位置。Windows 給每個程序一套自己的虛擬記憶體,以 4 KB 的分頁為單位,對應到實體 RAM(或暫時放在 SSD 上)。

遊戲的虛擬記憶體 位址 0 ~ 0x7FFF'FFFF'FFFF(128 TB) 每格 = 一個分頁(4 KB) 瀏覽器的虛擬記憶體 同樣是 0 ~ 128 TB,彼此看不到 同一個位址數字 ≠ 同一塊 RAM 分頁表(每個程序一份) 作業系統填寫、CPU 每次存取時查 虛擬分頁 → 實體位置 + 權限 權限:可讀 / 可寫 / 可執行 沒對應的位址 → 存取違規(當掉) 不在 RAM 裡 → 分頁錯誤,去 SSD 拿 實體 RAM(你買的 16 GB) 各程式的分頁散落在各處 灰色 = 空著或拿來當檔案快取 SSD 上的分頁檔 RAM 不夠時,較少用的分頁被搬來這裡

這帶來三個對模組很重要的結果:

  1. 外部程式碰不到遊戲的記憶體:同一個位址在兩個程序裡是不同的 RAM。外部程式要讀遊戲,必須先向 Windows 申請遊戲的 handle(OpenProcess),再請 Windows 代讀。反作弊就在這一步攔截(第 16 章)。但模組在遊戲程序裡面,用的是遊戲自己的位址,不需要經過這一步。
  2. 讀錯位址會當掉:如果某個位址沒有對應任何分頁,或分頁不允許讀,CPU 會觸發存取違規,程式立刻結束。這就是第 5 章說 FFI「沒有安全網」的原因。
  3. 程式碼不能直接改:程式碼所在的分頁標記為「可讀、可執行、不可寫」,要改得先呼叫 VirtualProtect 改權限。
延伸:RAM 本身與快取

RAM 是 CPU 的工作桌,速度比 SSD 快約 1,000 倍,但比 CPU 內建的快取慢很多:

層級大小(你的 i9-13900H,約略)拿一筆資料要多久(約略)誰管
暫存器每個核心十幾個 64 位元格子不到 1 奈秒編譯器(就是組合語言裡的 rax、rcx)
L1 快取每個 P 核心 48 KB 資料約 1 奈秒CPU 自動
L2 快取每個 P 核心 1.25 MB約 4 奈秒CPU 自動
L3 快取全部核心共用 24 MB約 10–20 奈秒CPU 自動
RAM16 GB約 80–100 奈秒作業系統分配
SSD(分頁檔)數百 GB約 50–100 微秒(比 RAM 慢約 1,000 倍)作業系統搬進搬出

例如一台只插了一條 16 GB RAM(單通道)的筆電,遊戲在船上就可能用到 4.7–6.4 GB,系統剩下的可用 RAM 常常不到 1 GB。RAM 不夠時,Windows 會把較少用的分頁搬去 SSD,遊戲用到時要等 SSD 讀回來,就會頓幀。

一個程序裡的三種區域

遊戲程序的記憶體空間(示意,不按比例) game.dll(載入位址每次啟動都不同) 程式碼區(.text) 機器碼:傷害函式、遮罩更新函式… 唯讀、可執行。GameGuard 會檢查 它有沒有被改(雜湊) 資料區(.data):全域變數 固定位置,可讀寫。例如: 基址 + 0x3326688 → 存著「健康系統在哪」 基址 + 0x3326708 → 存著「遮罩系統在哪」 位置固定,所以找到一次就能一直用 堆積(heap):執行中動態配置的資料 健康系統 key 表:哪隻怪 → 第幾筆 紀錄陣列的位址 位置每場不同、還會搬家 紀錄陣列(每隻怪一筆) 第 0 筆:某隻小蟲 第 1 筆:某個機器人 第 6 筆:Hive Lord +0x14 主血 = 118,350 +0xF8 區域 HP × 38 … 遮罩系統 每個單位一筆:模型的哪些 零件要畫、哪些要藏 (Plate Fix 寫的地方)
  • 程式碼區:dll 的機器碼,執行中不變。
  • 資料區:dll 裡固定位置的變數(全域變數),整場遊戲都在同一個地方。
  • 堆積:遊戲執行中才配置的資料,例如每隻怪的血量。位置每場都不同,還可能被遊戲搬家。

還有兩件事讓「找到血量」變難:

  • ASLR:Windows 每次啟動都把 dll 放在不同的位址(防止攻擊)。所以不能記「血量在 0x7FFF…」這種絕對位址,只能記「從 dll 開頭算起第幾個 byte」,叫 RVA。實際位址 = 這次的基址 + RVA。
  • 指標:堆積上的資料位置不固定,但資料區裡的全域變數會存著它的位址。這種「存著位址的值」叫指標。從固定的全域變數出發,沿著指標一路找下去,就能找到每場位置都不同的資料。
(預設值是 10/02 那次 game.dll 的 update 函式指標所在位置)
Hive Lord HUD 此時知道了目標:Hive Lord 的血量是一個 int32,放在遊戲程序的堆積上某處,位置每場都不同。要穩定找到它,需要一個固定的起點(game.dll 資料區的某個全域變數,以 RVA 記住),再沿著指標找下去。但要知道「指標指到的那一大塊資料裡,哪 4 bytes 才是主血」,得懂遊戲怎麼組織資料(第 7 章)。
在原始碼裡
  • hive_lord_hud.lua 第 601–618 行 基址 + RVA:GetModuleHandleA 拿到這次 game.dll 的基址,加上 RVA 0x3326688 讀出健康系統的位址。基址每次啟動都不同,RVA 不變。
    展開這段程式碼
    function H.game_base()
        if H.GAME_BASE then return H.GAME_BASE end                -- test harness
        local ok, m = pcall(function() return tonumber(ffi.cast('uintptr_t', ffi.C.GetModuleHandleA('game.dll'))) end)
        if ok and m and m ~= 0 then return m end
        return nil
    end
    
    local function health_sys()
        local base = H.game_base()
        if not base then return nil, 'game.dll not found' end
        local S = ptr(base + HEALTH_SYS_RVA)
        if not S then return nil, 'health system not created' end
        local keys, cap, recs = ptr(S + HS_KEYS), u32(S + HS_CAP), ptr(S + HS_RECS)
        if not (keys and cap and recs) or cap == 0 or cap > 0x40000 then
            return nil, string.format('health system layout unexpected (cap=%s)', tostring(cap))
        end
        return {S = S, keys = keys, cap = cap, recs = recs, empty = u32(S + HS_EMPTY)}
    end
  • plate_fix.lua 第 140–148 行 基址 + RVA,再驗證:從 game.dll 基址加 RVA 讀出系統位址,並檢查 key 表與容量合不合理,不合理就什麼都不做。
    展開這段程式碼
    local function table_header(base_rva, keys_off, cap_off)
        local base = M.game_base()
        if not base then return nil end
        local S = ptr(base + base_rva)
        if not S then return nil end
        local keys, cap = ptr(S + keys_off), u32(S + cap_off)
        if not keys or not cap or cap == 0 or cap > 0x40000 then return nil end
        return S, keys, cap
    end
重點
  1. 數字在記憶體裡就是幾個 bytes,int32 是 4 bytes、低位在前。
  2. 每個程序有自己的虛擬記憶體;外部程式要讀得經過 Windows,模組在裡面則不用。
  3. 讀錯位址會當掉;dll 位置每次不同,所以記 RVA、從全域變數沿指標找。
自我檢查:今天 game.dll 載入在 0x7FFFB5F70000,一個全域變數的 RVA 是 0x3326688,它的實際位址是多少?明天呢?

今天是 0x7FFFB5F70000 + 0x3326688 = 0x7FFFB9296688。明天基址會不同,但 RVA 一樣是 0x3326688,用明天的基址加上去就好。這就是只記 RVA 的原因。

自我檢查:為什麼不能直接在程式裡寫死「血量在 0x29A60000A64」?

血量在堆積上,位置每場、甚至每次被遊戲搬家時都會變。寫死的位址下次就指到別的東西或根本不存在,讀了會讀到錯的資料,或觸發存取違規直接當掉。

第 7 章 · 執行期

遊戲資料的形狀:結構、系統、雜湊表

這一章要回答找到一大塊記憶體之後,怎麼知道哪幾個 bytes 是主血?遊戲怎麼從「這隻怪」找到「它的那一塊」?
新概念
結構、位移
綁在一起的一包資料;欄位從開頭算起第幾個 byte。
陣列、跨距
同一種資料一筆接一筆;每筆的大小。
ECS:實體、元件、系統
物件的編號;掛在編號上的資料;集中管理同一種資料的程式。
雜湊表
用編號快速查到「第幾筆」的表。
設計數值、執行中數值
寫在遊戲資料檔裡的值(最大血量);只在記憶體裡的值(目前血量)。

結構與位移

C++ 用 結構(struct)把相關的資料綁在一起。編譯後名字消失,只剩「這個欄位從開頭算起第幾個 byte」,叫位移。很多筆同樣的結構一筆接一筆排成陣列,每筆大小叫跨距,第 n 筆在「開頭 + n × 跨距」。

開發者寫的 C++(我們看不到,這是推測的樣子)
struct HealthRecord {        // 一隻怪的血量紀錄
    // ...
    int32_t main_hp;         // 主血
    // ...
    int32_t armor[39];       // 每區裝甲
    int32_t zone_hp[38];     // 每區 HP
    int32_t end_marker;      // 0xFFFF
};

HealthRecord* records;       // 很多筆連在一起
records[6].main_hp -= 500;   // 第 6 隻扣 500 血
編譯後在記憶體裡只剩位移
// 每筆紀錄 0x1B8 = 440 bytes,一筆接一筆
// 第 n 筆在:records + n × 0x1B8

+0x014  主血          (4 bytes)
+0x05C  裝甲 × 39     (每個 4 bytes)
+0x0F8  區域 HP × 38  (每個 4 bytes)
+0x190  0xFFFF 標記

// 所以「第 6 隻的主血」=
//   records + 6 × 0x1B8 + 0x14
// 我們的模組就是照這個公式去讀

點下面的區塊,看 Hive Lord 那一筆血量紀錄(440 bytes)裡每一段是什麼:

+0x000+0x0DC+0x1B8(440 bytes)

點一個區塊。

遊戲怎麼管理怪物:ECS

HD2 用一種叫 ECS 的架構:場上每個東西只是一個實體編號(例如某隻 Hive Lord 是 0x22A);資料一包一包掛在編號上,叫元件(血量元件、模型元件…);同一種元件由一個系統集中管理。健康系統把所有怪的血量紀錄放在一個大陣列裡。

健康系統要從「實體編號」找到「陣列裡第幾筆」,用的是雜湊表:一張固定大小的表,每格放 {實體編號, 第幾筆}。用編號算出一個起始格,是就讀出來,不是就往下一格找。

設計數值與執行中數值

「Hive Lord 最大血量 150,000、每塊板子 15,000、裝甲值 5」這些是設計數值,寫在 data/ 的遊戲資料表裡,遊戲沒開也讀得到(第 8 章)。「這隻 Hive Lord 現在剩 118,350」是執行中數值,只在記憶體的那筆紀錄裡。

這個區分很有用:設計數值可以變成指紋。例如 Hive Lord 那 39 個裝甲值(預設區 5,然後 6、10、5、5…)在戰鬥中永遠不變,而且組合很獨特。所以在記憶體裡看到一筆紀錄的裝甲欄位剛好是這 39 個數字,就幾乎可以確定它是 Hive Lord 的紀錄。

Hive Lord 的 38 個部位(設計數值)
slot內部名稱部位HP構造值裝甲
Hive Lord HUD 此時知道了要讀的東西長什麼樣:健康系統陣列裡的一筆紀錄,每筆 440 bytes,主血在 +0x14,38 個部位的 HP 在 +0xF8,裝甲在 +0x5C,結尾有 0xFFFF 標記。但這些位移、跨距、健康系統的位置,都不是猜的,是 B 部分的工具查出來的。
在原始碼裡
  • hive_lord_hud.lua 第 522–530 行 紀錄裡的位移:主血、裝甲、結尾標記相對於部位陣列的位移,以及每個部位的最大值表。這就是第 7 章那張記憶體圖的程式版本。
    展開這段程式碼
    local OFF_MAIN, OFF_ARMOR0, OFF_MARKER = -0xE4, -0x98, 0x98
    local WIN_LO, WIN_HI = 0xF0, 0xA0  -- read [Z - 0xF0, Z + 0xA0)
    local WIN_LEN = WIN_LO + WIN_HI
    
    -- Per-slot max HP / constitution, built from H.GROUPS.
    local MAXHP, MAXCON = {}, {}
    for _, g in ipairs(H.GROUPS) do
        for _, z in ipairs(g.zones) do MAXHP[z.slot], MAXCON[z.slot] = z.hp, z.con or g.con or 0 end
    end
  • hive_lord_hud.lua 第 546–567 行 指紋:39 個裝甲值:每次讀紀錄都檢查結尾標記、39 個戰鬥中不會變的裝甲值、以及每個部位 HP 的範圍。任何一項不對就當作不是 Hive Lord。
    展開這段程式碼
    -- Read the record around zone base z into WIN and validate it. Returns true if it still looks like a Hive Lord.
    -- Armor never changes in a fight, so it is the fingerprint: default zone, then slots 0..37.
    local ARMOR = {5, 6, 10}
    for _ = 1, 13 do ARMOR[#ARMOR + 1] = 5 end
    for _ = 1, 7 do ARMOR[#ARMOR + 1] = 2 end
    ARMOR[#ARMOR + 1] = 6; ARMOR[#ARMOR + 1] = 6
    for _ = 1, 14 do ARMOR[#ARMOR + 1] = 5 end
    
    -- HP is only sanity-bounded: destroyed zones (even ones without constitution) go below zero.
    local function read_record(z)
        if safe_read(z - WIN_LO, WIN_LEN, WIN) < WIN_LEN then return false end
        local zb = WIN_LO
        if i32(WIN, zb + OFF_MARKER) ~= 0xFFFF then return false end
        for i = 1, #ARMOR do
            if i32(WIN, zb + OFF_ARMOR0 - 4 + (i - 1) * 4) ~= ARMOR[i] then return false end
        end
        for slot = 0, 37 do
            local v = i32(WIN, zb + slot * 4)
            if v > MAXHP[slot] or v < -(MAXCON[slot] + MAXHP[slot]) then return false end
        end
        return true
    end
  • hive_lord_hud.lua 第 621–634 行 走過雜湊表:把 key 表整個讀一遍,找出每個 (實體, 第幾筆)。不算雜湊,所以不必知道遊戲用的乘數。
    展開這段程式碼
    local function each_key(hs, fn)
        local per = 0x10000 / 8
        for first = 0, hs.cap - 1, per do
            local n = math.min(per, hs.cap - first)
            if safe_read(hs.keys + first * 8, n * 8, B8) < n * 8 then return false end
            local p, list = ffi.cast('uint32_t*', B8), {}
            for i = 0, n - 1 do
                local k, v = tonumber(p[i * 2]), tonumber(p[i * 2 + 1])
                if k ~= hs.empty and k ~= 0 and v < 0x100000 then list[#list + 1] = {k, v} end
            end
            for _, kv in ipairs(list) do if fn(kv[1], kv[2]) then return true end end
        end
        return true
    end
  • plate_fix.lua 第 126–137 行 同一組指紋,加上板子到遮罩的對照:跟 HUD 用同一組 39 個裝甲值認出 Hive Lord;另外列出每塊板子對應的模型遮罩編號,以及等待時間與重試次數。
    展開這段程式碼
    -- The Hive Lord's armour values (default zone, then zones 0..37) never change in a fight: our fingerprint.
    local ARMOR = {5, 6, 10}
    for _ = 1, 13 do ARMOR[#ARMOR + 1] = 5 end
    for _ = 1, 7 do ARMOR[#ARMOR + 1] = 2 end
    ARMOR[#ARMOR + 1] = 6; ARMOR[#ARMOR + 1] = 6
    for _ = 1, 14 do ARMOR[#ARMOR + 1] = 5 end
    
    -- Plate zone -> its model mask (GoreComponent) ; constitution 35000 for every plate.
    local PLATE_MASK = {}
    for k = 0, 5 do PLATE_MASK[7 - k], PLATE_MASK[14 - k] = 1 + 4 * k, 2 + 4 * k end
    local PLATE_CON = 35000
    local FIX_DELAY, FIX_TRIES, RETRY_S = 1.5, 3, 2
  • plate_fix.lua 第 151–164 行 同樣的雜湊表走法:分段讀出 key 表,略過空格,對每個 (實體, 第幾筆) 呼叫 fn。
    展開這段程式碼
    local function each_key(keys, cap, fn)
        local per = 0x10000 / 8
        for first = 0, cap - 1, per do
            local n = math.min(per, cap - first)
            if safe_read(keys + first * 8, n * 8, BUF) < n * 8 then return false end
            local p, list = ffi.cast('uint32_t*', BUF), {}
            for i = 0, n - 1 do
                local k, v = tonumber(p[i * 2]), tonumber(p[i * 2 + 1])
                if k ~= 0xFFFFFFFF and k ~= 0 and v < 0x100000 then list[#list + 1] = {k, v} end
            end
            for _, kv in ipairs(list) do if fn(kv[1], kv[2]) then return true end end
        end
        return true
    end
重點
  1. 編譯後只剩位移:「紀錄 +0x14 是主血」就是全部的資訊。
  2. 遊戲用系統集中管理同一種資料,用雜湊表從實體編號查到第幾筆。
  3. 不會變的設計數值可以當指紋,確認找到的資料是對的。
自我檢查:健康系統的紀錄陣列從 R 開始,每筆 0x1B8 bytes,Hive Lord 是第 6 筆,它的主血在哪個位址?

R + 6 × 0x1B8 + 0x14 = R + 0xA50 + 0x14 = R + 0xA64。

自我檢查:為什麼用「裝甲值」而不是「血量」當指紋?

血量在戰鬥中一直變,下一幀就對不上了;裝甲值從頭到尾不變,又是 39 個數字的獨特組合,不容易跟別的東西撞在一起。HUD 早期用「主血 = 150000」當錨點,結果 Hive Lord 一受傷就找不到了(v9 的失敗)。

B 部分:怎麼知道遊戲裡有什麼離線讀檔案、遊戲內觀察、讀懂機器碼、安全地讀
第 8 章 · 研究工具

離線讀遊戲檔案:FileDiver

這一章要回答遊戲沒開的時候,能從檔案裡知道多少?工具是怎麼把一堆雜湊和二進位變回看得懂的東西的?
新概念
封存檔格式
標頭、型別表、檔案表、資料。
名稱清單
社群收集的「雜湊 ↔ 原名」對照表。
datalib、型別庫
遊戲的設計數值表;描述每個結構有哪些欄位、各在哪個位移的檔案。

FileDiver 是社群寫的開源解包工具。它的原理建立在第 1 章和第 7 章:封存檔是有固定格式的二進位檔,名稱是雜湊,設計數值是結構陣列。

FileDiver 怎麼運作

遊戲的資源怎麼存

  • data/ 資料夾裡是一堆用 16 位十六進位命名的封存檔,每個封存檔分成三個檔案:主檔、.stream(串流資料,例如音效)、.gpu_resources(貼圖、模型頂點)。
  • 主檔的格式:標頭(魔數 0xF0000011、型別數、檔案數)→ 型別表 → 檔案表(每個檔案的名稱雜湊、型別雜湊、在三個檔案中的位移和大小)→ 資料。我們自己打包模組用的 patch 檔就是同一個格式。
  • 檔案名稱只存 murmur64 雜湊,不存字串。FileDiver 附了約 25 萬行的已知名稱清單(hashes/),把雜湊翻回可讀的路徑;查不到的就只能顯示雜湊。Hive Lord 的區域名稱就是我們自己暴力破解 32-bit 雜湊(murmur64 的高 32 位元)找回來的。
  • FileDiver 開啟 data/ 時讀過所有封存檔的表格,建立「(名稱, 型別) → 位置」的索引;後面的 patch 會蓋過前面的同名檔案,跟遊戲的行為一樣。

它能匯出什麼

模型(glTF / glb 這兩種通用 3D 檔案格式,含骨架和動畫)、貼圖(png)、Wwise 音效(ogg / wav)、文字表(JSON)、動畫狀態機、prefab(預先組好的物件組合)等。我們用到的是:

  • Hive Lord 模型:filediver.exe -g <遊戲資料夾> -i content/fac_bugs/cha_hive_lord/cha_hive_lord.unit --model-format glb。從骨架和碰撞節點的名稱與位置,確認了 spine0 在尾端、每塊板子對應哪根骨頭、尾巴上方那塊板子不屬於任何區域。
  • 動畫狀態機(sm-state-dumper):出土警告用的位置層、動作層狀態名稱。

遊戲資料表(datalib)

  • 遊戲玩法的數值(每種敵人的元件:血量、裝甲、武器、爆炸…)存在 datalib 二進位資料塊裡(generated_entities.dl_bin 等),另有一份型別庫 dl_library.dl_typelib,描述每個結構的欄位名稱、型別、位移和大小。FileDiver 的原始碼內附這些資料塊和對應的 Go 結構。
  • 坑:FileDiver 手寫的 Go 結構會隨遊戲更新而過時。HealthComponent 實際是 22,096 bytes、51 個欄位,用舊結構讀出來是亂碼。所以我們改成直接從型別庫讀目前的欄位位移;另一個坑是每個資料塊前面的標頭(DLInstance header)是 28 bytes,不是 24,讀錯 4 bytes 後面就全部錯位。

我們加的工具

原始碼在 tools/filediver-src(本機修改,沒有回饋給原作者的專案):

工具用途
components/hive-lord-health-dumper從 datalib 解出 Hive Lord 的 HealthComponent:38 區的名稱雜湊、HP、構造值、裝甲、扣主血比例。HUD 的 H.GROUPS 和兩個模組的裝甲指紋都來自這裡。
components/entity-probe列出某個資源出現在哪些元件表裡,以及原始 bytes。
type-layout / type-parents從型別庫印出結構的欄位位移,取代過時的 Go 結構。
enum-names / enum-lengths、component-dump列舉值名稱、通用元件 dump。
explosion-dumper、bot-spread-dumper、bot-weapon-layout給爆炸、機器人命中率模組用的資料表工具。
datalib.EntitiesBlob()在 FileDiver 的 datalib 套件加了一個公開函式,讓自己的工具能讀到解壓後的 entities 資料。
兩個工具的分工:FileDiver 告訴我們「Hive Lord 有 38 區、每區多少 HP、裝甲是 6, 10, 5…」;Recon 在遊戲裡用這組數字找到那筆紀錄、dump 出 game.dll,再由靜態分析找到管理它的全域變數(見「記憶體位置怎麼找到的」)。
Hive Lord HUD 此時(10/03)從 FileDiver 拿到了大半答案:
  • 我們加寫的 hive-lord-health-dumper 解出 Hive Lord 的血量元件:主血 150,000、38 個部位、每個部位的 HP、構造值、裝甲值。裝甲值成了 HUD 和 Plate Fix 共用的指紋。
  • FileDiver 內建的結構定義已經過時(血量元件實際是 22,096 bytes),改從型別庫讀目前的位移。
  • 匯出模型骨架,發現原本以為 spine0 在頭那端,其實在尾端,面板的身體畫反了。
  • 解出動畫狀態機,後來的出土警告就靠它的狀態名稱。
但 FileDiver 只能回答「遊戲定義了什麼」,回答不了「現在這隻 Hive Lord 的紀錄在記憶體哪裡」。
在原始碼裡
  • hive_lord_hud.lua 第 86–106 行 FileDiver 解出來的設計數值:38 個部位的最大 HP 和構造值,全部來自離線解出的血量元件。這些數字之後被拿來驗證記憶體裡的紀錄。
    展開這段程式碼
    -- =========================================================================
    -- Hive Lord data (tools/research/hive_lord_health.json)
    -- =========================================================================
    H.MAIN_HEALTH_MAX = 150000
    H.GROUPS = {
        {label = 'HEAD',  color = {255, 230, 140, 40},  zones = {
            {slot = 0, hp = 150000}, {slot = 1, hp = 150000}, {slot = 22, hp = 10000}, {slot = 23, hp = 10000}}},
        {label = 'SPINE', con = 35000, color = {255, 230, 200, 60},  zones = {
            {slot = 2, hp = 15000}, {slot = 3, hp = 15000}, {slot = 4, hp = 15000}, {slot = 5, hp = 15000},
            {slot = 6, hp = 15000}, {slot = 7, hp = 15000}, {slot = 8, hp = 20000}, {slot = 9, hp = 15000},
            {slot = 10, hp = 15000}, {slot = 11, hp = 15000}, {slot = 12, hp = 15000}, {slot = 13, hp = 15000},
            {slot = 14, hp = 15000}}},
        {label = 'BODY',  color = {255, 220, 70, 60},   zones = {
            {slot = 15, hp = 150000}, {slot = 16, hp = 150000}, {slot = 17, hp = 150000}, {slot = 18, hp = 150000},
            {slot = 19, hp = 150000}, {slot = 20, hp = 150000}, {slot = 21, hp = 150000}}},
        {label = 'FINS',  color = {255, 120, 170, 210}, zones = {
            {slot = 24, hp = 5000}, {slot = 25, hp = 5000}, {slot = 26, hp = 5000}, {slot = 27, hp = 5000},
            {slot = 28, hp = 5000}, {slot = 29, hp = 5000}, {slot = 30, hp = 5000}, {slot = 31, hp = 5000},
            {slot = 32, hp = 5000}, {slot = 33, hp = 5000}, {slot = 34, hp = 5000}, {slot = 35, hp = 5000},
            {slot = 36, hp = 5000}, {slot = 37, hp = 5000}}},
    }
重點
  1. 封存檔有固定格式,名稱是雜湊,靠社群的名稱清單翻回來。
  2. 設計數值和結構定義可以離線拿到,型別庫比工具內建的結構更可靠。
  3. 離線能查的先查完,進遊戲的次數就少很多。
自我檢查:FileDiver 為什麼有些資源顯示成一串十六進位,而不是名稱?

檔案裡只存名稱的雜湊。名稱清單裡沒有那個雜湊對應的原名,工具就只能顯示雜湊本身。雜湊不能反推,只能靠猜名字、算雜湊、比對。

第 9 章 · 研究工具

在遊戲裡觀察:Recon

這一章要回答反作弊會擋外部的除錯工具,那要怎麼看遊戲執行時的記憶體?
新概念
偵察模組
只讀不寫、把觀察結果寫進 log 的模組。
PE 格式、區段
exe / dll 的檔案格式;它分成的幾個區域(程式碼、資料…)。
dump
把一段記憶體原封不動存成檔案。
特徵碼(AOB)
一串用來搜尋的 bytes,可以含萬用字元。

這一章的主角 Recon,是我們為了做 HUD 自己寫的研究用模組,沒有公開發佈。重點不是這個工具本身,而是它的原理:任何人都可以照同樣的方法,寫一個自己的偵察模組。

Recon 的原理很簡單:把觀察工具本身做成模組。它跟 HUD 一樣由 Loader 載入、在遊戲程序裡執行(第 3 章),用 FFI 讀自己程序的記憶體(第 5、6 章),所以不會碰到反作弊攔截外部存取的那一關。

FileDiverRecon(HD2Recon)
是什麼開源的 HD2 資源解包工具(用 Go 這種程式語言寫的,xypwn/filediver),我們再加了自己的小工具我們為這個專案自己寫的研究用模組(沒有公開發佈)
在哪裡執行你的電腦上,遊戲不用開遊戲程序裡面,跟 HUD 一樣由 Loader 載入
看的是硬碟上 data/ 的資源檔、遊戲資料表執行中的記憶體(解殼後的 game.dll、堆積)
輸出檔案:glb 模型、png、JSON、文字報表HD2Recon_<時間>.log 和 dump 檔
會不會動到遊戲不會,只讀檔案只讀;唯一例外是實驗性的 hook_update(見下)

Recon 怎麼運作

GameGuard 會擋外部的除錯器和記憶體讀取工具,所以沒辦法用 Cheat Engine、x64dbg 這類工具直接看遊戲。Recon 的做法是把「偵察」本身做成一個模組:它在遊戲裡面執行,用 LuaJIT FFI 讀自己程序的記憶體,把結果寫進 log。

使用循環

  1. 把這次要查的指令寫進 %TEMP%\HD2Recon_cmd.lua(一般的 Lua,例如 R.scan('48 8B ?? 20'))。
  2. 啟動遊戲。Recon 被 Loader 載入後,先自動把 game.dll 和 helldivers2.exe 的基址、大小、各區段寫進 log(R.about)。
  3. 接著讀取指令檔,用 loadstring 編譯、setfenv 給它一個能呼叫 R.* 的環境,包在 pcall 裡執行。出錯只會寫一行 run error,不影響遊戲。
  4. 關遊戲、讀 log,決定下一輪要查什麼。每次重開遊戲就會重跑一次指令檔。

主要功能

函式做什麼
R.about / R.walk解析 PE 標頭:模組基址、每個區段的位置與權限。由此發現 game.dll 用 Themida 加殼:7 個沒有名字的區段,加上 .winlice(Themida 把部分程式碼轉成自己的虛擬指令來執行,放在這裡)和 .boot(啟動時把程式碼解密的部分)。
R.scan / R.findAOB 特徵碼掃描(48 8B ?? 20,?? 是萬用字元),可選只掃可執行區段或全部(Themida 會把程式碼放在不尋常的區段)。
R.rip解 x64 的 RIP 相對位移,從一條指令算出它指向的全域變數或函式位址。
R.dump / R.savehex dump 一段記憶體到 log,或存成二進位檔。Hive Lord 血量紀錄的格式就是從這種 dump 推出來的。
R.strings / R.wstrings / R.find_string抽出 ASCII / UTF-16 字串,或找某個字串在哪裡。
R.exports列出模組的匯出函式。game.dll 只有一個 get_plugin_api。
R.ldld_census掃描整個程序的記憶體,找遊戲資料表區塊(以 LDLD 開頭),再用 FileDiver 的型別名稱清單對出是哪種表。實測 4.7 GiB 約 23 秒、587 個區塊,24 種型別對出 23 種。
R.dump_module把已經在記憶體中解殼的 game.dll(約 71 MiB)整個寫成檔案。硬碟上的 game.dll 是加密的,這份 dump 才是能反組譯的版本,後面所有靜態逆向(Ghidra、capstone)都靠它。
R.hook_update / R.on_tick實驗性。game.dll 的 get_plugin_api 會把 11 個回呼函式指標寫進 .data 的一個結構,其中一個是每次模擬 tick 呼叫的 update(dt)。這個功能把那個指標換成自己的 Lua 函式(先確認槽位裡的值等於預期才換,並一定會呼叫原本的函式),量到遊戲模擬約 63 Hz。這是 Recon 唯一會寫入的地方,寫的是資料區的一個指標,不是程式碼。早期 HUD(v0–v2)用它驅動,之後改成包裝全域 update()。

安全上,每次讀取前都先用 VirtualQuery 確認那段記憶體已配置、可讀、不是 guard page,從不呼叫 VirtualProtect,也從不修改程式碼。

它在這個專案裡的角色:HUD 前 69 個版本是跟 Recon 打包在一起發佈的(共用它的 log 和 tick),v70 才獨立成 Hive Lord HUD。HP 紀錄的第一次 dump 和 game.dll 的解殼 dump 都是 Recon 做的;健康系統和可見性遮罩系統的位置,則是從那份解殼 dump 靜態分析出來的。

Hive Lord HUD 此時(10/03,v7–v10)用 Recon 的做法找到了血量紀錄:
  • v7:在堆積裡分時搜尋 [150000, 150000, 15000×6] 這種數字序列(前兩個部位、加上板子的設計 HP)。
  • v8:找到後把附近的記憶體 dump 出來,對照 FileDiver 的設計數值,推出紀錄格式:主血在 −0xE4、裝甲在 −0x9C、部位 HP 從 0 開始(相對於部位陣列),結尾 0xFFFF。
  • v9 失敗、v10 修正:用「主血 = 150000」當錨點,Hive Lord 一受傷就找不到;改用不會變的裝甲值當指紋。
這個「搜尋堆積」的方法能用,但有一個藏得很深的問題(第 11 章)。
在原始碼裡
  • hive_lord_hud.lua 第 51–60 行 曾經跟 Recon 打包在一起的痕跡:如果偵察模組 Recon 也在,log 會寫進它的檔案。前 69 個版本的 HUD 就是跟 Recon 打包在一起的。
    展開這段程式碼
    local function writer(line)
        local R = recon()
        H.recent[#H.recent + 1] = line
        if #H.recent > 3 then table.remove(H.recent, 1) end
        line = '[HiveLordHud] ' .. line
        if R and R.write then R.write(line) return end
        print(line)
        local f = open_own_log()
        if f then pcall(function() f:write(line, '\n'); f:flush() end) end
    end
重點
  1. 在反作弊下觀察遊戲的辦法,是把觀察工具做成模組,在遊戲裡面讀。
  2. 用指令檔遙控要查什麼,結果寫 log,每次重開遊戲跑一輪。
  3. dump 出來的記憶體,加上 FileDiver 的設計數值,就能推出資料的格式。
自我檢查:Recon 和 Cheat Engine 都是讀遊戲記憶體,為什麼一個會被反作弊擋、一個不會?

Cheat Engine 是外部程式,要先跟 Windows 申請遊戲的 handle,反作弊的驅動就在這一步攔截。Recon 在遊戲程序裡面,讀的是自己的記憶體,不需要申請 handle。

第 10 章 · 研究工具

讀懂機器碼:逆向工程與組合語言

這一章要回答要找到「固定的起點」(第 6 章的全域變數),就得知道遊戲自己是怎麼找血量的。沒有原始碼,怎麼讀懂遊戲的程式?
新概念
加殼(Themida)
把程式碼加密,執行時才在記憶體裡解開。
反組譯、組合語言
把機器碼轉成人勉強讀得懂的文字指令。
暫存器
CPU 內建的十幾個超快速小格子。
交叉引用(xref)
找出「誰用到了某個位址」。
靜態、動態分析
不執行程式直接讀機器碼;在程式執行時觀察。

逆向工程是在沒有原始碼的情況下,從程式的行為和機器碼推出它在做什麼。本文用到兩種方式:

靜態分析

不執行程式,直接讀 dump 出來的機器碼。用反組譯器轉成組合語言,再找:某個位移被誰用到、某個全域變數被誰讀、某個函式被誰呼叫(叫交叉引用,xref)。

動態分析

在程式執行時觀察:讀某段記憶體、看數值怎麼變、記錄函式被呼叫了幾次。因為反作弊會擋外部的除錯器,我們把觀察工具做成模組(Recon)。

  • 特徵碼(AOB,array of bytes):一串 bytes 的樣式,可以含萬用字元,例如 48 8B ?? 20,用來在記憶體裡找特定的程式碼。
  • 指紋:一組不會變、又夠獨特的值,用來確認「找到的真的是我要的東西」。例如 Hive Lord 39 個部位的裝甲值,在戰鬥中永遠不變。

為什麼要 dump:加殼

game.dll 被 Themida 加殼:硬碟上的檔案是加密的,載入時才在記憶體裡解開。所以直接拿硬碟上的 game.dll 去反組譯只會得到亂碼,要用 Recon 的 dump_module,把記憶體裡已經解開的 game.dll 整個存下來(約 71 MiB),再拿去分析。

組合語言入門

反組譯器(capstone、Ghidra)把機器碼轉成組合語言:一行一個很小的動作。例如 C++ 的一行:

C++
hp = std::min(hp, -1);   // 血量最多 -1
組合語言(真的出現在 game.dll)
mov   eax, [r9+rdx*4+0xf8]  ; 從記憶體讀 hp
mov   ecx, 0xffffffff       ; ecx = -1
cmp   eax, ecx              ; 比較
cmovl ecx, eax              ; 較小的留在 ecx
mov   [r9+rdx*4+0xf8], ecx  ; 寫回記憶體
  • 暫存器:rax、rcx、r9…是 CPU 內建的小格子,計算前要先把資料搬進來。
  • 方括號 [ … ] = 去這個位址讀寫記憶體。[r9+rdx*4+0xf8] = 「紀錄開頭 + 第幾區 × 4 + 0xF8」,剛好就是第 7 章的位移。
  • 全域變數:mov rdi, [rip + 0x2aa2ec6] = 讀「下一條指令的位址 + 0x2aa2ec6」那裡存的值。把位移算出來,就得到全域變數的 RVA。
  • 函式參數:前 4 個放在 rcx、rdx、r8、r9;call 是呼叫函式;cmp + je/jne 是比較和跳躍(等於 if)。

找到固定起點的過程

四個步驟

  1. 先從遊戲資料知道要找什麼值。FileDiver 解出 Hive Lord 的 HealthComponent:主血 150,000、每塊板子 15,000、39 個固定的裝甲值(5, 6, 10, 5…)。這組數字就是在記憶體裡認出它的指紋。
  2. 在遊戲中搜尋,找到那筆紀錄。單位是 handle,不能順著指標走到元件,所以早期版本(v7–v10)分時掃描整個堆積,找這組數字。找到後把前後的 bytes dump 出來,對照戰鬥中哪些值在變,推出紀錄格式:主血 +0x14、裝甲 +0x5C、38 區 HP +0xF8、結尾標記 0xFFFF,一筆 0x1B8 bytes。
  3. 反過來從程式碼找出管理這些紀錄的全域變數。
    • 用 Recon addon 把已經在記憶體中解殼的 game.dll 整個 dump 出來(硬碟上的檔案被 Themida 加密)。
    • 用 Python + capstone 掃整個程式碼,找出用到這些偏移量的指令(0x1B8 的跨距、+0xF8、+0x14),找到傷害函式 0x9235F0 等血量相關函式。
    • 這幾個函式全部從同一個位置 [rip + X] 載入系統指標,換算出來就是 game.dll + 0x3326688。再看函式怎麼查表,得出 key 表、容量、紀錄陣列的偏移。
    • 可見性遮罩系統 +0x3326708 的找法一樣:從 gore 函式 0x903C70 追到遮罩設定函式 0x721D20,它們也從一個全域變數載入系統指標;entry 的格式(applied / override / touched / base)是讀設定函式和更新函式 0x721680 的程式碼推出來的。
  4. 實際使用與驗證。程式裡存的是相對位移(RVA),執行時用 GetModuleHandleA('game.dll') + RVA 算出實際位址。每次讀取都重新檢查指紋,不符就跳過。遊戲中的 log 確認過兩者都正確:v83 一次就找到紀錄,Plate Fix v3 寫入後板子在 0.1 秒內隱藏。

對應的程式(hive_lord_plate_fix.lua,節錄):

local HEALTH_RVA, VIS_RVA = 0x3326688, 0x3326708

local function table_header(base_rva, keys_off, cap_off)
    local base = M.game_base()                -- GetModuleHandleA('game.dll')
    if not base then return nil end
    local S = ptr(base + base_rva)            -- 全域變數裡存的系統指標
    if not S then return nil end
    local keys, cap = ptr(S + keys_off), u32(S + cap_off)
    if not keys or not cap or cap == 0 or cap > 0x40000 then return nil end
    return S, keys, cap
end

-- ptr / u32 都經過 ReadProcessMemory:位址不對只會回傳 nil,不會當掉
這些偏移量綁定遊戲 build 25480438。遊戲更新後位置可能移動:HUD 會退回比較慢的堆積掃描,Plate Fix 則停止動作,需要照第 3 步重新找一次。

實際的組合語言:遊戲怎麼查血量

這是 game.dll 在呼叫傷害函式之前查健康系統的真實程式碼,以及依它還原的 C++(名稱是我們取的)。對照第 7 章的雜湊表說明一起看:

這段在呼叫傷害函式 0x9235F0 之前,先用 entity 編號在健康系統的雜湊表裡找出紀錄的 index。587 處程式碼都用同一個全域變數,這是其中一處。

組合語言 · game.dll+0x8837BB
008837BB  mov  rdi, [rip+0x2aa2ec6]   ; rdi = *(game.dll+0x3326688):健康系統
008837C2  mov  ecx, r14d              ; i = 0
008837C5  mov  r9d, [rdi+0x1038]      ; 容量(2 的次方)
008837CC  mov  r10d, [rdi+0x1040]     ; 雜湊乘數
008837D3  imul r10d, edx              ; h = 乘數 × entity(edx)
008837D7  lea  r15d, [r9-1]           ; mask = 容量 - 1
008837E0  mov  r11, [rdi+0x1030]      ; key 表:{entity, index} × 容量
008837E7  mov  ebp, [rdi+0x103c]      ; 「空格」的 key 值
008837F0  mov  eax, r15d
008837F3  lea  r8d, [rcx+r10]
008837F7  and  r8, rax                ; slot = (i + h) & mask
008837FA  mov  eax, [r11+r8*8]        ; key = keys[slot].entity
008837FE  cmp  eax, ebp
00883800  je   0x883860               ; 空格:沒有這個 entity
00883802  cmp  eax, edx
00883804  je   0x883864               ; 找到:index 在 [r11+r8*8+4]
00883806  inc  ecx                    ; 下一格(線性探測)
00883808  cmp  ecx, r9d
0088380B  jb   0x8837f0
   ...
0088387C  mov  rcx, rdi               ; 第 1 個參數 = 健康系統
   ...
00883913  call 0x9235f0               ; 傷害函式
還原的 C++(示意)
// game.dll+0x3326688
extern HealthSystem* g_health_system;

struct KeyValue { uint32_t entity, index; };

struct HealthSystem {
    // ...
    /* +0x1030 */ KeyValue*     keys;
    /* +0x1038 */ uint32_t      capacity;
    /* +0x103C */ uint32_t      empty_key;
    /* +0x1040 */ uint32_t      multiplier;
    // ...
    /* +0x1058 */ HealthRecord* records;  // 每筆 0x1B8 bytes
};

uint32_t find_index(HealthSystem* hs, uint32_t entity) {
    uint32_t mask = hs->capacity - 1;
    uint32_t h    = hs->multiplier * entity;
    for (uint32_t i = 0; i < hs->capacity; ++i) {
        KeyValue& kv = hs->keys[(i + h) & mask];
        if (kv.entity == hs->empty_key) break;   // 沒有
        if (kv.entity == entity) return kv.index;
    }
    return NOT_FOUND;
}

// 傷害函式拿到的就是 records[index]
apply_damage(g_health_system, entity, /* ... */);

我們的 Lua 用的是同一張表(HS_KEYS = 0x1030、HS_CAP = 0x1038、HS_RECS = 0x1058),但不算雜湊,而是把 key 表整個讀一遍找 entity。慢一點,但不需要知道雜湊函式的細節,遊戲改了雜湊方式也不會壞。

game.dll 基址每次啟動都不同(ASLR)GetModuleHandleA('game.dll') 全域變數基址 + 0x3326688存著健康系統的指標(RVA,綁定遊戲 build) key 表 S+0x1030(entity, index) 雜湊表容量 S+0x1038 紀錄陣列 S+0x1058每筆 0x1B8 bytes會被遊戲搬家 一筆紀錄陣列 + index× 0x1B8 +0x14主血 +0xF838 區 HP 用 Hive Lord 的 entity 在 key 表裡查到 index,再到紀錄陣列取那一筆
Hive Lord HUD 此時(10/05,v83):靜態分析找到所有血量函式都從同一個全域變數 game.dll + 0x3326688 載入健康系統(整個 game.dll 有 587 處程式碼讀它)。HUD 從此不再搜尋堆積,而是走「基址 + RVA → 健康系統 → 雜湊表 → 第幾筆 → +0x14」這條固定的路。這一改也順便修好了第 11 章那個問題。
在原始碼裡
  • hive_lord_hud.lua 第 578–599 行 逆向工程找到的健康系統:這段註解記錄了第 10 章的成果:傷害函式 0x9235F0 怎麼查表、全域變數的 RVA、key 表與紀錄陣列的位移,以及為什麼搜尋堆積會跟上凍結的舊副本。
    展開這段程式碼
    -- =========================================================================
    -- The live HP record straight from the game's health system (a game.dll global; RVA of build 25480438).
    -- The damage code (game.dll 0x9235F0) indexes it the same way: hash of (entity u32, index u32) at +0x1030,
    -- capacity at +0x1038, empty key at +0x103C, runtime records at +0x1058, 0x1B8 bytes each; zone HP at +0xF8
    -- (our Z), main HP at +0x14. The game reallocates that array and the old copies stay readable and still pass
    -- our fingerprint, so a heap scan can end up following a frozen copy. Going through the global always lands on the
    -- live one; the heap scan is only the fallback when the global cannot be read (e.g. after a game update).
    -- =========================================================================
    do
    local HEALTH_SYS_RVA, HS_KEYS, HS_CAP, HS_EMPTY, HS_RECS = 0x3326688, 0x1030, 0x1038, 0x103C, 0x1058
    local REC_STRIDE, REC_Z = 0x1B8, 0xF8
    local B8 = ffi.new('uint8_t[?]', 0x10000)
    local function u32(addr)
        if safe_read(addr, 4, B8) < 4 then return nil end
        return tonumber(ffi.cast('uint32_t*', B8)[0])
    end
    local function ptr(addr)
        if not addr or safe_read(addr, 8, B8) < 8 then return nil end
        local p = tonumber(ffi.cast('uint64_t*', B8)[0])
        if p < 0x10000 or p > USER_END then return nil end
        return p
    end
  • plate_fix.lua 第 4–14 行 逆向工程找到的 bug 原因:區域狀態(碰撞)一定會同步,但隱藏模型的 gore 只在算出致命一擊的那台機器上執行。註解裡的位址都是第 10 章的方法找到的。
    展開這段程式碼
    -- A Hive Lord plate that is destroyed (zone HP -35000) always loses its collider and its crack overlay: that is zone
    -- state, synced to everyone (game.dll 0x925F10). The plate model itself is only hidden by gore (0x9057D0 -> 0x903C70),
    -- which runs only on the machine that resolves the killing hit, and only if that hit passes gore's checks. When
    -- gore never comes (seen on a client when a teammate's Eagle killed the plate), the plate stays drawn - looking
    -- intact again - while shots pass through it to the flesh behind.
    --
    -- This module watches every Hive Lord through the game's own tables and, when a plate has been dead for
    -- FIX_DELAY seconds and is still drawn, writes the same "hidden" override bit that gore would have written. The
    -- game then updates the drawn mask itself. It only changes what this player sees: no damage, collision, network
    -- or code is touched. Every read/write goes through Read/WriteProcessMemory, so a stale address fails instead of
    -- crashing. RE notes: tools/research/hive_lord_armor_break.md. Globals are RVAs of build 25480438.
  • plate_fix.lua 第 112–124 行 從組合語言讀出的表格布局:健康系統與可見性遮罩系統的全域變數、key 表、entry 布局,全是從 game.dll 的組合語言讀出來的。
    展開這段程式碼
    -- ---------- game tables ----------
    -- Health system (game.dll+0x3326688): keys (entity u32, index u32) @+0x1030, capacity @+0x1038, empty key @+0x103C,
    --   records @+0x1058, 0x1B8 bytes each: zone HP i32[38] @+0xF8, armor i32[39] @+0x5C, 0xFFFF marker @+0x190.
    -- Visibility-mask system (game.dll+0x3326708): keys @+0x18, capacity @+0x20, entries @+0x38 (24 bytes: applied
    --   mask @+0, override bits @+8, touched bits @+16), base masks @+0x40 (8 bytes each).
    -- The setters (0x721D20 & co.) push to the material at once only while the unit's render record is live
    -- ([rec+0x14] bit 0); then they write the base mask. Otherwise they only write override/touched, and the updater
    -- 0x721680 later pushes (base & ~touched) | (override & touched) and keeps that in the entry's applied mask.
    -- So "drawn" must not be judged by the base mask alone (the non-live path never changes it): a plate counts as
    -- hidden when either the base or the applied mask has its bit. Every try logs all four masks.
    local HEALTH_RVA, VIS_RVA = 0x3326688, 0x3326708
    local REC_STRIDE = 0x1B8
    local Z_OFF, ARMOR_OFF, MARKER_OFF = 0xF8, 0x5C, 0x190
重點
  1. 加殼的程式要從記憶體 dump 出解開的版本才能分析。
  2. 組合語言裡的 [暫存器 + 位移] 就是結構欄位,[rip + X] 就是全域變數。
  3. 找出遊戲自己用來找資料的那個全域變數,模組就有了固定、可靠的起點。
自我檢查:為什麼要找「遊戲自己用的」全域變數,而不是繼續在堆積裡搜尋指紋?

遊戲自己用的那條路,永遠指向遊戲正在用的那一份資料。搜尋堆積可能找到已經被拋棄、卻還留在記憶體裡的舊副本(第 11 章),而且搜尋很慢、很耗時間。

第 11 章 · 研究工具

讀取要安全、要正確

這一章要回答知道位置之後,怎麼讀才不會讓玩家的遊戲當掉,也不會讀到錯的資料?
新概念
ReadProcessMemory
Windows 讀記憶體的 API:位址錯了只會回傳失敗。
舊副本
遊戲已經不用、卻還留在記憶體裡的資料。
時間預算
每幀允許模組花多少時間。

直接讀 vs ReadProcessMemory

用 FFI 把位址轉成指標直接讀,位址錯了就是存取違規、遊戲當掉(第 6 章)。Recon 的做法是先用 VirtualQuery 問「這段能不能讀」再讀,但「問」和「讀」之間有極短的空檔,遊戲的其他執行緒可能剛好在這個空檔把記憶體釋放掉。

ReadProcessMemory 沒有這個空檔:讀不到就回傳失敗,不會當掉。所以要給玩家用的 HUD 和 Plate Fix 一律用它讀自己的程序;Recon 是開發用工具,才接受直接讀的小風險。HUD 的離線測試也實際抓到過:直接讀堆積時,記憶體在檢查和讀取之間被釋放,測試程式當掉。

「釋放」不等於「消失」:舊副本

C++ 程式歸還一塊記憶體,或陣列長大時搬到新位置,舊的那塊通常只是交還給程式自己的配置器,內容還在、也還讀得到,只是遊戲不再更新它。如果模組是「在記憶體裡搜尋」找資料,就可能找到這種舊副本:指紋完全正確,數值卻凍結不動。

每次讀都要驗證

  • 每次讀紀錄都重新檢查指紋(39 個裝甲值 + 0xFFFF 標記)和數值範圍(每個部位的 HP 不能超過最大值)。任何一項不符,就當作沒找到,不用這筆資料。
  • 遊戲會搬家:每幀都重新從全域變數讀陣列位置,每秒用實體編號重新查一次第幾筆。
  • 找不到時有退路:全域變數讀不到(例如遊戲更新後位置變了),就退回比較慢的堆積搜尋,並在 log 寫下原因。

時間預算

搜尋堆積要讀好幾 GB 的記憶體,一次做完會讓那一幀卡住好幾秒。所以要切成小塊,每幀只做一點。HUD 一開始每幀花 8 ms,在 130 FPS 時讓 FPS 掉了一半,後來降到 4 ms。改成直接走全域變數之後,平常每幀的成本只剩零點幾毫秒。

Hive Lord HUD 此時(10/05):玩家回報「板子已經炸了,面板還顯示 20 幾 %」。查 log 發現,HUD 的堆積搜尋在某次重新搜尋時採用了一份舊副本,之後 130 秒數值都沒變。這就是 v83 改成從全域變數出發(第 10 章)的直接原因。改完之後一整場戰鬥只需要找一次,再也沒有凍結。
在原始碼裡
  • hive_lord_hud.lua 第 520 行 時間預算:堆積搜尋每幀最多 4 ms。原本是 8 ms,在 130 FPS 時讓 FPS 掉了一半。
    展開這段程式碼
    local SLICE_S, CHUNK = 0.004, 1024 * 1024   -- 4 ms per frame (was 8: halved the FPS while it ran at ~130 FPS)
  • hive_lord_hud.lua 第 532–542 行 所有讀取都經過 ReadProcessMemory:註解寫得很清楚:堆積記憶體可能在檢查和讀取之間被釋放,直接讀會當掉;ReadProcessMemory 只會回傳失敗。
    展開這段程式碼
    -- All reads go through ReadProcessMemory on our own process: heap memory can be freed by another
    -- thread between a VirtualQuery check and a raw pointer read (that segfaulted in testing); RPM just fails.
    local SELF = k32.GetCurrentProcess()
    local NREAD = ffi.new('size_t[1]')
    local SCRATCH = ffi.new('uint8_t[?]', CHUNK)
    local WIN = ffi.new('uint8_t[?]', WIN_LEN)
    
    local function safe_read(addr, n, dst)
        if k32.ReadProcessMemory(SELF, ffi.cast('const void*', addr), dst, n, NREAD) == 0 then return 0 end
        return tonumber(NREAD[0])
    end
  • hive_lord_hud.lua 第 663–689 行 找不到時的退路:先走健康系統這條固定的路;全域變數讀不到(例如遊戲更新後)才退回比較慢的堆積搜尋,並在 log 寫下原因。
    展開這段程式碼
    function H.locator_start()
        local list, why = H.direct_find()
        if list then
            H.loc = {phase = 'done', direct = true, cands = {}, adopted = nil, t0 = now_s(), scanned = 0, anchors = 0, last_poll = -1e9}
            local ids = {}
            for _, c in ipairs(list) do
                H.loc.cands[#H.loc.cands + 1] = {z = c.z, ent = c.ent, idx = c.idx, changed = -1e9}
                ids[#ids + 1] = string.format('entity 0x%X index %d', c.ent, c.idx)
            end
            if list[1] then
                writer('HP record found via the health system: ' .. table.concat(ids, ', '))
                H.direct_waiting = nil
            else
                H.loc.retry_at = now_s() + 3
                if not H.direct_waiting then writer('health system readable, no Hive Lord record yet - retrying every 3s') end
                H.direct_waiting = true
            end
            return
        end
        if not H.direct_fail_logged then
            writer('direct HP lookup unavailable (' .. tostring(why) .. ') - using the heap scan')
            H.direct_fail_logged = true
        end
        H.loc = {phase = 'scan', addr = 0x10000, rbase = nil, rend = nil, off = 0,
                 scanned = 0, anchors = 0, cands = {}, adopted = nil, t0 = now_s()}
        writer('heap scan started (time-sliced, ~4ms/frame)')
    end
  • hive_lord_hud.lua 第 692–706 行 不要找到自己的副本:模組自己的緩衝區裡也有剛讀出來的紀錄副本,搜尋時一定要排除,否則會找到自己。
    展開這段程式碼
    local OWN = {
        {tonumber(ffi.cast('uintptr_t', SCRATCH)), CHUNK},
        {tonumber(ffi.cast('uintptr_t', WIN)), WIN_LEN},
    }
    
    local function test_anchor(L, hit)
        for _, r in ipairs(OWN) do
            if hit >= r[1] - WIN_LEN and hit < r[1] + r[2] then return end
        end
        L.anchors = L.anchors + 1
        if #L.cands < 8 and read_record(hit) then
            for _, c in ipairs(L.cands) do if c.z == hit then return end end
            L.cands[#L.cands + 1] = {z = hit, sig = signature(), changed = -1e9}
        end
    end
  • hive_lord_hud.lua 第 710–745 行 分時搜尋:每幀只搜尋一小塊,時間一到就停,下一幀接著做。
    展開這段程式碼
    local function scan_step(L)
        local t_end = clock() + SLICE_S
        while clock() < t_end do
            if L.rbase == nil then
                if L.addr >= USER_END or k32.VirtualQuery(ffi.cast('const void*', L.addr), mbi, MBI_SIZE) == 0 then
                    return true
                end
                local m = mbi[0]
                local rb, rs = tonumber(ffi.cast('uintptr_t', m.BaseAddress)), tonumber(m.RegionSize)
                if rb + rs <= L.addr then return true end
                L.addr = rb + rs
                if m.State == MEM_COMMIT and m.Type == MEM_PRIVATE and m.Protect == PAGE_READWRITE then
                    L.rbase, L.rend, L.off = rb, rb + rs, 0
                end
            else
                local take = math.min(CHUNK, L.rend - L.rbase - L.off)
                local cbase = L.rbase + L.off
                local got = safe_read(cbase, take, SCRATCH)
                -- Compare aligned int32s in place: building a Lua string would leave stale copies of the
                -- record in the heap for later scans to find.
                local ok = got == take
                if ok then
                    local words = ffi.cast('const int32_t*', SCRATCH)
                    for i = 0, got / 4 - 1 do
                        if words[i] == 6 and words[i + 1] == 10 and i + 1 < got / 4 then
                            test_anchor(L, cbase + i * 4 - OFF_ARMOR0)
                        end
                    end
                end
                L.off = L.off + take
    -- …(共 36 行,完整內容見原始碼頁)
  • hive_lord_hud.lua 第 775–878 行 每秒重新驗證:每秒用實體編號重新找一次紀錄並驗證;紀錄搬走或被釋放就重新搜尋,而不是改跟另一筆閒置的紀錄。
    展開這段程式碼
    function H.read_hp()
        local hp = {main = nil, zones = {}}
        local L = H.loc
        if not L then return hp end
        if L.phase == 'done' and not L.adopted and L.retry_at and now_s() >= L.retry_at then
            H.locator_start()
            return hp
        end
        if L.phase ~= 'done' or not L.cands[1] then return hp end
        local t = now_s()
        if t - L.last_poll >= 1 then
            L.last_poll = t
            local keep = {}
            for _, c in ipairs(L.cands) do
                if L.direct then c.z = H.direct_z(c, true) end
                if c.z and read_record(c.z) then
                    local sig = signature()
                    if sig ~= c.sig then c.sig, c.changed = sig, t end
                    keep[#keep + 1] = c
                end
            end
            L.cands = keep
            if not keep[1] then
                writer('HP records moved or freed - rescanning')
                H.locator_start()
                return hp
            end
            -- The Hive Lord being fought is the record whose HP changed most recently.
            local still_valid = false
            for _, c in ipairs(keep) do if c == L.adopted then still_valid = true end end
    -- …(共 104 行,完整內容見原始碼頁)
  • plate_fix.lua 第 51–87 行 同樣只用 ReadProcessMemory:「永遠不直接解參考指標:遊戲會釋放、搬移這些表。」跟 HUD 用一樣的安全讀取。
    展開這段程式碼
    -- ---------- memory access (never dereference raw pointers: the game frees and moves these tables) ----------
    pcall(ffi.cdef, 'void* GetModuleHandleA(const char* name);')
    pcall(ffi.cdef, 'void* GetCurrentProcess(void);')
    pcall(ffi.cdef, 'int ReadProcessMemory(void* process, const void* addr, void* buf, size_t size, size_t* read);')
    pcall(ffi.cdef, 'int WriteProcessMemory(void* process, void* addr, const void* buf, size_t size, size_t* written);')
    pcall(ffi.cdef, 'int QueryPerformanceCounter(int64_t* c); int QueryPerformanceFrequency(int64_t* f);')
    local k32 = ffi.load('kernel32')
    local SELF = k32.GetCurrentProcess()
    local NIO = ffi.new('size_t[1]')
    local BUF = ffi.new('uint8_t[?]', 0x10000)
    local REC = ffi.new('uint8_t[?]', 0x1B8)
    local WB = ffi.new('uint8_t[1]')
    local USER_END = 0x7FFFFFFFFFFF
    
    local function safe_read(addr, n, dst)
        if k32.ReadProcessMemory(SELF, ffi.cast('const void*', addr), dst, n, NIO) == 0 then return 0 end
        return tonumber(NIO[0])
    end
    local function u32(addr)
        if safe_read(addr, 4, BUF) < 4 then return nil end
        return tonumber(ffi.cast('uint32_t*', BUF)[0])
    end
    local function ptr(addr)
        if not addr or safe_read(addr, 8, BUF) < 8 then return nil end
        local p = tonumber(ffi.cast('uint64_t*', BUF)[0])
        if p < 0x10000 or p > USER_END then return nil end
        return p
    end
    local function mask(addr)
        if safe_read(addr, 8, BUF) < 8 then return nil end
    -- …(共 37 行,完整內容見原始碼頁)
重點
  1. 給玩家用的模組一律用 ReadProcessMemory:位址錯了只失敗、不當掉。
  2. 釋放的記憶體還讀得到,搜尋可能找到凍結的舊副本;從遊戲自己的指標出發才可靠。
  3. 每次讀都驗證、每幀都有時間上限、失敗時有退路並寫 log。
自我檢查:舊副本「指紋完全正確」,那要怎麼防?

指紋只能證明「這是 Hive Lord 的紀錄」,不能證明「這是遊戲正在用的那一份」。所以不能靠搜尋,要從遊戲自己的全域變數和雜湊表找:遊戲當下用哪一份,那條路就指向哪一份。

C 部分:怎麼做出來、交到玩家手上程式骨架、離線測試、打包部署、限制、反作弊
第 12 章 · 製作

模組程式的骨架

這一章要回答把前面的原理組合起來,一個穩定的模組程式長什麼樣子?
新概念
單例守衛
用一個獨特的全域變數,確保模組只被載入一次。
解除武裝(disarm)
出錯後永久停止自己,讓遊戲照常。
另一個模組的 API
Mod Options Menu 這類模組開放給其他模組呼叫的函式。

Hive Lord HUD 的原始碼檔頭列出了它的段落,剛好就是前面每一章的原理按順序排好:

段落做什麼對應原理
Hive Lord data38 個部位的設計數值第 7、8 章
Detection找 Hive Lord 單位、距離、出土狀態第 4 章(引擎 API)
Live HP locator從全域變數找到血量紀錄;找不到時退回搜尋第 6、10、11 章
Zone tracking逐幀比較部位狀態,判斷是誰改的第 15 章(多人)
GUI保留模式的圖元、淡入淡出、只送有變的第 4 章
Per-frame worker每幀的主流程第 2 章
Settings設定檔 + Mod Options Menu本章
Install包裝全域 update()第 2、4 章

每個模組都該有的幾條規則

  1. 單例守衛:開頭先檢查一個獨特的全域變數,已經存在就不再執行。
  2. 環境檢查:需要的 API、Loader 不在,就寫 log 並停手,而不是硬跑。
  3. 包裝 update,出錯就解除武裝:每幀工作包在 pcall 裡,第一次出錯就永久關閉自己、清掉畫面,並寫下原因。
  4. 一定呼叫原本的 update。
  5. 呼叫前先確認:world 還在、unit 還活著(引擎錯誤救不回來)。
  6. 時間預算與自我計時:量每幀花了多少,定期寫進 log。
  7. log 要限流:只寫狀態改變,不要每幀一行。

跟別的模組合作:設定選單

Mod Options Menu 也是一個透過 Loader 載入的模組,它在全域放了一個 ModOptionsMenu 表,其他模組可以呼叫 register_option 註冊設定、用 get 讀值。因為模組之間的載入順序不保證,所以 HUD 不在載入時找它,而是在 update 裡才找,找到了才註冊。沒有裝它,HUD 就用設定檔裡的值。

Hive Lord HUD 此時(10/04,v72–v78)把所有設定搬進 Mod Options Menu,拿掉所有快捷鍵。過程中學到:選單的修改要按遊戲的「套用」才生效,按 Esc 會丟掉,所以「預覽」改成按鈕式:套用後顯示 15 秒,再自己把選項切回關。(10/07,v116)功能凍結後做了一次大整理:3,568 行減到約 2,600 行,研究用的診斷移到另一個開發用模組。
在原始碼裡
  • hive_lord_hud.lua 第 6–14 行 段落索引:整個檔案的段落順序,剛好就是第 12 章那張表:資料、偵測、讀血量、區域追蹤、畫面、每幀流程、設定、安裝。
    展開這段程式碼
    -- Sections (in file order):
    --   Hive Lord data      zone layout, max HP / constitution per zone (from the unpacked HealthComponent)
    --   Detection           finding the Hive Lord unit, its distance, surfacing and the emerge / pre-emerge states
    --   Live HP locator     the HP record via the game's health system (heap scan only as a fallback)
    --   Zone tracking       per-frame zone bookkeeping; host state events and the '<' upper-bound marker
    --   GUI                 prims and groups: status text, armour panel (poly / pixel), tag, warning, preview, boss bar
    --   Per-frame worker    world selection, gui lifetime, render()
    --   Settings            cfg file and the Bingus Mod Options Menu rows
    --   Install             the wrapper around the engine's global update()
  • hive_lord_hud.lua 第 24–26 行 單例守衛:已經載入過就直接結束,同一個全域表也是整個 HUD 的狀態容器。
    展開這段程式碼
    if rawget(_G, 'HD2HiveLordHud') then return end
    local H = {version = 121, release = 'v3', armed = false, faults = 0, frame = 0}
    rawset(_G, 'HD2HiveLordHud', H)
  • hive_lord_hud.lua 第 2251–2331 行 每幀的主流程:每 2 秒找一次單位、每幀讀血量、處理死亡與屍體、更新距離與動畫狀態、最後畫出畫面,順便量自己的更新率。
    展開這段程式碼
    function H.update()
        H.frame = (H.frame or 0) + 1
        -- Lua update rate (the overlay can only move this often), logged every 30 s while a Hive Lord is tracked.
        local tr = now_s()
        H.rate_t0 = H.rate_t0 or tr
        H.rate_n = (H.rate_n or 0) + 1
        if tr - H.rate_t0 >= 30 then
            local c = H.cost
            if H.live.unit and c then
                local normal_n = c.n - c.scan_n
                writer(string.format('update rate: %.0f Hz | HUD cost: avg %.3f ms, max %.2f ms per frame%s', H.rate_n / (tr - H.rate_t0),
                    normal_n > 0 and (c.sum - c.scan_sum) / normal_n or 0, c.max,
                    c.scan_n > 0 and string.format(' | during HP search: %d frames, avg %.2f ms', c.scan_n, c.scan_sum / c.scan_n) or ''))
            end
            if c then c.sum, c.max, c.n, c.scan_sum, c.scan_n = 0, 0, 0, 0, 0 end
            H.rate_t0, H.rate_n = tr, 0
        end
        local L, t = H.live, now_s()
        t_now = t
        -- A dead world already took its gui with it: forget, never destroy.
        if H.ui.gui ~= nil and not world_is_live(H.ui.world) then
            H.ui.gui, H.ui.world, H.grp = nil, nil, {}
        end
    
        if t - L.last_scan >= 2 then
            L.last_scan = t
            local worlds = attempt(App.worlds)
            if type(worlds) ~= 'table' then return end
            local u, _, uname = H.find_hive_lord(worlds, t)
            local a = u and (unit_addr(u) or u)
    -- …(共 81 行,完整內容見原始碼頁)
  • hive_lord_hud.lua 第 2510–2559 行 跟另一個模組合作:設定選單:Mod Options Menu 是另一個模組提供的全域 API。HUD 在 update 裡才去找它(載入順序不保證),找到才註冊選項,每 10 幀讀一次值。
    展開這段程式碼
    function H.options_register(M)
        local function reg(id, spec)
            local ok, done, why = pcall(M.register_option, id, spec)
            if not (ok and done) then writer('mod options: register ' .. id .. ' failed: ' .. tostring(ok and why or done)) end
            return ok and done
        end
        H.option_ok = reg(OPTION_ID, {
            type = 'toggle', mod = 'Hive Lord HUD', label = 'Enable Hive Lord HUD', default = true,
            description = 'Turns the whole Hive Lord HUD on or off: HP bar, armor panel, tag, distance and '
                .. 'emerge warning. Off stops all of its work.'})
        if not H.option_ok then return end
        local n = 0
        for _, o in ipairs(H.OPTIONS) do
            local spec = {type = o.type, mod = 'Hive Lord HUD', label = o.label, description = o.description, gap = o.gap,
                          default = o.default, choices = o.choices, step = o.step}
            if o.type == 'choice' then spec.default = to_menu(o, o.default) end
            if o.type == 'slider' then spec.min, spec.max = SIZE_RANGE[o.key][1], SIZE_RANGE[o.key][2] end
            o.ok = reg(H.opt_id(o), spec)
            if o.ok then n = n + 1 end
        end
        writer(string.format('mod options: registered %s + %d of %d settings', OPTION_ID, n, #H.OPTIONS))
        pcall(M.set, 'hivelordhud.preview', false)     -- preview is per session: never comes back on by itself
        -- cfg.menu: 0 = menu never seen (copy every saved setting into it); 1, 2 = older menu layouts (copy the rows
        -- added since, see `since`); 3 = the menu is the source.
        if (H.cfg.menu or 0) < 3 then
            for _, o in ipairs(H.OPTIONS) do
                if o.ok and (o.since or 1) > (H.cfg.menu or 0) then pcall(M.set, H.opt_id(o), to_menu(o, H.cfg[o.key])) end
            end
            writer('mod options: copied saved settings into the menu (from cfg level ' .. tostring(H.cfg.menu) .. ')')
            H.cfg.menu = 3
    -- …(共 50 行,完整內容見原始碼頁)
  • plate_fix.lua 第 287–304 行 一個開關的設定選單:有 Mod Options Menu 就註冊一個開關,每 10 幀讀一次;沒有就一直開著。
    展開這段程式碼
    function M.poll_option()
        local MOM = rawget(_G, 'ModOptionsMenu')
        if type(MOM) ~= 'table' then return end
        if not M.option_tried then
            M.option_tried = true
            local ok, done = pcall(MOM.register_option, OPTION_ID, {
                type = 'toggle', mod = 'Hive Lord Plate Fix', label = 'Fix dead plates that stay on', default = true,
                description = "A plate destroyed by damage your game did not resolve itself (e.g. a teammate's Eagle) loses its collision but can stay drawn, looking intact. When a plate has been at 0% for 1.5 s and is still drawn, hide it the way the game would have. Only changes what you see."})
            M.option_ok = ok and done
            if not M.option_ok then writer('mod options: register failed') end
        end
        if not M.option_ok or M.frame % 10 ~= 0 then return end
        local ok, v = pcall(MOM.get, OPTION_ID)
        if ok and type(v) == 'boolean' and v ~= M.enabled then
            M.enabled = v
            writer('fix ' .. (v and 'enabled' or 'disabled') .. ' from Mod Options Menu')
        end
    end
重點
  1. 模組骨架:單例守衛 → 環境檢查 → 包裝 update(pcall + 解除武裝)→ 每幀工作。
  2. 每條規則都對應前面的一個原理,大多是當掉之後才補上的。
  3. 模組之間可以合作,但不能假設載入順序。
自我檢查:為什麼「出錯就永久關閉」比「出錯就下一幀再試」好?

同一個錯誤下一幀通常還會再發生。一直重試會每幀都出錯、把 log 灌爆,還可能在錯的狀態下繼續呼叫引擎 API 而讓遊戲當掉。永久關閉只損失這個模組的功能,遊戲安全,log 裡也留下第一次出錯的原因。

第 13 章 · 製作

在電腦上假裝成遊戲:離線測試

這一章要回答每次進遊戲測試都要重開、還可能當掉。能不能在電腦上就先抓到大部分的錯?
新概念
lupa
在 Python 裡執行 Lua 的套件,有 LuaJIT 版本。
假物件(stub)
模仿遊戲 API 的假函式。
變異測試
故意把程式改壞,確認測試會失敗。

原理:模組只透過三個管道跟遊戲互動——引擎 API、FFI 讀記憶體、Loader。只要把這三個管道換成假的,模組就分不出自己是不是在遊戲裡。

  • 同款的 Lua:用 lupa 的 LuaJIT 版本執行真正的模組原始碼。用新版 Lua 的話,// 這類遊戲不支援的語法會通過測試,進遊戲才載入失敗。
  • 假的引擎:自己寫 World、Unit、Gui、Camera,記錄模組呼叫了什麼。可以讓它們故意模仿遊戲的壞習慣,例如「對已經死掉的單位呼叫就當掉」,確認模組不會這樣做。
  • 假的記憶體:在真的記憶體裡放一份假的健康系統、雜湊表、血量紀錄,讓模組照真正的路去找。再放幾個「長得像但不對」的誘餌,確認驗證會擋下它們。
  • 變異測試:把程式故意改壞(例如拿掉指紋檢查),測試必須失敗。抓不到故意放的 bug,代表測試沒在檢查東西。
  • 畫面也能離線看:把模組呼叫的 Gui.rect、Gui.text 記錄下來,用 Python 畫成圖片,進遊戲前就能看版面。Hive Lord HUD 的展示圖和模組圖示就是這樣產生的。
Hive Lord HUD 此時有幾十個測試腳本(test_v83.py、test_v108.py…),多數版本都記錄著「殺掉了幾個變異」。它們抓到過不少進遊戲前就會出事的錯,例如:在定義某個表之前就用到它(模組會在遊戲裡直接解除武裝)、診斷用的緩衝區被誤當成血量紀錄採用(它也通過了指紋檢查)。也踩過一個測試本身的坑:測試讀寫的是真正的設定檔,把玩家的設定改掉了,之後所有測試都改成導到暫存資料夾。
在原始碼裡
  • plate_fix.lua 第 100–110 行 留給測試的接縫:時間和 game.dll 基址都可以被測試替換,離線測試才能在假的記憶體結構上執行這個模組。
    展開這段程式碼
    function M.now()                                   -- seconds; tests replace this
        k32.QueryPerformanceCounter(QB)
        return tonumber(QB[0]) / QFREQ
    end
    
    function M.game_base()
        if M.GAME_BASE then return M.GAME_BASE end      -- tests
        local ok, m = pcall(function() return tonumber(ffi.cast('uintptr_t', ffi.C.GetModuleHandleA('game.dll'))) end)
        if ok and m and m ~= 0 then return m end
        return nil
    end
重點
  1. 模組跟遊戲的互動只有三個管道,全部換成假的就能離線執行。
  2. 假物件要模仿遊戲的嚴格與壞習慣,誘餌要能騙過不小心的驗證。
  3. 變異測試確認「測試真的有在檢查」。
自我檢查:測試全部通過,進遊戲卻失敗,最可能是哪裡不一樣?

假物件跟真的遊戲不一樣:太寬鬆(真的引擎會當掉的呼叫,假的不會)、資料形狀不同(測試只造了一種記憶體布局),或用了不同版本的 Lua。修法是把那個差異補進假物件,再加一條對應的變異測試。

第 14 章 · 製作

打包與部署:把程式變成 patch 檔

這一章要回答一個 .lua 檔,怎麼變成模組管理器能匯入、遊戲能載入的東西?
新概念
manifest.json
模組管理器讀的描述檔:名稱、說明、選項。
GUID
模組永遠不變的身分證號碼。
slot
data/ 裡某個 patch 檔的編號位置。

patch 檔長什麼樣

打包工具做的事,就是把第 1 章的封存檔格式反過來寫一次:標頭、型別表(只有 lua 一種)、檔案表(名稱雜湊、位置、大小),最後是資料。資料本身是「長度 + 2(代表純文字)+ 原始碼」,對齊到 16 bytes。在下面輸入資源名稱和程式內容,看實際的 bytes(跟我們的打包工具算法相同):

zip 與 manifest

模組管理器匯入的是一個 zip:manifest.json(名稱、說明、選項)、每個選項一個資料夾(裡面是 patch 檔)、Source/(明文原始碼,讓人能審查)。幾個重點:

  • GUID 永遠不變:管理器靠它認出「這是同一個模組的新版本」。Hive Lord HUD 的 GUID 從 v1 到 v3 都是 4ef072f2-…。
  • Name 只用英文、數字、空白:它會被當成資料夾名稱,一個冒號就讓匯入失敗。
  • 選項 = 分開的 patch 檔:Hive Lord HUD 有兩個選項,「Hive Lord HUD」和「Dead Plate Fix」,各是一個 patch 檔,玩家可以只裝其中一個。

部署:管理器會重排編號

Arsenal 每次部署都會重新分配 patch 編號,而且每個選項各佔一個 slot。開發時為了快,我們會直接把新的 patch 檔複製進 data/,這時候不能寫死編號:要用檔案內容(找自己的 -- HD2-Addon: 那一行)確認哪個 slot 是自己的,並且同時更新 Arsenal 自己保存的那份副本,否則下次 Deploy 會把舊版放回去。遊戲執行中也不能部署:Lua 只在啟動時載入。

Hive Lord HUD 此時(10/04)因為寫死編號吃了虧:Arsenal 重排之後,我們複製到 patch_1 的新版蓋掉了另一個模組(Megapack 的一個選項),遊戲還載入了放在 patch_7 的舊版。之後寫了部署腳本:依內容找出自己的 slot、找不到或找到兩個以上就中止、遊戲在執行就拒絕部署。發佈則用 build_hive_lord.py:Release v1 = 內部 build 70,v2 = build 87(加入 Dead Plate Fix 選項),v3 = build 121。
重點
  1. 打包就是照封存檔格式寫一個只有 lua 資源的 patch 檔,名稱用 murmur64。
  2. manifest 的 GUID 不變、Name 只用安全字元,選項各自一個 patch 檔。
  3. 管理器會重排編號,任何直接部署都要先依內容找到自己的 slot。
自我檢查:如果每次發佈都產生一個新的 GUID,會發生什麼事?

模組管理器會把每一版當成不同的模組。玩家的清單裡會同時出現好幾個 Hive Lord HUD,舊版不會被取代,還可能兩個一起載入。

第 15 章 · 限制

多人連線與遊戲更新:模組的邊界

這一章要回答模組讀到的東西一定是真的嗎?今天能用的模組,明天還能用嗎?
新概念
主機、用戶端
開房的人;加入的人。
權威
誰說了算。很多事由主機決定,用戶端只收到結果。
build
遊戲的版本號。

用戶端看到的不一定是全貌

多人遊戲裡,主機決定很多事(敵人 AI、部位是否被打壞),用戶端只收到結果。HD2 的每台電腦會自己模擬看得到的子彈和傷害,主機只送「狀態改變」「死亡」這類結果。所以用戶端記憶體裡的數字,可能跟主機不一樣,而模組只讀得到自己這台的。

作者永遠以用戶端身分加入別人的遊戲,所以 HUD 必須誠實面對一件事:用戶端上的區域 HP 不一定是真的。從 30 多份遊戲 log 和 game.dll 的網路程式碼分析得到:

情況用戶端收到什麼HUD 怎麼顯示
從開場就在每台機器各自模擬傷害;主血大致準確,結束時主機的死亡事件把主血歸零直接顯示
中途加入主血是一個快照(可能過時 0–51k);已裂開的板子收到精確的 -1;只受過部分傷害的板子從滿血開始算板子和主血前面加「<」(最多剩這麼多);收到 -1 的板子顯示 <70%
主機修正狀態區域狀態(裂開 / 摧毀)只由主機決定;事件到達時 HP 被強制設為 min(HP, -1) 或 min(HP, -35000)第一次發現主機修正「我們落後」後,所有板子 % 加「<」

判斷某次狀態變化是誰造成的(H.state_diag):同一幀 HP 剛好落在 -1 或 -構造值、而主血沒有下降 → 主機事件;HP 是被傷害打過門檻、主血同時下降 → 本機命中。一場用戶端戰鬥的 log 裡,14 次狀態變化全部來自主機,而我們的 HP 每次都落後、從沒超前,所以「<」代表的是上限,不是估計。

曾經研究過讓主機廣播真實血量(rpc_heal / rpc_synced_health_request),結論是路徑不通(Hive Lord 不在同步系統 0x90 裡),而且送出網路請求會影響別人的遊戲,安全上也不做。

這也決定了用戶端模組做不到的事:例如「讓 Hive Lord 不要跑太遠」,AI 在主機上跑,用戶端的模組改不了。

遊戲更新會改掉一切位址

遊戲更新時 game.dll 會重新編譯:函式和全域變數的位置(RVA)、結構的位移、資料表的大小都可能改變。本文所有的位址都只對 build 25480438 有效。所以模組要設計成「對不上就安全停手」:

  • 每次讀都驗證指紋,不對就不用(第 11 章)。
  • 主要的路失效時有退路(HUD 退回堆積搜尋;Plate Fix 直接停止動作)。
  • log 寫清楚「為什麼停手」,玩家回報時才知道是更新造成的。
  • Loader 本身也綁定遊戲版本,大更新後要等 Loader 先更新。
Hive Lord HUD 此時為了誠實面對多人的限制,加了「<」標記:中途加入、或發現主機修正過我們的數字之後,板子的 % 和主血前面會加「<」,代表「最多剩這麼多」。也研究過能不能讓主機送出真實血量,結論是路不通,而且送網路請求會影響別人的遊戲,所以不做。
在原始碼裡
  • hive_lord_hud.lua 第 880–944 行 誰改了這個部位?:逐幀比較部位狀態。HP 剛好落在 -1 或 -構造值、主血卻沒掉,代表是主機的事件;從第一次主機修正起,板子的 % 都加上「<」。
    展開這段程式碼
    -- =========================================================================
    -- Zone tracking
    -- =========================================================================
    -- Per-frame zone bookkeeping for H.state_diag (last zone HP, last main HP), plus one log line for a big one-frame main
    -- HP drop or the drop to 0 at death (the host's death event snaps a client's main HP to 0). Research logging (per-
    -- second zone changes, raw record dumps, late-join hit logs) lives in the optional diag module (hive_lord_diag.lua).
    function H.track_zones(L, hp)
        local t = now_s()
        local d = L.diag
        local id = L.adopted.ent or L.adopted.z     -- the direct lookup's address moves with the array; the entity doesn't
        if not d or d.id ~= id then
            d = {id = id, last = {}}
            L.diag = d
            for slot = 0, 37 do d.last[slot] = hp.zones[slot] end
            return
        end
        H.state_diag(d, hp, t)
        if hp.main and d.last_main and (d.last_main - hp.main >= 8000 or (hp.main == 0 and d.last_main > 0)) then
            writer(string.format('MAIN JUMP t=%.1f: %d -> %d in one frame (drop %d)', t, d.last_main, hp.main, d.last_main - hp.main))
        end
        d.last_main = hp.main
        for slot = 0, 37 do
            local v = hp.zones[slot]
            if v ~= nil then d.last[slot] = v end
        end
    end
    
    -- Who changed a zone's state? Our machine only resolves the hits it simulates; the host sends zone-state
    -- events, which the state machine applies with HP = min(HP, -1) (cracked) or min(HP, -constitution) (dead).
    -- So: a state change that lands HP on exactly -1 / -constitution in a frame where our main HP did not drop came from
    -- …(共 65 行,完整內容見原始碼頁)
重點
  1. 用戶端模組只看得到自己這台電腦的資料,主機決定的事改不了。
  2. 顯示的數字要誠實標出不確定性。
  3. 遊戲更新會讓位址失效,模組要「對不上就安全停手」。
自我檢查:為什麼遊戲更新後,HUD 會變慢但還能用,Plate Fix 卻直接不動作?

HUD 只讀資料,全域變數失效時可以退回比較慢、但不依賴位址的堆積搜尋。Plate Fix 要寫入遮罩,寫錯地方的後果嚴重,所以只要位置對不上就完全不寫,寧可不修也不冒險。

第 16 章 · 限制

反作弊:模組在什麼範圍裡運作

這一章要回答模組又讀又寫遊戲記憶體,反作弊為什麼不管?界線在哪裡?
新概念
反作弊(GameGuard)
監視有沒有外部程式動遊戲的程式。
程式碼雜湊檢查
定期檢查程式碼有沒有被改。
注入
把自己的程式碼塞進別的程序裡執行。

GameGuard 會做什麼

GameGuard(nProtect)是 Helldivers 2 的反作弊,由一個核心驅動程式和跟遊戲一起啟動的監控程序(GameMon)組成。它的內部細節沒有公開,以下是從它的行為和我們實際遇到的狀況整理的:

它做的事我們實際看到的
掃描電腦上的程式,比對已知作弊工具的特徵電腦上有被判定為作弊工具的程式時,遊戲會跳出「Suspicious program detected」並關閉。號稱能繞過反作弊的工具尤其危險,很多本身就是木馬。
保護遊戲程序:擋外部程式開遊戲的 handle 去讀寫記憶體或注入(把自己的程式碼塞進遊戲程序裡執行),偵測除錯器遊戲崩潰後程序殺不掉(Task Manager 結束「成功」但程序還在),只能重開機。
檢查程式碼完整性:定期對程式碼區段算雜湊所以我們從不修改 code bytes、不 hook 函式。
每次啟動自我更新,log 加密且每次啟動輪替log 無法自己解讀,有問題只能交給 Arrowhead 客服。

另外,game.dll 本身用 Themida 加殼,這是另一套保護,不屬於 GameGuard。硬碟上的 game.dll 是加密的,只有載入記憶體後才是解開的程式碼。

  • 加殼(Themida):把程式碼加密,執行時才在記憶體裡解開。所以硬碟上的 game.dll 讀不出東西,要從執行中的記憶體 dump 出來才能分析。
  • 殭屍程序:遊戲崩潰後,程序仍受反作弊保護,工作管理員結束不掉,只能重開機。開發時遇到好幾次。
  • 程式碼雜湊檢查:定期對程式碼區段算雜湊(第 3 小節),只要有 1 個 byte 被改就對不上。這也是為什麼模組從不修改程式碼。

為什麼 Plate Fix 讀寫記憶體,GameGuard 卻不管

因為 GameGuard 主要防的是「外面的程式動遊戲」,而 Plate Fix 是遊戲自己在動自己的記憶體。從作業系統的角度看,這是兩件完全不同的事。

1. 不跨程序

外掛通常是另一個 exe:OpenProcess 拿遊戲的 handle,再讀寫記憶體。GameGuard 的驅動就在這一步攔截。Plate Fix 是遊戲內建的 LuaJIT 在執行,用的 WriteProcessMemory(GetCurrentProcess(), …) 是「自己」的虛擬 handle(-1),不需要開任何外部 handle,根本不經過被攔的那一步。對系統來說,這跟遊戲自己執行 *p = x 一樣,只是多了「位址錯了回傳失敗、不會當掉」的保護。

2. 寫的是資料,不是程式碼

程式碼不會變,所以能算雜湊比對;HP、遮罩、座標這些資料遊戲自己每秒要寫上百萬次,沒辦法檢查有沒有被改。Plate Fix 只寫遮罩 entry 裡的 1 個 byte,而且寫的值跟 gore 本來就會寫的一模一樣,是遊戲正常運作時本來就會出現的狀態。

3. 它掃的是作弊程式,不是遊戲資源

GameGuard 比對執行中的程式和已知的外掛特徵,不會逐一檢查遊戲載入的 Lua 資源在做什麼。整個 Bingus 模組圈都這樣運作,例如 Megapack 的 Enemy Collision Synchronized 直接用 FFI 呼叫 game.dll 內部函式,第一人稱模組會配置可執行記憶體、替換 Camera API 的函式指標,都比 Plate Fix 侵入得多,也一樣沒被擋。

4. 伺服器端沒有東西可以發現

Plate Fix 不改傷害、碰撞、網路封包,只改你自己畫面上的一個模型遮罩。主機和其他玩家收不到任何差異。

為什麼插進去的資料和 Lua 不會被抓

一句話:GameGuard 這類反作弊找的是外來的程式碼和外來的存取(注入的 DLL、外部程式開的 handle、被改過的程式碼、已知的外掛程式)。模組加進去的是資料,然後由遊戲自己本來就有的程式碼去讀、去執行。從它的角度看,跟遊戲正常運作分不出來。逐層來看:

我們加了什麼誰去讀 / 執行它為什麼不會被當成作弊
patch 檔
data/9ba626afa44a3aa3.patch_N
遊戲自己的資源系統(Stingray 引擎)用 patch 檔蓋過原版資源是引擎本來就有的機制。遊戲執行時不驗證資源檔簽章(否則所有模組都載不進去),只有 Steam「驗證檔案完整性」時才會比對,而那會直接把檔案換回原版。反作弊不檢查遊戲資源檔的內容。
Lua 原始碼(純文字)遊戲自帶的 LuaJIT(bin/lua51.dll,2.1 版)沒有載入任何新的 DLL、沒有建立外部執行緒、沒有改任何一個 byte 的程式碼。對作業系統來說,只是遊戲自己的直譯器在處理一段文字。
JIT 產生的機器碼LuaJIT 把常跑的 Lua 編譯成機器碼,放在它自己配置的可執行記憶體這是遊戲跑任何 Lua 時本來就會做的事(Loader v18 調大的就是這塊快取)。反作弊如果禁止「程序裡出現新的可執行記憶體」,會連遊戲自己的 Lua 一起擋掉。
FFI 呼叫 Windows API
ReadProcessMemory 等
遊戲程序自己呼叫 kernel32呼叫者就是遊戲程序本身,用的是自己的虛擬 handle(-1),沒有任何「外部程式碰遊戲」的動作。
寫入的那 1 個 byte(Plate Fix)遊戲的遮罩更新函式下一幀讀到它寫在資料區,值跟 gore 本來會寫的一樣;程式碼雜湊檢查只看程式碼區段。

對照一下真正的外掛通常怎麼做:另一個 exe 開遊戲的 handle(被驅動攔截)、把 DLL 注入遊戲(多出一個不認識的模組)、修改程式碼讓子彈無限(程式碼雜湊對不上)。這三件事模組都沒有做。

這也說明了為什麼 GameGuard 擋得住外部的 Cheat Engine,卻擋不住 Recon:Recon 讀記憶體用的跟 Plate Fix 是同一條路。所以界線不在技術,而在做什麼:我們的模組只讀資料、只改自己畫面上的東西,不影響傷害、碰撞、網路,也不碰其他玩家。

「沒被擋」不等於「被允許」。這仍然是非官方修改,GameGuard 隨時會自動更新、加入新的檢查。所以設計上堅持:不碰程式碼、不送封包、只寫跟遊戲原本行為一模一樣的值。萬一規則改變,風險和影響範圍也盡量小。

不碰程式碼

不修改任何 code bytes(不會觸發 GameGuard 的程式碼雜湊檢查),不 hook 函式,不送網路封包。只呼叫引擎開放給 Lua 的 API 和 Windows 的記憶體讀寫 API。

出錯就退出

整個每幀邏輯包在 pcall 裡:第一次 Lua 錯誤就永久解除(拆掉 Gui、寫 log),原本的 update() 照常執行,遊戲不受影響。

記憶體存取

只用 Read/WriteProcessMemory,位址錯了只會回傳失敗。每次讀都重新驗證指紋;任何一項不符就跳過這一步並記錄原因。

引擎呼叫守則

  • 每次呼叫前確認 world 還活著(world_is_live)
  • 對任何單位呼叫前先 Unit.alive(u)
  • Hive Lord 死後完全不碰屍體
  • 完全不呼叫 stingray.Network.*(會讓引擎直接終止)
Hive Lord HUD 此時的設計原則就是這一章:HUD 只讀資料;Plate Fix 只寫一個「這塊板子要不要畫」的 bit,而且是遊戲自己本來就會寫的值;兩者都不改程式碼、不送網路封包、不影響其他玩家。
在原始碼裡
  • plate_fix.lua 第 88–96 行 唯一的寫入:1 個 byte:先讀出那個 byte、OR 進一個 bit、只寫回 1 byte,把跟遊戲同時寫入的時間窗縮到最小。寫的值跟遊戲自己的 gore 會寫的一樣。
    展開這段程式碼
    -- OR one bit into the byte that holds it: a single-byte write keeps the window against the game's own writes tiny.
    local function set_bit(addr, bit)
        local a = addr + math.floor(bit / 8)
        if safe_read(a, 1, WB) < 1 then return false end
        local v = bitlib.bor(WB[0], bitlib.lshift(1, bit % 8))
        if v == WB[0] then return true end
        WB[0] = v
        return k32.WriteProcessMemory(SELF, ffi.cast('void*', a), WB, 1, NIO) ~= 0
    end
  • plate_fix.lua 第 225–266 行 什麼時候才寫:健全性檢查(遮罩 32、33 必須是隱藏)不通過就什麼都不寫;板子摧毀 1.5 秒、而且 base 和 applied 兩個遮罩都還顯示,才寫隱藏位元;最多 3 次,每次都寫 log。
    展開這段程式碼
    function M.check(L, t)
        local rec = health_index(L.ent)
        if not rec or not read_hive_record(rec) then return false end         -- gone (despawned)
        if t >= (L.next_vis or 0) or not L.vis then L.vis, L.next_vis = vis_entry(L.ent), t + 1 end
        local v = L.vis
        local lo, hi, alo, ahi = nil, nil, nil, nil
        if v then lo, hi = mask(v.rendered); alo, ahi = mask(v.applied) end
        -- Same sanity check as the HUD: the two unwired masks (32, 33) are always hidden on a Hive Lord. Anything else
        -- means the entry now belongs to another unit (the table was compacted): look it up again, write nothing.
        local sane = hi and bitlib.band(hi, 3) == 3
        if v and not sane then L.vis = nil end
        for slot, pm in pairs(PLATE_MASK) do
            local hp = i32(Z_OFF + slot * 4)
            if hp <= -PLATE_CON and not L.dead[slot] then L.dead[slot] = t end
            local died, f = L.dead[slot], L.fix[slot]
            if M.enabled and sane and died and not (f and f.gave_up) then
                local applied_hidden = alo and mbit(alo, ahi, pm)
                local shown = not mbit(lo, hi, pm) and not applied_hidden
                if shown and t - died >= FIX_DELAY then
                    f = f or {n = 0}
                    L.fix[slot] = f
                    if t >= (f.next or 0) then
                        if f.n >= FIX_TRIES then
                            f.gave_up = true
                            writer(string.format('entity 0x%X plate slot %d: still drawn after %d tries - giving up (%s)',
                                L.ent, slot, f.n, M.masks(v)))
                        else
                            f.n, f.next, f.at = f.n + 1, t + RETRY_S, t
                            local ok = set_bit(v.override, pm) and set_bit(v.touched, pm)
                            writer(string.format('entity 0x%X plate slot %d: dead %.1f s but still drawn -> hid model mask %d (%s), try %d (%s)',
    -- …(共 42 行,完整內容見原始碼頁)
重點
  1. 反作弊找的是「外來的程式碼和外來的存取」,模組是由遊戲自己執行的資料。
  2. 沒被擋不等於被允許,界線在於做什麼:只改自己的畫面,不影響別人。
  3. 模組的能力等於一般程式,只裝可信、附原始碼的模組。
自我檢查:用同樣的方法做一個「自動瞄準」,技術上做得到嗎?該做嗎?

技術上這扇門確實開著(第 1、2、5 章)。但它給自己不公平的優勢、破壞別人的遊戲,也很容易被封號。這份文件介紹的原理,界線一直是「只影響你自己的畫面和電腦」。

參考開發時間線、方法、失敗與教訓、檔案、用語
參考

開發歷程

從「應該可以做一個 Hive Lord 血條,顯示主血和每塊裝甲的 HP」這一句話開始,5 天、121 個內部版本、3 次公開發行。每個版本都由作者在實際遊戲中測試,log 就是遙測資料。

10/02 之前:打地基
前置開始之前的準備:做 HUD 之前,作者已經準備好幾樣東西:用 LuaJIT FFI 呼叫 Windows API 的方式、一個只讀的記憶體偵察模組 Recon(第 9 章:AOB 掃描、dump、PE 區段)、FileDiver 工具鏈(解遊戲資料表)、Ghidra 與 capstone 反組譯環境,也研究過 HUD+ 這個社群 HUD 模組的 Gui 用法。
10/03:從無到有
離線先離線把資料解完:寫了 Go 工具 hive-lord-health-dumper,從遊戲的 datalib 解出 Hive Lord 的 HealthComponent:38 區、主血 150,000、每區 HP / 構造值 / 裝甲。發現 FileDiver 內建的結構定義已過時(實際大小 22,096 bytes),以及 DLInstance header 是 28 bytes 不是 24。
v0第一次崩潰:每個模擬 tick 都呼叫 Gui.rect,遊戲 1 分鐘和 3.5 分鐘後兩次硬當。原因:Gui 是保留模式,v0 每秒洩漏約 2,400 個圖元,而且向量參數格式猜錯。改照 HUD+ 的生命週期重寫。
v7–v10在記憶體裡找到 HP:單位是 handle 不能走指標,只好分時掃描堆積,找 [150000, 150000, 15000×6] 這種序列。v8 從實際 dump 確認紀錄格式(主血在 Z−0xE4、裝甲在 Z−0x9C、38 區 HP 在 Z)。v9 失敗後,v10 改用「戰鬥中永遠不變的裝甲值」當指紋,這個方法一直用到最後。
v11–v15身體畫反了:用 FileDiver 匯出模型骨架確認 spine0 在尾端,重畫剪影;主血改成直接讀欄位(用區域傷害推估的值永遠高 20k)。
v20–v30介面成形:Souls 風格 Boss 血條、受擊閃白、F7/F8 快捷鍵、剪影面板、淡入淡出、連擊傷害數字。v27 嘗試用遊戲原生字型,第一次繪製就讓遊戲當掉兩次,留下殭屍程序需要重開機,從此停用。
v40–v55中途加入的真相:多次中途加入測試後確認:主血是快照、已裂開的板子收到精確的 −1、部分傷害完全不同步,之後的命中是逐次傳送而不是絕對值。因此加入「<」標記。
v56–v62出土警告:解出 animation_get_state 14 格的意義。「出土前」訊號試了三種(頭部上升、第 4 格 = 3、停止移動),前兩種在 26 次出土的 log 裡命中率太低被淘汰,最後用「在地底停住不動」。
10/04:穩定與發行
v64修掉戰鬥後的崩潰:兩次崩潰都剛好在 Hive Lord 死後約 60 秒,原因是屍體被移除時我們還在對它呼叫引擎 API。改成死後完全不碰。
v65–v69HL 標記:Camera.world_to_screen 投影、畫面外箭頭;修正挑錯攝影機、自動校正 y 方向誤判;面板移到左邊避開任務清單。
v70Release v1:獨立打包成 Arsenal 模組(之前寄生在 Recon 偵察工具裡)。發現 Arsenal 每次部署會重排 patch 編號,固定寫入 patch_1 會蓋掉別人的模組,於是寫了只找自己 slot 的部署腳本。
v72–v80全部改成選單:接上 Mod Options Menu、移除所有快捷鍵、每個部件可選顯示時機、尺寸滑桿、預覽按鈕和倒數橫幅。
10/05:板子為什麼不掉
逆向板子破壞機制:從 game.dll 找出區域狀態處理器 0x925F10 和 gore 路徑 0x9057D0 / 0x903C70,確認「碰撞」和「模型」是兩條獨立路徑;再分析網路 RPC(0xBDE430,1,253 個呼叫點),確認每台機器自己算傷害,主機只送結果。
v83直接查遊戲的健康系統:一個「板子炸了面板卻顯示 20 幾 %」的回報,查出是堆積掃描跟上了一份凍結的舊副本。改為從 game.dll+0x3326688 直接查表,從此不再搜尋、不再卡住,CPU 成本也大降。
v86第一版 Plate Fix:在 HUD 裡加上「摧毀 1.5 秒仍在畫就寫隱藏位元」。
v87Release v2:Plate Fix 拆成獨立 addon,在 Arsenal 裡是可單獨勾選的選項,不裝 HUD 也能用。
10/06:設計與驗證
v88–v99視覺迭代:裝甲 % 標在模型上(後來移除)、單色模式、low-poly 畫風(每個零件只用 3–5 個面)、正面視角的頭。
v100–v103遊戲風格:彩色模式的 Elden Ring 血條、單色模式仿 HD2 彈藥條、仿「Bug Breach Detected」的警告橫幅、仿小隊標記的紅色 HL 標記。
v105–v106顏色校正與真三角形:從截圖量到 Gui 顏色被放大約 3 倍,加上反向換算。反組譯 helldivers2.exe 確認 Gui.triangle 的參數格式後,low-poly 面改用真正的三角形。
PF v3Plate Fix 在遊戲中驗證成功:第一次真的遇到卡住的板子時,v2 寫入了但判斷錯誤(只看 base 遮罩)。逆向遮罩更新函式後改成同時看 base 和 applied,v3 在下一場兩塊板子上都成功。
v107–v109誠實的數字:hostdiag 判斷每次狀態變化來自主機還是本機;主機修正過一次後,所有板子 % 改為上限「<」。單色成為預設。
10/07:收尾
v110–v115光影實驗與定案:做了 3D 細分光照(v110–v113),作者選擇退回 v109;之後定案為「焦痕」效果:保留 low-poly,每個三角形依固定權重不均勻變黑。
v116功能凍結 + 重構:作者說「感覺沒有東西要修改了」。HUD 從 3,568 行精簡到約 2,600 行;研究用的診斷移到另一個開發用 addon;註解清掉版本歷史和 log 名稱。
v121Release v3:粗體文字修正(v117–v120)、移除探測 log、新圖示(從測試環境直接渲染目前的面板)。
參考

開發方法與工具鏈

原則是「離線優先」:能在電腦上算完、測完的事,就不讓作者進遊戲測。遊戲時間只用來回答離線回答不了的問題。

1. 從遊戲資料離線解答

  • FileDiver(Go):解遊戲封存檔、datalib、typelib;另外寫了 hive-lord-health-dumper、entity-probe、sm-state-dumper 等小工具。
  • 模型匯出成 glb 看骨架與碰撞節點名稱。
  • 雜湊暴力破解(murmur64(name) >> 32)還原區域名稱。

2. 靜態逆向

  • 遊戲跑起來後用 Recon addon 把已解殼的 game.dll / helldivers2.exe dump 出來。
  • 用 Python + capstone 秒級掃描特定偏移量的存取,找出函式;Ghidra 輔助。
  • 所有發現都寫進研究筆記。

3. 離線測試

  • 用 lupa(Python 內嵌 Lua)載入真正的模組原始碼,搭配假的引擎 API(World、Unit、Gui、Camera…)與假的遊戲記憶體結構。
  • 變異測試:故意把程式改壞(例如拿掉健全性檢查),確認測試會失敗。大多數版本都記錄「殺掉 N 個變異」。
  • 模擬「對已銷毀單位呼叫就崩潰」的 stub,驗證死後 70 秒內 0 次引擎呼叫。

4. 看得到的設計審查

  • 把測試中記錄下的 Gui 呼叫渲染成 PNG(PIL),在進遊戲前就能看到版面。
  • 模擬遊戲的顏色放大,預覽實際效果。
  • 展示圖和模組圖示也由同一套渲染器產生。

5. 遊戲中驗證

  • 作者正常遊玩(永遠是用戶端),HUD 的 log 記錄所需資訊:hostdiag、MAIN JUMP、[PlateFix]、每 30 秒的更新率與每幀成本。
  • 每個實驗性 API 在第一次呼叫前先寫一行 log:如果遊戲當掉,最後一行就是元兇。

6. 建置與部署

  • 打包腳本產生 Arsenal 可匯入的 zip(固定 GUID,選項各自一個 patch)。
  • 部署腳本只覆寫含有我們 addon 標記的 slot,遊戲執行中會拒絕部署。
參考

試過但放棄的

做法結果原因
遊戲原生字型停用第一次繪製就讓遊戲當掉,兩次都留下殭屍程序
堆積掃描找 HP降為備援會跟上凍結的舊副本;改查健康系統
用區域傷害推估主血移除漏算爆炸和預設區傷害,永遠高約 20k
頭部上升 / 第 4 格動畫值預測出土移除26 次出土中 19 次沒有預兆;第 4 格其實是受擊反應
讓主機廣播真實血量(rpc_heal 等)放棄Hive Lord 不在同步系統裡;且會影響他人遊戲
把 Hive Lord 拴在 100 m 內不可能敵人 AI 在主機上跑,行為樹是 C++
裝甲 % 標在模型上v116 移除作者決定;碰撞節點不會動,要改用動畫骨頭
F6–F11 快捷鍵v76 移除全部改到選單
單色「挖空」板子、3D 細分光照退回作者偏好;最後定案為焦痕效果
每個字都疊加粗體副本(位移 3 px)改寫字會散開;改成 1 px 階梯疊加、整數像素
參考

踩過的坑

現象原因對策
開遊戲 1–3 分鐘後當掉把保留模式 Gui 當立即模式用,圖元無限增加建立一次、之後只 update;world 死掉就遺忘
Hive Lord 死後約 60 秒當掉屍體被移除時還在呼叫 Unit.alive 等 API死後完全不碰,用位址跳過
距離永遠是 0 mUnit.world_position(u, 0) 對所有單位回傳原點改用具名骨頭(head、root)
文字一直空白同一幀建立又更新的文字不會顯示用真實內容建立,建立當幀不更新
板子上有髒點顏色 alpha 92%,地形透過來需要清楚的東西一律不透明
標記上下顛倒自動校正取樣時兩點都在畫面外固定方向,只在兩點都在畫面內時檢查
部署後遊戲載入舊版、別人的模組被蓋掉Arsenal 每次部署重排 patch 編號部署腳本依內容標記找 slot
重新上色後三角形全部塌成一條線Gui.triangle 會交換 y/z,Gui.update_triangle 不會v113 修正後連同整段實驗一起退回
主程式區塊無法再加 localLuaJIT 每個函式最多 200 個 local新功能包在 do … end 區塊或掛在 H 表上
「Armor panel」選單列永遠是灰的選單把 HUD 放在原生 DISPLAY 頁,無邊框視窗模式會把第 3 列(解析度)變灰不是 HUD 的 bug,不用修
參考

下載的 zip 裡有什麼

檔案內容
Source/mods/hivelordhud/hive_lord_hud.luaHUD 本體的完整原始碼(資源名 mods/hivelordhud/hive_lord_hud)。檔頭有各段落索引:Hive Lord data、Detection、Live HP locator、Zone tracking、GUI、Per-frame worker、Settings、Install,順序剛好對應第 12 章的表。
Source/mods/hivelordhud/plate_fix.luaDead Plate Fix 的完整原始碼(mods/hivelordhud/plate_fix),可單獨運作。
options/core/…patch_0、options/platefix/…patch_0兩個選項各自的 patch 檔,也就是遊戲實際載入的東西(第 14 章)。
manifest.json、icon.png模組管理器讀的描述檔與圖示。

開發時用的打包與部署腳本、離線測試、研究筆記和開發用的診斷模組沒有公開。這份文件提到它們,是為了說明背後的原理。

安裝

  1. 在 Arsenal 啟用 Bingus Shared Loader(選配:Vanilla Plus Megapack 的 Mod Options Menu)。
  2. 匯入 HiveLordHUDv3.zip,在 Options 勾選 Hive Lord HUD、Dead Plate Fix 或兩者,然後 Deploy。
  3. log 在 %LOCALAPPDATA%\CowboyBingus\Helldivers2\Logs\HiveLordHud_*.log,[PlateFix] 開頭的是修正紀錄。

打包格式(簡述)

HiveLordHUDv3.zip
├─ manifest.json        GUID 4ef072f2-…(固定,Arsenal 靠它辨識同一個模組的更新)
├─ icon.png
├─ options/core/9ba626afa44a3aa3.patch_0       ← HUD 的 lua 資源
├─ options/platefix/9ba626afa44a3aa3.patch_0   ← Plate Fix 的 lua 資源
└─ Source/…                                    ← 原始碼明文

每個 patch = Stingray 封存檔:header + type 表(lua)+ 檔案表,資源名稱用 murmur64 雜湊,
資料區 16 bytes 對齊;lua 資源 = (長度, 2 = 純文字原始碼) + 原始碼,第一行必須是 -- HD2-Addon: <資源名>

用語小辭典

用語意思在哪一章
機器碼 / 編譯CPU 能直接執行的 bytes / 把原始碼翻成機器碼第 1 章
.exe / .dll / 程序裝機器碼的檔案 / 執行中的程式第 1 章
引擎做遊戲的共用底層(HD2 是 Stingray)第 1 章
資源 / 封存檔 / patch 檔遊戲用的檔案 / 打包它們的大檔 / 同名資源會蓋過原版的封存檔第 1 章
雜湊 / murmur64把文字變成固定長度數字的公式 / 遊戲用的那一種第 1 章
幀 / 遊戲迴圈畫面更新一次 / 不停重複的更新與繪製第 2 章
Lua / LuaJIT腳本語言 / 它的高速版本,HD2 內建第 2 章
全域變數 / update()整個 Lua 環境共用的變數 / 引擎每幀呼叫的函式第 2 章
hook把一個呼叫導到自己身上,再轉回原本的地方第 2 章
回呼先交給別人、讓對方在某個時機呼叫的函式第 3 章
Loader / require找出並執行所有模組的模組 / Lua 載入另一個腳本第 3 章
模組管理器Arsenal、HD2MM:把模組寫成 patch 檔的工具第 3 章
log程式寫下發生了什麼的文字檔第 3 章
API開放給別人呼叫的函式第 4 章
world / unit / 骨頭場景 / 場景裡的物件 / 模型骨架的節點第 4 章
handle代表某個東西的號碼牌第 4 章
Gui / 保留模式引擎畫 2D 介面的功能 / 建立一次、之後修改第 4 章
pcall / assert攔住 Lua 錯誤的呼叫 / 引擎的自我檢查,失敗直接結束遊戲第 4 章
FFI從 Lua 呼叫 C 函式、操作記憶體的功能第 5 章
Windows APIWindows 開放給所有程式呼叫的函式第 5 章
byte / 十六進位 / int32記憶體最小單位 / 0x 開頭的寫法 / 4 bytes 的整數第 6 章
虛擬記憶體 / 分頁每個程序自己的位址空間 / 4 KB 的管理單位第 6 章
存取違規讀寫不存在或沒權限的位址,程式直接當掉第 6 章
程式碼區 / 資料區 / 堆積機器碼 / 固定位置的全域變數 / 執行中配置的資料第 6 章
ASLR / 基址 / RVAdll 位置每次不同 / 這次的位置 / 從 dll 開頭算起的位移第 6 章
指標存著另一個位址的值第 6 章
結構 / 位移 / 陣列 / 跨距一包資料 / 欄位在第幾個 byte / 一筆接一筆 / 每筆大小第 7 章
ECS(實體 / 元件 / 系統)編號 / 掛在編號上的資料 / 集中管理同一種資料的程式第 7 章
雜湊表用編號查到第幾筆的表第 7 章
設計數值 / 執行中數值 / 指紋資料檔裡的值 / 記憶體裡的值 / 用來確認找對東西的不變值第 7 章
名稱清單雜湊 ↔ 原名的對照表第 8 章
datalib / 型別庫設計數值表 / 描述結構欄位位移的檔案第 8 章
偵察模組只讀不寫、把觀察結果寫 log 的模組第 9 章
PE 格式 / 區段exe、dll 的檔案格式 / 它分成的區域第 9 章
dump把一段記憶體存成檔案第 9 章
特徵碼(AOB)一串用來搜尋的 bytes第 9 章
加殼(Themida)程式碼加密,執行時才解開第 10 章
反組譯 / 組合語言 / 暫存器把機器碼轉成文字指令 / 那種文字指令 / CPU 內的小格子第 10 章
交叉引用 / 靜態 / 動態分析找出誰用到某個位址 / 讀機器碼 / 執行中觀察第 10 章
ReadProcessMemory讀記憶體的 Windows API,位址錯了只回傳失敗第 11 章
舊副本遊戲不用、卻還留在記憶體裡的資料第 11 章
時間預算每幀允許模組花的時間第 11 章
單例守衛 / 解除武裝確保只載入一次 / 出錯後永久停止自己第 12 章
lupa / 假物件 / 變異測試在 Python 跑 Lua / 模仿遊戲 API 的假函式 / 故意改壞確認測試會失敗第 13 章
manifest / GUID / slot模組描述檔 / 模組永遠不變的身分證 / patch 檔的編號位置第 14 章
主機 / 用戶端 / 權威開房的人 / 加入的人 / 誰說了算第 15 章
build遊戲版本號第 15 章
反作弊 / GameGuard監視外部程式動遊戲的程式 / HD2 用的那一套第 16 章
程式碼雜湊檢查 / 注入檢查程式碼有沒有被改 / 把程式碼塞進別的程序執行第 16 章

文件日期 2026-10-07。所有位址對應遊戲 build 25480438;組合語言取自 10/02 的解殼 dump。