前面幾篇建立了記憶體操作、Hook技術,以及Client.cpp的解析度/UI修正邏輯。這篇涵蓋剩下的關鍵拼圖:更進階的函式替換技術、自訂記憶體分配器、整個初始化流程怎麼統籌起來,以及玩家唯一需要手動編輯的 config.ini 設定系統。

函式替換:比 Hook 攔截更徹底的手段

之前學到的Hook模式是「攔截 + 選擇性呼叫原始函式」(修改參數後還是繼續原邏輯)。函式替換更進一步:完全不呼叫原始函式,自己重新實作整個行為。適用情境:原始函式的邏輯整個不符需求、或原始函式有反除錯/反注入邏輯,呼叫它反而會讓遊戲偵測到我們的修改。

繞過Themida混淆的GetProcAddress:v83有一個內部函式類似Windows的GetProcAddress,但加了Themida混淆邏輯,可能會檢查「函式指標是否被Hook過」。直接替換成呼叫真正的Windows API,跳過這層檢查:

cpp
📋 複製
void* __cdecl Replacement_MyGetProcAddress(HMODULE hModule, LPCSTR lpProcName) {
    return (void*)::GetProcAddress(hModule, lpProcName);  // 完全不理會原始的混淆邏輯
}

混合模式:先呼叫原始函式,再修正:完全替換建構子這類複雜函式風險很高(容易漏掉某些初始化步驟)。更安全的做法是先讓原始邏輯跑完,再修正不想要的副作用:

cpp
📋 複製
void __fastcall Replacement_CWvsApp_Ctor(void* thisPtr, void* edx) {
    original_CWvsApp_Ctor(thisPtr);               // 先讓原始建構子跑完
    *(BOOL*)((BYTE*)thisPtr + 0x20) = TRUE;       // 再把全螢幕設定強制改回視窗模式
    *(int*)((BYTE*)thisPtr + 0x3C)  = 1;          // 假裝只有一個螢幕,停用多螢幕偵測
}

字串重導向:替換StringPool::GetString(遊戲取得所有UI字串的入口),先查自訂字串表,找不到才用原始邏輯,就能在執行期修改任何顯示文字(例如登入畫面的伺服器選單名稱):

cpp
📋 複製
std::unordered_map<int, std::wstring> g_customStrings;
const wchar_t* __fastcall Replacement_StringPool_GetString(void* thisPtr, void* edx, int stringId) {
    auto it = g_customStrings.find(stringId);
    if (it != g_customStrings.end()) return it->second.c_str();
    return original_StringPool_GetString(thisPtr, stringId);
}

ZAllocEx:自訂記憶體分配器

MapleStory引擎有自己的記憶體管理系統ZAllocator,內部Heap大小是針對800×600調整的——解析度放大後需要更多材質記憶體,容易超出預先分配的容量導致分配失敗崩潰。

ZAllocEx 不修改ZAllocator內部結構,而是把遊戲呼叫的底層行為整個替換掉,改用Windows系統的 HeapAlloc(建立獨立Heap,幾乎沒有大小限制):

cpp
📋 複製
static HANDLE g_heap = HeapCreate(HEAP_NO_SERIALIZE, 4 * 1024 * 1024, 0);  // 初始4MB,可動態增長

void* ZAllocEx::Alloc(size_t size) { return HeapAlloc(g_heap, 0, size); }
void  ZAllocEx::Free(void* ptr)    { HeapFree(g_heap, 0, ptr); }

Alloc/Free/Realloc 三個函式必須一起替換,不能只換其中一個——如果分配走了ZAllocEx但釋放時用的是原始ZFree(沒被Hook到),會發生「跨Heap釋放」,導致Heap結構損毀崩潰。

MainMain:初始化流程的統籌者

MainMain 是一個Singleton(整個程式生命週期只有一個實例),負責統籌所有模組的初始化順序:

cpp
📋 複製
void MainMain::CreateInstance(void(*mainFunc)()) {
    s_instance = new MainMain();
    ZAllocEx::Init();
    ZAllocEx::InstallHooks(true);         // 先裝好記憶體分配器
    s_instance->LoadConfig();             // 讀取config.ini
    Client::m_nGameWidth = s_instance->m_gameWidth;   // 同步設定給Client模組
    WaitForThemida();                     // 等待Themida解壓縮
    mainFunc();                           // 呼叫dllmain.cpp的MainFunc(),安裝所有遊戲Hook
}

WaitForThemida() 用輪詢的方式確認遊戲的加密程式碼真的解壓縮完成了,才能安全地在那段記憶體安裝Hook:

cpp
📋 複製
void MainMain::WaitForThemida() {
    while (*(BYTE*)addys::CWvsApp_SetUp != 0x55) {  // 0x55 = PUSH EBP,函式開頭的標準特徵
        Sleep(100);
    }
    Sleep(500);  // 多等一點,確保徹底完成
}

x86函式開頭的標準Prologue是PUSH EBP(機器碼0x55),加密狀態下這個位置的值不固定,解壓縮後就會變成0x55——這就是判斷「解壓縮完成了沒」的依據。

config.ini:玩家唯一要碰的檔案

