☰
OpenFrontIO 的 zbin 实战指南:基于 zod 的紧凑二进制序列化协议设计与实现
2026/10/4 13:26:54 网站建设 项目流程
  • 游戏开发
  • 后端

【免费下载链接】OpenFrontIO

Online browser-based RTS game

项目地址:https://gitcode.com/gh_mirrors/op/OpenFrontIO
点击查看免费下载

导读

zbin 是 OpenFrontIO(在线浏览器 RTS 游戏)中负责游戏与大厅 WebSocket 二进制帧编解码的核心库。它让zod 保持唯一事实来源(single source of truth),同时为 schema 自动生成一套紧凑的二进制线协议(wire format):每个zb.*构建器返回的仍是真正的 zod schema(z.infer、.optional()、纯 zod 组合照常可用),二进制编解码器则通过 WeakMap 旁表按 schema 实例注册。读完本文,你将掌握 zbin 的自动推导机制、zb.uint/zb.float/zb.mapped/zb.stamped等构建器的适用场景与线编码、无版本字节下的兼容性纪律、上下文(字典压缩)的用法与指纹校验,以及它如何被 src/core/ZbinWire.ts 用于客户端与服务器的实际帧传输。

快速上手:一个最小可运行示例

zbin 的使用入口在 zbin/index.ts,构建器集合在 zbin/zb.ts。核心用法非常直观——先描述 schema,再序列化/反序列化:

