:用Detours庫截獲EndScene實現(xiàn)游戲鉤子)
簡介面向游戲開發(fā)者的API鉤子技術(shù)資料包專注于截獲DirectX接口調(diào)用可用于性能分析、調(diào)試、作弊檢測及游戲模組制作等場景。壓縮包共12個文件以C源代碼和頭文件為主涵蓋鉤子函數(shù)聲明、工程配置、導(dǎo)入庫與預(yù)編譯頭文件整體僅71KB輕量且結(jié)構(gòu)清晰便于快速查閱。文件類型方面三個C源文件實現(xiàn)鉤子的設(shè)置與移除邏輯四個頭文件提供接口聲明工程文件與組件定義文件用于構(gòu)建和管理項目核心的Detours庫則簡化了底層掛鉤操作。技術(shù)實現(xiàn)上資源基于微軟Detours庫通過字節(jié)碼注入方式掛鉤DirectX函數(shù)并演示了從初始化、設(shè)置鉤子到退出解除鉤子的完整流程。讀者可參考ReplaceApi等代碼學(xué)習(xí)如何聲明鉤子函數(shù)、調(diào)用DetourAttach綁定原接口從而在游戲運行時動態(tài)改變渲染效果、捕獲輸入事件或記錄調(diào)用日志為后續(xù)二次開發(fā)打下基礎(chǔ)。該資料已有五百四十人學(xué)習(xí)適合具備基礎(chǔ)C知識、希望深入圖形底層機制的游戲開發(fā)者和模組作者參考能夠幫助快速理解攔截原理并搭建自己的擴展框架。1. APIHOOK鉤子截獲DirectX API游戲開發(fā)里的手術(shù)刀還是定時炸彈想在游戲運行時不改源碼就插入自己的代碼APIHOOK鉤子截獲DirectX API函數(shù)調(diào)用是繞不開的技術(shù)路線。游戲開發(fā)里主要用它干三件事性能分析、功能調(diào)試、做MOD或者反外掛檢測。你拿到的這個壓縮包是一個完整的C實現(xiàn)樣例——用微軟Detours庫掛住DirectX API里面有源碼、頭文件、Detours依賴庫和Visual Studio工程文件照著編一遍再看懂ReplaceApi和HookApi這對文件就能把它遷移到自己的游戲或工具里。它不是玩具demo而是接近實戰(zhàn)的一套鉤子框架。適合三類人想給老游戲加功能但沒源碼的開發(fā)者、研究游戲渲染和輸入管線的學(xué)習(xí)者、做引擎底層的工程師。2. 從壓縮包到能跑的工程文件清單、VS版本遷移與Detours庫匹配2.1 壓縮包里每一份文件是干什么的打開壓縮包第一眼看到的是一堆后綴各異的文件。先別急著編譯花兩分鐘把每份文件歸位后面能少踩一半的坑。這個包的結(jié)構(gòu)其實很經(jīng)典是VS6時代的標(biāo)準(zhǔn)插件工程布局放現(xiàn)在依然能看懂。文件類型作用StdAfx.h / StdAfx.cpp預(yù)編譯頭集中收錄公共頭文件和宏首次編譯后生成.pch加速后續(xù)編譯HookApi.dsw / HookApi.dspVS6工程文件dsw是工作區(qū)dsp是項目文件代表這份代碼的原始編譯環(huán)境HookApi.def模塊定義文件聲明DLL導(dǎo)出函數(shù)控制導(dǎo)出符號名和序號HookApi.h / HookApi.cpp鉤子實現(xiàn)安裝和移除鉤子的核心C源碼ReplaceApi.h / ReplaceApi.cpp替換實現(xiàn)定義替代函數(shù)體處理被攔截后要執(zhí)行的自定義邏輯detours.h / detours.libDetours庫微軟研究院提供的API掛鉤庫負責(zé)字節(jié)碼級的函數(shù)跳轉(zhuǎn)與恢復(fù)HookApi.bbs中間產(chǎn)物VS6生成的瀏覽信息文件編譯殘留對功能沒有影響這里面最值得先讀的是ReplaceApi.cpp和HookApi.cpp。HookApi.cpp負責(zé)“掛”ReplaceApi.cpp負責(zé)“替”兩者配合才是完整的一套鉤子。StdAfx.h里通常還有調(diào)試開關(guān)或者通用宏后面編譯報錯時經(jīng)常要先回來看它。2.2 老工程在新版Visual Studio里怎么打開原始工程是VS6時代的.dsw/.dsp格式。我一般不建議直接雙擊.dsw讓新版VS轉(zhuǎn)換因為轉(zhuǎn)換過程中經(jīng)常出幺蛾子——字符集設(shè)置、平臺工具集、依賴庫路徑全亂。更穩(wěn)的做法是新建一個工程把源碼文件手動拖進去配置路徑重來一遍。# 推薦操作順序Windows下在工程根目錄執(zhí)行 mkdir build cd build # 1. 打開VS開發(fā)者命令行確認cl.exe可用 # 2. 在VS里新建一個Win32項目或空項目 # 然后把以下文件加入工程 # StdAfx.h / StdAfx.cpp / HookApi.h / HookApi.cpp # ReplaceApi.h / ReplaceApi.cpp # 3. 把Detours的頭文件和庫拷貝到工程目錄方便引用 copy ..\detours.h . copy ..\detours.lib .這段腳本不是自動編譯命令而是操作清單。為什么新建項目而不是直接轉(zhuǎn)換因為.dsw轉(zhuǎn)換后配置項容易丟尤其“配置類型”和“附加依賴項”與其去猜不如重建。參數(shù)上需要注意工程類型選“動態(tài)鏈接庫(DLL)”因為要把鉤子注入游戲進程最后生成的一定是DLL而不是exe。字符集這塊是個經(jīng)典老坑。VS6時代默認多字節(jié)字符集新版VS默認Unicode。如果代碼里的char*去接API返回的寬字符串編譯報錯是必然的。我的習(xí)慣是屬性→常規(guī)→字符集改成“使用多字節(jié)字符集”讓新編譯器去適配老代碼而不是反過來改源碼。2.3 Detours庫版本匹配與依賴路徑detours.h和detours.lib必須匹配且必須與目標(biāo)程序位數(shù)一致。VS6的老工程默認是32位如果你的目標(biāo)游戲是32位——那個年代的PC游戲基本沒有64位——就用32位Detours如果要鉤64位程序就得切換平臺工具集到x64并換成64位的detours.lib。提示編譯報錯“無法解析的外部符號__imp_DetourAttach”十有八九是lib版本和工程平臺不匹配先查平臺再查路徑不要一上來就懷疑源碼。我見過最離譜的情況是把64位的detours.lib硬塞進32位工程鏈接器直接報一堆無法解析的外部符號。排查了半天才發(fā)現(xiàn)是庫版本問題。所以第一次編譯失敗時先看三件事平臺是不是x86detours.lib是不是對應(yīng)的x86版本路徑是否真的被加進了“附加庫目錄”。這三項檢查完絕大多數(shù)編譯問題都能定位。還有一個容易被忽略的點detours.h在某些老版本里依賴windows.h的特定宏如果項目設(shè)置了強制預(yù)編譯頭包含順序?qū)戝e也會編譯失敗標(biāo)準(zhǔn)順序是StdAfx.h在最前。3. 核心實現(xiàn)用DetourAttach替換DirectX API函數(shù)的完整套路3.1 DirectX掛鉤的特殊性函數(shù)藏在虛函數(shù)表里如果一個API是普通DLL導(dǎo)出函數(shù)比如MessageBoxADetours掛起來很簡單直接在導(dǎo)出地址上下鉤子就行。但DirectX完全不同——Direct3D9的接口是COM風(fēng)格真正的方法如EndScene、Present不是從DLL導(dǎo)出的而是存在接口對象的虛函數(shù)表vtable里。這意味著你要先拿到接口指針再從虛函數(shù)表里取出目標(biāo)槽位的函數(shù)地址最后才能把這個地址交給DetourAttach。這也解釋了為什么很多人用Detours鉤不了DirectX他們以為直接對d3d9.dll的導(dǎo)出表下鉤子就能掛上實際上d3d9.dll里導(dǎo)出的是Direct3DCreate9這種工廠函數(shù)而EndScene的實現(xiàn)在設(shè)備的虛表里。常見做法是先調(diào)用Direct3DCreate9創(chuàng)建一個設(shè)備用來獲取地址或者在游戲運行過程中從現(xiàn)有設(shè)備指針出發(fā)直接讀虛表。后一種更實用因為進游戲后設(shè)備早就建好了。3.2 定義原函數(shù)類型、替換函數(shù)與Trampoline指針下面這段是標(biāo)準(zhǔn)掛鉤骨架Windows下用C寫目標(biāo)用D3D9最常見的掛鉤點EndScene做例子// 以Direct3D9的EndScene為例這是幀渲染結(jié)束前的最后一站 // 聲明原函數(shù)指針類型調(diào)用約定必須和COM接口保持一致 typedef HRESULT (WINAPI* EndSceneFn)(LPDIRECT3DDEVICE9); // Trampoline指針保存被替換前的真實函數(shù)地址 EndSceneFn RealEndScene nullptr; // 替換函數(shù)游戲每幀渲染結(jié)束前都會走到這里 HRESULT WINAPI HookEndScene(LPDIRECT3DDEVICE9 pDevice) { // 先調(diào)用原始函數(shù)保證渲染流程不被破壞 HRESULT hr RealEndScene(pDevice); // 在這里插入自己的邏輯畫FPS、寫日志、改渲染參數(shù)…… // 注意此代碼在渲染線程執(zhí)行耗時操作要克制 return hr; }邏輯說明RealEndScene是一個函數(shù)指針變量保存的是原始EndScene入口地址。Detours庫會把原函數(shù)入口處的幾條指令改寫成跳轉(zhuǎn)指令跳到HookEndScene同時把被覆蓋的指令搬到一個私有區(qū)域也就是Trampoline。所以鉤子函數(shù)里必須先調(diào)RealEndScene再執(zhí)行自己的邏輯順序反了會讓整個渲染鏈路斷裂。參數(shù)說明WINAPI代表__stdcall調(diào)用約定LPDIRECT3DDEVICE9是D3D9設(shè)備接口指針。EndScene接收一個設(shè)備指針后續(xù)所有繪制調(diào)用都靠這個指針發(fā)起。3.3 安裝鉤子的Transaction從Begin到CommitDetours要求安裝和卸載都放在事務(wù)里不能簡單粗暴地直接附加。下面這段是完整安裝流程#include detours.h // 從設(shè)備虛函數(shù)表里取出EndScene的槽位地址 // 注意不同DirectX SDK版本里索引可能不同這里以常見版本為例 void* GetEndSceneAddress(LPDIRECT3DDEVICE9 pDevice) { // COM對象首地址指向虛表指針虛表指針再指向函數(shù)指針數(shù)組 void*** vtable reinterpret_castvoid***(pDevice); return vtable[42]; // EndScene在IDirect3DDevice9 vtable中的索引 } // 安裝鉤子 void InstallHook(LPDIRECT3DDEVICE9 pDevice) { void* endSceneAddr GetEndSceneAddress(pDevice); if (!endSceneAddr) return; // 1. 開始一個Detours事務(wù) DetourTransactionBegin(); // 2. 把當(dāng)前線程加入事務(wù) // 多線程場景要把所有可能執(zhí)行目標(biāo)函數(shù)的線程都加進來 DetourUpdateThread(GetCurrentThread()); // 3. 把真實地址關(guān)聯(lián)到Trampoline同時把替換函數(shù)掛上去 // 注意DetourAttach的參數(shù)順序Trampoline指針在前鉤子函數(shù)在后 DetourAttach((PVOID)RealEndScene, HookEndScene); // 4. 提交事務(wù)此時才真正改寫目標(biāo)函數(shù)入口處的指令 if (DetourTransactionCommit() ! NO_ERROR) { // 事務(wù)失敗回滾所有修改 DetourTransactionAbort(); } }這段代碼有幾個關(guān)鍵點。第一GetEndSceneAddress用了三級指針的reinterpret_cast因為COM對象布局是“對象首字節(jié)→虛表指針→函數(shù)指針數(shù)組”取地址必須解引用兩次。第二vtable[42]是經(jīng)驗值——在主流D3D9 SDK里EndScene排在第42個虛函數(shù)但如果目標(biāo)游戲用的SDK版本不同這個索引會漂移后面避坑章節(jié)會細說。第三DetourTransactionBegin和DetourTransactionCommit必須成對Commit失敗要立刻Abort否則內(nèi)存里留著半截改動進程幾乎必然崩潰。3.4 卸載鉤子的對稱操作卸載是安裝的鏡像操作很多人在這里翻車是因為參數(shù)寫反了或者多線程場景下在線程還停在鉤子函數(shù)里時強行卸載。void UninstallHook() { if (RealEndScene nullptr) return; // 開始卸載事務(wù) DetourTransactionBegin(); DetourUpdateThread(GetCurrentThread()); // 參數(shù)順序與Attach完全一致Trampoline指針在前鉤子函數(shù)在后 DetourDetach((PVOID)RealEndScene, HookEndScene); if (DetourTransactionCommit() ! NO_ERROR) { DetourTransactionAbort(); } }邏輯說明DetourDetach把原函數(shù)入口處的指令恢復(fù)原樣并把Trampoline指針歸位。參數(shù)寫反的直接后果是Detours找不到要恢復(fù)的函數(shù)卸載后原函數(shù)指令變成一堆亂跳轉(zhuǎn)游戲在下一幀就崩。我的血淚經(jīng)驗是Attach和Detach永遠放在同一份代碼里對稱寫復(fù)制粘貼時不要打亂順序。另外一個習(xí)慣是如果程序退出時不需要再調(diào)用原函數(shù)干脆不寫卸載邏輯讓進程退出時操作系統(tǒng)回收DLL反而比強制Detach更安全。但這只適用于進程即將結(jié)束的場景如果是熱卸載插件卸載邏輯就必須嚴(yán)謹。4. 避坑記錄DirectX鉤子最容易翻車的五個瞬間以下五條都是實際項目里踩過或者幫別人排查過的屬于血淚經(jīng)驗。第一次跑就全綠是運氣好翻車了按現(xiàn)象對號入座即可。4.1 現(xiàn)象一鉤子裝上后目標(biāo)函數(shù)紋絲不動原因最常見的是拿到的函數(shù)地址根本不對。直接掛在d3d9.dll的導(dǎo)出表上或者虛表索引寫錯DetourAttach掛在一個無關(guān)函數(shù)上自然沒有任何效果。 解決在調(diào)試器里打印真實地址。用Visual Studio的Watch窗口觀察pDevice指向的虛表數(shù)清楚目標(biāo)函數(shù)在第幾個槽位。不要憑記憶硬編碼索引索引一定要實測確認。4.2 現(xiàn)象二一掛上去游戲立刻訪問沖突崩潰原因函數(shù)簽名不匹配。聲明用的是HRESULT (WINAPI*)(LPDIRECT3DDEVICE9)但真實函數(shù)可能是不同的調(diào)用約定或參數(shù)個數(shù)導(dǎo)致棧不平衡返回時跳錯地址。 解決用typedef嚴(yán)格匹配真實簽名再檢查一遍調(diào)用約定。還有一個隱蔽問題如果替換函數(shù)里用了與設(shè)備無關(guān)的全局狀態(tài)要考慮線程安全——EndScene在渲染線程執(zhí)行如果統(tǒng)計變量在另一個UI線程被讀寫需要加鎖或用原子操作否則調(diào)試時會看到隨機崩潰。4.3 現(xiàn)象三編譯時找不到DetourAttach符號原因detours.lib沒有真正鏈接。常見的是detours.h放進包含目錄了但lib沒加入附加庫目錄或者lib文件在但路徑?jīng)]配。 解決工程屬性→鏈接器→常規(guī)→附加庫目錄填lib所在路徑鏈接器→輸入→附加依賴項寫detours.lib。改完看編譯輸出確認“無法解析的外部符號”是否消失。4.4 現(xiàn)象四卸載鉤子的瞬間游戲崩潰原因卸載時還有線程停在HookEndScene里或者DetourDetach參數(shù)寫反。Detours卸載需要線程安全某線程正在執(zhí)行鉤子函數(shù)時強行恢復(fù)指令等于地板突然抽掉。 解決卸載前先暫停所有可能執(zhí)行渲染代碼的線程如果做不到就在進程退出路徑上只做Detach不重啟線程。更務(wù)實的選擇像2.2節(jié)說的那樣很多鉤子工程干脆不寫卸載邏輯等進程退出讓系統(tǒng)回收。4.5 現(xiàn)象五同一個游戲今天能鉤住明天鉤不住原因目標(biāo)游戲更新了DirectX版本或者改用了不同路徑加載D3D9設(shè)備虛表索引漂移。另一個隱蔽原因是游戲自己也在用Detours或MinHook掛鉤多個庫在同一函數(shù)上疊加后裝的把先裝的覆蓋了。 解決啟動時動態(tài)探測虛表而不是寫死索引對疊加掛鉤場景啟動順序要保持一致。多鉤子疊加時的鏈?zhǔn)焦芾碓谧詈笠徽抡归_這里記住一個原則自己的鉤子盡量晚安裝、早卸載把沖突概率降到最低?,F(xiàn)象排查優(yōu)先級最可能原因鉤子不生效地址→索引→事務(wù)返回值虛表索引寫錯崩潰簽名→線程→狀態(tài)污染函數(shù)簽名不匹配編譯失敗lib路徑→位數(shù)→包含順序detours.lib未鏈接卸載崩潰線程安全→對稱性參數(shù)寫反或線程未暫停時好時壞版本→疊加沖突虛表索引漂移5. 落地應(yīng)用從函數(shù)截獲到幀率統(tǒng)計、MOD制作與作弊檢測5.1 性能分析在EndScene里掛一個FPS計數(shù)器鉤子裝好后的第一個需求基本是顯示幀率。做法是維護幀計數(shù)和時間戳在HookEndScene里累加每秒算一次FPS。注意不要在渲染熱路徑里做文件讀寫或字符串格式化這些高頻調(diào)用會拖垮幀率。// 幀率統(tǒng)計在HookEndScene里調(diào)用 void CountFPS(LPDIRECT3DDEVICE9 pDevice) { static int frameCount 0; static double lastTime 0.0; static float fps 0.0f; double now GetTickCount64() / 1000.0; // 單位秒 frameCount; if (now - lastTime 1.0) { fps frameCount / (now - lastTime); frameCount 0; lastTime now; } // 把fps輸出到日志或用ID3DXFont繪制到屏幕上 // 這里只做統(tǒng)計繪制調(diào)用放在EndScene的其他分支里 }邏輯說明GetTickCount64拿毫秒時間戳除以1000轉(zhuǎn)成秒lastTime初始為0第一次調(diào)用會直接跳過顯示分支。參數(shù)說明如果幀率波動劇烈可以改成滑動窗口平均取最近60幀的均值。對于性能分析還可以在調(diào)用RealEndScene前后各插一個高精度計時器相減得到單幀渲染耗時這個數(shù)據(jù)對定位渲染瓶頸非常有用。注意GetTickCount64在32位老系統(tǒng)上不可用老代碼里常用timeGetTime或QueryPerformanceCounter替代。5.2 MOD制作在EndScene里畫自定義UI或替換渲染效果很多游戲MOD的本質(zhì)就是在渲染管線末端插入自己的繪制調(diào)用。HookEndScene拿到pDevice之后所有D3D設(shè)備方法都能用——畫FPS文本、繪制矩形、修改光照參數(shù)甚至打斷整個場景的渲染提交再自己重畫一遍。需要特別提醒的是D3D9的繪制狀態(tài)是全局的你插入的繪制會污染游戲的渲染狀態(tài)所以繪制前要保存狀態(tài)SetRenderState、SetTexture等繪制后要恢復(fù)。常見的做法是在鉤子函數(shù)里調(diào)用BeginScene/EndScene包裹自定義繪制并顯式保存和恢復(fù)所有被修改的狀態(tài)塊。5.3 反外掛與作弊檢測鉤子的另一面在反外掛場景鉤子的用途是記錄敏感API的調(diào)用頻率、調(diào)用棧哈希、參數(shù)特征用于識別外掛行為。舉例一個正常游戲進程每秒不會調(diào)用幾千次SetCursorPos如果檢測到這種異常模式就標(biāo)記為可疑。但這里有一個明顯的邊界如果你自己也在用鉤子做反外掛那你也可能被別的反外掛系統(tǒng)當(dāng)成作弊者。多套鉤子疊加時穩(wěn)定性極難保證這也是很多競技游戲直接拒絕染指鉤子技術(shù)的原因。做反外掛檢測時需要克制只記錄統(tǒng)計特征不要修改任何函數(shù)行為保持hook的“透明性”。5.4 什么時候不該用鉤子DirectX 12時代渲染架構(gòu)大幅改變命令列表機制讓傳統(tǒng)APIHOOK變得極為脆弱很少有人還在D3D12上做EndScene級掛鉤。如果目標(biāo)游戲是D3D12或Vulkan老老實實改源碼或者用官方調(diào)試層比逆向鉤子靠譜得多。這也是這份壓縮包的價值邊界它是研究D3D9/D3D11及更老DirectX游戲的利器不是萬能工具。具體來說D3D9時代的游戲普遍運行在兼容模式鉤子介入相對穩(wěn)定D3D11的Present函數(shù)仍然可以掛但延遲和同步問題更多到了D3D12掛鉤成本已經(jīng)高到不劃算。6. 進階技巧多鉤子共存、動態(tài)卸載與鉤子生效驗證6.1 三步驗證法鉤子真的掛上了嗎裝完鉤子先別急著寫功能先驗證。最直接的辦法是在替換函數(shù)里OutputDebugString一行日志用DebugView觀察輸出頻率。如果日志沒輸出先查地址再查DetourAttach返回值。第二步是確認原功能沒被破壞游戲畫面是否正常交互是否流暢有沒有明顯的幀率暴跌。第三步才是加自己的邏輯并逐步確認每次改動的影響。很多乍看是鉤子導(dǎo)致的問題最后定位到是函數(shù)簽名或線程同步的問題所以驗證順序決定了排查效率。6.2 多鉤子共存與動態(tài)卸載的鏈?zhǔn)揭?guī)則多個鉤子掛同一個函數(shù)時Detours按棧式鏈管理后掛的在頂層先執(zhí)行依次穿透到最底層每一層都必須調(diào)用自己的Trampoline才能把鏈條傳下去。如果你忘調(diào)了下層鉤子全部失效。動態(tài)卸載注意不要在鉤子函數(shù)執(zhí)行過程中卸載當(dāng)前線程正站在被改寫指令的路徑上恢復(fù)指令的瞬間就是崩潰的瞬間。// 多鉤子鏈的正確協(xié)作示例 // 假設(shè)另一個模塊在EndScene上也掛了鉤 HRESULT WINAPI HookEndScene_My(LPDIRECT3DDEVICE9 pDevice) { // 這里先執(zhí)行自己的邏輯 MyFrameLogic(pDevice); // 必須調(diào)用RealEndScene才能把鏈條傳給下一層鉤子 return RealEndScene(pDevice); }邏輯說明如果RealEndScene已經(jīng)被上層鉤子重寫那調(diào)用它會進入上層鉤子的替換函數(shù)如果上層忘了調(diào)用Trampoline鏈條就斷了。參數(shù)說明在多鉤子環(huán)境下你的RealEndScene最終指向的不一定是D3D9原始函數(shù)而是鏈中相鄰下一鉤子的替換函數(shù)。所以不要在初始化時緩存返回的指針以為它是“最終真實地址”每次調(diào)用時動態(tài)穿透才是正確姿勢。很多人在老游戲上加MOD鉤子、代理DLL、注入器全上鏈條特別長。我的習(xí)慣是先卸載自己再卸載別人先暫停線程再恢復(fù)指令整個過程按序執(zhí)行不要圖省事。從那以后我每次做鉤子工程都強制走一遍驗證流程——裝上鉤子先確認輸出日志再確認原功能沒被破壞最后才加自己的邏輯。這個習(xí)慣幫我避開了一大半的崩潰現(xiàn)場。希望幫到你。本文還有配套的精品資源點擊獲取