AutoTypes.h 解析:MapleStory 型別定義

本篇定位

這是 Phase 3 的第 4 篇,也是本階段的收尾。 AutoTypes.h 定義了和 MapleStory 遊戲內部資料結構對應的 C++ 型別,讓我們能用「有意義的欄位名稱」存取記憶體,而不是每次都手動計算偏移量。 讀完這篇,你會看到 Phase 3 的所有拼圖如何拼在一起。


一、為什麼需要型別定義?

有了地址表(AddyLocations.h)和工具函式(Memory.cpp),我們已經可以讀寫記憶體。但這樣的程式碼很脆弱:

// 沒有型別定義 — 靠偏移量手動計算,像謎語一樣難讀
DWORD hp     = Memory::Read<DWORD>(charBase + 0x00);
DWORD maxHp  = Memory::Read<DWORD>(charBase + 0x04);
DWORD mp     = Memory::Read<DWORD>(charBase + 0x08);
DWORD level  = Memory::Read<DWORD>(charBase + 0x10);

問題:

  • 0x04 是什麼?你必須記住「偏移 4 是 maxHp」
  • 如果遊戲版本更新,一個欄位插入到中間,所有偏移都得改
  • 完全看不出這段記憶體「是什麼東西」

AutoTypes.h 的解法:把遊戲的記憶體結構映射成 C++ struct:

// 有型別定義 — 清晰、結構化
CharacterStats* stats = (CharacterStats*)charBase;
DWORD hp    = stats->hp;
DWORD maxHp = stats->maxHp;
DWORD mp    = stats->mp;
DWORD level = stats->level;

二、基本型別別名

AutoTypes.h 首先定義一批縮寫,讓程式碼更簡潔:

#pragma once
#include <cstdint>
#include <Windows.h>
 
// 整數型別縮寫
using u8  = uint8_t;    // 1 byte 無符號整數(0 ~ 255)
using u16 = uint16_t;   // 2 byte 無符號整數
using u32 = uint32_t;   // 4 byte 無符號整數
using u64 = uint64_t;   // 8 byte 無符號整數
using i8  = int8_t;     // 1 byte 有符號整數
using i16 = int16_t;
using i32 = int32_t;
using i64 = int64_t;
 
// Windows 慣用型別的別名(語意更清晰)
using Address   = uintptr_t;   // 記憶體地址(32 位元下等同 u32)
using Offset    = ptrdiff_t;   // 地址之間的差值(可為負數)
using VoidPtr   = void*;       // 通用指標

為什麼要這些別名?

uint32_t hpunsigned int hp 明確,因為 unsigned int 在不同平台可能是 2 或 4 byte,而 uint32_t 永遠是 4 byte。在記憶體操作中,大小精確是必要條件。


三、遊戲物件 Struct 定義

MapleStory 的遊戲物件(角色、NPC、怪物)在記憶體中按照固定排列存放。我們用 C++ struct 還原這個排列:

3.1 CharacterStats — 角色屬性

#pragma pack(push, 1)   // 告訴編譯器:不要在欄位之間加填充(Padding)
 
struct CharacterStats {
    // 基礎屬性(從 charBase 開始的偏移)
    i32  hp;          // +0x00  目前 HP
    i32  maxHp;       // +0x04  最大 HP
    i32  mp;          // +0x08  目前 MP
    i32  maxMp;       // +0x0C  最大 MP
    i32  str;         // +0x10  力量
    i32  dex;         // +0x14  敏捷
    i32  int_;        // +0x18  智力(int 是 C++ 關鍵字,加底線避免衝突)
    i32  luk;         // +0x1C  幸運
    i32  level;       // +0x20  等級
    i64  exp;         // +0x24  目前 EXP(8 byte,因為高等級 EXP 很大)
    i32  fame;        // +0x2C  人氣
    i16  job;         // +0x30  職業 ID(2 byte)
    u8   _pad[2];     // +0x32  填充對齊(讓下一個欄位對齊 4 byte)
    i32  mesos;       // +0x34  楓幣
};
 
#pragma pack(pop)   // 恢復預設對齊設定

3.2 WzVector2D — 遊戲內座標

struct WzVector2D {
    i32 x;
    i32 y;
};

3.3 CLifeObj — 遊戲中的生命物件(角色、怪物的父類別)

struct CLifeObj {
    // 虛函式表指標(所有 C++ 多型物件的第一個欄位)
    void*        vftable;       // +0x00  Virtual Function Table 指標
 
    WzVector2D   position;     // +0x04  物件在地圖上的座標 (x, y)
    WzVector2D   velocity;     // +0x0C  物件的移動速度向量
 
    i32          objectId;     // +0x14  物件的唯一識別碼
    u8           state;        // +0x18  當前狀態(站立/跳躍/攻擊...)
    u8           faceLeft;     // +0x19  朝向(0 = 右,1 = 左)
    u8           _pad[2];      // +0x1A  對齊填充
};

四、#pragma pack 的重要性

這是最容易踩坑的地方

C++ 編譯器為了效能,預設會在 struct 的欄位之間插入「填充位元組(Padding)」,讓每個欄位對齊到自然邊界:

// 沒有 #pragma pack 的情況
struct WithPadding {
    u8   flag;     // +0x00 (1 byte)
    // ← 這裡 compiler 自動加 3 byte padding,讓 value 對齊到 4 byte 邊界
    i32  value;    // +0x04 (4 byte)
};
// sizeof(WithPadding) = 8(不是 5)

但遊戲的記憶體是遊戲的編譯器排列的,它的排列方式可能和你的 struct 不同。如果你的 struct 和遊戲記憶體的實際排列不一致,所有欄位的偏移都會錯:

CharacterStats* stats = (CharacterStats*)charBase;
stats->mp;  // 如果 struct 有多餘 padding,這個偏移就錯了

#pragma pack(1) 告訴編譯器「不要加任何填充」,讓 struct 的記憶體排列和遊戲的完全一致。

如何確認 struct 的大小對不對?

使用 static_assert

static_assert(sizeof(CharacterStats) == 0x38, "CharacterStats size mismatch");

如果大小不對,編譯器會直接報錯,提早發現問題。


五、列舉型別:讓數字有意義

MapleStory 大量使用整數代表狀態、職業等,AutoTypes.h 把這些整數定義成 enum

// 職業 ID
enum class JobId : i16 {
    Beginner     = 0,
    Warrior      = 100,
    Fighter      = 110,
    Crusader     = 111,
    Hero         = 112,
    Page         = 120,
    WhiteKnight  = 121,
    Paladin      = 122,
    Spearman     = 130,
    DragonKnight = 131,
    DarkKnight   = 132,
    Magician     = 200,
    FirePoison   = 210,
    // ...以此類推
};
 
// 遊戲狀態
enum class GameState : u8 {
    Idle     = 0x00,
    Walking  = 0x01,
    Jumping  = 0x02,
    Attacking= 0x03,
    Dead     = 0x10,
};

使用 enum class 而非舊式 enum

  • 不會洩漏到外部命名空間
  • 必須明確加上 JobId:: 前綴才能使用
  • 可以指定底層型別(: i16),確保大小精確

六、函式指標型別定義

MapleStory 的函式在記憶體裡存在固定地址,我們可以定義函式指標型別,讓「呼叫遊戲函式」的語法更清晰:

// CWvsApp::SetUp 的函式簽名
// __thiscall 表示使用 C++ 成員函式呼叫慣例(this 指標放在 ECX 暫存器)
using FnCWvsApp_SetUp = void(__thiscall*)(void* thisPtr);
 
// 使用方式:
FnCWvsApp_SetUp originalSetUp = (FnCWvsApp_SetUp)addys::CWvsApp_SetUp;
originalSetUp(gameAppPtr);  // 呼叫遊戲原版的 SetUp

呼叫慣例(Calling Convention)是什麼?

決定「函式呼叫時,參數怎麼傳、誰負責清理堆疊」的規則。常見的有:

  • __cdecl:C 語言預設,呼叫方清理堆疊
  • __stdcall:Windows API 預設,被呼叫方清理堆疊
  • __thiscall:C++ 成員函式預設,this 指標透過 ECX 暫存器傳入

在 Hook 時,必須使用和原始函式完全相同的呼叫慣例,否則堆疊會失衡,導致程式崩潰。


七、Phase 3 總結:三個檔案的協作關係

graph LR
    A["AddyLocations.h<br/>—<br/>知道地址在哪"] --> C
    B["AutoTypes.h<br/>—<br/>知道地址裡是什麼型別"] --> C
    C["Memory.cpp<br/>—<br/>知道怎麼讀寫"] --> D["Client.cpp<br/>dllmain.cpp<br/>等上層模組"]

    A -.->|"addys::CWvsApp_SetUp\n= 0x009F5239"| C
    B -.->|"FnCWvsApp_SetUp\n= void(__thiscall*)(void*)"| C
    C -.->|"Memory::WriteProtected\nMemory::Read<T>"| D

三個元件各司其職,缺一不可:

  • 沒有地址 → 不知道去哪裡操作
  • 沒有型別 → 讀出來的數字沒有意義
  • 沒有工具函式 → 每次操作都要手寫 VirtualProtect

八、常見問題

Q:如果我想新增一個遊戲資料 struct,怎麼確認欄位偏移正確?

最可靠的方式是用 Cheat Engine 的「Dissect Data/Structures」功能,附加到遊戲後讓它自動分析一個物件的記憶體佈局,找出每個偏移對應的值。再對照 IDA Pro 的反組譯來確認欄位名稱。

Q: enum class: i16 是必要的嗎?

如果遊戲的職業 ID 欄位是 2 byte(i16),那麼你的 struct 裡對應欄位的型別就必須是 JobId(底層為 i16),總大小才不會出錯。如果不指定底層型別,enum class 預設用 int(4 byte),會讓 struct 的大小多出 2 byte,破壞後續欄位的偏移。

Q:可以在 struct 裡面放 C++ 標準函式庫的容器嗎?(如 std::string

絕對不行。你的 struct 的目的是「描述遊戲記憶體裡面有什麼」,遊戲記憶體裡沒有 std::string 物件,只有 char 陣列或自訂的字串類別。放進去只會讓大小和偏移全部錯誤,程式立刻崩潰。


延伸閱讀