Hello World

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

0%

WOW BLP 纹理格式与 Python 解析

最近在折腾魔兽世界的贴图提取,BLP 是暴雪自研的 2D 纹理容器,WOW 里角色皮肤、UI 图标、地形贴图全是这个格式。本文从二进制层面拆解 BLP2 的结构,最后给一个纯 Python 的 blp2png 转换 demo。

BLP2 是什么

BLP(Blizzard Layered Picture)是暴雪为旗下游戏设计的纹理容器格式,WOW 使用的版本为 BLP2(magic = "BLP2")。它本质上是一个带 mipmap 链的图片封装,支持三种编码方式:

encoding 值 名称 说明
1 Palettized 256 色调色板 + 索引,类似 GIF
2 Compressed S3TC/DXT 块压缩(DXT1/3/5)
3 BGRA 原始 32 位像素,无压缩

配合 alphaBitDepthalphaEncoding 两个字段,可以细分出 9 种实际子格式。

文件整体布局

1
2
3
4
5
6
7
8
9
10
11
12
13
┌─────────────────────────────────────────────┐  Offset 0
│ BLPHeader (148 bytes) │
├─────────────────────────────────────────────┤ Offset 148
│ Palette (1024 bytes, 256×BGRx) │
├─────────────────────────────────────────────┤ Offset 1172
│ Mip Level 0 (full resolution) │
├─────────────────────────────────────────────┤
│ Mip Level 1 (½ × ½) │
├─────────────────────────────────────────────┤
│ ... │
├─────────────────────────────────────────────┤
│ Mip Level N (1×1) │
└─────────────────────────────────────────────┘

Palette 区域始终存在且固定 1024 字节,即使 DXT/BGRA 编码不使用它(此时全零填充)。

Header 结构(148 字节)

所有字段小端序(little-endian)。以下是 BLPConverter.h 中的原始定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
const char BLPID[4] = {'B', 'L', 'P', '2'};

struct BLPHeader
{
char id[4]; // "BLP2"
unsigned int version; // 必须为 1
unsigned char encoding; // 1 = palettized, 2 = DXT, 3 = raw BGRA
unsigned char alphaBitDepth; // 0, 1, 4, 8 (72 见于 doodad,按 DXT5 处理)
unsigned char alphaEncoding; // 7 indicates DXT5, 1 is DXT3
unsigned char hasMips; // 0 = no mips, 1 = has mips
unsigned int xResolution;
unsigned int yResolution;
unsigned int mipOffsets[16];
unsigned int mipSizes[16];
};

enum
{
BLP_ENCODING_INVALID, // 0
BLP_ENCODING_PALETTIZED, // 1
BLP_ENCODING_COMPRESSED, // 2
BLP_ENCODING_BGRA, // 3
BLP_ENCODING_COUNT
};

字段偏移表

Offset Size 字段 说明
0 4 id Magic:'B','L','P','2'
4 4 version 必须为 1
8 1 encoding 1=Palettized, 2=DXT, 3=BGRA
9 1 alphaBitDepth 0 / 1 / 4 / 8 / 72
10 1 alphaEncoding DXT 子类型标识
11 1 hasMips 0=无 mip 链, 1=有
12 4 xResolution 像素宽度
16 4 yResolution 像素高度
20 64 mipOffsets[16] 每级 mip 相对文件头的偏移
84 64 mipSizes[16] 每级 mip 的数据字节数

各字段详解

id(4 bytes)
文件标识。合法值为 "BLP2"(0x42 0x4C 0x50 0x32)。若读到 "PTCH" 说明这是补丁文件而非纹理,应直接跳过。其他任何值均视为非法文件。

version(uint32)
格式版本号,WOW 使用的 BLP2 固定为 1。不存在其他合法版本。

encoding(uint8)
决定像素数据的编码方式,是解析分支的第一判据:

枚举名 含义
0 BLP_ENCODING_INVALID 非法,不应出现
1 BLP_ENCODING_PALETTIZED 256 色调色板索引
2 BLP_ENCODING_COMPRESSED S3TC/DXT 块压缩
3 BLP_ENCODING_BGRA 原始 32-bit BGRA 像素

