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.cppMainFunc() 函式最末尾才被呼叫的。

原因:我們的 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 一模一樣,不會留下任何痕跡。


延伸閱讀