UE5.8 DLC、Patch 与 Chunk 更新:一次热更新失控后的复盘

📑 目录

UE5.8 DLC、Patch 与 Chunk 更新:一次热更新失控后的复盘

一、赛季前夜的 2.3GB

老陈是《边境行商》项目的技术负责人。五月最后一个周四,策划大刘说赛季 S3 要加一个沙漠主城和两套载具皮肤。美术阿 May 已经把资源交到 Content 目录,老陈带着两名后端做最后的打包。

晚上十一点,构建机吐出最终产物。老陈扫了一眼包体,手抖了一下:Patch 包 2.3GB。他第一反应是有人把未压缩的 4K 贴图放进了基础包。可审了一圈资产,最大的一张贴图也只有 2K,且压缩格式正常。

问题出在 Chunk 上。S3 的沙漠主城被划到 Chunk 3,载具皮肤被划到 Chunk 5。策划在赛季 S2 末期改过一次载具配置文件,导致 Chunk 0 里共享的载具基类引用路径变了 Cooker 签名。Patch 工具对比新旧 Pak 时发现 Chunk 0、Chunk 3、Chunk 5 的哈希全变了,于是把三块 Pak 都重新下发。玩家实际改动的内容不到 80MB,却要吞下 2.3GB。

那次事故之后,老陈把团队召集到会议室,在白板上画了一条主线:Patch 要小,DLC 要稳,Chunk 边界不能乱动。这篇文章就是那次复盘的延伸,围绕 UE5.8 的 Pak 补丁生成、差异更新、DLC 内容包、版本兼容、Chunk 划分、下载安装、平台审核和测试验证,把踩过的坑整理成可以复用的 checklist。

二、Pak 补丁生成:Cook 之后发生了什么

2.1 从 Cooked 资产到 Pak 文件

Cook 阶段结束后,资产已经转换成目标平台格式。接下来的 Pak 阶段由 UnrealPak 负责,把 cooked 文件按 Chunk 分组写入 .pak 文件。每个 Pak 文件内部是一个简单的归档结构:文件头、索引表、数据块。索引表记录每个资产的挂载路径、压缩方式、偏移和哈希。

UE5.8 默认启用 IoStore,但传统 Pak 仍然是 DLC 和 Patch 分发的主力格式。IoStore 的 .ucas.utoc 更适合基础包加载,而 .pak 在运行时挂载、热更新、DLC 扩展上更灵活。老陈的项目采用混合策略:基础包走 IoStore,赛季更新和 DLC 走 Pak。

生成 Patch Pak 的典型命令行如下:

"%UE5_8%\Engine\Binaries\Win64\UnrealPak.exe" ^
  D:\Builds\Patch\pakchunk1000-Windows.pak ^
  -Create=D:\Builds\Patch\response.txt ^
  -compress -compressionformat=Zlib ^
  -cryptokeys=D:\Builds\crypto.json

-Create 参数接收一个响应文件,里面列出要打包的文件路径。-compressionformat 可以是 Zlib、Brotli、Oodle,不同平台可选的压缩器不同。-cryptokeys 用于签名,平台商店通常要求 Pak 必须带签名,否则启动器会拒绝挂载。

2.2 Patch 包是怎么算出来的

Patch 不是简单的新 Pak 覆盖旧 Pak,而是新旧版本之间的差异集合。UE5.8 的 BuildPatchTool(简称 BPT)或者项目自研的 diff 工具会执行以下步骤:

  1. 读取旧版本每个 Chunk 的清单,拿到每个资产的哈希、大小、偏移。
  2. 读取新版本清单,做同样的事情。
  3. 对比同一 Chunk 内同名资产的哈希,哈希不同则标记为变更。
  4. 对新增资产直接加入 Patch 列表。
  5. 对删除资产记录为移除指令。
  6. 按平台要求生成增量包或完整替换 Pak。
flowchart TD
    A["旧版本 Pak 清单"] --> B["新版本 Pak 清单"]
    B --> C{同一 Chunk 同一路径?}
    C -->|"是"| D["对比哈希"]
    C -->|"否"| E["标记新增或删除"]
    D -->|"哈希相同"| F["无需下发"]
    D -->|"哈希不同"| G["加入差异列表"]
    E --> H["生成 Patch 包"]
    G --> H
    F --> I["输出 manifest"]
    H --> I

