解析度修改原理與實作

本篇定位

這是 Phase 5 的第 2 篇,深入解析 Client::UpdateResolution() 的完整實作。 MapleStory v83 預設只支援 800×600,要讓它跑在現代螢幕上(如 1280×720、1920×1080),需要在三個層次同時修改記憶體。


一、問題全景:800×600 藏在哪些地方?

用 IDA Pro 搜尋立即值 0x320(800)和 0x258(600),可以找到超過 30 個出現位置。根據功能分類:

類型出現位置修改方式
Direct3D 全域變數GameWidth_Addr / GameHeight_AddrMemory::Write
Back Buffer 設定BackBuffer_Width / BackBuffer_HeightMemory::Write
機器碼立即值散布於 CWvsApp::SetUpCWvsApp::RunMemory::WriteProtected
Viewport 設定D3DVIEWPORT9 結構體Code Cave Hook
UI 硬式座標登入 / 選角 / HUD 各處Memory::Write(見 03 / 04)

二、UpdateResolution() 完整流程

void Client::UpdateResolution() {
    // 取得從 config.ini 讀入的目標解析度
    int newW = Client::m_nGameWidth;   // 例如 1280
    int newH = Client::m_nGameHeight;  // 例如 720
 
    // ── 步驟一:寫入全域變數 ──────────────────────────────────────────────
    Memory::Write<int>(addys::GameWidth_Addr,     newW);
    Memory::Write<int>(addys::GameHeight_Addr,    newH);
    Memory::Write<int>(addys::BackBuffer_Width,   newW);
    Memory::Write<int>(addys::BackBuffer_Height,  newH);
 
    // ── 步驟二:Patch 機器碼中的硬式解析度值 ────────────────────────────
    PatchResolutionInCode();
 
    // ── 步驟三:修正 Viewport(視口)設定 ────────────────────────────────
    PatchViewport();
 
    std::cout << "[Client] Resolution set to " << newW << "x" << newH << std::endl;
}

三、步驟一:寫入全域變數

最直接的修改,遊戲的 Direct3D 初始化函式會讀取這些變數來建立 Back Buffer:

// addys::GameWidth_Addr  = 0x00C4FCA4
// addys::GameHeight_Addr = 0x00C4FCA8
 
Memory::Write<int>(addys::GameWidth_Addr,  1280);
Memory::Write<int>(addys::GameHeight_Addr, 720);

記憶體變化:

修改前:
0x00C4FCA4: 20 03 00 00   → 0x0320 = 800
0x00C4FCA8: 58 02 00 00   → 0x0258 = 600

修改後:
0x00C4FCA4: 00 05 00 00   → 0x0500 = 1280
0x00C4FCA8: D0 02 00 00   → 0x02D0 = 720

時機很重要

