Hello World

吞风吻雨葬落日 欺山赶海踏雪径

0%

WOW 3.3.5a DBC 文件格式与解析

最近在排查 AzerothCore + PlayerBots 的 LFG 问题时,又翻到了 LFGDungeons.dbc。顺便把 3.3.5a 的 DBC 二进制格式和解析方式整理一下,方便以后直接读表,不用每次都去翻源码。

DBC 是什么

DBC 全称 DataBase Client,是魔兽世界客户端用的静态数据表。3.3.5a 这一代仍然是经典的 WDBC 格式。

服务端启动时会从 DataDir/dbc/ 读取这些文件,把地图、法术、LFG 副本、技能、阵营等“客户端权威定义”加载进内存。常见路径:

1
2
3
4
<DataDir>/dbc/Map.dbc
<DataDir>/dbc/Spell.dbc
<DataDir>/dbc/LFGDungeons.dbc
<DataDir>/dbc/enUS/Map.dbc

注意几点:

  1. 主 DBC 一般来自客户端 MPQ 解包后的 DBFilesClient/*.dbc
  2. AzerothCore 还会尝试加载同名本地化目录里的字符串覆盖
  3. 部分表还允许通过数据库二次覆盖,但那是另一层逻辑,不改变 DBC 文件本身格式

对应源码入口:

  • src/common/DataStores/DBCFileLoader.cpp
  • src/server/shared/DataStores/DBCfmt.h
  • src/server/shared/DataStores/DBCStructure.h
  • src/server/game/DataStores/DBCStores.cpp

文件整体结构

3.3.5a 的 DBC 非常规整,整个文件只有三块:

1
2
3
4
5
6
7
+----------------------+
| Header (20 bytes) |
+----------------------+
| Records block | recordCount * recordSize
+----------------------+
| String block | stringBlockSize bytes
+----------------------+

全部多字节数值都是 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
2
3
4
5
6
7
import struct
from pathlib import Path

data = Path("Map.dbc").read_bytes()
magic, record_count, field_count, record_size, string_size = struct.unpack_from("<4sIIII", data, 0)
assert magic == b"WDBC"
print(record_count, field_count, record_size, string_size)

先做长度校验:

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
2
3
4
5
6
7
8
string_block = data[20 + record_count * record_size:]

def read_cstring(offset: int) -> str:
if offset >= len(string_block):
return ""
end = string_block.find(b"\x00", offset)
raw = string_block[offset: end if end != -1 else None]
return raw.decode("utf-8", errors="replace")

为什么必须有 format 字符串

因为 DBC 不自描述。AzerothCore 用 DBCfmt.h 里的 format 字符串描述:

  1. 每列在文件里怎么读
  2. 哪些列要进 C++ 结构体
  3. 哪些列直接跳过
  4. 哪一列是主键/索引

例如:

1
2
char constexpr LFGDungeonEntryfmt[] =
"nssssssssssssssssxiiiiiiiiixxixixxxxxxxxxxxxxxxxx";

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
2
文件头里的 recordSize  = 磁盘上真实记录大小
format 映射后的大小 = C++ 结构体大小

两者通常不相等,因为 x/X/d 在文件里占位,但不会进结构体。

核心加载时还会断言:

1
ASSERT(DBCFileLoader::GetFormatRecordSize(fmt) == sizeof(T));

所以:

  1. len(fmt) 必须等于 fieldCount
  2. format 版本必须和客户端版本匹配
  3. 结构体字段顺序必须和 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
2
3
4
dbc/Map.dbc
dbc/enUS/Map.dbc
dbc/zhCN/Map.dbc
...

后加载的本地化 DBC 用来覆盖字符串字段。

解析步骤

方式一:无 schema 探测

适合先看一张陌生表长什么样。

  1. 读 20 字节 header,校验 WDBC
  2. 校验文件总长
  3. 如果 recordSize == fieldCount * 4,按全 4 字节字段切分
  4. 每个字段先当 uint32 读出
  5. 同时尝试解释成 float / string offset
  6. 偏移落在 string block 内,且内容可打印,就标成字符串候选

伪代码:

1
2
3
4
5
6
7
records_offset = 20
for i in range(record_count):
base = records_offset + i * record_size
fields = [
struct.unpack_from("<I", data, base + 4 * c)[0]
for c in range(field_count)
]

这种方式快,但遇到带 b/X 的表会翻车,因为不是每列都是 4 字节。

方式二:按 AzerothCore format 精确解析

这是正规做法。

  1. 读 header
  2. 确认 len(fmt) == fieldCount
  3. 按 format 计算每列磁盘偏移:
    • b/X 占 1 字节
    • 其他占 4 字节
  4. 按字符取值:
    • n/i -> uint32
    • f -> float
    • s -> uint32 offset -> 字符串
    • b -> uint8
    • x/X/d -> 跳过或仅调试输出
  5. 如果有 n,建立 id -> record 索引

字段偏移计算:

1
2
3
4
5
6
7
def compute_field_offsets(fmt: str):
offsets = []
pos = 0
for ch in fmt:
offsets.append(pos)
pos += 1 if ch in ("b", "X") else 4
return offsets

核心加载路径等价于:

1
2
3
4
5
LoadDBCStores
-> LoadDBC(..., sLFGDungeonStore, "LFGDungeons.dbc")
-> DBCFileLoader::Load
-> AutoProduceData
-> AutoProduceStrings

实战: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
2
3
4
5
6
0 = LFG_TYPE_NONE
1 = LFG_TYPE_DUNGEON
2 = LFG_TYPE_RAID
4 = LFG_TYPE_ZONE
5 = LFG_TYPE_HEROIC
6 = LFG_TYPE_RANDOM

另外核心里还有:

1
uint32 Entry() const { return ID + (TypeID << 24); }

客户端 LFG slot 经常带 type 高位,服务端入队时会先:

1
uint32 dungeon = slot & 0x00FFFFFF;

这也是为什么日志里看到 dungeon 0 时,要先分清:

  1. 是真实 DBC 里有没有 id=0
  2. 还是运行时状态把默认值 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
#!/usr/bin/env python3
import struct
from pathlib import Path

def parse_dbc(path: str, fmt: str, limit: int = 10):
data = Path(path).read_bytes()
magic, record_count, field_count, record_size, string_size = struct.unpack_from("<4sIIII", data, 0)
assert magic == b"WDBC"
assert len(fmt) == field_count

# 计算每列磁盘偏移
offsets = []
pos = 0
for ch in fmt:
offsets.append(pos)
pos += 1 if ch in ("b", "X") else 4
assert pos == record_size

string_block = data[20 + record_count * record_size:
20 + record_count * record_size + string_size]

def cstring(off: int) -> str:
if off >= len(string_block):
return ""
end = string_block.find(b"\x00", off)
raw = string_block[off:] if end < 0 else string_block[off:end]
return raw.decode("utf-8", errors="replace")

rows = []
for row in range(min(limit, record_count)):
base = 20 + row * record_size
item = {}
i_idx = f_idx = s_idx = 0
locale_buf = []
locale_name = None

def flush_locale():
nonlocal locale_buf, locale_name, s_idx
if locale_buf and locale_name is not None:
item[f"{locale_name}_best"] = next((x for x in locale_buf if x), "")
s_idx += 1
locale_buf = []
locale_name = None

for col, ch in enumerate(fmt):
off = base + offsets[col]
if ch in ("x", "X", "d"):
if ch == "x" and locale_buf:
flush_locale()
continue
if ch == "n":
item["ID"] = struct.unpack_from("<I", data, off)[0]
flush_locale()
elif ch == "i":
item[f"i{i_idx}"] = struct.unpack_from("<I", data, off)[0]
i_idx += 1
flush_locale()
elif ch == "f":
item[f"f{f_idx}"] = struct.unpack_from("<f", data, off)[0]
f_idx += 1
flush_locale()
elif ch == "s":
text = cstring(struct.unpack_from("<I", data, off)[0])
if locale_name is None:
locale_name = f"s{s_idx}"
item[locale_name] = text
locale_buf.append(text)
elif ch == "b":
item[f"b{i_idx}"] = data[off]
flush_locale()
rows.append(item)
return rows

# 例子:解析 LFG
fmt = "nssssssssssssssssxiiiiiiiiixxixixxxxxxxxxxxxxxxxx"
for row in parse_dbc("LFGDungeons.dbc", fmt, limit=5):
print(row["ID"], row.get("s0_best") or row.get("s0"), row.get("i0"), row.get("i5"), row.get("i8"))
# i0=MinLevel, i5=MapID, i8=TypeID

如果只是想先摸表,不带 format 也行:

1
2
3
4
5
6
7
8
9
10
def probe(path: str, limit: int = 3):
data = Path(path).read_bytes()
magic, n, fields, rec_size, str_size = struct.unpack_from("<4sIIII", data, 0)
assert magic == b"WDBC"
assert rec_size == fields * 4 # 简化前提:全 4 字节字段
string_block = data[20 + n * rec_size:]
for row in range(min(limit, n)):
base = 20 + row * rec_size
vals = [struct.unpack_from("<I", data, base + 4 * c)[0] for c in range(fields)]
print(row, vals[:8])

常见坑

  1. magic 不是 WDBC
    可能拿错版本,或者文件已经不是 3.x DBC。Cataclysm 之后逐步转 DB2,不能直接套这套解析。

  2. format 长度和 fieldCount 不一致
    基本就是客户端版本和 DBCfmt.h 对不上。

  3. 字符串乱码
    多半是把 uint32 当内联字符串读了,或者 string offset 解释错。

  4. recordSize != fieldCount * 4
    表里有 1 字节字段,不能再用“每列 4 字节”的简化读取器。

  5. 中文显示异常
    优先按 UTF-8 解,失败再考虑本地代码页。解析工具里建议 errors="replace",别直接崩。

  6. ID 不连续
    很正常。n 是业务 id,不是行号;索引表大小通常是 max(id) + 1

和 AzerothCore 的对应关系

读 DBC 时可以按这个顺序查:

  1. DBCfmt.h:这张表的 format 字符串
  2. DBCStructure.h:字段业务含义
  3. DBCFileLoader.cpp:磁盘怎么读
  4. DBCStores.cpp:启动时加载哪些文件

例如排查 LFG 时:

  1. 解析 LFGDungeons.dbc,确认目标 id / type / map / 等级区间
  2. 对照 LFGMgrGetLFGDungeon(id)GetDungeonType(id)
  3. 如果运行日志出现 Wrong dungeon type 0 for dungeon 0
    先别急着怀疑 DBC 少了数据,很多时候是 join 集合被运行时状态写成了默认值 0

小结

3.3.5a 的 DBC 本质上就是:

1
固定 20 字节头 + 定长记录数组 + 字符串池

难的不是文件格式本身,而是:

  1. 字段 schema 不在文件里
  2. 本地化字符串是偏移,不是内联
  3. 服务端 format 与结构体是“人为约定”

所以最稳的做法还是:

  1. 先用 header 确认这是合法 WDBC
  2. DBCfmt.h 的 format 精确解码
  3. DBCStructure.h 给字段命名
  4. 需要时再导出 CSV,方便和运行时日志对照

以后再遇到地图、法术、LFG 相关问题,直接把对应 .dbc 拉出来看,比只盯 SQL 表高效得多。