template/docs/dev/replay/replay-array.md
2026-07-13 23:19:17 +08:00

11 KiB
Raw Blame History

需求综述

录像由数万条录像步组成,每条录像步包含可变数量的参数,参数类型涵盖数字、字符串、布尔值和 bigint。直接操作 ArrayBuffer 的字节布局会导致系统耦合存储格式细节、修改录像困难。需要一个独立工具封装底层存储的读写,并提供插入、删除、修改等编辑能力。

需求理解

明确需求

  1. 变长指令编码。指令码使用 uint8uint16,宽度取决于已注册命令的最大 ID。
  2. 自描述参数格式。参数使用类型标识 + 变长内容的编码,每条录像步的参数数量可变。类型标识同时承担短字符串的长度信息,节省额外字节。
  3. 录像可编辑。支持在任意有效位置插入、删除、修改录像步——不是只追加的日志。
  4. 顺序流式读取。播放是按顺序逐帧进行的,需要流式读取器避免每次随机访问的 O(n) 偏移扫描。

隐含需求

  1. 容量管理。ArrayBuffer 不可动态扩容,写入前需检查容量,不足时分配新缓冲并复制。
  2. 偏移累积。指令数组中的每条录像步长度不固定,随机访问某一条时需从头扫描累积偏移。流式读取器可以利用顺序访问特性只扫描一次。

设计前提

  1. 双缓冲存储。指令数组和参数数组各自使用独立的 ArrayBuffer,通过 DataView 读写。
  2. 不可变元数据。IReplayMetadata 中的格式参数在初始化时确定,读取时不修改。
  3. 边解码边播放。不一次性解码全部数据——沙箱通过流式读取器按需解码。

核心概念定义

指令数组

录像中所有指令码与参数数量的序列化存储。每条录像步在指令数组中占用 1+W 字节W 为指令码宽度):第一个 uint8 为参数数量,后续 uint8uint16 为指令码。

参数数组

录像中所有参数内容的序列化存储。每个参数由一个 uint8 类型标识开头后接对应类型的二进制内容。类型标识同时编码短字符串的长度8-256 → 长度 n-7

流式读取器

持有一个内部偏移指针,每次调用 read 解码并返回当前指针指向的录像步,然后推进指针。由于指令数组中每条录像步的总字节数不固定,指针以步索引而非字节偏移为单位——读取器内部维护已扫描过的累积字节偏移,每次 read 仅解码一步。

指令宽度

指令码的存储字节数。初始为 uint81 字节)。命令 ID 上限在 registerCommand 注册时即可获知——若注册了 > 255 的 ID直接初始化为 uint16,不涉及运行时重编码。若需要变更已存在录像的指令宽度(例如切换为更大的指令码空间以兼容新增命令,或压缩为更小的空间以节省体积),则通过 setCommandWidth 方法将全部指令条目按新宽度重编码。

接口设计分析

IReplayArray

设计思路

由于录像需要支持任意有效位置的插入和删除——比如玩家在已录制的操作序列中间补充了额外操作,或者修复了某段错误的录像——只提供追加写入是不够的。因此需要 insertdelete 方法,以及在原位置修改的 set 方法。追加仍然是最频繁的操作——因此需要 add 方法。

由于沙箱播放是顺序推进的,每次调用 get(index) 都需要从头扫描积累偏移——因此设计 createReadStream 创建流式读取器。流式读取器内部维护步索引指针和已累积字节偏移,每次 read 仅解码一步,将偏移计算从 O(n²) 降为 O(n)。同时保留 get 用于跳转播放等非顺序场景。

由于命令 ID 上限在注册阶段已知,指令宽度在 IReplayArray 构造时即确定——若最大 ID > 255 则初始化为 uint16。但已存在的录像可能需要变更宽度——例如加载旧 uint8 录像后注册了 > 255 的新命令需要升级,或者想将 uint16 录像压缩回 uint8 以节省存储。因此需要 setCommandWidth 方法将全部指令条目按新宽度重编码。

接口分析

  • 成员 length: number:预期频率中频。安全系统检测步数变化、沙箱判断播放是否结束。
  • 成员 metadata: IReplayMetadata:预期频率低频。存档时读取以持久化格式参数。
  • 方法 add(id: number, params: ReplayParamValue[]):预期频率高频。每次 record 调用一次。
  • 方法 insert(index: number, id: number, params: ReplayParamValue[]):预期频率低频index 等于 length 时等同于 addindex 超出 length 时触发警告并忽略。
  • 方法 delete(index: number):预期频率低频
  • 方法 set(index: number, id: number, params: ReplayParamValue[]):预期频率低频
  • 方法 get(index: number): IReplayStepHandler:预期频率低频。跳转播放时使用。
  • 方法 createReadStream(startIndex?: number): IReplayReadStream:预期频率中频。沙箱播放时调用。
  • 方法 setCommandWidth(width: ReplayCommandWidth):预期频率低频。变更已存在录像的指令码宽度,将全部指令重编码。

预期体量