import { zb } from "./zbin"; const MsgSchema = zb.object({ type: zb.literal("hash"), // 线上占用 0 字节 hash: zb.float(), // float64,逐位精确 turnNumber: zb.uint(), // LEB128 varint }); type Msg = zb.infer<typeof MsgSchema>; const msg: Msg = { type: "hash", hash: 0.5, turnNumber: 12 }; const ctx = zb.context(); const bytes = MsgSchema.serialize(msg, ctx); // Uint8Array const back = MsgSchema.parseBytes(bytes, ctx); // 解码 + zod 校验

三个根构建器zb.object、zb.discriminatedUnion、zb.union、zb.stamped会在 schema 实例上额外挂载三个方法(定义见 zbin/zb.ts 的ZbMethods):

方法行为
serialize(value, ctx?)仅编码,不校验,出错抛ZbEncodeError
parseBytes(bytes, ctx?)解码后执行schema.parse完整 zod 校验,出错抛ZbDecodeError/ZodError
decodeBytesUnvalidated(bytes, ctx?)仅结构解码,不校验,返回类型是断言的z.output<S>

其中serialize使用共享的可复用ByteWriter(初始容量 4096 字节,见 zbin/zb.ts 的sharedWriter),常规路径不产生逐消息的 buffer/DataView/扩容链分配;只有当自定义 codec 重入调用serialize时(writerInUse保护)才退回新建 writer。

兼容性纪律:schema 即格式,所有对端必须同构运行

线上没有版本字节,也没有字段标签。zbin 负载是裸的顺序字节流:schema 本身就是格式。来自不同 commit 的对端会互相误解码,且往往是静默的——重排对象字段、插入枚举成员、重排 union 变体都会产出结构合法、能通过parseBytes但值错误的结果。截断能被捕获,语义漂移不能。

OpenFront 客户端与服务器由同一构建产物交付,因此这是一个有意为之的取舍:省掉每条消息的版本开销,换来所有使用 zbin 的组件必须来自同一构建。如果这一点不再成立,必须在发送任何 zbin 帧之前给握手加上版本。

以下修改会改变线格式,只有所有对端同步修改才是安全的:

  • 增删、重命名或重排对象字段;
  • 把字段改成 optional/nullable 或撤销(presence 位布局会移动);
  • 字段在boolean与其它类型之间切换(布尔值住在头部位中);
  • 重排z.enum、union 变体或z.literal([...])成员;
  • 改变 tuple 元数,或重命名zb.mapped表;
  • 改变映射表的赋值顺序。

金色测试守护线格式:tests/zbin/golden.test.ts 用十六进制向量钉死布局——例如{a: true, b: 300, c: null}序列化为03ac02(presence 头 0b011)、zb.int(-1)是01、八布尔全 true 的对象是单个ff字节。因为其它测试都在同一套编码器/解码器上往返,它们无法观察线格式本身(字段反转、presence 位重排、varint 改基准只要双向同步变化都会保持绿色);这些 hex 向量是唯一钉住布局的东西。因此一个意外改动会让测试失败,而不是腐蚀一场游戏。

自动推导:纯 zod 类型开箱即用

从 zb 根可达的纯 zod schema 无需注解即可处理:字符串、布尔、bigint、字面量、枚举、对象、数组、record/partialRecord、tuple(含.rest)、判别联合与无标签联合、z.lazy,以及optional/nullable/default包装。推导逻辑在 zbin/zb.ts 的derive()中按def.type分派。

只有真正有歧义或偏门的场景需要显式构建器:

构建器为什么需要线编码
zb.uint()/zb.int()JSON 无法区分 int 与 floatLEB128 / zigzag varint
zb.float()同上float64 小端(逐位精确)
zb.string(opts)安全附加min/max/regexvarint 长度 + UTF-8
zb.mapped(name)字典压缩1-2 字节 varint 索引,转义 + 内联兜底
zb.json(schema)冷、复杂的子树varint 长度 + JSON
zb.stamped(union, extras)判别联合上的交集无法内省tag + extras + 变体
zb.custom(schema, codec)完全自定义控制由你定义

zb.bigint()、zb.literal、zb.enum是对称性提供的纯别名——底层 zod 类型本就自动推导,所以z.bigint()等用法完全一致。同理,普通z.string()、z.boolean()、z.enum()、z.object()、z.array()、z.record()、z.tuple()在 zb 根内部都能直接使用。

约束必须放进构建器选项

链式调用 zod 方法会克隆 schema,而克隆体没有 codec:

zb.uint({ max: 400 }); // 正确 zb.uint().max(400); // 抛错:"plain z.number() is ambiguous on the wire" zb.mapped("cid").min(1); // 静默:回退成普通字符串,~1 字节变 9 字节 zb.mapped("cid").describe("…"); // 静默:同上

数值型构建器会大声失败;字符串形态构建器(zb.string、zb.mapped)与zb.json/zb.custom会静默回退到自动推导的 codec,只是改变线格式——所以所有约束都放进 options。.optional()、.nullable()、.default(v)、.array()链式调用是安全的(zbin/zb.ts 的applyNumberOpts/applyStringOpts会把 options 内的min/max/regex应用到真正的 zod schema 上)。

会产生克隆的方法(.extend()、.pick()、.partial()、.optional())返回没有serialize/parseBytes/decodeBytesUnvalidated的纯 zod schema。克隆嵌套在 zb 根内仍能正确编码;想让它再次成为根,用zb.object(Ext.shape)重新包装即可。

zb.json与zb.custom返回克隆,注解只作用于结果本身——把它们应用到共享子 schema 不会改变该子 schema 在别处的编码方式(对应测试见 tests/zbin/zbin.test.ts 的 "keeps zb.json local to the schema it is applied to")。

编码要点:presence 位头、最小 varint 与零成本字段

综合 zbin/zb.ts 的对象、数组、联合、tuple 等 codec 实现与 tests/zbin/golden.test.ts 的向量:

  • 对象字段按声明顺序编码,前置一个 presence 位头:optional/nullable 标志和布尔值都是位,所以八个布尔的消息就是一个字节(ff)。
  • 位按字段声明顺序、按(presence、null、bool-value)次序分配,LSB 优先打包(byte = bit >> 3,mask = 1 << (bit & 7))。头部 ≤4 字节时按 JS 整数读取,避免为每个解码对象分配 subarray 视图。
  • 字面量字段与判别联合 tag 分别花费 0 字节与 ~1 字节:单值 literal 走constCodec,enc只校验值相等、dec直接返回常量,minBytes: 0。
  • varint 是最小的:一个值只有唯一合法编码(ByteReader.uint()对末尾为零组、mult !== 1的情况抛 "non-minimal varint encoding"),非最小输入被拒绝——这保证了编码消息的字节相等性(重放哈希、去重依赖这一点)。
  • 字符串是 varint 长度前缀 + UTF-8(解码用fatal: true的TextDecoder,非法 UTF-8 抛ZbDecodeError而非静默替换成 U+FFFD);ASCII 走快速路径,非 ASCII 先编码再移位回填。
  • z.record按Object.keys顺序编码,同样的逻辑 record 按两种顺序构建会产生两种不同的负载。哈希或比较前先排序(tests/zbin/zbin.test.ts 的 "record byte output follows key insertion order" 验证了这一点)。record 键必须是字符串或z.enum(枚举键编码为序号),并拒绝__proto__键与重复键。
  • 无标签zb.union选择第一个 zod 解析接受该值的变体。每次被拒绝的候选都要一次完整safeParse(zod 构建出ZodError后约 10 µs),且当变体重叠时会静默收窄值(zod 对象会剥离未知键)。让变体互斥;热路径上传select或改用判别联合:
zb.union([A, B], { select: (v) => ("a" in v ? 0 : 1) });
  • zb.json子树是 JSON 而非二进制,因此豁免上述逐位精确性:NaN/Infinity变成null、-0变成0、Date变成字符串、undefined键消失,bigint抛错。解码端解析时丢弃__proto__键以防原型污染(zbin/zb.ts 的jsonCodec.dec)。
  • zb.float逐位精确且小端:1是000000000000f03f,-0是0000000000000080(golden 向量);NaN 与 ±Infinity 也能往返(见 tests/zbin/zbin.test.ts)。

错误契约与资源边界

错误矩阵(zbin/README.md 与 tests/zbin/hardening.test.ts 双重印证):

方法是否校验抛出异常
serialize否ZbEncodeError
parseBytes是ZbDecodeError、ZodError
decodeBytesUnvalidated否ZbDecodeError

parseBytes先解码再跑schema.parse。来自对端的任何数据都用它。decodeBytesUnvalidated只是按断言返回z.output<S>:什么都不检查,min/max/regex/.refine()全部跳过,恶意负载可以给出 schema 说max: 4的 1 MB 字符串,或从zb.float()给出NaN。注意 OpenFront 的服务器是意图中继(relay)——客户端收到的一回合内容是其它客户端创作的,"socket 可信"不等于"值可信"。只在处理本进程自产数据时用decodeBytesUnvalidated(在游戏中它服务于 GameServer 的 rejected-intent 遥测,见 src/core/ZbinWire.ts 的decodeClientMessageUnvalidated)。

所有结构性损坏都以ZbDecodeError呈现,绝不让裸RangeError/SyntaxError逃逸:截断、尾随字节、坏枚举/联合序号、非法 presence 标志、未知字典索引、非最小 varint、非法 UTF-8、损坏的内嵌 JSON、超预算集合计数、超过深度上限的嵌套。decodeContract()会把任何非ZbDecodeError的异常(比如zb.custom手写 codec 抛的 TypeError)重包为ZbDecodeError。

限制表(常量定义在 zbin/bytes.ts):

限制值
zb.uint范围[0, 2^53)
zb.int范围±2^52(zigzag 翻倍后仍须精确)
zb.bigint宽度1024 位(MAX_BIGINT_BITS)
每条消息解码元素数2^20(MAX_DECODE_ITEMS)
嵌套深度64(MAX_DECODE_DEPTH)
映射表条目数65,535(MAX_MAPPING_SIZE)

元素预算按消息计,由该消息内所有集合共享。它存在的原因:元素可以编码为零字节(单值字面量、全字面量对象),这让"计数 vs 剩余输入"单独不足以作为边界——没有它,四个字节就能驱动 1600 万次分配。readCount()只使用"元素最小字节数是否非零"这一事实来防呆:元素的声明最小值可能高估其真实最小编码(nullable/optional 字段写零个主体字节),因此不能拿它精确相乘,否则会把合法紧凑数组误判为畸形帧(tests/zbin/hardening.test.ts 中有该回归用例:曾经把 live-stats 快照误拒、发送端被踢)。零宽元素靠 reader 的每条消息元素预算兜底;编码端也会在超过MAX_DECODE_ITEMS时直接抛ZbEncodeError。z.lazy递归是唯一需要深度守卫的地方,每层嵌套只花攻击者一个字节,没有它几 KB 输入就会以RangeError撑爆 JS 栈。

上下文:字典压缩(Contexts)

const ctx = zb.context(); ctx.mapping("clientId"); ctx.assign("clientId", "aB3dEf7h"); // → index 0 ctx.assignAll("clientId", roster); // 或批量播种

一个zb.mapped("clientId")字段:值在表内时编码为varint(index + 1)——前 127 项每项 1 字节,最多 16k 每项 2 字节;不在表内时编码为 varint 0 + 内联字符串(转义路径,永远正确,只是不紧凑)。线上没有学习机制:双方必须从共享数据构建出相同的表(赋值顺序是线契约的一部分)。这让解码保持每条消息无状态——没有流位置耦合,重连也不会破坏。

ZbContext(zbin/context.ts)提供mapping(name, { max })(max必须在[1, 65535],默认 65535,重复声明抛错)、assign(返回索引,表满返回 -1)、assignAll、indexOf/valueAt、size。mappedCodec用单槽备忘缓存每个上下文解析一次表名,避免每个编码 id 都做一次 Map 查找。

表由运行时数据播种,所以"同一构建"并不保证它们一致。索引超出接收端表尾是响亮的ZbDecodeError,但两张等长、不同顺序的表会把每个 id 解码成错误值且不报错——对clientId来说就是把意图归到错误的玩家头上。在依赖某张表之前,先在带外(例如游戏开始的握手消息里)比较ctx.fingerprint(name):

if (local.fingerprint("clientId") !== remote.clientIdFingerprint) { throw new Error("roster mismatch"); }

fingerprint是顺序敏感的 FNV-1a 摘要(zbin/context.ts 的fingerprint(),值间插入0xff分隔符,使["ab","c"]与["a","bc"]区分),把静默的顺序错配变成可检测的错误——tests/zbin/hardening.test.ts 的 "gives reordered dictionaries a distinguishing fingerprint" 正是这个场景。

另外两个上下文约定:编码时用到未声明的表是ZbEncodeError(名字拼错会失败,而不是静默损失压缩收益);完全不传上下文编码依然合法——全部内联。

在 OpenFront 中的实际落地:游戏与大厅 WebSocket 帧

zbin 不是玩具库——它是 OpenFront 游戏/大厅 WebSocket 的全部二进制帧格式。见 src/core/ZbinWire.ts 的头部注释:两个 socket 上的每一帧都是 zbin 负载,没有 JSON 回退、没有协商、没有版本字节;HTTP 侧保持全 JSON(API worker 闭源,归档游戏记录由期望 JSON 的工具读取)。

clientID字典在双方从GameStartInfo.players以完全相同的方式播种(数组顺序就是线契约):createGameWireContext(players)对每个玩家assign一次CLIENT_ID_MAPPING。名单在开局固定、start 消息总是先于第一条字典编码帧到达,因此表不可能分叉。start 消息本身不带上下文编码(它正是接收端建表的数据来源),名单之外的 id(如ADMIN_BOT_CLIENT_ID)走转义路径内联。

帧级编解码封装:

encodeServerMessage(msg, ctx) // start 消息传 undefined,其余传 ctx decodeServerMessage(bytes, ctx) // parseBytes,完整校验 encodeClientMessage / decodeClientMessage decodeClientMessageUnvalidated(bytes, ctx) // 仅结构解码,供 GameServer 拒绝意图遥测 encodeLobbyMessage / decodeLobbyMessage // 大厅列表广播,无 player id,不需要字典

真实的 schema 定义在 src/core/Schemas.ts(ClientMessageSchema、ServerMessageSchema、PublicLobbyMessageSchema、StampedIntent等),而 tests/zbin/wire.test.ts 模拟了服务器与客户端各从同一 roster 建表(ctxPair()),覆盖每种意图类型的完整往返——包括spawn、attack、boat、donate_gold、build_unit、quick_chat、toggle_pause等 27 种 stamped intents 的ServerMessage往返,并验证mark_disconnected/update_game_config等带zb.json子树的配置型消息。此外 zbin 还用于StatsSchemas、快照编码(src/core/snapshot/SnapshotCodec.ts)与Transport.ts/LobbySocket.ts的收发路径。

库边界与构建集成

zbin/README.md 的 Boundaries 一节明确了设计约束,源码与构建配置可交叉验证:

  • 这是一个自包含库:zod 是其唯一依赖,不得从游戏代码导入任何东西(待有生产里程后可作为独立包抽取的候选)。
  • 它由根 tsconfig.json 编译,并随src/一起复制进两个 Docker 阶段(见 Dockerfile),保证客户端与服务端共享同一份实现。
  • 字节层原语(zbin/bytes.ts)无依赖,浏览器、Worker、Node 三端安全:ByteWriter是可扩容小端 writer(u8/uint/int/f64/bigint/str/reserve/orU8/finish),ByteReader是带边界检查的 reader(expectEnd拒绝尾随字节);产出Uint8Array<ArrayBuffer>可直接交给 DOM 类型下的WebSocket.send。

测试矩阵:金测、加固、模糊与协议级验证

zbin 的测试分布在 tests/zbin/ 下,构成了五层防线:

  1. golden.test.ts——hex 向量钉死布局(presence 头、varint、枚举序号、zb.stamped顺序、float64 字节序等),是唯一能"看见"线格式本身的测试。
  2. zbin.test.ts——字节原语、对象/容器/联合/tuple/z.lazy/zb.json/zb.mapped/zb.stamped的功能往返,含 1000 组随机值模糊往返、空对象零字节、zb.custom手写 codec(把 8 字符小写 id 压进 ≤6 字节)、{a: undefined}与{}不可区分等边界。
  3. hardening.test.ts——资源边界(超大计数、零宽元素、跨集合元素预算、递归深度)、统一ZbDecodeError契约(非法 UTF-8、非最小 varint、required 缺失、__proto__键、重复键)、静默误用守卫(zb.custom不被布尔位打包吞掉、zb.json注解不泄漏、拼错映射名报错、指纹区分乱序字典、numeric enum 反向映射剔除且追加成员序号稳定、重入 serialize)。
  4. protocol.test.ts与wire.test.ts——从src/core/Schemas.ts的真实 schema 出发,验证游戏线协议的端到端往返与拒绝路径。
  5. fuzz.test.ts——随机字节输入的稳健性探索。

结语与选型建议

zbin 的哲学可以浓缩为三句话:schema 即格式(无版本字节、无字段标签,换取零消息开销);schema 是唯一事实来源(编解码器按实例挂在 WeakMap 旁表,zod 的类型推导、组合、校验能力全部保留);防呆优先于静默优化(编码端拒绝歧义数字与未声明映射、解码端统一错误契约、资源边界按消息设限)。在"客户端与服务器必须同构交付"成立的项目里(OpenFront 正是如此),这种取舍能同时拿到 zod 的开发体验与接近手写二进制协议的紧凑度;一旦多版本共存不可避免,务必在首个 zbin 帧前加入握手版本——这正是 zbin/README.md 反复强调的兼容性红线。若你想在自己的项目里复用它,只需把 zbin 目录整体迁出(保持 zod 唯一依赖),并同步迁移 tests/zbin/ 下的金测与加固测试作为线格式的守护契约。

  • 游戏开发
  • 后端

【免费下载链接】OpenFrontIO

Online browser-based RTS game

项目地址:https://gitcode.com/gh_mirrors/op/OpenFrontIO
点击查看免费下载

相关推荐

上一篇:番茄小说下载器:技术解析与全平台数字图书馆构建指南
下一篇:Textual MouseUp 事件详解:捕获鼠标释放、监听按钮抬起与 Click 事件链

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询