Helldivers 2 模組是怎麼運作的
一個不是開發商寫的程式,怎麼會在遊戲裡執行?它怎麼知道遊戲的資料放在哪裡?做模組用的工具又各自是什麼原理?這份文件用從零做出 Hive Lord HUD 的過程當主線,一章講一個原理。每章結尾會告訴你「這時候 HUD 做到哪了」。
地圖:一個模組從想法到你的畫面
先看全貌。一個模組的一生分成兩段:在你的電腦上把它做出來(製作期),以及在遊戲裡被執行(執行期)。圖上每一格標了講它的章節。
每一章的長相
- 這一章要回答:一個問題。整章都在回答它。
- 新概念:這一章第一次出現的詞,最多五、六個,用到之前先說明。
- 內文:盡量寫成「因為…所以…」,讓概念之間的關係看得見。
- Hive Lord HUD 此時:這個原理在做 HUD 時實際發生了什麼。
- 在原始碼裡:真正的模組裡實作這一章的那幾行,並連到有說明的原始碼頁。
- 重點與自我檢查:讀完先試著回答,再展開看答案。答不出來的,就是還沒建立起來的連結。
Source/ 資料夾就是完整原始碼)。這份文件只講模組的原理。遊戲本身是什麼
- 機器碼、編譯
- 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.dll | LuaJIT:執行 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 模組(換造型的、加功能的)都建立在這一點上。
- 遊戲是編譯過的機器碼,原始碼和名稱都不在我們手上。
- 資源用「型別 + 名稱」的雜湊來找,檔案裡看不到文字名稱。
- patch 檔裡同名的資源會蓋過原版,而且遊戲不檢查,這是模組能存在的根本原因。
自我檢查:為什麼把一個 .dds 貼圖檔直接丟進 data/ 不會有任何效果?
引擎不認得散落的檔案,它只從封存檔裡、用「型別雜湊 + 名稱雜湊」找資源。要讓遊戲用你的貼圖,得把它包進一個 patch 封存檔,並且用跟原版一模一樣的名稱雜湊,才能蓋過原版。
自我檢查:如果遊戲在讀 patch 檔時會檢查數位簽章,模組還能存在嗎?
不能(至少不能用這種方式)。簽章檢查會發現 patch 檔不是官方的而拒絕載入。HD2 沒有做這個檢查,模組才有一扇門。
遊戲怎麼每一幀執行腳本
- 幀、遊戲迴圈
- 遊戲不停重複「更新 → 畫面」,每一次叫一幀。
- 函式
- 一段有名字、可以被呼叫的程式。
- 腳本語言、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。
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
- 引擎內建 Lua,每一幀都會呼叫全域函式
update。 - 全域變數可以被改,所以換掉
update就能每幀執行自己的程式(hook)。 - 但要先有人執行你的程式一次,它才有機會去換
update。
自我檢查:如果 hook 時忘了呼叫 original(...),會發生什麼事?
引擎每幀呼叫的就只剩你的函式,遊戲自己 Lua 層該做的事全部不做了。輕則某些功能失效,重則遊戲壞掉。所以一定要呼叫原本的函式,並把它的回傳值傳回去。
自我檢查:HD2 的遊戲邏輯幾乎都在 C++ 裡,那 Lua 模組還能做什麼?
Lua 層雖然薄,但它在遊戲程序裡、每幀被呼叫,而且能用引擎開放的 API。再加上第 5 章的 FFI,Lua 可以呼叫 Windows 的函式、直接讀遊戲的記憶體,所以能做的事遠超過「遊戲本身用 Lua 做的事」。
誰來執行「別人寫的」Lua:Bingus Shared Loader
- 回呼(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 啟動後做的事
- 放大 LuaJIT 的 JIT 快取:所有模組共用遊戲的同一個 LuaJIT,預設快取太小,裝多了會一直被清空(flush)。v18 一開始就調到 16 MB / 8,000 段編譯好的程式(traces),被清空後再長到 64 MB / 16,000 段,並在 log 寫一行
LuaJIT cache:。 - 建立共用 log 資料夾
%LOCALAPPDATA%\CowboyBingus\Helldivers2\Logs,開啟BingusSharedLoader.log。 - 建立全域表
CowboyBingusModLoader:放版本、API 等級、模組狀態清單、open_log(檔名)(只接受^[%w_-]+%.log$這種檔名,避免模組寫到別的路徑)、JIT 快取統計(jit.flushes等)。 - 找到遊戲的 data 資料夾:用
GetModuleFileNameA取得執行檔路徑,確認結尾是/bin/helldivers2.exe(不是就停止),推出/data/。 - 掃描所有 patch 檔:
FindFirstFileA列出9ba626afa44a3aa3.patch_*,逐一打開,檢查封存檔魔數0xF0000011和表格,讀每個 lua 資源的開頭,用這個樣式比對第一行:
bytecode 裡還有一份 murmur64 實作(^%-%- HD2%-Addon: (mods/[A-Za-z0-9_]+/[A-Za-z0-9_/]+)hasher),看起來會核對宣告的名稱和資源的雜湊是否一致(推測,未在原始碼確認)。 - 載入:自動發現的 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 就沒有關係了。 |
| 寫 log | CowboyBingusModLoader.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 更新。
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
- Loader 用 patch 檔蓋掉一個「遊戲啟動一定會載入」的原版腳本,並保留它原本的功能。
- Loader 再掃描所有 patch 檔,用第一行
-- HD2-Addon:找出模組,逐一require。 - 共用一個入口,所有模組才不會互相蓋掉。
自我檢查:如果你把模組的 Lua 編譯成 bytecode 再打包,會發生什麼事?
Loader 認不出它:bytecode 會丟掉註解,第一行的 -- HD2-Addon: 就不見了。模組會被部署進遊戲,但永遠不會被執行,也不會出現在 Loader 的 log 裡。
自我檢查:模組「沒反應」時,第一個該看哪個檔案?為什麼?
BingusSharedLoader.log。它記錄了每個模組有沒有被找到、是 loaded 還是 load failed: 原因。如果你的模組根本不在清單上,問題出在打包或部署;如果是 load failed,問題出在程式本身。
模組在遊戲裡能做什麼:引擎 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 ofcontent/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 還活著沒、參數格式對不對。這些規則幾乎都是當掉之後才學到的(見下面)。
- 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
- 引擎 API 讓模組能看場景、找物件、畫畫面,但沒開放的事(例如讀血量)就做不到。
- Lua 錯誤可以攔,引擎內部的錯誤會直接結束遊戲,所以要「呼叫前先確認」。
- Gui 是保留模式:建立一次、之後修改、記得銷毀。
自我檢查:為什麼模組已經包了 pcall,HUD 還是會讓遊戲當掉?
當掉的原因在引擎內部(assert 失敗、存取已經不存在的物件),錯誤沒有回到 Lua,pcall 根本沒機會攔。唯一的辦法是不讓這種呼叫發生。
自我檢查:HUD 能用 World.units 找到 Hive Lord,為什麼還讀不到它的血量?
拿到的只是一個 handle,不是資料的位置;引擎也沒有開放讀血量的 API。血量存在 game.dll 管理的記憶體裡,要用第 5 章的方法自己去讀。
引擎沒開放的事:LuaJIT FFI
- 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 章「只裝可信模組」的原因。
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')
- FFI 讓 Lua 呼叫 Windows API、直接操作記憶體,是模組能做「引擎沒開放的事」的關鍵。
- 它沒有安全網,錯了遊戲直接當掉。
- 模組的能力因此等於一般程式,這同時是力量也是風險。
自我檢查:如果遊戲帶的 LuaJIT 把 FFI 關掉了,HUD 還做得出來嗎?
血條和面板做不出來:引擎沒有讀血量的 API,只能靠 FFI 讀記憶體。標記、出土警告這類只用引擎 API 的部分還可以。
記憶體:資料住在哪裡
- 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 上)。
這帶來三個對模組很重要的結果:
- 外部程式碰不到遊戲的記憶體:同一個位址在兩個程序裡是不同的 RAM。外部程式要讀遊戲,必須先向 Windows 申請遊戲的 handle(
OpenProcess),再請 Windows 代讀。反作弊就在這一步攔截(第 16 章)。但模組在遊戲程序裡面,用的是遊戲自己的位址,不需要經過這一步。 - 讀錯位址會當掉:如果某個位址沒有對應任何分頁,或分頁不允許讀,CPU 會觸發存取違規,程式立刻結束。這就是第 5 章說 FFI「沒有安全網」的原因。
- 程式碼不能直接改:程式碼所在的分頁標記為「可讀、可執行、不可寫」,要改得先呼叫
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 自動 |
| RAM | 16 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 讀回來,就會頓幀。
一個程序裡的三種區域
- 程式碼區:dll 的機器碼,執行中不變。
- 資料區:dll 裡固定位置的變數(全域變數),整場遊戲都在同一個地方。
- 堆積:遊戲執行中才配置的資料,例如每隻怪的血量。位置每場都不同,還可能被遊戲搬家。
還有兩件事讓「找到血量」變難:
- ASLR:Windows 每次啟動都把 dll 放在不同的位址(防止攻擊)。所以不能記「血量在 0x7FFF…」這種絕對位址,只能記「從 dll 開頭算起第幾個 byte」,叫 RVA。實際位址 = 這次的基址 + RVA。
- 指標:堆積上的資料位置不固定,但資料區裡的全域變數會存著它的位址。這種「存著位址的值」叫指標。從固定的全域變數出發,沿著指標一路找下去,就能找到每場位置都不同的資料。
- 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
- 數字在記憶體裡就是幾個 bytes,int32 是 4 bytes、低位在前。
- 每個程序有自己的虛擬記憶體;外部程式要讀得經過 Windows,模組在裡面則不用。
- 讀錯位址會當掉;dll 位置每次不同,所以記 RVA、從全域變數沿指標找。
自我檢查:今天 game.dll 載入在 0x7FFFB5F70000,一個全域變數的 RVA 是 0x3326688,它的實際位址是多少?明天呢?
今天是 0x7FFFB5F70000 + 0x3326688 = 0x7FFFB9296688。明天基址會不同,但 RVA 一樣是 0x3326688,用明天的基址加上去就好。這就是只記 RVA 的原因。
自我檢查:為什麼不能直接在程式裡寫死「血量在 0x29A60000A64」?
血量在堆積上,位置每場、甚至每次被遊戲搬家時都會變。寫死的位址下次就指到別的東西或根本不存在,讀了會讀到錯的資料,或觸發存取違規直接當掉。
遊戲資料的形狀:結構、系統、雜湊表
- 結構、位移
- 綁在一起的一包資料;欄位從開頭算起第幾個 byte。
- 陣列、跨距
- 同一種資料一筆接一筆;每筆的大小。
- ECS:實體、元件、系統
- 物件的編號;掛在編號上的資料;集中管理同一種資料的程式。
- 雜湊表
- 用編號快速查到「第幾筆」的表。
- 設計數值、執行中數值
- 寫在遊戲資料檔裡的值(最大血量);只在記憶體裡的值(目前血量)。
結構與位移
C++ 用 結構(struct)把相關的資料綁在一起。編譯後名字消失,只剩「這個欄位從開頭算起第幾個 byte」,叫位移。很多筆同樣的結構一筆接一筆排成陣列,每筆大小叫跨距,第 n 筆在「開頭 + n × 跨距」。
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)裡每一段是什麼:
點一個區塊。
遊戲怎麼管理怪物: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.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
- 編譯後只剩位移:「紀錄 +0x14 是主血」就是全部的資訊。
- 遊戲用系統集中管理同一種資料,用雜湊表從實體編號查到第幾筆。
- 不會變的設計數值可以當指紋,確認找到的資料是對的。
自我檢查:健康系統的紀錄陣列從 R 開始,每筆 0x1B8 bytes,Hive Lord 是第 6 筆,它的主血在哪個位址?
R + 6 × 0x1B8 + 0x14 = R + 0xA50 + 0x14 = R + 0xA64。
自我檢查:為什麼用「裝甲值」而不是「血量」當指紋?
血量在戰鬥中一直變,下一幀就對不上了;裝甲值從頭到尾不變,又是 39 個數字的獨特組合,不容易跟別的東西撞在一起。HUD 早期用「主血 = 150000」當錨點,結果 Hive Lord 一受傷就找不到了(v9 的失敗)。
離線讀遊戲檔案: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 資料。 |
- 我們加寫的
hive-lord-health-dumper解出 Hive Lord 的血量元件:主血 150,000、38 個部位、每個部位的 HP、構造值、裝甲值。裝甲值成了 HUD 和 Plate Fix 共用的指紋。 - FileDiver 內建的結構定義已經過時(血量元件實際是 22,096 bytes),改從型別庫讀目前的位移。
- 匯出模型骨架,發現原本以為 spine0 在頭那端,其實在尾端,面板的身體畫反了。
- 解出動畫狀態機,後來的出土警告就靠它的狀態名稱。
- 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}}}, }
- 封存檔有固定格式,名稱是雜湊,靠社群的名稱清單翻回來。
- 設計數值和結構定義可以離線拿到,型別庫比工具內建的結構更可靠。
- 離線能查的先查完,進遊戲的次數就少很多。
自我檢查:FileDiver 為什麼有些資源顯示成一串十六進位,而不是名稱?
檔案裡只存名稱的雜湊。名稱清單裡沒有那個雜湊對應的原名,工具就只能顯示雜湊本身。雜湊不能反推,只能靠猜名字、算雜湊、比對。
在遊戲裡觀察:Recon
- 偵察模組
- 只讀不寫、把觀察結果寫進 log 的模組。
- PE 格式、區段
- exe / dll 的檔案格式;它分成的幾個區域(程式碼、資料…)。
- dump
- 把一段記憶體原封不動存成檔案。
- 特徵碼(AOB)
- 一串用來搜尋的 bytes,可以含萬用字元。
這一章的主角 Recon,是我們為了做 HUD 自己寫的研究用模組,沒有公開發佈。重點不是這個工具本身,而是它的原理:任何人都可以照同樣的方法,寫一個自己的偵察模組。
Recon 的原理很簡單:把觀察工具本身做成模組。它跟 HUD 一樣由 Loader 載入、在遊戲程序裡執行(第 3 章),用 FFI 讀自己程序的記憶體(第 5、6 章),所以不會碰到反作弊攔截外部存取的那一關。
| FileDiver | Recon(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。
使用循環
- 把這次要查的指令寫進
%TEMP%\HD2Recon_cmd.lua(一般的 Lua,例如R.scan('48 8B ?? 20'))。 - 啟動遊戲。Recon 被 Loader 載入後,先自動把 game.dll 和 helldivers2.exe 的基址、大小、各區段寫進 log(
R.about)。 - 接著讀取指令檔,用
loadstring編譯、setfenv給它一個能呼叫R.*的環境,包在pcall裡執行。出錯只會寫一行run error,不影響遊戲。 - 關遊戲、讀 log,決定下一輪要查什麼。每次重開遊戲就會重跑一次指令檔。
主要功能
| 函式 | 做什麼 |
|---|---|
R.about / R.walk | 解析 PE 標頭:模組基址、每個區段的位置與權限。由此發現 game.dll 用 Themida 加殼:7 個沒有名字的區段,加上 .winlice(Themida 把部分程式碼轉成自己的虛擬指令來執行,放在這裡)和 .boot(啟動時把程式碼解密的部分)。 |
R.scan / R.find | AOB 特徵碼掃描(48 8B ?? 20,?? 是萬用字元),可選只掃可執行區段或全部(Themida 會把程式碼放在不尋常的區段)。 |
R.rip | 解 x64 的 RIP 相對位移,從一條指令算出它指向的全域變數或函式位址。 |
R.dump / R.save | hex 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 靜態分析出來的。
- v7:在堆積裡分時搜尋 [150000, 150000, 15000×6] 這種數字序列(前兩個部位、加上板子的設計 HP)。
- v8:找到後把附近的記憶體 dump 出來,對照 FileDiver 的設計數值,推出紀錄格式:主血在 −0xE4、裝甲在 −0x9C、部位 HP 從 0 開始(相對於部位陣列),結尾 0xFFFF。
- v9 失敗、v10 修正:用「主血 = 150000」當錨點,Hive Lord 一受傷就找不到;改用不會變的裝甲值當指紋。
- 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
- 在反作弊下觀察遊戲的辦法,是把觀察工具做成模組,在遊戲裡面讀。
- 用指令檔遙控要查什麼,結果寫 log,每次重開遊戲跑一輪。
- dump 出來的記憶體,加上 FileDiver 的設計數值,就能推出資料的格式。
自我檢查:Recon 和 Cheat Engine 都是讀遊戲記憶體,為什麼一個會被反作弊擋、一個不會?
Cheat Engine 是外部程式,要先跟 Windows 申請遊戲的 handle,反作弊的驅動就在這一步攔截。Recon 在遊戲程序裡面,讀的是自己的記憶體,不需要申請 handle。
讀懂機器碼:逆向工程與組合語言
- 加殼(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++ 的一行:
hp = std::min(hp, -1); // 血量最多 -1
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)。
找到固定起點的過程
四個步驟
- 先從遊戲資料知道要找什麼值。FileDiver 解出 Hive Lord 的 HealthComponent:主血 150,000、每塊板子 15,000、39 個固定的裝甲值(5, 6, 10, 5…)。這組數字就是在記憶體裡認出它的指紋。
- 在遊戲中搜尋,找到那筆紀錄。單位是 handle,不能順著指標走到元件,所以早期版本(v7–v10)分時掃描整個堆積,找這組數字。找到後把前後的 bytes dump 出來,對照戰鬥中哪些值在變,推出紀錄格式:主血 +0x14、裝甲 +0x5C、38 區 HP +0xF8、結尾標記 0xFFFF,一筆 0x1B8 bytes。
- 反過來從程式碼找出管理這些紀錄的全域變數。
- 用 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的程式碼推出來的。
- 實際使用與驗證。程式裡存的是相對位移(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,不會當掉
實際的組合語言:遊戲怎麼查血量
這是 game.dll 在呼叫傷害函式之前查健康系統的真實程式碼,以及依它還原的 C++(名稱是我們取的)。對照第 7 章的雜湊表說明一起看:
這段在呼叫傷害函式 0x9235F0 之前,先用 entity 編號在健康系統的雜湊表裡找出紀錄的 index。587 處程式碼都用同一個全域變數,這是其中一處。
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 ; 傷害函式// 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 + 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
- 加殼的程式要從記憶體 dump 出解開的版本才能分析。
- 組合語言裡的
[暫存器 + 位移]就是結構欄位,[rip + X]就是全域變數。 - 找出遊戲自己用來找資料的那個全域變數,模組就有了固定、可靠的起點。
自我檢查:為什麼要找「遊戲自己用的」全域變數,而不是繼續在堆積裡搜尋指紋?
遊戲自己用的那條路,永遠指向遊戲正在用的那一份資料。搜尋堆積可能找到已經被拋棄、卻還留在記憶體裡的舊副本(第 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.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 行,完整內容見原始碼頁)
- 給玩家用的模組一律用 ReadProcessMemory:位址錯了只失敗、不當掉。
- 釋放的記憶體還讀得到,搜尋可能找到凍結的舊副本;從遊戲自己的指標出發才可靠。
- 每次讀都驗證、每幀都有時間上限、失敗時有退路並寫 log。
自我檢查:舊副本「指紋完全正確」,那要怎麼防?
指紋只能證明「這是 Hive Lord 的紀錄」,不能證明「這是遊戲正在用的那一份」。所以不能靠搜尋,要從遊戲自己的全域變數和雜湊表找:遊戲當下用哪一份,那條路就指向哪一份。
模組程式的骨架
- 單例守衛
- 用一個獨特的全域變數,確保模組只被載入一次。
- 解除武裝(disarm)
- 出錯後永久停止自己,讓遊戲照常。
- 另一個模組的 API
- Mod Options Menu 這類模組開放給其他模組呼叫的函式。
Hive Lord HUD 的原始碼檔頭列出了它的段落,剛好就是前面每一章的原理按順序排好:
| 段落 | 做什麼 | 對應原理 |
|---|---|---|
| Hive Lord data | 38 個部位的設計數值 | 第 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 章 |
每個模組都該有的幾條規則
- 單例守衛:開頭先檢查一個獨特的全域變數,已經存在就不再執行。
- 環境檢查:需要的 API、Loader 不在,就寫 log 並停手,而不是硬跑。
- 包裝 update,出錯就解除武裝:每幀工作包在
pcall裡,第一次出錯就永久關閉自己、清掉畫面,並寫下原因。 - 一定呼叫原本的 update。
- 呼叫前先確認:world 還在、unit 還活著(引擎錯誤救不回來)。
- 時間預算與自我計時:量每幀花了多少,定期寫進 log。
- log 要限流:只寫狀態改變,不要每幀一行。
跟別的模組合作:設定選單
Mod Options Menu 也是一個透過 Loader 載入的模組,它在全域放了一個 ModOptionsMenu 表,其他模組可以呼叫 register_option 註冊設定、用 get 讀值。因為模組之間的載入順序不保證,所以 HUD 不在載入時找它,而是在 update 裡才找,找到了才註冊。沒有裝它,HUD 就用設定檔裡的值。
- 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
- 模組骨架:單例守衛 → 環境檢查 → 包裝 update(pcall + 解除武裝)→ 每幀工作。
- 每條規則都對應前面的一個原理,大多是當掉之後才補上的。
- 模組之間可以合作,但不能假設載入順序。
自我檢查:為什麼「出錯就永久關閉」比「出錯就下一幀再試」好?
同一個錯誤下一幀通常還會再發生。一直重試會每幀都出錯、把 log 灌爆,還可能在錯的狀態下繼續呼叫引擎 API 而讓遊戲當掉。永久關閉只損失這個模組的功能,遊戲安全,log 裡也留下第一次出錯的原因。
在電腦上假裝成遊戲:離線測試
- 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 的展示圖和模組圖示就是這樣產生的。
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
- 模組跟遊戲的互動只有三個管道,全部換成假的就能離線執行。
- 假物件要模仿遊戲的嚴格與壞習慣,誘餌要能騙過不小心的驗證。
- 變異測試確認「測試真的有在檢查」。
自我檢查:測試全部通過,進遊戲卻失敗,最可能是哪裡不一樣?
假物件跟真的遊戲不一樣:太寬鬆(真的引擎會當掉的呼叫,假的不會)、資料形狀不同(測試只造了一種記憶體布局),或用了不同版本的 Lua。修法是把那個差異補進假物件,再加一條對應的變異測試。
打包與部署:把程式變成 patch 檔
- 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 只在啟動時載入。
build_hive_lord.py:Release v1 = 內部 build 70,v2 = build 87(加入 Dead Plate Fix 選項),v3 = build 121。- 打包就是照封存檔格式寫一個只有 lua 資源的 patch 檔,名稱用 murmur64。
- manifest 的 GUID 不變、Name 只用安全字元,選項各自一個 patch 檔。
- 管理器會重排編號,任何直接部署都要先依內容找到自己的 slot。
自我檢查:如果每次發佈都產生一個新的 GUID,會發生什麼事?
模組管理器會把每一版當成不同的模組。玩家的清單裡會同時出現好幾個 Hive Lord HUD,舊版不會被取代,還可能兩個一起載入。
多人連線與遊戲更新:模組的邊界
- 主機、用戶端
- 開房的人;加入的人。
- 權威
- 誰說了算。很多事由主機決定,用戶端只收到結果。
- 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.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 行,完整內容見原始碼頁)
- 用戶端模組只看得到自己這台電腦的資料,主機決定的事改不了。
- 顯示的數字要誠實標出不確定性。
- 遊戲更新會讓位址失效,模組要「對不上就安全停手」。
自我檢查:為什麼遊戲更新後,HUD 會變慢但還能用,Plate Fix 卻直接不動作?
HUD 只讀資料,全域變數失效時可以退回比較慢、但不依賴位址的堆積搜尋。Plate Fix 要寫入遮罩,寫錯地方的後果嚴重,所以只要位置對不上就完全不寫,寧可不修也不冒險。
反作弊:模組在什麼範圍裡運作
- 反作弊(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 APIReadProcessMemory 等 | 遊戲程序自己呼叫 kernel32 | 呼叫者就是遊戲程序本身,用的是自己的虛擬 handle(-1),沒有任何「外部程式碰遊戲」的動作。 |
| 寫入的那 1 個 byte(Plate Fix) | 遊戲的遮罩更新函式下一幀讀到它 | 寫在資料區,值跟 gore 本來會寫的一樣;程式碼雜湊檢查只看程式碼區段。 |
對照一下真正的外掛通常怎麼做:另一個 exe 開遊戲的 handle(被驅動攔截)、把 DLL 注入遊戲(多出一個不認識的模組)、修改程式碼讓子彈無限(程式碼雜湊對不上)。這三件事模組都沒有做。
這也說明了為什麼 GameGuard 擋得住外部的 Cheat Engine,卻擋不住 Recon:Recon 讀記憶體用的跟 Plate Fix 是同一條路。所以界線不在技術,而在做什麼:我們的模組只讀資料、只改自己畫面上的東西,不影響傷害、碰撞、網路,也不碰其他玩家。
不碰程式碼
不修改任何 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.*(會讓引擎直接終止)
- 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、5 章)。但它給自己不公平的優勢、破壞別人的遊戲,也很容易被封號。這份文件介紹的原理,界線一直是「只影響你自己的畫面和電腦」。
開發歷程
從「應該可以做一個 Hive Lord 血條,顯示主血和每塊裝甲的 HP」這一句話開始,5 天、121 個內部版本、3 次公開發行。每個版本都由作者在實際遊戲中測試,log 就是遙測資料。
hive-lord-health-dumper,從遊戲的 datalib 解出 Hive Lord 的 HealthComponent:38 區、主血 150,000、每區 HP / 構造值 / 裝甲。發現 FileDiver 內建的結構定義已過時(實際大小 22,096 bytes),以及 DLInstance header 是 28 bytes 不是 24。Gui.rect,遊戲 1 分鐘和 3.5 分鐘後兩次硬當。原因:Gui 是保留模式,v0 每秒洩漏約 2,400 個圖元,而且向量參數格式猜錯。改照 HUD+ 的生命週期重寫。animation_get_state 14 格的意義。「出土前」訊號試了三種(頭部上升、第 4 格 = 3、停止移動),前兩種在 26 次出土的 log 裡命中率太低被淘汰,最後用「在地底停住不動」。Camera.world_to_screen 投影、畫面外箭頭;修正挑錯攝影機、自動校正 y 方向誤判;面板移到左邊避開任務清單。0x925F10 和 gore 路徑 0x9057D0 / 0x903C70,確認「碰撞」和「模型」是兩條獨立路徑;再分析網路 RPC(0xBDE430,1,253 個呼叫點),確認每台機器自己算傷害,主機只送結果。game.dll+0x3326688 直接查表,從此不再搜尋、不再卡住,CPU 成本也大降。Gui.triangle 的參數格式後,low-poly 面改用真正的三角形。hostdiag 判斷每次狀態變化來自主機還是本機;主機修正過一次後,所有板子 % 改為上限「<」。單色成為預設。開發方法與工具鏈
原則是「離線優先」:能在電腦上算完、測完的事,就不讓作者進遊戲測。遊戲時間只用來回答離線回答不了的問題。
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 m | Unit.world_position(u, 0) 對所有單位回傳原點 | 改用具名骨頭(head、root) |
| 文字一直空白 | 同一幀建立又更新的文字不會顯示 | 用真實內容建立,建立當幀不更新 |
| 板子上有髒點 | 顏色 alpha 92%,地形透過來 | 需要清楚的東西一律不透明 |
| 標記上下顛倒 | 自動校正取樣時兩點都在畫面外 | 固定方向,只在兩點都在畫面內時檢查 |
| 部署後遊戲載入舊版、別人的模組被蓋掉 | Arsenal 每次部署重排 patch 編號 | 部署腳本依內容標記找 slot |
| 重新上色後三角形全部塌成一條線 | Gui.triangle 會交換 y/z,Gui.update_triangle 不會 | v113 修正後連同整段實驗一起退回 |
| 主程式區塊無法再加 local | LuaJIT 每個函式最多 200 個 local | 新功能包在 do … end 區塊或掛在 H 表上 |
| 「Armor panel」選單列永遠是灰的 | 選單把 HUD 放在原生 DISPLAY 頁,無邊框視窗模式會把第 3 列(解析度)變灰 | 不是 HUD 的 bug,不用修 |
下載的 zip 裡有什麼
| 檔案 | 內容 |
|---|---|
Source/mods/hivelordhud/hive_lord_hud.lua | HUD 本體的完整原始碼(資源名 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.lua | Dead Plate Fix 的完整原始碼(mods/hivelordhud/plate_fix),可單獨運作。 |
options/core/…patch_0、options/platefix/…patch_0 | 兩個選項各自的 patch 檔,也就是遊戲實際載入的東西(第 14 章)。 |
manifest.json、icon.png | 模組管理器讀的描述檔與圖示。 |
開發時用的打包與部署腳本、離線測試、研究筆記和開發用的診斷模組沒有公開。這份文件提到它們,是為了說明背後的原理。
安裝
- 在 Arsenal 啟用 Bingus Shared Loader(選配:Vanilla Plus Megapack 的 Mod Options Menu)。
- 匯入
HiveLordHUDv3.zip,在 Options 勾選 Hive Lord HUD、Dead Plate Fix 或兩者,然後 Deploy。 - 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 API | Windows 開放給所有程式呼叫的函式 | 第 5 章 |
| byte / 十六進位 / int32 | 記憶體最小單位 / 0x 開頭的寫法 / 4 bytes 的整數 | 第 6 章 |
| 虛擬記憶體 / 分頁 | 每個程序自己的位址空間 / 4 KB 的管理單位 | 第 6 章 |
| 存取違規 | 讀寫不存在或沒權限的位址,程式直接當掉 | 第 6 章 |
| 程式碼區 / 資料區 / 堆積 | 機器碼 / 固定位置的全域變數 / 執行中配置的資料 | 第 6 章 |
| ASLR / 基址 / RVA | dll 位置每次不同 / 這次的位置 / 從 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。