alphaBitDepth(uint8)
Alpha 通道位深,含义随 encoding 变化:

  • Palettized(encoding=1):0=无 alpha,1=1-bit,4=4-bit,8=完整 8-bit
  • DXT(encoding=2):0=DXT1 不透明,1=DXT1 带 alpha,4/8 配合 alphaEncoding 区分 DXT3/DXT5
  • 特殊值 72:见于部分 doodad 贴图,行为等同 DXT5(暴雪工具链 bug 遗留)

alphaEncoding(uint8)
仅在 DXT 模式下有意义,用于区分 DXT3 与 DXT5:

  • 1 → DXT3(显式 4-bit alpha)
  • 7 → DXT5(插值 alpha)
  • Palettized 模式下写入经验值:A1 格式写 1,其余写 8(读取时忽略)

hasMips(uint8)
是否包含 mipmap 链。0 表示只有 mip0(全尺寸),1 表示后续还有逐级减半的 mip 数据。即使为 0,mipOffsets[0] / mipSizes[0] 仍然有效。

xResolution / yResolution(uint32 × 2)
图像宽高(像素)。必须为非零值,通常为 2 的幂(WOW 贴图规范),但格式本身不强制。

mipOffsets[16](uint32 × 16)
每级 mip 数据相对文件起始的字节偏移。mip0 通常为 1172(= 148 header + 1024 palette)。未使用的槽位为 0。

mipSizes[16](uint32 × 16)
每级 mip 数据的字节数。与 mipOffsets 配对使用,data[offset : offset+size] 即为该级原始像素数据。未使用的槽位为 0。

Python struct 解包

1
2
3
4
5
6
7
8
9
10
import struct

HEADER_STRUCT = struct.Struct("<4sI4BII16I16I")
assert HEADER_STRUCT.size == 148

# 解包示例
(raw_id, version, encoding, alpha_bit_depth, alpha_encoding,
has_mips, width, height, *rest) = HEADER_STRUCT.unpack_from(data, 0)
mip_offsets = rest[:16] # tuple of 16 uint32
mip_sizes = rest[16:32] # tuple of 16 uint32

格式串含义:< 小端,4s magic,I version,4B 四个 uint8 标志位,II 宽高,16I16I 偏移与大小数组。

格式分类矩阵

三个字段组合决定实际编码:

flowchart TB
    A[encoding] -->|1| B[Palettized]
    A -->|2| C[DXT Compressed]
    A -->|3| D[Raw BGRA]

    B --> B0[alphaBitDepth=0 → PAL_A0]
    B --> B1[alphaBitDepth=1 → PAL_A1]
    B --> B4[alphaBitDepth=4 → PAL_A4]
    B --> B8[alphaBitDepth=8 → PAL_A8]

    C --> C1[abd=0 → DXT1 不透明]
    C --> C2[abd=1 → DXT1 1-bit alpha]
    C --> C3[abd=4, ae=1 → DXT3]
    C --> C4[abd=8, ae=1 → DXT3]
    C --> C5[abd=8, ae=7 → DXT5]
    C --> C6[abd=72 → DXT5 特殊情况]

完整对照表:

encoding alphaBitDepth alphaEncoding 格式 ID 说明
1 0 - BLP_PAL_A0 调色板,无 alpha
1 1 - BLP_PAL_A1 调色板,1-bit alpha
1 4 - BLP_PAL_A4 调色板,4-bit alpha
1 8 - BLP_PAL_A8 调色板,8-bit alpha
2 0 any BLP_DXT1_A0 DXT1 不透明
2 1 any BLP_DXT1_A1 DXT1 带 1-bit alpha
2 4 1 BLP_DXT3 DXT3
2 8 1 BLP_DXT3 DXT3
2 8 7 BLP_DXT5 DXT5
2 72 any BLP_DXT5 DXT5(doodad 常见)
3 8 - BLP_BGRA 原始 BGRA

Palette 格式

固定 256 条目,每条 4 字节,存储顺序为 B, G, R, x(x 为填充字节,无意义)。

读取时需要交换通道:

1
2
raw = np.frombuffer(data, dtype=np.uint8, count=1024, offset=148)
palette_rgb = raw.reshape(256, 4)[:, [2, 1, 0]] # BGRx → RGB

各编码的 Mip 数据布局

Palettized(encoding=1)

Mip0 数据由两部分拼接:

1
2
3
4
┌──────────────────────────────┬──────────────────────────┐
│ indices (W×H bytes) │ alpha data (变长) │
│ 每像素 1 字节调色板索引 │ 取决于 alphaBitDepth │
└──────────────────────────────┴──────────────────────────┘
alphaBitDepth Alpha 数据 大小
0 0
1 位打包,每像素 1 bit ⌈W×H / 8⌉ bytes
4 半字节打包,每像素 4 bit(0-15 映射到 0-255) ⌈W×H / 2⌉ bytes
8 完整 alpha 平面 W×H bytes

DXT Compressed(encoding=2)

DXT 以 4×4 像素块为单位压缩:

DXT 类型 块大小 特点
DXT1 8 bytes 2 个 RGB565 参考色 + 2-bit 索引表
DXT3 16 bytes 8B 显式 4-bit alpha + 8B 颜色块
DXT5 16 bytes 8B 插值 alpha + 8B 颜色块

Mip 数据大小公式:

1
2
3
blocks_x = (width + 3) / 4
blocks_y = (height + 3) / 4
size = blocks_x × blocks_y × block_size

Raw BGRA(encoding=3)

最简单的情况,每像素 4 字节 B, G, R, A 顺序排列:

1
size = width × height × 4

读取后交换 R/B 通道即得 RGBA。

DXT 解码原理

DXT1 颜色块(8 字节)

1
2
3
Bytes 0-1: color0 (RGB565, little-endian)
Bytes 2-3: color1 (RGB565, little-endian)
Bytes 4-7: 16 个 2-bit 索引(每像素 2 bit,共 32 bit)

RGB565 → RGB888 转换:

1
2
3
4
5
def c565_to_rgb(c: int) -> tuple[int, int, int]:
r = ((c >> 11) & 31) * 255 // 31
g = ((c >> 5) & 63) * 255 // 63
b = (c & 31) * 255 // 31
return r, g, b

颜色表生成规则:

  • color0 > color1:4 色不透明模式,插值出 2 个中间色
  • color0 <= color1:3 色 + 透明黑(alpha=0)

DXT3 Alpha(前 8 字节)

每像素 4 bit 显式 alpha,乘以 17 映射到 0-255:

