机制
文件说明
Arcaea 的内容捆绑包由两类文件共同构成:
- 内容捆绑包文件:保存实际资源数据,后缀通常为
.cb。 - 元数据文件:保存版本、哈希、文件路径、偏移量、分块等信息,后缀通常为
.json。
两者必须配套使用。.cb 文件本身并不保存目录结构,也不保存文件名;这些信息全部来自元数据。因此,缺少元数据时无法可靠拆包,缺少 .cb 文件时元数据也无法完成资源下载。
内容捆绑包
内容捆绑包并不是压缩包或标准归档格式。它的核心结构可以理解为:把若干资源文件的二进制内容按顺序直接拼接在一起,再由元数据记录每个文件的分块编号、偏移量、长度大小。
例如某个文件在元数据中记录如下:
{
"path": "songs/example/base.ogg",
"byteOffset": 1024,
"length": 4096,
"partIndex": 0,
"sha256HashBase64Encoded": "..."
}这表示客户端或解包器应当从第 0 个 .cb 分块中,从第 1024 字节开始读取 4096 字节,并把这段数据保存为 songs/example/base.ogg。读取完成后还会用 sha256HashBase64Encoded 校验文件内容。
当捆绑包较大时,会拆分为多个 .cb 文件。以 6.14.0 为例,服务端可识别以下形式:
6.14.0.json
6.14.0_0.cb
6.14.0_1.cb
6.14.0_2.cb
...旧式单文件形式也可以被 Arcaea-Server 识别:
6.14.0.json
6.14.0.cbArcaea-Bundler 默认生成的是第一种分块命名格式。
元数据
元数据是一个 JSON 文件,描述内容捆绑包的版本关系和文件索引。一个简化后的结构如下:
{
"versionNumber": "6.14.1.1",
"previousVersionNumber": "6.14.1",
"applicationVersionNumber": "6.14.1",
"uuid": "abcdef123",
"removed": [
"songs/example/base.ogg"
],
"added": [
{
"path": "songs/example/base.ogg",
"byteOffset": 0,
"length": 4096,
"partIndex": 0,
"sha256HashBase64Encoded": "..."
}
],
"pathToHash": {
"songs/example/base.ogg": "..."
},
"pathToDetails": {
"songs/unlocks": "...",
"songs/packlist": "...",
"songs/songlist": "..."
},
"generatedUnixTimestamp": 1777355565,
"totalPartitions": 1
}各字段含义如下:
versionNumber:当前内容捆绑包版本号。previousVersionNumber:父内容捆绑包版本号。全量包固定为null。applicationVersionNumber:对应客户端版本号。在 Arcaea-Server 的严格模式下,服务端会按此字段把内容捆绑包分组;在图路径模式下,它主要用于确定当前客户端版本的目标最新内容捆绑包版本。uuid:本次元数据的随机标识。Arcaea-Bundler 会生成 9 位十六进制字符串。removed:客户端在应用此更新时需要删除的文件路径列表。文件内容发生变化时,Arcaea-Bundler 会同时把该路径加入removed和added,表示先移除旧文件再写入新文件。added:本次更新实际写入.cb文件的文件列表。每一项包含路径、偏移、长度、所在分块和 SHA-256 校验值。pathToHash:当前版本完整资源状态中,所有文件路径到 SHA-256 Base64 值的映射。它主要用于下一次打包时判断哪些文件新增、变更或删除。pathToDetails:songs/unlocks、songs/packlist、songs/songlist三个关键文件的详情哈希。Arcaea-Bundler 使用固定密钥计算 HMAC-SHA256 后再进行 Base64 编码。generatedUnixTimestamp:元数据生成时的 Unix 时间戳。totalPartitions:分块数量。缺少此字段时,解包器会按旧式单文件.cb处理。
元数据记录
元数据记录是 Arcaea-Bundler 自己使用的辅助文件,默认位于输入文件夹下的 metadata.oldjson。它不是客户端直接下载的文件,也不需要放到 Arcaea-Server 的 database/bundle 文件夹中。
它的内容是一个 JSON 数组,每一项保存某次打包后的完整资源状态。为了减少体积,记录中会移除 added 和 removed 字段,只保留之后判断增量更新所需的信息,例如:
[
{
"versionNumber": "6.14.1",
"previousVersionNumber": null,
"applicationVersionNumber": "6.14.1",
"uuid": "abcdef123",
"pathToHash": {
"songs/songlist": "..."
},
"pathToDetails": {
"songs/unlocks": "...",
"songs/packlist": "...",
"songs/songlist": "..."
},
"generatedUnixTimestamp": 1777355565,
"totalPartitions": 1
}
]下次打包同一个输入文件夹时,程序会读取元数据记录中版本号最大的记录:
- 沿用其中的
applicationVersionNumber。 - 将其中的
versionNumber作为默认previousVersionNumber。 - 根据默认规则生成下一个
versionNumber。 - 使用其中的
pathToHash与当前输入文件夹逐项比较,生成新的added和removed。
删除 metadata.oldjson 会重置增量记录。重置后再次打包时,程序无法知道过去版本的文件状态,因此会把当前输入文件夹视为一次全量打包。
注意
可以手动修改 metadata.oldjson,但必须保证版本关系、pathToHash 和实际资源状态一致。否则生成的增量包可能无法从父版本正确更新到目标版本。
assets 文件夹
打包器的输入文件夹等同于客户端资源目录中的 assets 文件夹。
目前官方的结构示例:
input_dir/
├─ songs/
│ ├─ unlocks
│ ├─ packlist
│ ├─ songlist
│ └─ example_song/
│ ├─ 0.aff
│ ├─ 1.aff
│ ├─ 2.aff
│ ├─ base.ogg
│ ├─ base.jpg
│ └─ base_256.jpg
├─ img/
│ └─ bg/
└─ tl/其中以下三个文件是必需的:
songs/unlockssongs/packlistsongs/songlist
Arcaea-Bundler 会优先按上述顺序处理这三个文件,然后递归遍历输入文件夹中的其他文件。默认的元数据记录文件 metadata.oldjson 以及其他 .oldjson 文件会被跳过,不会写入捆绑包。
除上述必需文件外,打包器不会检查资源是否符合客户端业务规则。换言之,只要是输入文件夹中的普通文件,就会被视为可打包资源;它是否能被客户端正确使用,取决于 songlist、packlist、unlocks 以及客户端本身对资源路径和格式的要求。
增量更新机制
Arcaea-Bundler 的增量判断基于文件路径和 SHA-256:
- 读取元数据记录中最新版本的
pathToHash。 - 遍历当前输入文件夹,计算每个文件的 SHA-256。
- 当前存在但旧记录不存在的路径会加入
added。 - 当前存在且哈希变化的路径会同时加入
removed和added。 - 旧记录存在但当前不存在的路径会加入
removed。 - 路径和哈希都未变化的文件不会写入新的
.cb,但会继续保留在新元数据的pathToHash中。
因此,增量包中的 .cb 只保存新增或变更的文件,并不包含完整资源。客户端需要已经处于 previousVersionNumber 对应的资源状态,才能正确应用此增量包。
如果希望生成全量包,可以删除或更换元数据记录,并显式指定新的 -av 和 -bv。
服务端机制
Arcaea-Server 会从 database/bundle 文件夹中读取内容捆绑包。每个元数据 JSON 需要有同名的 .cb 文件或同名前缀的分块文件。例如:
database/bundle/
├─ 6.14.1.json
├─ 6.14.1_0.cb
├─ 6.14.1_1.cb
└─ 6.14.1_2.cb服务端启动或刷新内容捆绑包缓存时,会递归扫描 database/bundle:
- 读取每个
.json元数据文件。 - 根据
versionNumber、previousVersionNumber、applicationVersionNumber建立版本索引。 - 查找同名
.cb,或根据totalPartitions查找*_0.cb、*_1.cb等分块。 - 计算元数据和各分块文件大小,用于返回给客户端。
客户端请求热更新时,会访问游戏 API 的 /game/content_bundle 端点,并通过请求头提供:
AppVersion:客户端版本。ContentBundle:客户端当前内容捆绑包版本。DeviceId:设备标识,用于生成下载令牌记录。
之后服务端会根据 BUNDLE_STRICT_MODE 选择不同的更新策略。
严格模式
BUNDLE_STRICT_MODE 默认为 True。在严格模式下,服务端只从 applicationVersionNumber 等于请求头 AppVersion 的内容捆绑包中选择更新。
具体流程是:
- 扫描缓存时,服务端先按
applicationVersionNumber将内容捆绑包分组,并按versionNumber从小到大排序。 - 客户端请求时,服务端取出
AppVersion对应的整组内容捆绑包。 - 返回结果生成时,再过滤掉
versionNumber小于或等于客户端ContentBundle的项。
也就是说,严格模式不会根据 previousVersionNumber 计算路径。只要某个内容捆绑包属于同一个 applicationVersionNumber 分组,并且版本号大于客户端当前 ContentBundle,它就可能出现在返回列表中。因此在此模式下,建议同一个客户端版本下的内容捆绑包版本保持线性递增,且每个增量包都能从前一个版本正确更新。
图路径模式
当 BUNDLE_STRICT_MODE 设为 False 时,服务端会使用基于版本关系图的更新方式。
服务端会把每个内容捆绑包视为一条有向边:
previousVersionNumber -> versionNumber全量包的 previousVersionNumber 为 null 时,服务端内部会把它视作 0.0.0。客户端请求时:
- 服务端仍会用请求头
AppVersion找到该客户端版本下最大的versionNumber,作为目标版本。 - 起点是请求头
ContentBundle;如果客户端没有提供内容捆绑包版本,则起点视为0.0.0。 - 服务端在
previousVersionNumber -> versionNumber组成的图中进行广度优先搜索,寻找从起点到目标版本的最短路径。 - 返回结果只包含这条路径上的内容捆绑包,顺序即为客户端应当应用更新的顺序。
这里的 applicationVersionNumber 只用于确定“目标版本”,不用于限制图中的每一条边。换言之,图路径模式下的中间更新包由版本关系决定;如果不同客户端版本之间复用了相同的 versionNumber 和 previousVersionNumber,或版本图意外连通,就可能影响路径选择。因此在图路径模式下,应避免让不兼容的更新包共享同一条版本边。
例如存在以下内容捆绑包:
0.0.0 -> 6.14.1
6.14.1 -> 6.14.1.1
6.14.1.1 -> 6.14.1.2
6.14.1 -> 6.14.1.2当客户端当前 ContentBundle 为 6.14.1、目标版本为 6.14.1.2 时,图路径模式会选择 6.14.1 -> 6.14.1.2 这条更短的路径,而不是依次返回 6.14.1.1 和 6.14.1.2。
注意
图路径模式依赖 previousVersionNumber 和 versionNumber 组成的版本图。若缺少可达路径,服务端无法为该客户端找到更新包;若存在多条同长度路径,最终选择取决于服务端扫描和缓存到的顺序。由于服务端用 (versionNumber, previousVersionNumber) 作为内容捆绑包索引,同一条版本边也不应被多个互不兼容的包复用。
正常情况下返回结构类似:
{
"orderedResults": [
{
"contentBundleVersion": "6.14.1.1",
"appVersion": "6.14.1",
"jsonSize": 12345,
"jsonUrl": "https://example.com/bundle_download/...",
"bundleParts": [
{
"bundleSize": 104857600,
"bundleUrl": "https://example.com/bundle_download/..."
}
]
}
]
}客户端会按 orderedResults 的顺序下载元数据和各分块文件,并根据元数据中的 removed、added、哈希和分块索引完成更新。
提示
修改 database/bundle 中的文件后,需要重启服务端或在 Web 管理页面刷新内容捆绑包缓存,否则服务端仍会使用旧缓存。