这个流程图里最危险的环节是 D。只要哈希不同,无论资产实际变化多少,都会进入 Patch 包。很多团队以为改一张 1KB 的 JSON 配置只会下发 1KB,但如果这份配置导致某个 Chunk 的 Pak 签名整体失效,平台差异工具可能把整个 Chunk 都当成新文件。

老陈后来定了一条规矩:Patch 生成后必须跑一遍 diff 报告,看每个 Chunk 的 "changed bytes / total bytes" 比例。比例超过 30% 就要人工复核。

2.3 签名与加密

Pak 签名分两层。一层是 Pak 文件本身的完整性校验,由 FPakFile 在挂载时验证;另一层是发布平台要求的数字签名,比如 Steam 的 depotsignature、PlayStation 的 pkg signature、iOS 的 App Store 签名。

UE5.8 在 DefaultCrypto.ini 里配置 Pak 加密密钥。加密会增加运行时 CPU 开销,但能防止玩家拆包偷跑未发布内容。老陈的经验是:加密只给剧情过场和高价值 DLC 开,基础包不加密,避免低端设备加载时掉帧。

三、差异更新:怎么让 Patch 真正变小

3.1 差异更新不是压缩

很多人把差异更新和压缩混为一谈。压缩是把单个文件变小,差异更新是只传变化的部分。一个好的 Patch 系统需要同时做好两件事:内容差异要小,传输差异也要小。

UE5.8 自带的差异更新基于文件级哈希。更精细的做法是块级差异,比如 rsync 算法或者 Oodle 的 Oodle Network。块级差异可以识别文件内部未变化的数据段,只传修改过的块。老陈的项目在 PC 端用了块级差异,移动端因为实现复杂仍然走文件级差异。

一次差异更新可以带来的流量节省用下面这个式子描述:

Spatch=i=1n(Snew,iSsame,i)+SmetaS_{patch} = \sum_{i=1}^{n} \left( S_{new,i} - S_{same,i} \right) + S_{meta}

其中 Snew,iS_{new,i} 是第 ii 个变更资产的新大小,Ssame,iS_{same,i} 是两个版本之间未变化部分的大小,SmetaS_{meta} 是 manifest 和索引的元数据开销。理想的 Patch 里 Ssame,iS_{same,i} 尽可能大,SmetaS_{meta} 尽可能小。

行内公式也能说明问题:如果一次更新只改了 5% 的资产内容,但 Pak 签名导致整包失效,那么玩家下载量就是 100%100\% 而不是 5%5\%

3.2 影响 Patch 大小的常见操作

老陈总结了一份内部黑名单,列出最容易把 Patch 撑大的操作:

  • 重命名目录:Pak 内路径变了,diff 工具认为是新增和删除两批文件。
  • 移动资产到另一个 Chunk:依赖关系重算,原 Chunk 和新 Chunk 签名都可能变。
  • 修改共享父类:比如载具基类加了一个 UPROPERTY,所有子类 Blueprint 的序列化签名都会变。
  • 更新引擎版本:即便内容没动,Cook 格式变化也会导致全部 Pak 重签名。
  • 批量重导贴图:导入时间戳或 GUID 变化会让纹理包哈希飘移。

为了避免这些问题,老陈在版本控制里加了 pre-submit 检查:任何修改 DefaultGame.ini 中 Chunk 配置、或者移动 /Game/Core/ 下资产的提交,必须附 diff 评估报告。

3.3 热更新的边界

热更新指的是不通过商店审核直接推送的更新。UE5.8 支持热更新 Pak,但受平台政策限制。iOS 不允许下载可执行代码,Android 和 PC 相对宽松,主机平台基本禁止热更新游戏逻辑。

老陈的项目把热更新范围严格限定在:

  • 材质和贴图替换
  • 音频和本地化文本
  • 数据表格和配置
  • 非逻辑性 UI 布局

任何涉及 C++ 编译、蓝图事件图表修改、Gameplay Ability 逻辑变更的更新,都必须走商店审核流程。这个边界需要在项目启动器和后端 CDN 规则上同时卡住,不能靠口头约定。

四、DLC 内容包:独立、隔离、可回滚