早期登入器把伺服器IP、解析度直接寫死在DLL裡,每次要換設定都得重新編譯。config.ini(和 dinput8.dll 放在同一目錄)解決了這個問題——玩家只需要編輯一個純文字檔,重啟遊戲就生效:

ini
📋 複製
[Connection]
ServerIP = 127.0.0.1
Port = 8484

[Display]
Width = 1280
Height = 720
Windowed = true

[Debug]
ShowConsole = false

ServerIP 建議直接填IP位址而非網域名稱——Hook替換連線目標的邏輯如果只處理IPv4字串格式,直接填domain name可能需要額外的DNS解析步驟才能生效。Width/Height 建議用16:9比例的標準值,太小會讓部分UI超出可見範圍,太大則字型可能因為遊戲的字型渲染不支援超解析度而模糊。設定檔只在DLL載入時讀取一次,遊戲執行中修改不會即時生效,必須重啟遊戲。

INIReader:輕量的設定檔解析器

不用Windows內建的 GetPrivateProfileString,是因為那需要絕對路徑、型別支援有限、且僅限Windows平台。INIReader.h 是一個header-only的C++解析器,核心邏輯是把整個檔案讀成 區段.鍵名 → 值 的map:

cpp
📋 複製
std::string Get(const std::string& section, const std::string& key,
                 const std::string& defaultValue) const {
    auto it = m_values.find(MakeKey(section, key));  // section和key都轉小寫,不分大小寫
    return (it != m_values.end()) ? it->second : defaultValue;
}
long GetInteger(...) const { /* 呼叫Get後用std::stol轉換,失敗則回傳預設值 */ }
bool GetBoolean(...) const { /* "true"/"yes"/"1"/"on" 視為true */ }

所有Get系列函式都要求預設值參數,這代表就算設定檔不存在或缺少某個欄位,登入器也不會崩潰,只是套用預設值運作。有個已知限制:行內注釋是用搜尋;來截斷處理的,如果設定值本身就包含分號(例如密碼含;),會被誤截斷——但在IP、Port、解析度這些數字/布林值的使用情境下不會遇到這個問題。

WZ 與 IMG 資源整合

MapleStory的美術資源都封裝在.wz檔案裡(Map.wz地圖、UI.wz介面、String.wz文字等),透過IWzFileSystem這個虛擬檔案系統讀取。登入器Hook IWzFileSystem::Init,在標準WZ索引建立完成後,注入額外的自訂資源路徑:

cpp
📋 複製
void __fastcall Hook_IWzFileSystem_Init(void* thisPtr, void* edx, const wchar_t* basePath) {
    original_IWzFileSystem_Init(thisPtr, basePath);      // 先建立標準WZ索引
    InjectCustomWzDirectory(thisPtr, L"CustomWZ");       // 再注入自訂資源目錄
}

替換或新增資源有兩種做法:直接替換整個WZ檔案(簡單但私服更新版本時可能要重新合併)、或記憶體層攔截特定資源讀取(Hook Bitmap讀取函式,針對特定路徑回傳自訂圖像,其餘交給原始邏輯)。

另外還Hook了 CWvsApp::InitializeResMan,官方v83的資源管理器連線時會嘗試向Nexon的CDN驗證資源版本,私服沒有這個CDN,請求會卡住載入畫面——Hook它強制使用本地WZ模式,跳過這個驗證步驟。

替換WZ後,只要CRC完整性檢查Hook已正確安裝,遊戲就不會偵測到WZ被修改;如果忘記裝CRC Hook,遊戲啟動時會偵測到WZ被改過而強制關閉。

Resource.rc:讓設定檔跟著 DLL 一起走

config.ini、UI資源這些檔案是怎麼「內建」在一個dinput8.dll裡,不需要額外附帶檔案?答案是 Windows PE 格式的資源嵌入機制。

resource.h 定義每個嵌入資源的識別碼,Resource.rc 宣告要把哪個檔案嵌入哪個ID:

cpp
📋 複製
// resource.h
#define IDR_RCDATA1  101   // config.ini
#define IDR_RCDATA2  102   // EzorsiaV2_UI.wz
rc
📋 複製
// Resource.rc
IDR_RCDATA1  RCDATA  "config.ini"
IDR_RCDATA2  RCDATA  "EzorsiaV2_UI.wz"

編譯時,Resource Compiler 把這些檔案的內容直接打包進DLL的 .rsrc 資源節。執行期用 FindResource + LoadResource 從記憶體讀取:

cpp
📋 複製
HMODULE hMod = GetModuleHandle(NULL);
HRSRC hRes = FindResource(hMod, MAKEINTRESOURCE(IDR_RCDATA1), RT_RCDATA);
HGLOBAL hGlobal = LoadResource(hMod, hRes);
LPVOID pData = LockResource(hGlobal);  // 指向config.ini原始位元組,交給INIReader解析

選擇嵌入而非外部檔案,是為了讓最終使用者只需要拿到一個 dinput8.dll 就能用,不需要理解其他設定檔案該放在哪裡——代價是每次要調整內建資源就得重新編譯整個DLL。


下一篇是這條路線的最後一篇:如何測試、除錯這個DLL,以及打包成可以發布給玩家使用的登入器。