1
alpha[i] = ((block[i // 2] >> ((i % 2) * 4)) & 0xF) * 17

DXT5 Alpha(前 8 字节)

两个参考值 a0, a1 + 48-bit 索引表(每像素 3 bit):

  • a0 > a1:8 级线性插值
  • a0 <= a1:6 级插值 + 强制 0 和 255 端点

解析流程

flowchart TB
    A[读取文件字节] --> B{文件 >= 148B?}
    B -->|No| ERR1[NotBlpError]
    B -->|Yes| C[解析 Header]
    C --> D{magic == BLP2?}
    D -->|PTCH| ERR2[PatchFileError]
    D -->|其他| ERR1
    D -->|Yes| E{version == 1?}
    E -->|No| ERR3[VersionError]
    E -->|Yes| F[classify_format]
    F --> G{encoding?}
    G -->|1| H[读取 Palette + 索引 + Alpha]
    G -->|2| I[DXT 解压缩]
    G -->|3| J[BGRA 通道交换]
    H --> K[ImageBuffer]
    I --> K
    J --> K
    K --> L[输出 PNG]

Python blp2png Demo

以下是一个精简但完整的 BLP2 → PNG 转换脚本,覆盖 Palette / DXT1 / DXT3 / DXT5 / BGRA 全部格式。依赖 numpyPillow,DXT 解码为纯 Python 实现(无需 imagecodecs)。

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
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
#!/usr/bin/env python3
"""Minimal BLP2 → PNG converter (pure Python + numpy + Pillow)."""

import math
import struct
import sys
from pathlib import Path

import numpy as np
from PIL import Image

HEADER_FMT = "<4sI4BII16I16I"
HEADER_SIZE = struct.calcsize(HEADER_FMT) # 148


def parse_blp(data: bytes):
"""解析 BLP2 header,返回 (encoding, alpha_depth, alpha_enc, w, h, mip_off, mip_size)."""
if len(data) < HEADER_SIZE:
raise ValueError("file too small")
fields = struct.unpack_from(HEADER_FMT, data, 0)
magic, version = fields[0], fields[1]
if magic == b"PTCH":
raise ValueError("PTCH patch file, not BLP2")
if magic != b"BLP2":
raise ValueError(f"bad magic: {magic!r}")
if version != 1:
raise ValueError(f"unsupported version: {version}")
encoding, alpha_depth, alpha_enc, has_mips = fields[2:6]
w, h = fields[6], fields[7]
mip_offsets = fields[8:24]
mip_sizes = fields[24:40]
return encoding, alpha_depth, alpha_enc, w, h, mip_offsets[0], mip_sizes[0]


def read_palette_rgb(data: bytes) -> np.ndarray:
"""读取 256 色 BGRx 调色板,返回 (256,3) RGB 数组。"""
raw = np.frombuffer(data, dtype=np.uint8, count=1024, offset=HEADER_SIZE)
return raw.reshape(256, 4)[:, [2, 1, 0]].copy()


def c565_to_rgb(c: int):
return ((c >> 11) & 31) * 255 // 31, ((c >> 5) & 63) * 255 // 63, (c & 31) * 255 // 31


def decode_dxt1_block(block: bytes, alpha_mode: bool):
c0, c1 = struct.unpack_from("<HH", block, 0)
bits = struct.unpack_from("<I", block, 4)[0]
r0, g0, b0 = c565_to_rgb(c0)
r1, g1, b1 = c565_to_rgb(c1)
colors = [(r0, g0, b0, 255), (r1, g1, b1, 255)]
if c0 > c1 or not alpha_mode:
colors.append(((2*r0+r1)//3, (2*g0+g1)//3, (2*b0+b1)//3, 255))
colors.append(((r0+2*r1)//3, (g0+2*g1)//3, (b0+2*b1)//3, 255))
else:
colors.append(((r0+r1)//2, (g0+g1)//2, (b0+b1)//2, 255))
colors.append((0, 0, 0, 0))
return [colors[(bits >> (2*i)) & 3] for i in range(16)]


def decode_dxt3_alpha(block: bytes):
return [((block[i//2] >> ((i%2)*4)) & 0xF) * 17 for i in range(16)]


def decode_dxt5_alpha(block: bytes):
a0, a1 = block[0], block[1]
bits = int.from_bytes(block[2:8], "little")
table = [a0, a1] + [0]*6
if a0 > a1:
for i in range(1, 7):
table[i+1] = ((7-i)*a0 + i*a1) // 7
else:
for i in range(1, 5):
table[i+1] = ((5-i)*a0 + i*a1) // 5
table[6], table[7] = 0, 255
return [table[(bits >> (3*i)) & 7] for i in range(16)]


def decompress_dxt(data: bytes, w: int, h: int, kind: str) -> np.ndarray:
"""解压 DXT1/3/5 数据为 (H, W, 4) RGBA 数组。"""
out = np.zeros((h, w, 4), dtype=np.uint8)
bx_count, by_count = (w+3)//4, (h+3)//4
block_size = 8 if kind == "dxt1" else 16
pos = 0
for by in range(by_count):
for bx in range(bx_count):
if kind == "dxt1":
pixels = decode_dxt1_block(data[pos:pos+8], alpha_mode=True)
alphas = [p[3] for p in pixels]
elif kind == "dxt3":
alphas = decode_dxt3_alpha(data[pos:pos+8])
pixels = decode_dxt1_block(data[pos+8:pos+16], alpha_mode=False)
else: # dxt5
alphas = decode_dxt5_alpha(data[pos:pos+8])
pixels = decode_dxt1_block(data[pos+8:pos+16], alpha_mode=False)
pos += block_size
for i in range(16):
px, py = bx*4 + i%4, by*4 + i//4
if px < w and py < h:
r, g, b, _ = pixels[i]
out[py, px] = (r, g, b, alphas[i])
return out


def blp_to_png(src: str, dst: str | None = None):
data = Path(src).read_bytes()
encoding, alpha_depth, alpha_enc, w, h, mip_off, mip_size = parse_blp(data)
level0 = data[mip_off : mip_off + mip_size]

if encoding == 1: # Palettized
palette = read_palette_rgb(data)
n = w * h
indices = np.frombuffer(level0, dtype=np.uint8, count=n).reshape(h, w)
rgb = palette[indices] # (H, W, 3)
if alpha_depth == 8:
alpha = np.frombuffer(level0, dtype=np.uint8, count=n, offset=n).reshape(h, w)
rgba = np.dstack([rgb, alpha])
img = Image.fromarray(rgba, "RGBA")
elif alpha_depth == 1:
nb = math.ceil(n / 8)
bits = level0[n : n+nb]
alpha = np.array([255 if bits[i//8] & (1 << i%8) else 0 for i in range(n)],
dtype=np.uint8).reshape(h, w)
rgba = np.dstack([rgb, alpha])
img = Image.fromarray(rgba, "RGBA")
elif alpha_depth == 4:
nb = math.ceil(n / 2)
nibs = level0[n : n+nb]
alpha = np.array([int(round(255 * ((nibs[i//2] >> ((i%2)*4)) & 0xF) / 15))
for i in range(n)], dtype=np.uint8).reshape(h, w)
rgba = np.dstack([rgb, alpha])
img = Image.fromarray(rgba, "RGBA")
else:
img = Image.fromarray(rgb, "RGB")

elif encoding == 2: # DXT
if alpha_depth == 0:
kind = "dxt1"
elif alpha_depth == 1:
kind = "dxt1"
elif alpha_depth in (4, 8) and alpha_enc == 1:
kind = "dxt3"
else:
kind = "dxt5"
rgba = decompress_dxt(level0, w, h, kind)
if alpha_depth == 0:
img = Image.fromarray(rgba[..., :3], "RGB")
else:
img = Image.fromarray(rgba, "RGBA")

elif encoding == 3: # BGRA
bgra = np.frombuffer(level0, dtype=np.uint8, count=w*h*4).reshape(h, w, 4)
rgba = bgra[..., [2, 1, 0, 3]] # B,G,R,A → R,G,B,A
img = Image.fromarray(np.ascontiguousarray(rgba), "RGBA")

else:
raise ValueError(f"unknown encoding: {encoding}")

out = dst or str(Path(src).with_suffix(".png"))
img.save(out, "PNG")
print(f"{src} ({w}x{h}) -> {out}")


if __name__ == "__main__":
if len(sys.argv) < 2:
print("Usage: python blp2png.py <input.blp> [output.png]")
sys.exit(1)
blp_to_png(sys.argv[1], sys.argv[2] if len(sys.argv) > 2 else None)

使用方式

1
2
3
4
5
6
7
pip install numpy Pillow

# 单文件转换
python blp2png.py texture.blp

# 指定输出路径
python blp2png.py icon.blp output/icon.png

运行效果

1
2
3
4
5
$ python blp2png.py CharacterSkin00.blp
CharacterSkin00.blp (256x256) -> CharacterSkin00.png

$ python blp2png.py SpellIcon_fireball.blp
SpellIcon_fireball.blp (64x64) -> SpellIcon_fireball.png

小结

  • BLP2 = 148B Header + 1024B Palette + N 级 Mip 数据,结构紧凑
  • 编码由 encoding + alphaBitDepth + alphaEncoding 三字段联合决定
  • Palettized 本质是索引图 + 独立 alpha 平面(位/半字节/字节三种打包)
  • DXT 以 4×4 块为单位,DXT1 8B/块、DXT3/5 16B/块
  • Python 解析核心就是 struct 拆 header → numpy 操作像素 → Pillow 写 PNG
  • 完整实现见 analysis/blpconv.py,可直接作为库使用:from blpconv import load_blp, blp_to_png