4.1 DLC 不是大号的 Patch

DLC 和 Patch 在 Pak 层面看起来都是新增或替换文件,但设计目标不同。Patch 是版本演进,玩家必须下载;DLC 是可选内容,玩家可以不买、不装、不玩。这个差异决定了 DLC 必须满足三个条件:

  • 独立清单:DLC 有自己的 manifest,不混入基础包 manifest。
  • 依赖单向:DLC 可以引用基础包和共享 Chunk,不能引用其他 DLC。
  • 运行时挂载:DLC Pak 在游戏启动后按需挂载,而不是安装阶段强制合并。
flowchart TD
    subgraph base ["基础包"]
        c0["Chunk 0 核心"]
        c10["Chunk 10 共享依赖"]
    end
    subgraph dlc1 ["DLC 1 沙漠扩展"]
        d1_pak["DLC1 Pak"]
        d1_map["Map_DesertCity"]
        d1_skin["Skin_DesertRider"]
    end
    subgraph dlc2 ["DLC 2 极地扩展"]
        d2_pak["DLC2 Pak"]
        d2_map["Map_IceBase"]
        d2_skin["Skin_ArcticSuit"]
    end
    d1_pak --> d1_map
    d1_pak --> d1_skin
    d2_pak --> d2_map
    d2_pak --> d2_skin
    d1_map --> c0
    d1_skin --> c10
    d2_map --> c0
    d2_skin --> c10

这个 architecture-beta 图展示了两个 DLC 的依赖关系。它们都依赖 Chunk 0 和 Chunk 10,但彼此之间没有横向引用。这意味着下架 DLC 1 不会影响 DLC 2,回滚 DLC 1 也不会污染基础包。

4.2 DLC 的 Cook 与打包

DLC 的 Cook 通常用 -dlcname 参数指定。UE5.8 的 Cooker 会基于基础包的 cooked 数据做增量 Cook,只处理 DLC 独有的资产和引用链上发生变化的资产。

RunUAT.bat BuildCookRun ^
  -project=D:\MyGame\MyGame.uproject ^
  -dlcname=DesertExpansion ^
  -platform=Win64 ^
  -clientconfig=Shipping ^
  -cook -stage -pak ^
  -compressed ^
  -manifests

-manifests 参数会生成用于差异更新的清单文件。DLC 包名通常是 pakchunk1000-Windows_001.pak 这种形式,Chunk ID 1000 以上是项目自定义的 DLC 区间。

老陈给 DLC 编号定了一套规则:

Chunk ID 区间用途
0 - 99基础包与共享依赖
100 - 499赛季内容
500 - 999活动与限时内容
1000 - 1999付费 DLC
2000+预留,用于平台分包

这套规则不是 UE5.8 强制的,而是项目内部约定。它的好处是让启动器一眼看出 Pak 的归属,出问题时不至于在日志里大海捞针。

4.3 版本兼容与回滚策略

DLC 上线后最怕两件事:新版本 DLC 被旧版本客户端加载,以及 DLC 本身出现严重 bug 需要紧急下架。

老陈的 manifest 里给每个 Chunk 加了 min_app_versionmax_app_version 两个字段:

{
  "build_id": "1.5.0.2048",
  "chunks": [
    {
      "id": 1001,
      "hash": "f3a9c2",
      "size": 456000000,
      "min_app_version": "1.5.0",
      "max_app_version": "1.5.9"
    }
  ]
}

启动器在挂载 DLC 之前会先检查客户端版本。如果玩家停留在 1.4.0,启动器会提示先更新基础包,而不是直接下载 DLC。这个检查放在 CDN 返回 manifest 之后、Pak 挂载之前,防止运行时因为类缺失或属性偏移崩溃。

回滚策略分两种。第一种是内容回滚:把 CDN 上对应 Chunk 的 Pak 替换为旧版本,同时更新 manifest 里的 hash。玩家下次启动时发现本地 hash 与 manifest 不符,会自动重新下载旧版本。第二种是功能回滚:在代码层通过 Feature Flag 屏蔽 DLC 入口,不让玩家看到购买按钮,但 Pak 仍然保留在 CDN 上。

