最近在排查 AzerothCore + PlayerBots 的 LFG 问题时,又翻到了 LFGDungeons.dbc。顺便把 3.3.5a 的 DBC 二进制格式和解析方式整理一下,方便以后直接读表,不用每次都去翻源码。
DBC 是什么
DBC 全称 DataBase Client,是魔兽世界客户端用的静态数据表。3.3.5a 这一代仍然是经典的 WDBC 格式。
服务端启动时会从 DataDir/dbc/ 读取这些文件,把地图、法术、LFG 副本、技能、阵营等“客户端权威定义”加载进内存。常见路径:
1 | <DataDir>/dbc/Map.dbc |
注意几点:
- 主 DBC 一般来自客户端 MPQ 解包后的
DBFilesClient/*.dbc - AzerothCore 还会尝试加载同名本地化目录里的字符串覆盖
- 部分表还允许通过数据库二次覆盖,但那是另一层逻辑,不改变 DBC 文件本身格式
对应源码入口:
src/common/DataStores/DBCFileLoader.cppsrc/server/shared/DataStores/DBCfmt.hsrc/server/shared/DataStores/DBCStructure.hsrc/server/game/DataStores/DBCStores.cpp
文件整体结构
3.3.5a 的 DBC 非常规整,整个文件只有三块:
1 | +----------------------+ |
全部多字节数值都是 little-endian。
Header(20 字节)
| Offset | Size | Type | 字段 | 说明 |
|---|---|---|---|---|
| 0 | 4 | char[4] | magic | 固定 WDBC,按 uint32 读是 0x43424457 |
| 4 | 4 | uint32 | recordCount | 记录条数 |
| 8 | 4 | uint32 | fieldCount | 每条记录字段数 |
| 12 | 4 | uint32 | recordSize | 单条记录字节数 |
| 16 | 4 | uint32 | stringBlockSize | 字符串块字节数 |
用 Python 读头:
1 | import struct |
先做长度校验:
1 | 20 + recordCount * recordSize + stringBlockSize == file_size |
对不上就说明文件截断、版本不对,或者根本不是 3.x 的 WDBC。
Records 块
- 紧接 header 之后
- 第
i条记录起始:20 + i * recordSize - 记录内字段按顺序紧密排列
- 没有字段名,没有类型元数据
- 绝大多数字段是 4 字节,少数表会出现 1 字节字段
也就是说,DBC 文件自己不告诉你“第 3 列是 float 还是 string”,解析者必须额外知道 schema。
String 块
- 紧接 records 之后
- 内容是一组以
\0结尾的 C 字符串拼接 - 记录里的字符串字段存的是 相对 string block 起点的 uint32 偏移
- 偏移
0通常对应空字符串
读取示意:
1 | string_block = data[20 + record_count * record_size:] |
为什么必须有 format 字符串
因为 DBC 不自描述。AzerothCore 用 DBCfmt.h 里的 format 字符串描述:
- 每列在文件里怎么读
- 哪些列要进 C++ 结构体
- 哪些列直接跳过
- 哪一列是主键/索引
例如:
1 | char constexpr LFGDungeonEntryfmt[] = |
format 字符定义在 DBCFileLoader.h:
| 字符 | 文件中大小 | 是否进入结构体 | 含义 |
|---|---|---|---|
n |
4 | 是 | 索引/主键,写入结构体,并用于 indexTable[id] |
i |
4 | 是 | uint32 |
f |
4 | 是 | float |
s |
4 | 是 | 字符串偏移,加载后变成 char* |
b |
1 | 是 | uint8 |
x |
4 | 否 | 跳过 4 字节未知/无用字段 |
X |
1 | 否 | 跳过 1 字节未知/无用字段 |
d |
4 | 否 | 排序键,文件中有,结构体不存 |
l |
4 | 是 | 逻辑值,本仓库加载路径基本不用 |
这里有个关键区别:
1 | 文件头里的 recordSize = 磁盘上真实记录大小 |
两者通常不相等,因为 x/X/d 在文件里占位,但不会进结构体。
核心加载时还会断言:
1 | ASSERT(DBCFileLoader::GetFormatRecordSize(fmt) == sizeof(T)); |
所以:
len(fmt)必须等于fieldCount- format 版本必须和客户端版本匹配
- 结构体字段顺序必须和 format 中“保留字段”一致
字符串与本地化
很多名字不是单个 string,而是:
1 | Name[16] + Name_lang_mask |
在 format 里常写成:
1 | ssssssssssssssssx |
也就是 16 个本地化字符串 + 1 个 mask。
常见 locale 槽位:
| Index | Locale |
|---|---|
| 0 | enUS |
| 1 | koKR |
| 2 | frFR |
| 3 | deDE |
| 4 | zhCN |
| 5 | zhTW |
| 6 | esES |
| 7 | esMX |
| 8 | ruRU |
服务端取名时通常先看当前 locale,没有就回退到第一个非空字符串。AzerothCore 启动时还会尝试:
1 | dbc/Map.dbc |
后加载的本地化 DBC 用来覆盖字符串字段。
解析步骤
方式一:无 schema 探测
适合先看一张陌生表长什么样。
- 读 20 字节 header,校验
WDBC - 校验文件总长
- 如果
recordSize == fieldCount * 4,按全 4 字节字段切分 - 每个字段先当 uint32 读出
- 同时尝试解释成 float / string offset
- 偏移落在 string block 内,且内容可打印,就标成字符串候选
伪代码:
1 | records_offset = 20 |
这种方式快,但遇到带 b/X 的表会翻车,因为不是每列都是 4 字节。
方式二:按 AzerothCore format 精确解析
这是正规做法。
- 读 header
- 确认
len(fmt) == fieldCount - 按 format 计算每列磁盘偏移:
b/X占 1 字节- 其他占 4 字节
- 按字符取值:
n/i-> uint32f-> floats-> uint32 offset -> 字符串b-> uint8x/X/d-> 跳过或仅调试输出
- 如果有
n,建立id -> record索引
字段偏移计算:
1 | def compute_field_offsets(fmt: str): |
核心加载路径等价于:
1 | LoadDBCStores |
实战:LFGDungeons.dbc
format:
1 | nssssssssssssssssxiiiiiiiiixxixixxxxxxxxxxxxxxxxx |
对应 LFGDungeonEntry 关键字段:
| 文件列 | format | 字段 | 说明 |
|---|---|---|---|
| 0 | n | ID | LFG dungeon id |
| 1-16 | s*16 | Name[16] | 本地化名 |
| 17 | x | Name_lang_mask | 跳过 |
| 18 | i | MinLevel | 最低等级 |
| 19 | i | MaxLevel | 最高等级 |
| 20 | i | TargetLevel | 目标等级 |
| 21 | i | TargetLevelMin | |
| 22 | i | TargetLevelMax | |
| 23 | i | MapID | 地图 id |
| 24 | i | Difficulty | 难度 |
| 25 | i | Flags | 标志 |
| 26 | i | TypeID | 类型 |
| 27 | x | Faction | 跳过 |
| 28 | x | TextureFilename | 跳过 |
| 29 | i | ExpansionLevel | 资料片 |
| 30 | x | OrderIndex | 跳过 |
| 31 | i | GroupID | random 分组 |
TypeID 对应:
1 | 0 = LFG_TYPE_NONE |
另外核心里还有:
1 | uint32 Entry() const { return ID + (TypeID << 24); } |
客户端 LFG slot 经常带 type 高位,服务端入队时会先:
1 | uint32 dungeon = slot & 0x00FFFFFF; |
这也是为什么日志里看到 dungeon 0 时,要先分清:
- 是真实 DBC 里有没有 id=0
- 还是运行时状态把默认值 0 塞进了 join 集合
正常 LFGDungeons.dbc 里,0 不是有效副本。
实战:Map.dbc
format:
1 | nxiixssssssssssssssssxixxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxixiffxixi |
关键字段:
| 字段 | 说明 |
|---|---|
| MapID | 地图 id |
| map_type | 0 世界 / 1 副本 / 2 团队 / 3 战场 / 4 竞技场 |
| Flags | 地图标志 |
| name[16] | 本地化名 |
| entrance_map/x/y | 入口信息 |
| expansionID | 0 Vanilla / 1 TBC / 2 WotLK |
| maxPlayers | 人数上限 |
简单表如 BankBagSlotPrices.dbc 只有:
1 | ni |
很适合拿来验证解析器有没有把 header 和字段偏移读对。
Python 解析示例
最小可用版本:
1 | #!/usr/bin/env python3 |
如果只是想先摸表,不带 format 也行:
1 | def probe(path: str, limit: int = 3): |
常见坑
magic 不是 WDBC
可能拿错版本,或者文件已经不是 3.x DBC。Cataclysm 之后逐步转 DB2,不能直接套这套解析。format 长度和 fieldCount 不一致
基本就是客户端版本和DBCfmt.h对不上。字符串乱码
多半是把 uint32 当内联字符串读了,或者 string offset 解释错。recordSize != fieldCount * 4
表里有 1 字节字段,不能再用“每列 4 字节”的简化读取器。中文显示异常
优先按 UTF-8 解,失败再考虑本地代码页。解析工具里建议errors="replace",别直接崩。ID 不连续
很正常。n是业务 id,不是行号;索引表大小通常是max(id) + 1。
和 AzerothCore 的对应关系
读 DBC 时可以按这个顺序查:
DBCfmt.h:这张表的 format 字符串DBCStructure.h:字段业务含义DBCFileLoader.cpp:磁盘怎么读DBCStores.cpp:启动时加载哪些文件
例如排查 LFG 时:
- 解析
LFGDungeons.dbc,确认目标 id / type / map / 等级区间 - 对照
LFGMgr里GetLFGDungeon(id)和GetDungeonType(id) - 如果运行日志出现
Wrong dungeon type 0 for dungeon 0
先别急着怀疑 DBC 少了数据,很多时候是 join 集合被运行时状态写成了默认值0
小结
3.3.5a 的 DBC 本质上就是:
1 | 固定 20 字节头 + 定长记录数组 + 字符串池 |
难的不是文件格式本身,而是:
- 字段 schema 不在文件里
- 本地化字符串是偏移,不是内联
- 服务端 format 与结构体是“人为约定”
所以最稳的做法还是:
- 先用 header 确认这是合法 WDBC
- 用
DBCfmt.h的 format 精确解码 - 用
DBCStructure.h给字段命名 - 需要时再导出 CSV,方便和运行时日志对照
以后再遇到地图、法术、LFG 相关问题,直接把对应 .dbc 拉出来看,比只盯 SQL 表高效得多。