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 hp比unsigned 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 陣列或自訂的字串類別。放進去只會讓大小和偏移全部錯誤,程式立刻崩潰。
延伸閱讀
- 03_AddyLocations.h_地址表解析 — 型別要搭配地址才有用
- 02_Memory.cpp_讀寫記憶體工具 — 實際操作記憶體的工具
- 02_解析度修改原理與實作 — Phase 5:使用 AutoTypes 讀取和修改遊戲解析度
- 01_什麼是函式掛鉤(Hooking) — Phase 4:函式指標型別在 Hook 中的應用