graph LR
    A["DLC 1001 v1.5.1"] --> B["玩家报告崩溃"]
    B --> C{回滚类型}
    C -->|"内容回滚"| D["CDN 替换为 v1.5.0 Pak"]
    C -->|"功能回滚"| E["Feature Flag 关闭入口"]
    D --> F["启动器校验失败"]
    E --> G["玩家不可见 DLC"]
    F --> H["自动下载旧版本"]
    H --> I["游戏正常启动"]

内容回滚不修改代码,适合修复资源错误。功能回滚不修改 Pak,适合处理逻辑 bug。两者可以单独使用,也可以叠加。

五、Chunk 划分实战:让边界稳定下来

5.1 划分原则

Chunk 划分没有标准答案,但有几个经过项目验证的原则:

按更新频率分。基础引擎和核心代码放进 Chunk 0,更新频率低。赛季地图、活动资源放进 Chunk 100 以后,更新频率高。共享依赖单独放一个 Chunk 10,避免被高频内容牵着走。

按加载时机分。启动时必需的内容放 Chunk 0,主菜单加载的内容放 Chunk 10,局内按需加载的内容放 Chunk 20 以后,DLC 内容放 Chunk 1000 以后。

按依赖方向分。Chunk 0 可以被所有其他 Chunk 引用,共享 Chunk 10 可以被所有非基础 Chunk 引用,DLC Chunk 只向上引用,不横向引用。

5.2 Primary Asset Labels 实战

UE5.8 的 Primary Asset Label 是 Chunk 划分的主要工具。老陈的项目里有这样几个 Label:

[/Script/Engine.AssetManagerSettings]
PrimaryAssetTypesToScan=(PrimaryAssetType="Map",AssetBaseClass=/Script/Engine.WorldAssetBase,Directories=((Path="/Game/Maps")),Rules=(Priority=-1,ChunkId=-1,bApplyRecursively=True,CookRule=AlwaysCook))
PrimaryAssetTypesToScan=(PrimaryAssetType="Skin",AssetBaseClass=/Script/MyGame.CharacterSkin,Directories=((Path="/Game/Characters/Skins")),Rules=(Priority=-1,ChunkId=-1,bApplyRecursively=True,CookRule=AlwaysCook))

[/Script/Engine.PrimaryAssetLabel]
+LabelAssets=(Label="CoreBase",ChunkId=0,Priority=10,ApplyRecursively=True,AssetCollection="/Game/Core")
+LabelAssets=(Label="SharedDeps",ChunkId=10,Priority=10,ApplyRecursively=True,AssetCollection="/Game/Shared")
+LabelAssets=(Label="SeasonS3",ChunkId=103,Priority=20,ApplyRecursively=True,AssetCollection="/Game/Seasons/S3")
+LabelAssets=(Label="DLCDesert",ChunkId=1001,Priority=30,ApplyRecursively=True,AssetCollection="/Game/DLC/DesertExpansion")

这里的关键是 ChunkId=-1。PrimaryAssetTypesToScan 只负责扫描资产,把具体的 Chunk 分配交给 Label。如果这里写死了 ChunkId,后续 Label 的优先级规则会被打乱。

5.3 Chunk 大小的工程约束

Chunk 太小,Pak 文件头开销占比高,运行时 IO 碎片化。Chunk 太大,Patch 时一动就牵动全身,移动端后台下载也容易被系统杀掉。

老陈团队内部有一张约束表:

平台单 Pak 大小建议原因
PC100MB - 500MB机械硬盘 Seek 和 SSD 并行加载的平衡
PS5 / Xbox100MB - 1GB高速 SSD 适合顺序读大文件
Switch50MB - 200MB卡带连续读快,数字版存储小
iOS / Android25MB - 200MB后台下载时间限制和存储碎片化

单 Chunk 下载时长可以粗略估算为:

Ttotal=SpakRnet11ploss+Tverify+TmountT_{total} = \frac{S_{pak}}{R_{net}} \cdot \frac{1}{1 - p_{loss}} + T_{verify} + T_{mount}

其中 RnetR_{net} 是玩家实际可用带宽,plossp_{loss} 是网络丢包率,TverifyT_{verify} 是 Pak 校验时间,TmountT_{mount} 是挂载时间。在丢包率较高的移动网络里,plossp_{loss} 会让有效带宽大幅下降,所以移动端的 Chunk 要比 PC 小得多。