预期代码体量 300-360 行。

  • 参数编码预期 100 行。七种参数类型各自需要独立的写入逻辑:布尔值为类型 0 接 1 字节int8/int16/int32/int64/float64 各为一个单字节类型标识1-5接对应宽度的定长写入bigint 需类型 6 接 uint16 长度再接 n 个 int32短字符串将长度编码进类型字节8-256长度 = n-7TextEncoder 编码后直接写入内容;长字符串用类型 7 接 uint16 长度再写内容。每种类型还需容量检查与扩容,七种类型的分支代码量相当可观。
  • 参数解码预期 80 行。与编码对称的七种读取分支根据类型字节分发。数值类型按宽度读bigint 读长度后拼接 int32短字符串从类型字节反推长度后 TextDecoder 解码,长字符串先读长度。每条分支均含边界检查。
  • 指令读写预期 60 行。写入时根据当前 commandWidth 决定写入 1 还是 2 字节,读取时同理。每条录像步的指令部分 = uint8 参数数量 + W 字节指令码。getcreateReadStream 均需维护字节偏移——每次读取或跳过一条录像步时,根据上一条的参数数量和参数总字节长度计算下一条的起始偏移。
  • 插入与删除预期 60 行。insert 需将指定位置及之后的所有指令和参数数据向后移动以腾出空间,再在空位写入新步。delete 则反向——将之后的数据向前移动覆盖被删除步。参数数组的移动量取决于被操作步的参数总字节数,需先解码获取。set 本质是先 deleteinsert
  • 容量管理与复制预期 30 行。两个 ArrayBuffer 各自独立管理容量,写入前检查,不足时分配更大新缓冲(如 2 倍增长),逐字节复制旧数据后再继续写入。
  • 流式读取器预期 25 行。内部维护步索引、指令数组字节偏移、参数数组字节偏移。read 解码当前步并推进三个变量,返回 IReplayStepHandler,超出 length 时返回 null。构造时可选 startIndex,需扫描至指定位置。
  • 指令宽度变更预期 25 行。setCommandWidth 遍历全部已写指令条目——解析每条录像步的参数数量和原宽度的指令码——按新宽度重新写入指令数组。commandWidth 可能变大也可能变小,编码前需根据新宽度判断每条指令码是否在范围内(例如 uint8 模式下指令 ID > 255 应报错)。

IReplayReadStream

设计思路

由于沙箱播放是顺序推进的,流式读取器持有当前步索引指针及已累积的字节偏移,每次调用 read 仅解码一步且指针自动推进——避免了 get(n) 每次 O(n) 扫描的累积开销。读取完毕后返回 null调用方可判断播放结束。

接口分析

  • 成员 index: number:预期频率低频。显示当前播放进度。
  • 成员 length: number:预期频率低频。用于判断是否已读取完毕。
  • 方法 read(): IReplayStepHandler | null:预期频率高频。沙箱每步调用一次。

预期体量

预期代码体量 25 行。

IReplayMetadata

设计思路

指令码宽度在读取时必须知晓才能正确解析每条录像步。uint8uint16 是不同的编码模式,不是一个可变参数——因此用枚举 ReplayCommandWidth 标记,存档时持久化此枚举值。

接口分析

  • 成员 commandWidth: ReplayCommandWidth:预期频率低频。初始化时根据最大命令 ID 设定,指令读写时使用。

预期体量

预期代码体量 5 行(含枚举定义)。


实现思路

参数编码格式

类型标识 参数类型 内容编码
0 布尔值 1 字节0 或 1
1 int8 1 字节
2 int16 2 字节
3 int32 4 字节
4 int64 8 字节
5 float64 8 字节
6 bigint uint16 长度 N后接 N 个 int32
7 长字符串≥250 字节) uint16 长度 N后接 TextEncoder 编码的 N 字节
8-256 短字符串n-7 字节) TextEncoder 编码的 n-7 字节,无额外长度字段

短字符串方案类型标识本身编码了长度8 表示 1 字节256 表示 249 字节),TextEncoder 编码后直接写内容。大多数录像参数(道具 ID 名、操作描述等)落在此范围内,基本无需额外长度字段。

指令宽度确定

  1. IReplayArray 构造时传入最大命令 IDIReplaySystem.registerCommand 的累积结果提供)。若 maxId > 255commandWidth 初始化为 Uint16;否则为 Uint8
  2. 注册阶段即确定宽度,后续 add/insert 不涉及宽度切换。
  3. setCommandWidth 用于变更已存在录像的指令宽度:遍历全部指令条目,将原宽度的指令码按新宽度重写,更新 metadata。若缩小宽度时存在超过新宽度表示范围的指令 ID应报错。

流式读取器内部状态

read() 每步仅解码当前指针指向的录像步:

  1. 从指令数组当前字节偏移读 uint8 参数数量,再按 commandWidth 读指令码。
  2. 根据参数数量,从参数数组当前字节偏移逐参数解码。
  3. 将指令数组字节偏移增加 1 + commandWidth,参数数组字节偏移增加已解码参数的总字节数。
  4. 返回 IReplayStepHandlerindex 为当前步索引。指针推进到下一步。

涉及文件

@user/data-common/src/replay/array.ts

  • 新增 ReplayCommandWidth 枚举
  • 编写 IReplayArrayIReplayReadStreamIReplayMetadata 接口
  • 编写 ReplayArray 类:实现 IReplayArray,封装双 ArrayBuffer 操作、指令宽度管理与变更、CRUD 编辑、流式读取
  • 编写 ReplayReadStream 类:实现 IReplayReadStream