C# 实现 WDF/WAS 解析:老游戏资源提取与 PNG 序列帧导出 简介面向游戏开发与逆向工程从业者的C#源码工具用于解析《大话西游》游戏内的WAS资源文件并将图像导出为通用PNG格式。源码覆盖了读取二进制、解析文件头、解密数据、提取图像及调用System.Drawing完成PNG编码的完整流程可直接编译运行也附带exe便于快速体验程序内置可视化界面可选择WAS文件并输出PNG适合快速验证与二次开发。压缩包共45个文件以7个cs源码文件、4个exe执行程序、工程配置sln/csproj/config/resx为主辅以pdb调试信息、txt说明及缓存文件整体仅344KB体量轻便。已有209人学习下载。通过研读这套源码开发者能深入理解特定游戏资源格式的封装与加密方式掌握自制资源导出工具的思路对研究其他同类游戏文件或构建自有资源管理管线也具有实际参考价值。1. WAS 导出 PNG先别急着找现成工具这份 C# 源码才是破局点手头翻出一套老《大话西游》客户端资源美术文件全部躺在 .wdf 数据包里包里每一段动画又以 .was 子格式存放。想用现成查看器直接拖出 PNG索引对不上、帧序错乱、偏色十有八九翻车。这份 C# 源码zip 解压后即可开读要解决的就是“读 WDF 包 → 解 WAS 帧 → 输出 PNG”这条链路适合做老游戏资源归档、同人 MOD、换肤工具也适合想抄一套二进制解析思路的 C# 开发者。下面按我自己的落地顺序把文件结构、解码参数和踩过的坑逐一拆开讲。2. 拆 WDF 容器先破外层资源包再按 hash 找 WAS 子文件2.1 WDF 目录索引的可变形态WDF 是网易系老游戏的客户端资源包和常见的 WAD、PAK 是同类东西核心逻辑很简单一个大文件前面是目录索引后面是数据区。麻烦在于版本太多索引字段顺序不同。常见做法是文件头先放 4 字节魔数紧跟着 4 字节条目数之后每个目录项固定 16 字节左右记录名称 hash、数据偏移、压缩长度、原始长度四元组。后期版本里有些目录项会带明文路径但早期版本只有 32 位 hash意味着你没法靠“文件名”直接查表得先把目标文件名算成同样的 hash 值再去比对。这步没对齐后面全白搭。另一个容易忽视的点WDF 的目录索引不一定连续存储有些改包工具会把索引挪到文件尾解析时先看头部能不能读到合法条目数再决定要不要跳转。2.2 用 BinaryReader 写一个最小 WDF 解析器写这类解析器C# 里最顺手的就是 BinaryReader FileStream不要直接 File.ReadAllBytes 一把梭大包几十 MB 到几百 MB一次性读完内存受不了。下面是我习惯的最小实现public sealed class WdfArchive : IDisposable { private readonly BinaryReader _reader; public Dictionaryuint, WdfEntry Entries { get; } new(); public WdfArchive(string path) { _reader new BinaryReader(File.OpenRead(path)); BuildIndex(); } private void BuildIndex() { _reader.BaseStream.Position 0; byte[] magic _reader.ReadBytes(4); // 索引条目数有的版本紧跟魔数有的版本中间隔 4 字节 int count _reader.ReadInt32(); if (count 0 || count 0xFFFF) { _reader.BaseStream.Position 4; _reader.ReadInt32(); // 跳过未知字段 count _reader.ReadInt32(); } for (int i 0; i count; i) { uint hash _reader.ReadUInt32(); uint offset _reader.ReadUInt32(); uint packedLen _reader.ReadUInt32(); uint rawLen _reader.ReadUInt32(); if (offset 0 || packedLen 0) continue; Entries[hash] new WdfEntry(hash, offset, packedLen, rawLen); } } public byte[] ReadRaw(uint hash) { if (!Entries.TryGetValue(hash, out WdfEntry e)) throw new KeyNotFoundException($hash {hash:X8} 不在索引); _reader.BaseStream.Position e.Offset; return _reader.ReadBytes((int)e.PackedLen); } public void Dispose() _reader.Dispose(); }逻辑说明BuildIndex 是我后来反复改过的部分。早期直接按“魔数条目数”读结果遇到一个改版包count 读出来是个天文数字循环直接跑崩。所以现在加了合法性探测count 不在 1 到 65535 之间时把文件指针挪到偏移 4跳过 4 字节再看一次。参数说明目录项四个字段都按小端读。hash 是 uint32用 Dictionaryuint,... 做索引查找读取 O(1)。packedLen 是数据区里实际占用的字节数rawLen 是解压后的字节数这两个值在后面判断块是否带压缩时用千万别混。2.3 目标 WAS 子文件的 hash 计算因为没有明文文件名通常的做法是先把候选文件名转成字节再算 DJB2 或 CRC32 和包内 hash 比对。DJB2 实现很轻量public static uint Djb2(byte[] bytes) { uint hash 5381; foreach (byte b in bytes) hash ((hash 5) hash) ^ b; return hash; }逻辑说明这就是老游戏资源解析里流传最广的“文件名 hash”算法把 scene.was、npc.was 这类不带路径的小写文件名传进去能和包内目录项对上的就是目标。对不上的情况也不少见后面避坑章专门说。参数说明注意这里的运算用的是 XOR不是加法。很多移植代码在这里多加一个盐值或初始值导致 hash 全部对不上。先拿一个已知文件名验算是排查 hash 问题最快的自检手段。2.4 遍历包内所有 WAS 条目如果不知道具体有哪些 WAS可以退化处理按 2.1 的目录项逐个试探。WAS 子文件的开头通常直接是帧数int32帧数合法范围 1~512用这个特征过滤能筛掉大部分非 WAS 数据。public static bool LooksLikeWas(byte[] raw) { if (raw.Length 12) return false; int frameCount BitConverter.ToInt32(raw, 0); return frameCount is 0 and 512; }逻辑说明这是典型的内容试探法。WDF 里除了 WAS 还有音频、地图配置等直接按扩展名过滤不可靠用首个字段校验更稳。它偶尔会误判但后面 WAS 帧解析还有一层校验误判很快会被过滤掉。到这里外层包已经能拆接下来进入 WAS 内部。3. 解 WAS 帧数据调色板、位深与 RLE少解码一层都是花屏3.1 WAS 帧头的关键字段WAS 本质是“序列帧容器”开头是一个 int32 的帧数后面跟着一组定长帧描述块。常见帧描述块边长 8 字节左右宽度uint16、高度uint16、像素格式byte、压缩标记byte。这里有两个容易翻车的点帧宽高是 ushort 不是 int像素格式各个版本含义不一致常见取值 0 表示 8 位调色板、1 表示 16 位 RGB565、2 表示 24 位 RGB。public sealed class WasFrame { public int Width; public int Height; public int Format; public bool Compressed; public byte[] Raw Array.Emptybyte(); } public static ListWasFrame ParseWas(byte[] was) { using var ms new MemoryStream(was); using var br new BinaryReader(ms); int frameCount br.ReadInt32(); if (frameCount is 0 or 512) throw new InvalidDataException($帧数异常: {frameCount}); var frames new ListWasFrame(frameCount); for (int i 0; i frameCount; i) { frames.Add(new WasFrame { Width br.ReadUInt16(), Height br.ReadUInt16(), Format br.ReadByte(), Compressed br.ReadByte() 1 }); } // 帧数据区两种布局带逐帧长度 / 纯连续流默认按连续流处理 for (int i 0; i frames.Count; i) { int len (i frames.Count - 1) ? (int)(was.Length - br.BaseStream.Position) : frames.Count 1 ? br.ReadInt32() : 0; frames[i].Raw br.ReadBytes(len); } return frames; }逻辑说明这个解析器是“两段式”设计先读完所有帧描述块再统一读数据。原因是部分版本把帧数据集中在描述块之后另一部分版本把每一帧的数据长度插在帧与帧之间。先读描述、再读数据遇到第二种布局时至少能保证前面描述不错位。参数说明逐帧长度字段我默认按 int32 读。实测有些包这里是 uint16后续帧会全部错位。判断方法很简单如果某一帧读出来的 Raw 长度明显大于剩余字节数就退回连续流解析二选一总有一个是对的。这是整个 WAS 解码里最“玄学”的一步。3.2 8 位调色板位图BGR 顺序和透明索引老游戏为了省内存大量帧用 8 位索引色真实颜色存在调色板里。调色板一般 256 项、每项 3 字节按 BGR 顺序排列。直接按 RGB 读的后果就是红蓝互换人物衣服变“夜光灯”。public static Bitmap DecodePaletteFrame(WasFrame f, byte[] palette, bool useAlpha) { if (f.Compressed) f.Raw RleDecode(f.Raw, f.Width * f.Height); var bmp new Bitmap(f.Width, f.Height, PixelFormat.Format8bppIndexed); var pal bmp.Palette; for (int i 0; i 256 i * 3 2 palette.Length; i) { int alpha useAlpha ? (i 0 ? 0 : 255) : 255; // 索引 0 做透明色 pal.Entries[i] Color.FromArgb( alpha, palette[i * 3 2], // R 取自 BGR 的第三字节 palette[i * 3 1], // G palette[i * 3]); // B } bmp.Palette pal; var rect new Rectangle(0, 0, f.Width, f.Height); var data bmp.LockBits(rect, ImageLockMode.WriteOnly, PixelFormat.Format8bppIndexed); try { Marshal.Copy(f.Raw, 0, data.Scan0, f.Raw.Length); } finally { bmp.UnlockBits(data); } return bmp; }逻辑说明LockBits 直接把原始索引字节搬到 Bitmap 的内存区避免逐像素 GetPixel/SetPixel——那种写法在 1024x1024 的帧上会慢到怀疑人生。调色板赋值必须在设置 Palette 属性之前完成否则改动不生效。参数说明useAlpha 是“魔术透明”开关。有些 WAS 约定索引 0 是透明色有些约定调色板里另设透明索引。不确定时先全部按不透明导出再用肉眼挑出该透明的帧比一开始就错漏百出更省事。3.3 16 位 RGB565 逐像素换算部分 WAS 用了 16 位 565每个像素两个字节。这种格式没有调色板但要注意字节序。下面的写法按小端读取public static Bitmap DecodeRgb565Frame(WasFrame f) { if (f.Compressed) f.Raw RleDecode(f.Raw, f.Width * f.Height); var bmp new Bitmap(f.Width, f.Height, PixelFormat.Format24bppRgb); var rect new Rectangle(0, 0, f.Width, f.Height); var data bmp.LockBits(rect, ImageLockMode.WriteOnly, PixelFormat.Format24bppRgb); unsafe { byte* dst (byte*)data.Scan0; int idx 0; for (int i 0; i f.Width * f.Height; i) { ushort v (ushort)(f.Raw[idx] | (f.Raw[idx 1] 8)); idx 2; dst[i * 3 2] (byte)((v 0xF800) 8); // 红 dst[i * 3 1] (byte)((v 0x07E0) 3); // 绿 dst[i * 3] (byte)((v 0x001F) 3); // 蓝 } } bmp.UnlockBits(data); return bmp; }逻辑说明565 的 5 位红、6 位绿、5 位蓝。把 5 位值左移 3 位、6 位值左移 2 位还原到 8 位这里的移位量是关键。C# 里要用 unsafe 才能拿到指针记得在 csproj 打开 AllowUnsafeBlocks。参数说明如果导出的图红色和蓝色互相“咬合”说明源数据是大端把(f.Raw[idx] | (f.Raw[idx1] 8))改成(f.Raw[idx] 8) | f.Raw[idx1]。老资源两种端序都出现过这是排查顺序里的第一项。3.4 RLE 压缩块先解压再填位图老引擎为了压体积WAS 帧数据常带行级 RLE。常见的控制字节逻辑最高位为 0 时表示后面 N 个字节原样复制最高位为 1 时表示后面 1 个字节重复 N 次。遇到 0x00 视为流结束。public static byte[] RleDecode(byte[] src, int expectedPixels) { var dst new Listbyte(expectedPixels); for (int i 0; i src.Length dst.Count expectedPixels;) { byte ctrl src[i]; if (ctrl 0) break; int len ctrl 0x7F; if ((ctrl 0x80) ! 0) { if (i src.Length) break; byte v src[i]; for (int j 0; j len dst.Count expectedPixels; j) dst.Add(v); } else { for (int j 0; j len i src.Length dst.Count expectedPixels; j) dst.Add(src[i]); } } if (dst.Count ! expectedPixels) throw new InvalidDataException($RLE 解压后 {dst.Count} 像素期望 {expectedPixels}); return dst.ToArray(); }逻辑说明这个 RLE 解码器是反复调过的版本。早期我把 ctrl 直接当成“原样复制长度”遇到高位为 1 的块就疯狂越界。现在先剥离最高位得到 len再按高位判断是重复块还是复制块。参数说明expectedPixels 传 Width*Height这是最便宜的自校验。解压完后对不上就直接抛异常而不是带着错位数据继续画否则出来的 PNG 是“花屏但能保存”的假成功排查成本反而更高。提示RLE 解码是最容易翻车的环节解码前先备份原始字节出现偏差时对比压缩流能更快定位。4. 转 PNG 落盘透明通道、批量命名与尺寸上限4.1 从 Bitmap 到 PNG 的最小路径WAS 帧解码完拿到 Bitmap 后PNG 落盘其实就一行frame.Save(Path.Combine(outDir, ${name}_{index:D3}.png), ImageFormat.Png);逻辑说明System.Drawing 的 Bitmap.Save 指定 ImageFormat.Png 会自己处理索引色到调色板的转换。8 位索引色 Bitmap 直接保存生成的 PNG 也是索引色体积小但如果带半透明通道GDI 对调色板 PNG 的 alpha 支持不完整建议先转 32bpp 再存。参数说明${name}_{index:D3}.png是帧命名格式index 补三位零方便后面按字典序合成动画。name 用 hash 的十六进制而不是明文因为老包可能没有明文文件名可用。4.2 要真透明先把 8 位索引图抬到 32 位某些 WAS 帧需要保留透明背景。直接改 8 位调色板的 alpha 并不可靠常见做法是先转成 Format32bppArgb再决定透明规则public static Bitmap To32bpp(Bitmap src, FuncColor, bool isTransparent) { var dst new Bitmap(src.Width, src.Height, PixelFormat.Format32bppArgb); var rect new Rectangle(0, 0, src.Width, src.Height); var srcData src.LockBits(rect, ImageLockMode.ReadOnly, src.PixelFormat); var dstData dst.LockBits(rect, ImageLockMode.WriteOnly, PixelFormat.Format32bppArgb); unsafe { for (int y 0; y src.Height; y) { byte* sp (byte*)srcData.Scan0 y * srcData.Stride; byte* dp (byte*)dstData.Scan0 y * dstData.Stride; for (int x 0; x src.Width; x) { byte b sp[x * 4 0], g sp[x * 4 1], r sp[x * 4 2]; dp[x * 4 0] b; dp[x * 4 1] g; dp[x * 4 2] r; dp[x * 4 3] isTransparent(Color.FromArgb(r, g, b)) ? (byte)0 : (byte)255; } } } src.UnlockBits(srcData); dst.UnlockBits(dstData); return dst; }逻辑说明这里故意不走 Graphics.DrawImage。DrawImage 在放大或边界处会做插值把不该有的半透明边缘洗出来逐像素搬虽然代码多但源像素和目标像素一一对应适合做美术资源还原。参数说明isTransparent 是自定义的透明判定。常见规则是“调色板索引 0 透明”或“RGB 等于某个魔术色”。我一般先跑一批看效果再回来调这个委托不要一上来就猜透明度规则。4.3 批量导出时的目录与命名规划多帧、多 WAS 文件混着导出时命名只要不统一后面合成动画就痛苦。我常用的输出结构是一个 WAS 对应一个目录out/ a1b2c3d4/ 000.png 001.png 002.pngpublic static void ExportWas(string wasName, WasFrame[] frames, string outDir) { string dir Path.Combine(outDir, wasName); Directory.CreateDirectory(dir); for (int i 0; i frames.Length; i) { Bitmap bmp DecodeFrame(frames[i]); bmp.Save(Path.Combine(dir, ${i:D3}.png), ImageFormat.Png); bmp.Dispose(); } }逻辑说明按 WAS 名分目录比全部平铺更容易定位和增量更新。导出成功后再压缩成 zip 归档原始 PNG 序列保留在本地后续重做时才不用重新解包。参数说明DecodeFrame 是 3.2/3.3 的入口封装内部按 frame.Format 分发。记得 Dispose Bitmap批量场景下不释放句柄导到几千帧时会碰到“GDI 中发生一般性错误”——那不是解码问题是句柄耗尽属于最常见的假故障。5. WAS 导出 PNG 避坑记录6 个最常见的翻车现场与排查方法5.1 导出 0 张hash 算法对不上现象程序跑完输出目录空空如也好像 WDF 里根本没有 WAS。原因文件名 hash 算法不是 DJB2 就是 CRC32换了半套实现盐值、初始值不对全表 miss。解决先用一个确定存在的文件名验算。我通常写一个单元测试把“已知文件名到已知 hash”做成用例跑通了再扫全包。这步不要省省了后面全是无用功。5.2 花屏但能保存帧描述块错位现象每一帧 PNG 都有颜色但完全看不出原图像雪花屏。原因帧描述块不是固定的 8 字节某版本多了 2 字节标志位导致后续所有帧宽高读偏移。解决在 ParseWas 里加一个“逐帧位置自证”每帧数据区读完后记录当前流位置如果下一帧描述块的宽高明显不合法宽或高为 0、超过 4096就回退到上一帧末尾按 2 字节对齐再读。这个回退逻辑是血泪经验。5.3 颜色偏绿偏紫调色板 BGR 顺序读反现象人物肤色发绿天空变紫红。原因调色板按 BGR 存储代码按 RGB 读取了。解决把palette[i*3]和palette[i*32]对调。注意有些包调色板带 4 字节 alpha每项要跳 4 字节而不是 3 字节。先看 palette 的 Length 和 2563 / 2564 是否匹配再决定步长。5.4 透明区域消失魔术色规则猜错现象导出 PNG 后边缘发黑或本该透明的披风变成纯黑块。原因引擎约定某个索引或某个 RGB 值是透明色代码没有处理透明区被当成黑色像素画出来了。解决先在画面上截一小块透明区域读出对应像素的 RGB 值把这个值配置到 isTransparent 委托里。不要猜截面数据不会骗人。5.5 内存爆掉宽高字段读到的是压缩长度现象导出几百帧后内存直线飙升甚至 OOM。原因某些 WAS 的帧头里宽高字段在压缩模式下存的是“压缩块长度”而非真实宽高直接拿去做 new Bitmap 会把对象撑爆。解决在 new Bitmap 之前加硬性防御宽高必须小于 4096像素数大于 0 且小于 16,777,2162048x2048 上限。不满足就跳过该帧并记录日志而不是直接崩溃。5.6 .NET 环境差异换台机器就“一般性错误”现象本机 Win10 跑得好好的放到 Windows Server 或 Linux 容器里Save 到 PNG 时抛异常。原因System.Drawing.Common 依赖 GDI在非交互式会话或缺依赖的 Linux 环境不可用。解决纯本机工具可以不改要跨平台把位图构建和 PNG 编码换成 ImageSharpAPI 是image.SaveAsPng(path)RGB565 那段逐像素写入逻辑可以平移。没有第三方依赖时至少要在 csproj 里显式引用 System.Drawing.Common 并锁定 Windows 兼容目标。6. 进阶验证把 PNG 序列合成动画上千帧也不心虚6.1 用 ImageMagick 把帧序列合成为 GIFWAS 是动画帧单张 PNG 看久了容易忽略瞬时残影。导出后用一条命令把同组帧合成动画预览magick -delay 10 -loop 0 -size 512x512 out/a1b2c3d4/*.png preview.gif逻辑说明delay 10 表示每帧停留 100msloop 0 无限循环。GIF 拖进浏览器动画节奏、错位、闪烁一眼可见。若同组帧宽高不一致加-coalesce统一画布能暴露对齐问题。参数说明-size 按实际最大帧宽高调整小了的后果是裁边而且不报错。这类静默失败最坑所以我总是先跑一张最宽的帧量一下尺寸。6.2 用像素统计自动筛出空帧和纯色帧上千帧全靠肉眼不现实。导出流程里加一段抽样统计非透明像素数、颜色种类数。数量 0 是空帧种类 1 是纯色帧自动打日志把人工复查范围缩到几十帧public static (int count, int colors) Analyze(Bitmap bmp) { var seen new HashSetint(); int cnt 0; for (int y 0; y bmp.Height; y 4) for (int x 0; x bmp.Width; x 4) { Color c bmp.GetPixel(x, y); if (c.A 0) continue; cnt; seen.Add(c.ToArgb() 0xFFFFFF); } return (cnt, seen.Count); }逻辑说明步长 4 是为了速度全量 GetPixel 在单帧 1024x1024 时明显拖慢批量导出。空帧可能是引擎有意保留的过渡帧也可能是解压失败抽样命中后人工一判就知道。6.3 调试阶段用 base64 预览不落盘调试调色板顺序、透明规则这类“改一个参数看一批图”的循环我总是直接把 Bitmap 编码成 data:image/png;base64 塞进本地调试页看效果省去反复删临时文件using var ms new MemoryStream(); bmp.Save(ms, ImageFormat.Png); string b64 Convert.ToBase64String(ms.ToArray());逻辑说明预览只服务验证正式导出还得落盘。base64 字符串可以直接在新标签页打开比切换图片查看器更顺手。我的收尾习惯是每天导出后挑三组代表性动画合成 GIF 过一遍顺手把 hash、帧数和空帧统计写进日志第二天排查直接看记录。这套流程跑下来几乎没再出现“导完才发现偏色”的返工。希望帮到你。本文还有配套的精品资源点击获取