简介:这份资源是一套用 C 语言实现的 VPK(Valve Package)文件处理库与工具集,面向从事 Valve 引擎游戏开发、模组制作或资源管理的开发者,尤其适合需要以轻量方式读写、打包与解析 CS:GO、半条命2 等游戏资源包的中级 C 程序员。压缩包仅 4KB,共 5 个文件,包含 vpk.h 头文件、VPKTool.c 核心实现、Makefile 构建脚本、Readme.md 使用说明以及 .gitignore 配置,结构精炼,便于直接嵌入现有工程或二次开发。已有 544 人学习关注。借助其中的 API 与命令行工具,读者可以理解 VPK 头部与文件索引的组织方式,实现资源的遍历、提取与打包,并参考其错误处理与跨平台编译思路,快速搭建自己的资源管理流程。
1. 从一堆打不开的资源包说起:VPKTool 到底解决什么问题
去年接了一个活儿,对方丢过来十几个.vpk文件,说里面是整套美术资源,让我把贴图和模型配置导出来做二次加工。我第一反应是找现成工具,结果要么是图形界面点半天、批量导出卡死,要么是只支持读取不支持写回,改一个文件得重新打包整个包。后来就自己啃格式,最后沉淀下来的东西就是这份VPKTool——一套用 C 写的、能相对快速地读写 vpks 的库和工具。
它解决的事情很具体:把 vpk 当成一个只读或者可写的“小文件系统”,用 C 接口打开、遍历、按路径取文件、按目录批量导出,还能反过来把一堆散文件打成包。因为核心是纯 C、不依赖运行时和第三方库,编译出来就是一个可执行文件加一组头文件,塞进任何构建系统都不费劲。适合两类人:一类是要批量处理资源包、写脚本又嫌别的语言启动慢的工程向开发者;另一类是想拿一个真实二进制格式练手、学习怎么在 C 里做内存映射和二进制解析的人。
2. 架构与目录:一个 C 库怎么切成三层
VPKTool 的代码量不大,但分层做得比较干净,基本是「格式层 → 索引层 → 接口层」三段式。格式层只认字节,不认文件;索引层负责把目录树读进内存并建立查找结构;接口层才是list / extract / pack这些给人和给其他程序用的东西。这么切的好处是,你想加一个新命令,基本只在接口层加文件,底下两层不用动。
2.1 目录结构与模块边界
目录我一般整理成下面这个样子,和常见 C 库的布局一致,方便别人#include的时候一眼看懂边界:
vpktool/ ├── include/ │ └── vpk.h # 唯一对外头文件,暴露句柄、错误码、API ├── src/ │ ├── vpk_format.h # 内部结构体:头部、条目、字节序工具 │ ├── vpk_format.c # 纯解析:读头、读树、写头、写树 │ ├── vpk_index.c # 目录树 → 内存索引,哈希/二分查找 │ ├── vpk_io.c # 文件读取、preload 拼接、分片读取 │ ├── vpk_write.c # 打包落盘(二次开发主战场) │ └── cli/ │ ├── main.c # 参数解析、子命令分发 │ ├── cmd_list.c │ └── cmd_extract.c ├── tests/ │ └── roundtrip.c # 打包→解包→逐字节比对 └── CMakeLists.txtinclude/vpk.h是唯一需要给别人看的东西,里面只放不透明句柄vpk_t *、错误码枚举和函数声明,绝不暴露内部结构体。血泪经验是:一旦你在公开头文件里放了struct vpk_entry,后面想改字段布局,所有下游代码全得跟着重编,库就不成其为库了。
vpk_format.c负责的事情特别纯粹——给一段内存,吐出结构体;给一个结构体,吐出字节。这个文件里没有fopen,所以单元测试可以直接拿一段const uint8_t[]喂进去验证,不需要准备真实文件。我第一次重构就是把这个文件里的FILE *全踢了出去,测试从“需要几个几十兆的样本包”变成“几行字节数组”,跑起来快了一个数量级。
2.2 核心数据结构:头部、条目与内存布局
vpks 的结构其实就三块:一个定长头部、一棵变长的目录树、然后是数据区。头部里最关键的是tree_size和(高版本的)file_data_size,前者告诉你目录树占多少字节,后者告诉你在 dir 文件里内嵌了多少实际数据。条目结构是定长的,但前面挂了一段变长的 preload 数据,这是整个格式里最容易读错位的地方。
/* src/vpk_format.h —— 只放格式相关的东西,不掺业务逻辑 */ #include <stdint.h> #define VPK_MAGIC 0x55aa1234u /* 小端读出来的固定魔数 */ #define VPK_TERMINATOR 0xffffu /* 每个条目结尾的哨兵值 */ #define VPK_MAX_ARCHIVE 0x7fffu /* 目录内嵌数据的 archive_index 约定值 */ typedef struct { uint32_t magic; /* 校验用,读错位置这里第一个炸 */ uint32_t version; /* 1 或 2,决定头部后面还有没有字段 */ uint32_t tree_size; /* 目录树总字节数,不含头部 */ uint32_t file_data_size; /* 仅 version >= 2 有效,v1 读出来后置 0 */ } vpk_header_t; typedef struct { uint32_t crc; /* 文件内容 CRC32,解包后可以拿来校验 */ uint16_t preload_len; /* 紧跟在本结构体后面的内联字节数 */ uint16_t archive_index; /* 0x7fff 表示数据就在 dir 文件里 */ uint32_t offset; /* 数据在对应包文件中的偏移 */ uint32_t length; /* 数据长度,不含 preload 部分 */ uint16_t terminator; /* 必须是 0xffff,否则说明已经读歪了 */ } vpk_entry_t;这里有个刻意的取舍:vpk_header_t里保留了file_data_size,哪怕 v1 用不上也置 0 而不是删掉。因为上层拿头部做判断的时候,写if (hdr.file_data_size > 0)比写if (hdr.version >= 2 && ...)少一次版本分支,可读性更好,代价只是 4 字节内存。
条目里的crc字段很容易被忽略,我一开始就没管它,结果解包出来的图片全是花屏,排查了半天才发现是偏移算错了半个字节,但程序一声不吭。后来加了 CRC 校验,同样的错误当场就能定位到具体文件。这个字段不要省,它是这个格式自带的后悔药。
2.3 构建系统与依赖控制
构建用 CMake,最低要求放到 3.10 就够,目标只有一个:产出libvpktool.a(或.so)加一个vpktool可执行文件,不引入任何外部依赖。压缩相关的逻辑(如果有)也不要链 zlib,直接用存储模式,把复杂度留给上层。
# CMakeLists.txt 关键片段 cmake_minimum_required(VERSION 3.10) project(vpktool C) set(CMAKE_C_STANDARD 99) set(CMAKE_C_STANDARD_REQUIRED ON) # 库目标:不带 CLI,方便别人只链库 add_library(vpktool STATIC src/vpk_format.c src/vpk_index.c src/vpk_io.c src/vpk_write.c ) target_include_directories(vpktool PUBLIC include) # 可执行目标:只在需要命令行工具时才编 add_executable(vpktool_cli src/cli/main.c src/cli/cmd_list.c src/cli/cmd_extract.c ) target_link_libraries(vpktool_cli PRIVATE vpktool) # 把警告当回事,二进制解析最怕隐式截断 if (CMAKE_C_COMPILER_ID MATCHES "GCC|Clang") target_compile_options(vpktool PRIVATE -Wall -Wextra -Wconversion) endif()-Wconversion这个选项在解析二进制的时候特别值钱。uint16_t和uint32_t之间的隐式转换、size_t和int之间的来回倒腾,编译器一句警告就能帮你挡掉一类越界读。我吃过一次亏:offset + length两个uint32_t相加溢出回绕,算出一个小得离谱的偏移,读出来一段垃圾数据但程序照样跑,最后是靠 CRC 才找回来的。
编译和跑测试就两条命令:
# 在源码根目录执行 cmake -S . -B build -DCMAKE_BUILD_TYPE=Release cmake --build build -j # 跑自带的往返测试(打包后再解包,逐字节比对) ctest --test-dir build --output-on-failure-DCMAKE_BUILD_TYPE=Release不要省,解析几万个条目的目录树时,-O2和-O0能差出好几倍。我拿一个约 4 万个条目的包试过,Debug 下遍历一遍要接近一秒,Release 下几十毫秒就完了,体感完全不同。
3. 核心链路拆解:从 fopen 到取出一份文件
这一章把「打开一个 vpk、按路径取出一个文件」这条链路完整走一遍。整条链路分三步:先读头部确定版本和树的大小;再把目录树整块读进内存并解析成索引;最后根据条目的archive_index决定去哪读数据。任何一步错位,后面全是垃圾,所以每一步都得有显式的边界检查。
3.1 头部解析:先判版本再定读多少字节
头部不是定长的。v1 只有 12 字节,v2 在后面追加了file_data_size和两段校验值。所以正确顺序是:先读前 12 字节,校验魔数,拿到version,再决定要不要继续读。直接fread一个 28 字节结构体进来是经典翻车姿势——v1 的包会被你读进一段属于目录树的数据当头部,然后整棵树从第一个字节就歪了。
/* src/vpk_format.c —— 按版本分段读头,任何一步失败都返回明确错误码 */ #include "vpk_format.h" #include <stdio.h> #include <string.h> /* 小端读取:不用强制类型转换,避免未对齐访问在不同平台上行为不一致 */ static uint32_t rd_u32(const uint8_t *p) { return (uint32_t)p[0] | ((uint32_t)p[1] << 8) | ((uint32_t)p[2] << 16) | ((uint32_t)p[3] << 24); } int vpk_read_header(FILE *fp, vpk_header_t *out) { uint8_t buf[12]; if (fread(buf, 1, sizeof(buf), fp) != sizeof(buf)) return VPK_ERR_IO; out->magic = rd_u32(buf + 0); if (out->magic != VPK_MAGIC) return VPK_ERR_MAGIC; /* 不是 vpk,直接退出 */ out->version = rd_u32(buf + 4); out->tree_size = rd_u32(buf + 8); out->file_data_size = 0; /* v1 默认没有这段 */ if (out->version >= 2) { uint8_t ext[16]; /* v2 多出 16 字节:file_data_size + 两段 md5 的开头部分被跳过 */ if (fread(ext, 1, 4, fp) != 4) return VPK_ERR_IO; out->file_data_size = rd_u32(ext); /* 剩下的校验区按固定长度整体跳过,不解析内容 */ if (fseek(fp, 16 + 16 + 4, SEEK_CUR) != 0) return VPK_ERR_IO; } /* 树大小必须落在合理区间,防止脏数据导致后面 malloc 几十 G */ if (out->tree_size == 0 || out->tree_size > (64u << 20)) return VPK_ERR_CORRUPT; return VPK_OK; }逐段说明一下。rd_u32用移位拼字节而不是*(uint32_t*)p,原因是后者在未对齐地址上是未定义行为,在 ARM 上会直接抛异常,在 x86 上只是“碰巧能跑”,这种代码换个平台就炸。参数p必须指向至少 4 字节可读的内存,调用方保证。
out->tree_size那个上限检查是我后加的。真实场景里遇到过被截断的包,tree_size读到的是几 GB 的垃圾值,malloc直接失败或者被系统杀掉,日志里什么都没有。加上 64MB 上限之后,这种情况会返回VPK_ERR_CORRUPT,配合错误码能立刻判断是文件坏了而不是程序有 bug。
3.2 目录树遍历:三层嵌套的字符串表
目录树的结构比较绕:它是「扩展名 → 目录路径 → 文件名」三层嵌套,每一层都是一串以\0结尾的字符串,以一个空字符串(也就是单独一个\0)表示本层结束。文件名之后紧跟着一个定长的vpk_entry_t,再后面是preload_len个字节的内联数据。遍历的时候这三者必须严格同步推进,漏跳一步就会全盘错位。
/* 从 p 开始读一个 \0 结尾的字符串,返回起始指针,p 前进到字符串之后 */ static const char *read_cstr(const uint8_t **p, const uint8_t *end) { const uint8_t *q = *p; while (q < end && *q != 0) q++; if (q >= end) return NULL; /* 没有终结符,说明树被截断了 */ const char *s = (const char *)*p; *p = q + 1; return s; } /* 遍历整棵树,对每个条目回调一次;cb 返回非 0 时提前结束 */ int vpk_walk_tree(const uint8_t *tree, size_t tree_size, vpk_visit_fn cb, void *user) { const uint8_t *p = tree, *end = tree + tree_size; const char *ext, *dir, *name; char full[512]; while ((ext = read_cstr(&p, end)) != NULL && ext[0] != '\0') { if (ext[0] == ' ') continue; /* 目录占位节点,跳过 */ while ((dir = read_cstr(&p, end)) != NULL && dir[0] != '\0') { if (dir[0] == ' ') continue; while ((name = read_cstr(&p, end)) != NULL && name[0] != '\0') { if (end - p < (ptrdiff_t)sizeof(vpk_entry_t)) return VPK_ERR_CORRUPT; vpk_entry_t e; memcpy(&e, p, sizeof(e)); /* 结构体不保证对齐,用 memcpy */ p += sizeof(e); if (e.preload_len > 0) { if ((size_t)(end - p) < e.preload_len) return VPK_ERR_CORRUPT; p += e.preload_len; /* preload 数据直接跳过,后面按需回读 */ } if (e.terminator != VPK_TERMINATOR) return VPK_ERR_CORRUPT; /* 拼接成 "dir/name.ext" 这种可直接用于查找的形式 */ snprintf(full, sizeof(full), "%s/%s.%s", dir, name, ext); if (cb(full, &e, user) != 0) return VPK_OK; } } } return VPK_OK; }read_cstr的两个参数含义:p是二级指针,既当输入又当输出,函数返回后它指向字符串结束符的下一个字节;end是树的末尾,所有读取都必须卡在这个边界内,否则一个损坏的包就能让程序读穿整个堆。函数返回NULL表示在到达end之前都没遇到\0,这棵树已经被截断了。
内层那个memcpy(&e, p, sizeof(e))而不是强转指针再解引用,理由和前面一样:p的地址是按字符串长度走出来的,几乎不可能满足vpk_entry_t的对齐要求。在 x86 上强转能跑,在别的架构上就是崩溃。这一点我踩过,代码在本地跑得好好的,交叉编译到目标板上第一帧就挂。
snprintf拼路径的地方,缓冲区给 512 字节。理论上路径可能更长,但实际包里极少超过这个数。稳妥做法是判断返回值>= sizeof(full)就报错而不是静默截断,我这里为了代码短省略了,你自己写的时候最好补上——截断后的路径去查找会返回“文件不存在”,反而更难排查。
3.3 数据定位与读取:archive_index 的三种语义
拿到vpk_entry_t之后,真正的数据在哪取决于archive_index这个 16 位字段,它有三种语义,混淆任何一种都会读出垃圾:
| archive_index 取值 | 数据位置 | 需要打开的文件 |
|---|---|---|
0x7fff | dir 文件内部,紧跟在目录树之后 | 当前这个 dir 文件本身 |
0~0x7ffe | 编号对应的外部数据包 | 同目录下按编号命名的分卷文件 |
任意值 +preload_len > 0 | 前preload_len字节在树里,剩余部分按上面两条规则 | 可能两个都要 |
第三种情况最容易被忽略。有些小文件(比如几 KB 的配置文件)会被整体塞进目录树的 preload 区,此时length可能是 0,你按length去读会得到空文件。正确做法是先把 preload 那一段拷出来,再判断length > 0才去数据区读剩下的部分。
/* 读取单个条目的完整内容,返回 malloc 出来的缓冲区,调用方负责 free */ int vpk_read_entry(vpk_t *v, const vpk_entry_t *e, const uint8_t *preload, uint8_t **out, size_t *out_len) { size_t total = (size_t)e->preload_len + e->length; uint8_t *buf = malloc(total ? total : 1); if (!buf) return VPK_ERR_NOMEM; if (e->preload_len > 0) memcpy(buf, preload, e->preload_len); /* 先拷内联部分 */ if (e->length > 0) { FILE *fp; if (e->archive_index == VPK_MAX_ARCHIVE) { fp = v->dir_fp; /* 数据在 dir 文件里 */ /* dir 内数据从目录树结束处开始算起 */ long base = (long)v->hdr.tree_size + (long)v->hdr_offset; if (fseek(fp, base + (long)e->offset, SEEK_SET) != 0) { free(buf); return VPK_ERR_IO; } } else { fp = vpk_open_archive(v, e->archive_index); /* 按需打开并缓存分卷句柄 */ if (!fp) { free(buf); return VPK_ERR_NO_ARCHIVE; } if (fseek(fp, (long)e->offset, SEEK_SET) != 0) { free(buf); return VPK_ERR_IO; } } if (fread(buf + e->preload_len, 1, e->length, fp) != e->length) { free(buf); return VPK_ERR_IO; /* 短读:文件被截断或偏移算错 */ } } *out = buf; *out_len = total; return VPK_OK; }关键点在于base这个偏移。dir 文件内嵌数据的起点是「头部长度 + tree_size」,不是文件开头,也不是tree_size。头部长度随版本变化(12 或 28),所以这里必须用打开时记下的vpk->hdr_offset,而不能写死。我第一版就是写死 12,结果所有 v2 的包都读出来错位 16 字节,症状是文件头看起来像对的、内容却全是乱的,非常迷惑。
vpk_open_archive里做了一层句柄缓存:同一批导出通常集中在少数几个分卷上,每次都fopen / fclose开销不小。缓存数组开 16 个槽位就够,超出就按最近最少使用淘汰。注意archive_index是 16 位,理论上能到三万多个分卷,但实际包不会这么多,别为它去开一个大数组。
4. 跑起来:编译、命令行用法与库集成
代码能编过只是第一步,真正用起来还得知道命令怎么敲、参数什么含义、失败了看哪里。这一章按「先当工具用,再当库用」的顺序走,两条路都走通了,才算真的把这个项目拿下了。
4.1 从零编译出可执行文件
假设你拿到的是源码压缩包,解压后进根目录。除了 CMake 不需要装别的东西,编译器 GCC 或者 Clang 都行,Windows 上用 MSVC 也能过——代码里没有用任何 POSIX 专有接口,文件操作全是标准 C 的fopen / fseek / fread。
# 1. 配置,Debug 版方便你加断点看目录树 cmake -S . -B build-debug -DCMAKE_BUILD_TYPE=Debug # 2. 编译,-j 后面跟核数,别把机器跑满 cmake --build build-debug -j 8 # 3. 编完的产物在这两个位置 ls build-debug/libvpktool.a ls build-debug/vpktool_cli # 4. 保险起见跑一遍自带测试 ctest --test-dir build-debug --output-on-failure如果第 1 步报找不到 C 编译器,多半是环境里只有cc没有gcc,显式指定一下:cmake -S . -B build -DCMAKE_C_COMPILER=gcc。第 3 步如果找不到libvpktool.a,检查是不是把BUILD_SHARED_LIBS打开了,那种情况下产物是.so或.dll。
Debug 版有个额外好处:可以在vpk_walk_tree里下断点,直接看每一层字符串读到哪了。二进制格式的调试,看内存比看日志快得多。我一般会在read_cstr返回后打一行printf("%s\n", s),几百行输出扫一眼就知道树的结构对不对。
4.2 命令行子命令与参数
命令行工具按子命令组织,vpktool_cli <子命令> [参数]。目前实现了三个:list列条目、extract导单个、extractall批量导。参数设计上尽量少,能用默认值就不强制传。
# 列出包内所有条目,输出 "大小<TAB>路径" ./vpktool_cli list pak_dir.vpk # 只看某个目录下的东西,第二个参数是路径前缀过滤 ./vpktool_cli list pak_dir.vpk materials/wood # 导出单个文件到指定路径,父目录不存在会自动创建 ./vpktool_cli extract pak_dir.vpk materials/wood/plank.vmt ./out/plank.vmt # 整包导出到目录,保留原始层级;-v 打印每个文件的写入结果 ./vpktool_cli extractall pak_dir.vpk ./out -v参数含义逐条说:第一个位置参数始终是 dir 文件的路径,注意是 dir 那个而不是分卷,分卷由工具根据archive_index自己去找,同目录下按编号命名即可。extract的第二个参数是包内路径,必须和list打出来的完全一致,包括大小写——这一点很多人栽跟头,Windows 上大小写不敏感用惯了,换到包里就是查不到。第三个参数是输出路径,写成目录就按原文件名存,写成完整文件名就按你给的存。
-v打开详细输出,会打印每个文件的 CRC 校验结果。如果某个文件校验对不上,会输出crc mismatch: <path>,这时候基本可以断定是偏移算错或者文件被截断,直接去看那个文件在包里的条目。
提示:
extractall默认不覆盖已存在的文件,要覆盖得显式加-f。这个默认值是刻意的,批量导出时误覆盖手改过的文件,那种痛谁试谁知道。
4.3 当成库嵌进自己的工程
命令行只是壳,真正有价值的是库接口。头文件里对外暴露的东西非常少,核心就四个函数,用不透明句柄串起来:
/* include/vpk.h —— 对外 API,只此一份,改这里等于改契约 */ typedef struct vpk_s vpk_t; /* 不透明句柄,调用方看不到内部 */ /* 打开一个 dir 包,成功返回 0 并写出句柄 */ int vpk_open(const char *dir_path, vpk_t **out); void vpk_close(vpk_t *v); /* 按包内路径取文件,buf 由库 malloc,调用方 free */ int vpk_get_file(vpk_t *v, const char *path, void **buf, size_t *len); /* 遍历全部条目,cb 返回非 0 时提前终止 */ typedef int (*vpk_visit_fn)(const char *path, const vpk_entry_t *e, void *user); int vpk_foreach(vpk_t *v, vpk_visit_fn cb, void *user);集成只需要三步:包含头文件、链libvpktool.a、调用。给个最小可编译的例子:
#include "vpk.h" #include <stdio.h> #include <stdlib.h> /* 回调:只打印路径和原始长度,返回值 0 表示继续遍历 */ static int on_entry(const char *path, const vpk_entry_t *e, void *user) { (void)user; printf("%-60s %u\n", path, e->length); return 0; } int main(void) { vpk_t *v = NULL; if (vpk_open("pak_dir.vpk", &v) != 0) { fprintf(stderr, "open failed\n"); return 1; } vpk_foreach(v, on_entry, NULL); void *buf = NULL; size_t len = 0; if (vpk_get_file(v, "materials/wood/plank.vmt", &buf, &len) == 0) { fwrite(buf, 1, len, stdout); /* 直接打到标准输出,方便管道处理 */ free(buf); } vpk_close(v); return 0; }编译命令要注意链接顺序,静态库放在源文件后面:
gcc -Iinclude demo.c -Lbuild-debug -lvpktool -o demo-Iinclude指定头文件搜索路径,-L指定库路径,-l是库名(去掉lib前缀和.a后缀)。如果链接时报undefined reference,九成是库路径写错了或者忘了把-lvpktool放在demo.c后面——GCC 的链接顺序是有讲究的,被依赖的库必须写在依赖它的目标之后。
vpk_get_file的buf由库内部malloc,调用方负责free。这个约定写在头文件注释里比较稳妥,否则很容易出现调用方自己free了一遍、库里析构又free一遍的双重释放。我一般会在返回前给缓冲区末尾多申请一个字节并置 0,这样调用方可以直接当 C 字符串用,省一次memcpy。
5. 避坑与排查:五个让程序静默出错的细节
二进制解析最难受的地方不是崩溃,是它不崩。数据读错了照样返回成功,你拿着垃圾数据往下走,问题在几十行之后才暴露。下面这五条是我在实际使用里挨个踩过来的,现象、原因、解决办法都记下来,照着排查能省不少时间。
5.1 目录树读进栈数组,大包直接段错误
现象:小包(几百个文件)跑得好好的,换一个几万文件的大包,程序在遍历开始前就Segmentation fault,没有任何输出。
原因:uint8_t tree[65536];这种写法把树放在了栈上。大包的tree_size轻松超过 1MB,栈上放不下,直接踩穿保护页。更隐蔽的情况是没崩但写坏了相邻变量,表现为“遍历到一半路径变成乱码”。
解决:一律malloc(hdr.tree_size),读完free。同时保留 3.1 里那个 64MB 上限判断,防止损坏文件的tree_size是个天文数字导致malloc失败后仍然继续用空指针。
5.2 漏读条目的 0xffff 终结符,后面全错位
现象:list出来的前几个文件正常,从某个位置开始路径变成乱码或空字符串,条目数量也对不上。
原因:vpk_entry_t的最后一个字段terminator是必须消费掉的 2 字节。如果你的结构体定义里漏了这个字段,或者读完忘了推进指针,下一个字符串就会从0xff 0xff开始读,整个树从这一点起全歪。
解决:结构体里保留terminator字段,读完立刻校验!= 0xffff就返回VPK_ERR_CORRUPT。这个校验是免费的,但能在第一时间把问题定位到具体条目,而不是等到几百行之后。
5.3 preload 数据没跳过,偏移整体前移
现象:大部分文件导出正常,个别小文件(通常是几 KB 的配置)内容全乱或者只有后半截。
原因:preload_len > 0的条目,后面紧跟preload_len字节的内联数据。遍历时如果只推进了sizeof(vpk_entry_t)而没跳过这段,下一个条目的起始位置就提前了,读出来的crc、offset全是错的。小文件更容易触发这个路径,是因为大文件很少走 preload。
解决:遍历时严格按「读结构体 → 跳过 preload_len → 校验 terminator」三步走。读取时把 preload 段和后面的length段拼起来,别只读后者。
5.4 v1 和 v2 头部长度混用,数据区起点算错
现象:条目列表能正常打出来,但extract出来的文件内容整体偏移固定字节数,看起来像“文件头对、内容错”。
原因:v1 头部 12 字节,v2 头部 28 字节。如果代码里写死了12去算数据区起点,v2 的包就会多读出 16 字节的头部残留。因为目录树本身是按tree_size读的,不受影响,所以列表功能看着完全正常,只有取数据时才出问题。
解决:打开时把实际头部长度记在句柄里(v->hdr_offset),后续所有基于tree_size的偏移计算都加上它。别在任何地方写死常量。
5.5 分卷文件找不到,返回空数据但没报错
现象:整包导出跑完提示成功,但输出目录里一堆 0 字节文件。
原因:archive_index != 0x7fff时数据在外部编号分卷里,工具需要按编号去同目录找对应文件。如果分卷缺失或者命名不符,fopen返回NULL,而调用处没有检查返回值,直接拿NULL去fseek,在某些平台上不崩,fread返回 0,于是写出 0 字节文件。
解决:vpk_open_archive返回NULL时必须向上传VPK_ERR_NO_ARCHIVE,extractall收到这个错误码就打印是哪个archive_index缺文件,然后继续处理下一个而不是整体退出。批量任务里因为一个缺文件全盘中断,是最没必要的浪费。
注意:如果你在 Windows 上用文本模式
fopen(path, "r")打开二进制包,换行符会被悄悄转换,所有偏移全部错乱。任何时候都要写"rb"。
6. 二次开发:加一个打包子命令并验证它
读得出来之后,下一步一定是写得回去。很多人卡在这一步,因为写入比读取多一个约束:所有偏移和大小必须提前算准,不能边写边改。我用的办法是两趟写入——第一趟只算大小不落盘,第二趟按算好的布局真正写。
写入的核心是把「目录树大小」和「数据区大小」算出来。树的布局是固定的:每个唯一的扩展名、目录、文件名各存一次,重复的复用。所以第一趟要做的其实是建三张字符串表并去重:
/* 第一趟:只统计,不写盘。返回树大小和数据区大小 */ static int vpk_calc_layout(const vpk_file_t *files, size_t n, uint32_t *tree_size, uint32_t *data_size) { /* 三张表分别存扩展名 / 目录 / 文件名,字符串本身按 \0 结尾存 */ strtab_t ext = {0}, dir = {0}, name = {0}; uint32_t tsz = 0, dsz = 0; for (size_t i = 0; i < n; i++) { /* 拆分 "a/b/c.ext",失败说明输入路径不合法,直接报错 */ if (split_path(files[i].path, &ext, &dir, &name) != 0) return VPK_ERR_BADPATH; dsz += files[i].size; } /* 三层各自:所有字符串 + 每层一个 \0 结束符 */ tsz = ext.len + dir.len + name.len + 3 /* 三个空字符串终结符 */ + (uint32_t)(n * sizeof(vpk_entry_t)) /* 每个文件一个固定条目 */ + (uint32_t)preload_total(files, n); /* 内联区,先按 0 算也行 */ *tree_size = tsz; *data_size = dsz; strtab_free(&ext); strtab_free(&dir); strtab_free(&name); return VPK_OK; }这个函数的参数含义:files是待打包文件数组,每项含路径、大小和数据指针;n是数量;tree_size和data_size是输出参数。返回 0 表示布局算成功,非 0 是错误码。
这里有两个容易漏的点。一是preload_total,如果你不打算用 preload 特性,全部返回 0 完全合法,格式允许preload_len == 0,而这是最简单也最不容易错的选择。二是条目数量n必须和实际写入的文件数严格一致,树里多写或少写一个条目,tree_size就算错了,整个包废掉。
第二趟按同样的顺序再遍历一遍,这次边算偏移边写:
/* 第二趟:先写头,再写树,最后写数据区,全程顺序写不需要 seek 回退 */ static int vpk_write_all(FILE *fp, const vpk_file_t *files, size_t n, uint32_t tree_size, uint32_t data_size) { vpk_header_t h = { VPK_MAGIC, 2, tree_size, data_size }; if (write_header(fp, &h) != 0) return VPK_ERR_IO; /* 头部长度随版本变化 */ long tree_pos = ftell(fp); if (write_tree(fp, files, n) != 0) return VPK_ERR_IO; /* 树写完必须正好是 tree_size,否则头部里的值就是错的 */ if ((uint32_t)(ftell(fp) - tree_pos) != tree_size) return VPK_ERR_LAYOUT; for (size_t i = 0; i < n; i++) { if (fwrite(files[i].data, 1, files[i].size, fp) != files[i].size) return VPK_ERR_IO; } return VPK_OK; }vpk_write_all里那个ftell断言值得单独说。写完树之后实际写了多少字节,必须和第一趟算出来的tree_size完全一致,差一个字节,头部里的声明就是假的,读的时候整棵树会被截断。加这一行断言,问题会当场暴露在写入端,而不是等你打包完再去读才发现。
验证方法我用的是往返测试:把一批文件打成包,再用list和extractall读回来,逐字节比对内容和 CRC。测试代码放tests/roundtrip.c,跑一次覆盖三个方向——条目数量、每个条目的 CRC、每个文件的完整字节。第一次跑通之前我改了三版,问题分别出在字符串去重把不同目录的带\0拼接搞混、条目顺序和写入顺序不一致、以及空文件被当成“大小为 0 就跳过”导致条目缺失。第三个别提多隐蔽,n少了一个,tree_size跟着少,读的时候最后一个文件凭空消失。
| 验证项 | 方法 | 失败时的典型症状 |
|---|---|---|
| 条目数量 | 打包前后计数比对 | 少一个文件,通常是空文件被跳过 |
| 条目 CRC | 读回后重算 CRC32 比对 | 内容错位,多半是偏移算错 |
| 字节完整性 | memcmp原始数据 | preload 拼接漏了一段 |
| 树大小一致性 | 写入端ftell断言 | 头部声明的值和实际不符 |
从那以后我每次动写入路径,哪怕只改一行,都强制跑一遍往返测试再提交。二进制格式的 bug 不像业务逻辑那样一眼能看出来,它安静得很,等你上线批量处理完几千个包才发现全错,那时候后悔药可没处买。希望这个思路能帮到你,少走几段我走过的弯路。
本文还有配套的精品资源,点击获取