六、下载与安装流程:从 CDN 到挂载

6.1 启动器的工作流

玩家打开游戏时,启动器会先请求 CDN 上的 manifest。manifest 包含每个 Chunk 的 hash、大小、最低版本和下载地址。启动器把 manifest 和本地缓存对比,决定需要下载哪些 Pak。

sequenceDiagram
    participant Player as "玩家"
    participant Launcher as "启动器"
    participant CDN as CDN
    participant Game as "游戏进程"

    Player->>Launcher: 点击启动
    Launcher->>CDN: GET /manifest/Win64/latest.json
    CDN-->>Launcher: manifest + Chunk 签名
    Launcher->>Launcher: 比对本地 Pak 签名
    alt 有 Patch
        Launcher->>CDN: 下载差异 Chunk
        CDN-->>Launcher: Patch 数据流
        Launcher->>Launcher: 校验并写入本地
    end
    alt 有未下载 DLC
        Launcher->>Player: 展示 DLC 列表
        Player->>Launcher: 选择下载 / 购买
        Launcher->>CDN: 下载 DLC Pak
        CDN-->>Launcher: DLC 数据流
    end
    Launcher->>Game: 启动进程并传入 Pak 列表
    Game->>Game: 挂载 Pak,注册 Chunk

这个 sequence 图展示了玩家点击启动图标后,启动器、CDN、游戏进程之间的交互链路。Patch 下载是静默的,DLC 下载需要玩家确认。两者共用同一套 CDN 和本地缓存,但生命周期不同:Patch 下载后即时生效,DLC 下载后可以延迟挂载。

6.2 断点续传与后台下载

移动端下载最怕中断。UE5.8 本身不提供断点续传,需要启动器自己实现。老陈的启动器把每个 Chunk 切成 2MB 的块,逐块下载并校验。如果中途被杀,下次启动时只下载未完成的块。

后台下载在 iOS 和 Android 上都有平台 API:iOS 用 NSURLSession 的 background configuration,Android 用 WorkManager。启动器需要把下载任务注册到系统,而不是自己维护一个常驻进程。这样即使游戏被切到后台,下载仍然可以继续。

6.3 安装与挂载

Pak 下载到本地后,启动器会把它放到可写目录,比如 Windows 的 ProjectSavedDir/DownloadedPaks/,Android 的 getExternalFilesDir()。游戏进程启动时,通过 FPakPlatformFile::Mount 挂载这些 Pak。

挂载顺序很重要。基础包 Pak 必须先挂载,共享 Chunk 其次,DLC 最后。如果 DLC 里有一个资产和基础包同名,后挂载的会覆盖先挂载的。老陈利用这个机制做 DLC 替换:DLC 可以覆盖基础包里的默认皮肤,而不需要修改基础包。

挂载完成后,Asset Manager 需要刷新。UE5.8 提供了 UAssetManager::ScanPathsForPrimaryAssets,让新挂载的资产进入可加载状态。如果只挂载不刷新,游戏里用软引用加载 DLC 资产时会返回空。

七、平台审核与热更新限制

7.1 主机平台

PS5、Xbox Series X|S、Switch 对可下载内容都有严格限制。主机 Patch 通常必须走平台方的差异更新通道,不能用项目自己的 CDN 热更新游戏逻辑。DLC 需要通过平台商店提交审核,获得 SKU 和产品 ID 后才能被玩家购买。

老陈在主机上的做法是:把所有可能的赛季内容提前 Cook 进基础包,但通过 Feature Flag 控制显示。这样赛季更新只需要下发一个 flag 文件,而不需要下发新 Pak。当然,这会增大初始安装包体积,需要在包体大小和更新灵活性之间取舍。

7.2 移动端

iOS App Store 明确禁止下载可执行代码。Blueprint 编译后的字节码、C++ 编译产物都属于可执行代码。老陈只把以下内容加入移动端热更新:

  • 贴图、材质、模型
  • 音效、音乐、语音
  • 数据表、本地化 JSON
  • UI 图片和布局配置

