简介:面向仍在维护或重制 XNA 游戏、需要提取图像素材的开发者与玩家,这份 XNB 转 PNG 工具用于把微软 XNA 框架生成的二进制资源转换为通用 PNG 图像,解决旧版转换器兼容性不足、可用工具稀少的问题。压缩包共 23 个文件,约 3.72MB,包含可执行转换程序、运行所需 DLL 依赖、XML 配置与说明文档、示例 XNB 素材及调试符号文件,并附“使用说明必读”文本,便于快速了解运行环境与转换流程。已有 5011 人学习下载,说明其在 XNA 资源处理场景中具有一定参考价值。借助包内示例与说明,读者可实践 XNB 二进制解析、图像解码和 PNG 编码等环节,将转换结果导入常见图形软件继续编辑;若遇到文件不兼容或输出异常,也可通过作者留下的联系方式获取技术支持,适合作为旧游戏资源整理与重制的基础工具。
1. XNB 转 PNG 到底在转什么:一份能跑通的转换器拆解
把一个.xnb文件手动改成.png,是很多人踩的第一个坑:文件头还是 XNB 的魔数,图片查看器当然打不开,因为改后缀不会让一个二进制容器变成位图。XNB 是 XNA 内容管线编译出来的产物,里面装着平台标识、压缩标志、类型读取器字符串,以及一块被 LZX 压过的纹理数据;像素本身还可能是 DXT 块压缩的。所以 XNB 转 PNG 本质上是三段解码串起来:拆头、解压、解纹理格式,最后才落成 PNG。
这份转换器要解决的就是这条链路上的确定性——不是拿一个在线工具转两张图看看效果,而是能把几万个文件跑完、失败能定位、结果能校验。适合的人很明确:做老项目美术资源迁移的、要给既有素材做替换的、以及在 CI 里需要对资源文件做哈希比对的从业者。后面几章按链路顺序拆,每一步都给能直接跑的代码和参数说明。
2. 拆开 XNB 头:10 字节定长头与 7-bit 编码的类型字符串
XNB 的头部结构非常固定,固定到有点反直觉——只有 10 个字节,但字段少不代表好处理,因为你必须在动手解压之前就把这几项全部校验一遍,否则后面出错时你根本分不清是解压挂了还是偏移算错了。
2.1 十字节定长头里三个必须校验的字段
头部布局是这样的:字节 0 到 2 是魔数XNB,字节 3 是平台标识,字节 4 是格式版本,字节 5 是标志位,字节 6 到 9 是一个 uint32 的文件总长度。平台标识常见的是w(Windows)、m(移动端)、x(主机),版本 4 对应较早的内容管线,版本 5 是后来那套。标志位的最高位0x80是压缩开关,这一位决定了你后面要不要走 LZX 那条路。
三个必须校验的字段:魔数不对直接判死,别硬着头皮往下解;file_size和实际文件长度不一致,说明文件被截断或者是被二次包裹过的,这种情况在做过资源加密的项目里很常见;平台标识不认识就等于后面的纹理格式假设可能全错。我一般会把这几个检查做在peek_header里,让它在真正读数据之前就抛异常,报错信息里带上文件名和实际值。
import struct from pathlib import Path PLATFORM = {ord('w'): 'Windows', ord('m'): 'Mobile', ord('x'): 'Console'} SURFACE = {0: 'Color', 1: 'Bgr565', 2: 'Bgra5551', 3: 'Bgra4444', 4: 'Dxt1', 5: 'Dxt3', 6: 'Dxt5', 12: 'Alpha8'} def peek_header(path): raw = Path(path).read_bytes() if len(raw) < 10 or raw[:3] != b'XNB': raise ValueError(f'{path}: magic 不是 XNB') h = { 'platform': PLATFORM.get(raw[3], hex(raw[3])), 'version': raw[4], # 4=老管线, 5=新管线 'flags': raw[5], 'file_size': struct.unpack_from('<I', raw, 6)[0], 'compressed': bool(raw[5] & 0x80), # bit7 才是压缩开关 } if h['file_size'] != len(raw): raise ValueError(f'{path}: 声明长度 {h["file_size"]} 实际 {len(raw)}') off = 10 if h['compressed']: # 压缩块紧跟一个 uint32:解压后的目标长度 h['decompressed_size'] = struct.unpack_from('<I', raw, off)[0] off += 4 h['payload_offset'] = off return h, rawpayload_offset是这个函数最有价值的输出。压缩文件是 14,未压缩文件是 10,后面所有解码都从这个偏移开始。decompressed_size别浪费,它会成为你解压环节唯一的长度基准,缺了它你没法判断 lzxd 是正常结束还是提前返回。标志位判断一定要用flags & 0x80而不是flags != 0,标志位里还有别的位在用,用不等零判断会在部分文件上翻车。
2.2 7-bit 长度前缀的类型字符串怎么读
偏移确定之后,内容区的第一个东西是一段字符串:类型读取器的完整名称。它用的是 .NET 那套 7-bit 变长整数做长度前缀——每个字节低 7 位是数据,最高位是续位标志,读起来像这样:遇到高位为 1 就继续读下一个字节,直到高位为 0 为止。字符串本身是 UTF-8 编码,长度单位是字节不是字符。
字符串后面跟一个 int32 的读取器版本号,再跟一个 7-bit 编码的共享资源计数。这三样东西加起来构成了内容区的前导信息,Texture2D 的正式数据从它们之后才开始。这里的坑在于类型字符串不能做全等匹配:不同版本的管线写出来的名字不一样,有的带Graphics命名空间有的不带,稳妥做法是判断子串Texture2DReader是否存在。
def read_7bit(buf, pos): value, shift = 0, 0 while True: b = buf[pos] pos += 1 value |= (b & 0x7F) << shift if not (b & 0x80): # 最高位为 0 表示这是最后一个字节 return value, pos shift += 7 def read_string(buf, pos): n, pos = read_7bit(buf, pos) return buf[pos:pos + n].decode('utf-8'), pos + n def parse_preamble(body): reader, pos = read_string(body, 0) if 'Texture2DReader' not in reader: raise ValueError(f'不是纹理资源: {reader}') version = struct.unpack_from('<i', body, pos)[0] pos += 4 shared, pos = read_7bit(body, pos) return {'reader': reader, 'reader_version': version, 'shared': shared, 'body_pos': pos}shared计数在纹理资源里基本都是 0,但你得读它,因为它占位,不读就会整体错位 1 到 2 个字节,后面解出来的图会变成一堆斜条纹。body_pos就是 Texture2D 数据的起点,这个值我习惯在日志里打出来,出问题时对一眼就知道是不是前导信息解析偏了。
2.3 纹理数据段:从 SurfaceFormat 到 mip 链
前导信息之后是 Texture2D 的实体:一个 int32 的 SurfaceFormat 枚举值、一个 int32 的宽度、一个 int32 的高度、一个 int32 的 mip 层数,然后每一层 mip 都是「int32 数据长度 + 该长度的字节」。这个结构看着简单,但 mip 层数写 0 的情况是存在的,某些导出器不写 mip 链,你得兜底按 1 层处理;还有的文件的 mip 层数写的是 1 但数据区明显能塞下更多层,那多半是把别的字段当成了层数,直接从字节数反推更靠谱。
宽度高度不需要额外换算,就是像素数。SurfaceFormat 的值决定了后面走哪条解码分支,0是直读的 RGBA8,456分别对应三种块压缩格式,12是单通道灰度。这些值在枚举表里还有别的成员,比如各种 16 位打包格式和浮点格式,实际资源里出现频率不高,但遇到时不能当成未知格式直接跳过,至少要报出来让人知道是哪种。
def parse_texture2d(body, pos): fmt = struct.unpack_from('<i', body, pos)[0]; pos += 4 width = struct.unpack_from('<i', body, pos)[0]; pos += 4 height = struct.unpack_from('<i', body, pos)[0]; pos += 4 levels = struct.unpack_from('<i', body, pos)[0]; pos += 4 if levels <= 0: levels = 1 # 不写 mip 链时的兜底 mips = [] for _ in range(levels): size = struct.unpack_from('<i', body, pos)[0]; pos += 4 mips.append(body[pos:pos + size]) pos += size if pos != len(body): # 尾部还有残留字节,说明层数或前导偏移算错了 raise ValueError(f'数据未对齐: 用掉 {pos} 总共 {len(body)}') return {'format': fmt, 'format_name': SURFACE.get(fmt, f'Unknown({fmt})'), 'width': width, 'height': height, 'mips': mips}最后那个对齐检查建议保留。它会替你抓出绝大多数「图片能出但内容不对」的问题——只要pos和len(body)对不上,说明层数、长度前缀或者偏移至少有一个是错的,这时候继续往下解码只会得到一张看着像图但完全不对的产物,比直接报错更浪费时间。
3. LZX 解压层:为什么 90% 的转换失败卡在这一步
头部解析能跑通之后,你会遇到第一个真正困难的地方。压缩的 XNB 里,payload_offset之后不是内容,是一段 LZX 裸流。这一步没有捷径:不解压,你拿到的就是一坨看起来有规律但完全读不懂的字节,硬按纹理格式解出来只会得到噪点图。
3.1 LZX 裸流与 CAB 容器的差别
LZX 本身是个通用压缩算法,常见于 CAB 归档格式。但 XNB 里的 LZX 是裸流——它没有 CAB 那套文件夹头和块索引,压缩块直接从第一个块头开始。这意味着你没法拿cabextract这类工具直接处理,得用能指定「从零开始解一个裸流」的实现。
另一个必须区分的是版本。较新的内容管线用的是 16 位窗口的 LZX 变体,窗口大小 64KB,块大小也是按这个窗口来的。较早的那套用的是完全不同的私有压缩算法,两者没有任何兼容性——你把老版本的文件喂给 LZX 解码器,它不会给你一个模糊的结果,它会直接抛异常或者返回空。所以version == 4和version == 5在解压这一层必须分两条路走,别想着一个解码器通吃。
还有一点容易被忽略:LZX 的窗口重置。裸流里没有显式的重置标记,解码器需要自己按固定间隔重置状态。多数库的默认值和 XNB 的实际情况是对得上的,但如果你换了一个库或者换了版本,这个重置间隔很可能就是默认值的偏差,症状是前几百字节正确、后面开始出乱码。这类问题没有捷径,只能拿一个已知正确的样本逐段对比。
3.2 用 lzxd 走通解压,并用目标长度卡住输出
Python 这边我一般用lzxd这个包,它是 libmspack 那套 LZX 实现的可移植版本,装完之后直接构造解码器就行。关键参数是窗口位数,XNB 用 16;解完之后一定要按头部读出来的decompressed_size截断并校验,因为解码器可能因为流尾标记、块对齐等原因多吐或者少吐几个字节,直接用它的返回值当内容长度,后面 7-bit 字符串一解析就崩。
def decompress_payload(raw, header): if not header['compressed']: return raw[header['payload_offset']:] payload = raw[header['payload_offset']:] target = header['decompressed_size'] from lzxd import LZXDecompressor dec = LZXDecompressor(window_bits=16) # XNB 固定 16 位窗口 out = dec.decompress(payload) if len(out) < target: raise ValueError(f'LZX 输出不足: {len(out)} < {target}') if len(out) > target: out = out[:target] # 按声明长度截断 return outwindow_bits=16不是可以随便改的参数。窗口小了,跨窗口的回溯引用解析不出来,会得到周期性错乱的输出;窗口大了,解码器会在流中途读到不存在的状态。不同发行版的lzxd构造参数名可能是window_bits也可能是别的写法,装完先看一眼它的构造函数签名再动手,这一步花两分钟能省掉半小时的迷惑。解压结果我建议顺手算个长度和 CRC 打进日志,批量跑的时候哪一批出问题一目了然。
如果你的环境不方便装这个包,还有个现实的选择:让转换器外壳调用外部工具完成拆包,把解出来的中间产物再拿回来做后续处理。代价是多一次磁盘 IO 和一个外部依赖,但换来了不用自己维护解压链路的稳定性,批量场景下这个交换通常是值的。
3.3 版本 4 的老文件需要另一套解压器
遇到version == 4的文件,前面那套逻辑会在 LZX 解码器里直接失败。这不是你代码写错了,是压缩算法根本不同。这类文件在实际项目里出现的比例不高,但一旦碰上就是全盘卡住,所以转换器里应该显式地把它识别出来并给出明确提示,而不是让它抛一个看不懂的底层异常。
我一般会在解压函数入口加一个版本分支:版本 5 走 LZX,版本 4 抛一个带明确说明的异常,日志里写清楚「老管线压缩,需要独立解压器」。这样批量跑的时候,你能一眼看出失败的文件是哪一类,是不是可以整体跳过。比起让它们在 LZX 解码器里炸出一堆堆栈,这种分类清楚得多。
顺带说一句,未压缩的 XNB 虽然少见,但处理起来最省事——payload_offset之后直接就是内容区,跳过整个解压环节。所以你的函数一定要保留未压缩分支,别假设所有文件都压过。
4. 从 SurfaceFormat 到 PNG:DDS 头复用与预乘 Alpha 还原
到这一步你手上是一段干净的纹理字节流,格式、宽高、mip 链都拿到了。剩下的工作是把这段字节变成像素,再写成 PNG。麻烦的地方在于块压缩格式:DXT1 每 4x4 像素块占 8 字节,DXT3 和 DXT5 每块 16 字节,手写解码器不是不行,但容易在边界块的填充和 alpha 插值上出错。
4.1 SurfaceFormat 枚举决定解码路径
不同格式的解码代价差别很大,先分类再动手:
| 枚举值 | 名称 | 每像素字节 | 处理方式 |
|---|---|---|---|
| 0 | Color | 4 | 直接按 RGBA8 读,无损 |
| 1 | Bgr565 | 2 | 拆 5/6/5 位通道再扩展到 8 位 |
| 3 | Bgra4444 | 2 | 拆 4 位通道,左移 4 位扩展 |
| 4 | Dxt1 | 0.5 | 8 字节一块,1 位 alpha |
| 5 | Dxt3 | 1 | 16 字节一块,显式 4 位 alpha |
| 6 | Dxt5 | 1 | 16 字节一块,插值 alpha |
| 12 | Alpha8 | 1 | 单通道,直接当灰度 |
实际资源里占绝对多数的是Color和Dxt5两种。Color是最省心的,直接Image.frombytes('RGBA', (w, h), mips[0])就完事,而且是无损的,这一点在后面做校验时很有用。Dxt1和Dxt5占了剩下的绝大部分,DXT3 反而少见。
有一个细节值得先记住:块压缩格式的数据长度不是按宽高直接算的,是先按 4 向上取整再乘块大小。宽度 33 的图,块列数是 9 不是 8,最后那一列块里有 3 列像素是填充出来的,解码完要裁掉。这个取整如果漏了,DDS 头里声明的尺寸和实际数据长度对不上,解码器要么报错要么给你一张宽度错位的图。
4.2 借一个 128 字节 DDS 头把块压缩交给 Pillow
与其手写 DXT 解码,我一般走一条更省事的路:手工拼一个 128 字节的 DDS 头,把纹理数据接在后面,然后让 Pillow 直接打开这个内存里的 DDS。Pillow 的 DDS 插件原生支持 DXT1/3/5 解码,这样块解码的活就交出去了,自己只管拼头。
DDS 头的关键字段是高度、宽度、mip 层数和像素格式。像素格式里填一个DDPF_FOURCC标志加四字符编码DXT1/DXT5,Pillow 就知道该走哪条解码路径。容量字段填第一层 mip 的字节数,对块压缩来说就是线性大小。
import struct from io import BytesIO from PIL import Image FOURCC = {4: b'DXT1', 5: b'DXT3', 6: b'DXT5'} def wrap_dds(width, height, fmt, payload, mip_count=1): fourcc = FOURCC[fmt] h = bytearray(128) h[0:4] = b'DDS ' struct.pack_into('<I', h, 4, 124) # 头长度 struct.pack_into('<I', h, 8, 0x1 | 0x2 | 0x4 | 0x1000 | 0x80000) struct.pack_into('<I', h, 12, height) struct.pack_into('<I', h, 16, width) struct.pack_into('<I', h, 20, len(payload)) # 线性大小 struct.pack_into('<I', h, 28, mip_count) struct.pack_into('<I', h, 76, 32) # 像素格式块长度 struct.pack_into('<I', h, 80, 0x4) # DDPF_FOURCC h[84:88] = fourcc struct.pack_into('<I', h, 108, 0x1000) # DDSCAPS_TEXTURE return bytes(h) + payload def decode_texture(tex): fmt, w, h = tex['format'], tex['width'], tex['height'] mip0 = tex['mips'][0] if fmt == 0: return Image.frombytes('RGBA', (w, h), mip0) if fmt == 12: return Image.frombytes('L', (w, h), mip0).convert('RGBA') if fmt in FOURCC: img = Image.open(BytesIO(wrap_dds(w, h, fmt, mip0))) img.load() return img.convert('RGBA') raise ValueError(f'暂不支持的格式: {tex["format_name"]}')这里有个依赖提醒:Pillow 对 DDS 的支持是分版本的,太老的版本读 DXT 会直接报错。转换器里最好在启动时做一次自检——用一张内置的 4x4 DXT1 数据跑一遍解码,能过再上量,不能过就提示升级依赖。这个自检逻辑花不了二十行代码,但能挡掉一批「环境不对导致全部失败」的无效排查。
4.3 预乘 Alpha:颜色发灰发暗的真正原因
图能出来了,但很多人到这一步会发现颜色不对:半透明区域发黑,整体偏暗,纯色块的边缘有一圈脏边。这不是解码错了,是预乘 Alpha 没还原。内容管线在编译纹理时默认会把 RGB 乘上 alpha 通道,好处是缩放和混合时边缘干净,代价是你直接读出来的 RGB 是已经乘过的值。
还原的方式是按 alpha 反除:alpha 为 0 的像素直接置全透明,alpha 为 255 的原样输出,中间值按 255/alpha 的系数放大并截断到 255。这个操作对Color格式和 DXT 格式都要做,因为预乘发生在压缩之前,块压缩只是把这个结果压得更狠了一点。
def unpremultiply(img): if img.mode != 'RGBA': img = img.convert('RGBA') px = img.load() w, h = img.size for y in range(h): for x in range(w): r, g, b, a = px[x, y] if a == 0: px[x, y] = (0, 0, 0, 0) elif a != 255: k = 255.0 / a px[x, y] = (min(255, int(r * k)), min(255, int(g * k)), min(255, int(b * k)), a) return img纯 Python 的双层循环在几万张图上会很慢,实际跑批量时换成用 numpy 做整数组运算,速度差一个数量级。但逻辑就是上面这几行,先在小样本上确认还原效果对,再谈优化。
要注意不是所有资源都该反预乘。有些项目在导入设置里关掉了预乘,这类图反除之后反而会过曝,边缘出现亮边。判断办法很简单:拿一张有明显半透明渐变的图,还原前后各看一眼,发黑的一侧就是没还原,过曝的一侧就是不该还原。这个开关最好做成命令行参数,别写死在代码里。
5. 避坑排查:五类查不到报错的静默失败
转换器最烦人的不是崩溃,是它安静地产出一张错图。下面这五类是我在不同项目里都遇到过的,症状各不相同,但共同点是错误信息里什么都看不出来。
5.1 输出全透明或纯黑,尺寸却完全正确
现象:PNG 能生成,宽高和原图一致,打开是全黑或者全透明,文件大小还异常地小。
原因:payload_offset算错了,你把 LZX 压缩流的一部分当成了像素数据喂给解码器。压缩流里全零字节比例很高,按 RGBA 读出来基本就是黑的。另一种可能是读的是 mip 链的最后一层——1x1 的那一层,缩放上去也是纯色。
解决:在decode_texture里断言len(mip0)和各格式的理论大小对得上。Color格式必须是w*h*4,DXT1 是ceil(w/4)*ceil(h/4)*8,DXT5 是同样块数乘 16。长度不符立刻抛异常,别让它产出图。
5.2 斜条纹、块错位、图片右半边是垃圾数据
现象:图能看,左上角一部分正常,越往右下越乱,或者整体呈现规则的斜向条纹。
原因:宽度不是 4 的倍数时,块压缩的块列数没做向上取整,导致每一行的块起始位置都偏了一点,累积起来就是斜条纹。另一个常见原因是parse_texture2d里 7-bit 字符串读多或读少了字节,整个内容区偏移了一两个字节,这种情况通常连格式值都是乱的。
解决:块尺寸统一用(w + 3) // 4和(h + 3) // 4计算,不要用整除。偏移问题用第 2 章那个尾部对齐检查兜住,pos != len(body)直接报错。
5.3 半透明边缘发黑,纯色区域偏暗
现象:整体能看,但所有带 alpha 的地方边缘是一圈脏黑,RGB 值明显低于预期。
原因:预乘 Alpha 没还原。内容管线默认预乘,你直接读出来的 RGB 已经乘过 alpha 了。
解决:跑一遍反预乘。注意判断是否需要还原,方式见 4.3 末尾。这条踩坑记录之所以值得单列,是因为它不会报任何错,很容易被当成「原图就这样」放过去。
5.4 报错 magic 不对或文件长度不匹配
现象:批量跑的时候有一部分文件在peek_header就挂了,报魔数不是 XNB 或者声明长度和实际长度不符。
原因:这类文件要么被二次包裹过(外层是自定义容器,内层才是 XNB),要么是从别处拷来的不完整文件。还有一种情况是文件本身没压缩但扩展名是.xnb,这种其实能正常处理,报错只出在长度字段上。
解决:先做一次全量扫描,把所有文件的头部信息打成一张表,按版本、平台、压缩标志分组看分布。异常文件单独拎出来人工看几个,通常能发现共同特征。转换器里把这类文件记为 skipped 而不是 failed,批量结束时单独列出来,别和真正的解析错误混在一起。
5.5 大批量时随机崩在解压环节,重跑又能过
现象:几千个文件跑下来,总有几十个在 LZX 解压时抛异常或者输出长度不足,单独重跑同一个文件有时候能过有时候不能。
原因:多半是并发读写或内存占用问题,不是文件本身的问题。解压器实例如果不是每个线程独立创建的,共享状态在并发下会互相污染。还有一种可能是大图(比如 4096 见方)解压后内存峰值很高,进程被系统限制。
解决:每个任务独立构造解码器实例,别复用。批量并发数控制在 CPU 核数以内,别开太大。解压前后各打一条日志带文件名和长度,重跑时对比就能确认是不是环境问题。
6. 批量转换与回写校验:把转换器挂进自己的管线
单文件跑通不算完,这套东西真正的价值在于能批量跑、能校验、能挂进流程。我自己用的时候会把它做成一个带子命令的脚本:convert走单文件或目录,selftest跑内置样本自检,roundtrip做无损回环比对。前两个是日常用,第三个是用来建立信任的。
6.1 批量任务的分片与失败重试
批量入口要处理的其实只有三件事:遍历、并发、失败归类。遍历用Path.rglob('*.xnb')就够,并发用进程池而不是线程池——解码是纯 CPU 活,GIL 会拖住线程池。失败要分三类记:解析失败(头部或结构不对)、解压失败(版本或流损坏)、解码失败(格式不支持),三类写进不同的日志文件,收工时各报一个数。
import argparse, traceback from concurrent.futures import ProcessPoolExecutor from pathlib import Path def convert_one(args): src, dst_root, unpremul = args try: header, raw = peek_header(src) body = decompress_payload(raw, header) pre = parse_preamble(body) tex = parse_texture2d(body, pre['body_pos']) img = decode_texture(tex) if unpremul: img = unpremultiply(img) out = Path(dst_root) / Path(src).with_suffix('.png').name out.parent.mkdir(parents=True, exist_ok=True) img.save(out) return ('ok', str(src), f'{tex["format_name"]} {tex["width"]}x{tex["height"]}') except Exception as e: return ('fail', str(src), f'{type(e).__name__}: {e}') def main(): ap = argparse.ArgumentParser() ap.add_argument('src'); ap.add_argument('dst') ap.add_argument('--jobs', type=int, default=4) ap.add_argument('--no-unpre <p> <a href="https://download.csdn.net/download/f529324416/10274396" style="color:#ec7500;font-size:14px;"> 本文还有配套的精品资源,点击获取 </a> <img alt="menu-r.4af5f7ec.gif" src="https://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif" style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;"> </p>