dinput8.cpp 程式碼導覽:Proxy 載入器實作
本篇定位
這是 Phase 2「專案結構解析」系列的第 3 篇。假設你已經讀過 03_dinput8代理DLL原理,理解「我們的 DLL 假裝成 dinput8.dll 替遊戲轉接訊號」的概念。本篇會打開程式碼,逐行解釋每一個細節是怎麼運作的。
1. 檔案結構概覽:菜單與廚師
這個 Proxy 功能橫跨兩個檔案,分工非常明確:
| 檔案 | 角色 | 比喻 |
|---|---|---|
dinput8.h | 聲明:說明這個類別有什麼功能 | 餐廳菜單,告訴你有哪些餐點 |
dinput8.cpp | 實作:把功能真正寫出來 | 廚師,按照菜單實際下廚 |
dinput8.h(菜單)
#pragma once // 防止同一個 .h 被 #include 兩次(只看一次菜單就夠)
class dinput8 {
public:
static void CreateHook(); // 宣告:這個類別有一個公開的靜態函式叫 CreateHook
// static = 不需要建立物件就能呼叫,直接用類別名稱呼叫
};什麼是「聲明 vs 實作」?
.h檔只是「告訴其他檔案:這個函式存在、長什麼樣子」。真正的程式碼邏輯都在.cpp裡。這就像菜單只列出「牛排」,但料理方式都在廚師腦子裡(cpp)。
2. 全域變數:FARPROC 是什麼?
FARPROC DirectInput8Create_Proc; // 備忘錄1:記住真實 DirectInput8Create 在哪個記憶體位置
FARPROC GetdfDIJoystick_Proc; // 備忘錄2:記住真實 GetdfDIJoystick 在哪個記憶體位置FARPROC 的意思
FAR= Far(遠端),歷史術語,現代 Windows 已統一記憶體模型,這個字等同沒有特殊意義PROC= Process(程序/函式)- 實際上
FARPROC就是 Windows 定義的一種函式指標型別——它存放的不是數值,而是「某個函式在記憶體中的地址」可以把它想成一張便利貼,上面寫的是「真正的 DirectInput8Create 住在記憶體第 X 號房間」。之後我們轉發時,只要照著便利貼的地址跳過去就好。
這兩個變數定義在所有函式外部(全域),所以整個 DLL 的生命週期內都能存取,不會因為某個函式執行完就消失。
3. 注釋中藏著的隱藏功能
在 FARPROC 宣告上方,有一段被拆成兩行的注釋:
// NOTE: this dll can also be used to remap the core functionality of keybinds,
// i.e. changing arrow keys in the game to WASD. but this would
FARPROC DirectInput8Create_Proc; // require reinterpreting the functions of the dll
// instead of just redirecting as is done here (to dinput8.dll)這段注釋說了什麼?
作者在提示:dinput8.dll 負責處理鍵盤/搖桿輸入,如果不只是「轉發」,而是「攔截並修改」輸入資料,就可以實現按鍵重對應(例如把方向鍵映射成 WASD)。
但這個專案的目標很單純——只做轉發,不重新解讀函式內容,所以實作上選擇最簡單的方式:抓到地址、直接跳過去。
4. CreateHook() 逐行解析
這是整個 Proxy 的核心初始化函式,分四個階段:
第一階段:找到系統目錄
void dinput8::CreateHook() {
char szPath[MAX_PATH];
// MAX_PATH = Windows 定義的常數,代表路徑最長允許 260 個字元
// szPath 是一個字元陣列(字串容器),用來存放路徑文字
if (GetSystemDirectoryA(szPath, sizeof(szPath))) {
// GetSystemDirectoryA:Windows API,把系統目錄路徑寫入 szPath
// 通常結果是 "C:\Windows\System32"
// 加上 A 後綴代表使用 ANSI 版本(處理一般英文路徑)
strcat(szPath, "\\dinput8.dll");
// strcat = string concatenate(字串串接)
// 把 "\dinput8.dll" 接在 szPath 後面
// 結果:szPath 現在等於 "C:\Windows\System32\dinput8.dll"
}為什麼要去 System32 找?
因為我們自己的
dinput8.dll(假的)放在遊戲資料夾,而 Windows 的真正dinput8.dll在系統目錄。我們必須明確指定完整路徑去找真品,否則 Windows 可能又找到我們自己這份假的,造成無限迴圈。
第二階段:路徑失敗的錯誤處理
else {
// 如果 GetSystemDirectoryA 失敗(極少見,但要處理)
Sleep(20);
// 等 20 毫秒,給系統一點緩衝時間
SuspendThread(MainMain::mainTHread);
// 暫停主執行緒(遊戲的主要執行流程先停下來)
// 原因:如果不暫停,遊戲可能繼續跑並且 crash,這樣彈窗根本來不及顯示
MessageBox(NULL,
L"Failed to load original dinput8.dll from system location, "
L"make sure your directory path is not longer than MAX_PATH",
L"systems directory inaccessible", 0);
// L"..." 中的 L 代表 Wide String(寬字元),Windows 視窗 API 需要這種格式
// 顯示一個錯誤彈出視窗,告訴使用者出了什麼問題
ExitProcess(0);
// 強制結束整個程序(遊戲 + 登入器一起關閉)
// 0 代表正常退出代碼
}SuspendThread 的用意
這裡有個微妙的設計:先暫停主執行緒,才顯示彈窗。如果不暫停,遊戲主執行緒可能繼續執行到依賴 dinput8 的程式碼,導致 crash 或凍結,使用者根本看不到錯誤訊息就閃退了。暫停執行緒讓錯誤彈窗有機會安全顯示。
第三階段:載入真正的 dinput8.dll
HMODULE hModule = LoadLibraryA(szPath);
// LoadLibraryA:把指定路徑的 DLL 載入到記憶體
// 回傳值 hModule 是一個「模組句柄」(Handle),
// 可以想成是「這個 DLL 的門牌號碼」,之後用這個號碼來查函式位置
if (hModule) {
// 如果載入成功(hModule 不是 NULL)第四階段:取得函式地址(GetProcAddress)
DirectInput8Create_Proc = GetProcAddress(hModule, "DirectInput8Create");
// GetProcAddress:在已載入的 DLL 裡,依名稱找到指定函式的記憶體地址
// 把地址存進備忘錄 DirectInput8Create_Proc
GetdfDIJoystick_Proc = GetProcAddress(hModule, "GetdfDIJoystick");
// 同上,找到 GetdfDIJoystick 的地址存進備忘錄
}
else {
// 如果 LoadLibraryA 失敗(DLL 找不到)
Sleep(20);
SuspendThread(MainMain::mainTHread);
MessageBox(NULL,
L"Failed to find original dinput8.dll, verify that a non-Ezorsia v2 dinput8.dll "
L"exists in your system directory",
L"Missing file", 0);
// 錯誤訊息特別提醒:確認系統目錄裡有「非 Ezorsia v2」版本的 dinput8.dll
// (有些使用者可能把假的 DLL 也複製進 System32,導致這裡又找到假的)
ExitProcess(0);
}
}整體流程回顧
取得 System32 路徑 ↓ 拼出完整 DLL 路徑(C:\Windows\System32\dinput8.dll) ↓ LoadLibraryA 載入到記憶體 ↓ GetProcAddress 取得兩個函式的記憶體地址 ↓ 存進全域備忘錄(FARPROC 變數)
5. 匯出函式逐字拆解
這是整個 Proxy 的「最後一哩路」——讓我們的假 DLL 對外提供和真品一模一樣名稱的函式。
extern "C" __declspec(dllexport) __declspec(naked) void DirectInput8Create() {
__asm jmp dword ptr[DirectInput8Create_Proc]
}
extern "C" __declspec(dllexport) __declspec(naked) void GetdfDIJoystick() {
__asm jmp dword ptr[GetdfDIJoystick_Proc]
}這一行密密麻麻的關鍵字,每個都有特定用意,拆開來看:
extern "C" — 防止 C++ 改名
C++ 的 Name Mangling 問題
C++ 編譯器為了支援「函式多載」(同名但不同參數的函式),會在編譯時把函式名稱改得面目全非。例如
DirectInput8Create可能被改成?DirectInput8Create@@YGHPAUHINSTANCE__@@...之類的亂碼。但遊戲呼叫時,它找的是原本的名字
DirectInput8Create,找不到就 crash。
extern "C"告訴編譯器:這個函式用 C 語言的命名規則,不要改名,保持原樣輸出。
__declspec(dllexport) — 對外公開函式
這個修飾詞告訴連結器(Linker):「這個函式要匯出(Export),讓其他程式(例如遊戲)可以透過 DLL 名稱呼叫它。」
沒有這個,函式就只存在於 DLL 內部,外面完全看不到,Proxy 就失效了。
__declspec(naked) — 裸函式,零包裝
什麼是 Prologue / Epilogue?
正常情況下,每個函式被呼叫時,編譯器會自動加入一段「前置碼」(Prologue)和「後置碼」(Epilogue):
- 前置碼:保存暫存器、建立堆疊框架(
push ebp; mov ebp, esp)- 後置碼:還原暫存器、清理堆疊(
pop ebp; ret)這些都是開銷,而且會破壞堆疊狀態。我們的函式要做的事只是直接跳轉,不能讓編譯器插手堆疊,否則跳過去之後堆疊結構就錯了,遊戲會 crash。
__declspec(naked)告訴編譯器:「什麼都不要加,這個函式的每一個位元組我自己全權掌控。」
__asm jmp dword ptr[...] — 組合語言直接跳轉
__asm jmp dword ptr[DirectInput8Create_Proc]
// ↑ ↑ ↑ ↑
// 內嵌 跳轉 讀取 全域備忘錄的位址
// 組合 指令 指標所 (裡面存的是真正的函式地址)
// 語言 指向的值__asm:告訴編譯器,以下是組合語言(Assembly),直接嵌入 CPU 指令jmp:無條件跳轉(Jump),CPU 直接跳到指定地址繼續執行dword ptr[...]:讀取中括號內地址所存的 4 位元組值(32 位元地址)- 整句意思:讀出備忘錄裡的地址,然後 CPU 直接跳過去
為什麼不用普通的函式呼叫?
普通的函式呼叫(
call)會把「返回地址」壓進堆疊,執行完再跳回來。但這裡我們只是「傳話」,我們不需要介入執行流程,只需要讓遊戲「直達」真實函式。jmp沒有任何額外的堆疊操作,效能最高、最乾淨,也不會破壞遊戲的呼叫框架。
6. 為什麼需要兩個匯出函式?
MapleStory v83 使用 DirectInput(舊版 Windows 輸入 API)處理鍵盤和搖桿。dinput8.dll 對外提供多個函式,其中這個版本的遊戲會查詢到這兩個:
| 函式名稱 | 用途 |
|---|---|
DirectInput8Create | 建立 DirectInput8 介面的主要入口,必須有 |
GetdfDIJoystick | 取得搖桿的資料格式定義,部分版本遊戲或引擎會查詢 |
少一個會怎樣?
如果我們的假 DLL 只匯出
DirectInput8Create,但遊戲啟動時也去呼叫GetdfDIJoystick,就會找不到這個函式,導致遊戲直接 crash 或無法正常初始化輸入系統。兩個都代理才能讓遊戲完全正常運作。
7. CreateHook() 何時被呼叫?
呼叫時機很關鍵
dinput8::CreateHook()是在dllmain.cpp的MainFunc()函式最末尾才被呼叫的。原因:我們的 DLL 除了做 Proxy,主要任務是替遊戲安裝各種記憶體 Hook(修改遊戲行為)。這些 Hook 必須先全部裝好,確認遊戲進程已經穩定,最後才初始化 Proxy 轉發機制。
順序錯誤會有風險——如果 Proxy 先啟動,遊戲輸入系統就開始運作,但那時 Hook 還沒裝完,可能造成競爭條件(Race Condition)或記憶體存取衝突。
呼叫位置示意(不是完整程式碼,僅示意順序):
// dllmain.cpp - MainFunc() 的最後段落
// ... 所有 Hook 安裝完畢 ...
dinput8::CreateHook(); // 最後才初始化 Proxy,一切就緒8. 完整資料流總結
遊戲呼叫 DirectInput8Create()
↓
找到我們的假 dinput8.dll(在遊戲資料夾)
↓
進入我們匯出的 DirectInput8Create()
↓
__asm jmp → 跳到備忘錄裡的地址
↓
實際執行 C:\Windows\System32\dinput8.dll 的 DirectInput8Create()
↓
結果回傳給遊戲(遊戲完全不知道中間經過了我們)
Proxy 的完美性
因為使用
naked+jmp,整個轉發過程對遊戲完全透明。遊戲看到的函式名稱、呼叫方式、回傳結果全部和直接呼叫系統 DLL 一模一樣,不會留下任何痕跡。
延伸閱讀
- 03_dinput8代理DLL原理 — 回顧 Proxy DLL 的概念層說明
- 02_dllmain.cpp_DLL入口點 — 了解 DLL 是如何啟動,以及 MainFunc() 的整體流程
stdafx.h預編譯標頭 — 了解為什麼每個 .cpp 都要 include stdafx.h- 01_什麼是記憶體位址與指標 — 下一個階段,了解 Hook 是如何在記憶體層面運作的