Google Play 相对宽松,但 Play Asset Delivery 要求按特定方式分包。Android App Bundle 会把项目按 install-timefast-followon-demand 三种交付类型拆分。UE5.8 的 Chunk ID 可以映射到这三种类型:Chunk 0 映射 install-time,Chunk 10-40 映射 fast-follow,Chunk 1000+ 映射 on-demand。

7.3 PC 平台

PC 是最自由的平台,Steam、Epic Games Store、自家启动器都支持自定义更新。Steam 有 SteamPipe,Epic 有 Epic Online Services 的 Patch 系统。老陈的项目在 PC 端用了自研启动器,配合 CDN 做差异更新。

PC 端需要注意的是反作弊和数字签名。EAC、BattlEye 等反作弊系统会校验游戏文件。如果 Pak 挂载方式绕过了它们的校验范围,可能导致玩家被封禁。老陈和反作弊厂商确认过挂载路径白名单,DLC Pak 必须放在白名单目录里。

八、测试与验证:别让事故再次发生

8.1 构建期验证

每次 CI 构建完成后,老陈的流水线会自动跑以下检查:

  • Chunk 归属检查:用 Asset Audit 工具确认关键资产落在预期 Chunk。
  • Patch 差异报告:对比上一个 release 版本,输出每个 Chunk 的 changed bytes 和新增/删除文件列表。
  • Pak 完整性校验:用 UnrealPak 的 -Test 参数验证 Pak 可读、索引完整、签名正确。
  • 重复资源检查:扫描不同 Chunk 之间是否有同一资产被重复打包。

8.2 运行时验证

QA 测试需要覆盖以下场景:

  • 从旧版本升级到新版本,确认 Patch 正确应用。
  • 在已安装 DLC 的情况下更新基础包,确认 DLC 仍然可用。
  • 卸载 DLC 后重新进入游戏,确认没有残留引用导致崩溃。
  • 断网、弱网、下载中断后恢复,确认断点续传工作正常。
  • 不同版本客户端同时在线,确认服务器对 DLC 内容的兼容性。

8.3 自动化测试脚本

老陈写了一个简单的 Python 脚本,用来比较两个版本的 Pak 清单并输出差异:

import json
from pathlib import Path

old_manifest = json.loads(Path('manifest_old.json').read_text())
new_manifest = json.loads(Path('manifest_new.json').read_text())

old_map = {c['id']: c for c in old_manifest['chunks']}
new_map = {c['id']: c for c in new_manifest['chunks']}

for cid, new_chunk in new_map.items():
    old_chunk = old_map.get(cid)
    if not old_chunk:
        print(f"新增 Chunk {cid}: {new_chunk['size']} bytes")
    elif old_chunk['hash'] != new_chunk['hash']:
        delta = new_chunk['size'] - old_chunk['size']
        print(f"Chunk {cid} 变化: delta={delta} bytes")

这个脚本只有 15 行左右,但在每次发版前都会跑一遍。它能快速发现哪个 Chunk unexpectedly 变了,帮助团队在大包出现之前就把问题定位出来。

8.4 灰度与监控

即使测试通过,老陈也不会全量推送。他会先对一个 CDN 节点开放 5% 的流量,观察下载成功率、崩溃率和玩家反馈。灰度指标稳定后,再逐步扩大到 50%、100%。

监控方面,启动器会上报每个 Chunk 的下载耗时、失败次数、校验失败次数。后端会把这些指标按平台、网络类型、地区分组。如果某个 Chunk 的失败率突增,会自动触发告警并暂停该节点的分发。

九、总结:标签、Chunk 与流程

那场 2.3GB 的事故教会老陈一件事:DLC 和 Patch 不是打包阶段才考虑的问题,而是贯穿整个项目周期的工程决策。资产归属要在导入时就定好,Chunk 边界要在设计阶段就稳定,更新流程要在第一次发版前就跑通。

UE5.8 提供了工具:Primary Asset Labels 管归属,UnrealPak 管归档,BuildPatchTool 管差异,Asset Manager 管运行时加载。但工具不会替你做决策。真正决定 Patch 大小的,是团队对共享依赖的治理、对高频更新内容的隔离、对平台规则的尊重。

老陈后来在白板上补了两句话:Patch 要小,是因为玩家流量贵;DLC 要稳,是因为付费内容经不起回滚。他把这两句话贴在构建机旁边,每次打包都能看见。