這個寫入必須在遊戲的 Direct3D 初始化(CWvsApp::SetUp之前完成。如果 SetUp 已經讀過這兩個變數並建立 Back Buffer,修改就晚了,需要等下一次 Reset(如切換全螢幕時)才會生效。


四、步驟二:Patch 機器碼中的硬式值

為什麼需要這一步?

遊戲的某些函式不從全域變數讀解析度,而是把數字直接硬式編碼在組合語言裡:

IDA Pro 反組譯結果(CWvsApp::SetUp 的片段):
0x009F52A0: 68 20 03 00 00   PUSH 800     ; 直接 push 數字 800!
0x009F52A5: 68 58 02 00 00   PUSH 600
0x009F52AA: E8 XX XX XX XX   CALL CreateWindowExA

這裡的 0x320(800)和 0x258(600)是 CreateWindowExAnWidthnHeight 參數。就算全域變數改了,這個 PUSH 800 還是會建出一個 800px 寬的視窗。

如何 Patch

PUSH imm32 指令的機器碼結構:

68 XX XX XX XX
│  └──────────── 立即值(4 byte,小端序)
└─────────────── PUSH imm32 的 opcode

我們只要覆寫那 4 個立即值的 byte(位於指令開頭 +1 的位置):

void Client::PatchResolutionInCode() {
    int newW = Client::m_nGameWidth;
    int newH = Client::m_nGameHeight;
 
    // 每個 patch 點:地址 + 1(跳過 opcode byte,直接寫立即值)
 
    // CWvsApp::SetUp 裡建立主視窗的呼叫
    Memory::WriteProtected<int>(addys::Patch_CreateWindow_Width,  newW);
    Memory::WriteProtected<int>(addys::Patch_CreateWindow_Height, newH);
 
    // CWvsApp::SetUp 裡設定 D3DPRESENT_PARAMETERS 的呼叫
    Memory::WriteProtected<int>(addys::Patch_D3DPresent_Width,  newW);
    Memory::WriteProtected<int>(addys::Patch_D3DPresent_Height, newH);
 
    // CWvsApp::Run 裡的視窗大小重置
    Memory::WriteProtected<int>(addys::Patch_Reset_Width,  newW);
    Memory::WriteProtected<int>(addys::Patch_Reset_Height, newH);
 
    // 其他散布的 patch 點...
}

Patch 前後的記憶體狀態:

【Patch 前】
0x009F52A0: 68 20 03 00 00   PUSH 800
0x009F52A5: 68 58 02 00 00   PUSH 600

【Patch 後:目標解析度 1280x720】
0x009F52A0: 68 00 05 00 00   PUSH 1280   (0x500 = 1280,小端序 00 05 00 00)
0x009F52A5: 68 D0 02 00 00   PUSH 720    (0x2D0 = 720,小端序 D0 02 00 00)

小端序(Little Endian)提醒

x86 CPU 用小端序存放多 byte 整數:最低位 byte 放在最低地址。 1280 = 0x00000500,小端序寫法:00 05 00 00(由低到高 byte)。 Memory::WriteProtected<int> 自動處理端序,不需要手動翻轉。


五、步驟三:修正 Viewport(視口)

D3DVIEWPORT9 是 Direct3D 9 定義「渲染目標的哪個區域要被渲染」的結構:

// DirectX SDK 定義
typedef struct D3DVIEWPORT9 {
    DWORD X;        // 左上角 X
    DWORD Y;        // 左上角 Y
    DWORD Width;    // 寬度
    DWORD Height;   // 高度
    float MinZ;     // 深度值下限(通常 0.0f)
    float MaxZ;     // 深度值上限(通常 1.0f)
} D3DVIEWPORT9;

遊戲在初始化 D3D 時設定 Viewport 為 {0, 0, 800, 600, 0.0f, 1.0f},如果不修正,3D 渲染會只佔據畫面左上角 800×600 的區域,其餘部分是黑色。

void Client::PatchViewport() {
    // Viewport 結構體放在遊戲記憶體的固定地址
    // 直接寫入 Width 和 Height 欄位
 
    DWORD vpWidth  = (DWORD)Client::m_nGameWidth;
    DWORD vpHeight = (DWORD)Client::m_nGameHeight;
 
    // +0x08 = Width 欄位的偏移(X=4byte, Y=4byte, then Width)
    Memory::Write<DWORD>(addys::Viewport_Struct + 0x08, vpWidth);
    // +0x0C = Height 欄位的偏移
    Memory::Write<DWORD>(addys::Viewport_Struct + 0x0C, vpHeight);
}

六、UpdateGameStartup() 的補充

除了解析度,UpdateGameStartup() 還修改幾個遊戲啟動行為:

void Client::UpdateGameStartup() {
    // 1. 強制視窗模式(覆蓋 D3DPRESENT_PARAMETERS.Windowed 欄位)
    //    遊戲預設全螢幕,改成 TRUE 讓它以視窗跑
    Memory::WriteProtected<BYTE>(addys::Patch_Windowed_Flag, 0x01);
 
    // 2. 停用「解析度不符時彈警告」的檢查
    //    原本:if (width != 800 || height != 600) ShowWarning();
    //    NOP 掉這個條件跳轉
    Memory::Nop(addys::Patch_ResCheck_Jump, 6);
 
    // 3. 修正字型渲染
    //    v83 的字型在非 800x600 解析度下位置偏移,
    //    patch 字型渲染函式的偏移計算
    Memory::WriteProtected<int>(addys::Patch_FontOffset_X, 0);
    Memory::WriteProtected<int>(addys::Patch_FontOffset_Y, 0);
}

七、解析度修改全流程圖

sequenceDiagram
    participant MM as MainMain
    participant C as Client
    participant Mem as Memory
    participant Game as MapleStory

    MM->>C: UpdateGameStartup()
    C->>Mem: WriteProtected(Windowed_Flag, TRUE)
    C->>Mem: Nop(ResCheck_Jump, 6)

    MM->>C: UpdateResolution()
    C->>Mem: Write(GameWidth_Addr, 1280)
    C->>Mem: Write(GameHeight_Addr, 720)
    C->>Mem: Write(BackBuffer_Width, 1280)
    C->>Mem: Write(BackBuffer_Height, 720)
    C->>Mem: WriteProtected(Patch_CreateWindow_Width, 1280)
    C->>Mem: WriteProtected(Patch_CreateWindow_Height, 720)
    C->>Mem: WriteProtected(Patch_D3DPresent_Width, 1280)
    C->>Mem: WriteProtected(Patch_D3DPresent_Height, 720)
    C->>Mem: Write(Viewport_Width, 1280)
    C->>Mem: Write(Viewport_Height, 720)

    Note over Game: 遊戲以 1280x720 視窗啟動
    Game->>C: 觸發 CWvsApp::SetUp Hook
    C->>C: FixLoginScreen()

八、常見問題

Q:修改解析度後遊戲畫面被拉伸了怎麼辦?

拉伸代表只修改了全域變數,但沒有 Patch 所有硬式編碼的值。遊戲用修改後的解析度建立視窗,但內部的 2D 精靈(Sprite)渲染仍以 800×600 為坐標系繪製,導致拉伸感。需要確認所有的 patch 點都被覆蓋。

Q:如何找到所有需要 Patch 的地址?

在 IDA Pro 中搜尋立即值(Search → Immediate value)輸入 0x320(800)和 0x258(600),逐一確認每個出現位置的語意,判斷是否需要修改。配合動態偵錯(OllyDbg / x64dbg)可以驗證哪些路徑實際會被執行到。

Q:為什麼不直接 Hook CWvsApp::SetUp,在 Hook 函式裡改參數?

理論上可以,但 SetUp 的函式非常複雜(有數百條指令),攔截後要完整還原所有參數非常繁瑣且容易出錯。直接 Patch 記憶體中的立即值更精確、影響範圍更小、更容易驗證。


延伸閱讀