- 游戏开发
- 后端
【免费下载链接】OpenFrontIO
Online browser-based RTS game
导读
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 与 float | LEB128 / zigzag varint |
zb.float() | 同上 | float64 小端(逐位精确) |
zb.string(opts) | 安全附加min/max/regex | varint 长度 + 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/ 下,构成了五层防线:
- golden.test.ts——hex 向量钉死布局(presence 头、varint、枚举序号、
zb.stamped顺序、float64 字节序等),是唯一能"看见"线格式本身的测试。 - zbin.test.ts——字节原语、对象/容器/联合/tuple/
z.lazy/zb.json/zb.mapped/zb.stamped的功能往返,含 1000 组随机值模糊往返、空对象零字节、zb.custom手写 codec(把 8 字符小写 id 压进 ≤6 字节)、{a: undefined}与{}不可区分等边界。 - hardening.test.ts——资源边界(超大计数、零宽元素、跨集合元素预算、递归深度)、统一
ZbDecodeError契约(非法 UTF-8、非最小 varint、required 缺失、__proto__键、重复键)、静默误用守卫(zb.custom不被布尔位打包吞掉、zb.json注解不泄漏、拼错映射名报错、指纹区分乱序字典、numeric enum 反向映射剔除且追加成员序号稳定、重入 serialize)。 - protocol.test.ts与wire.test.ts——从
src/core/Schemas.ts的真实 schema 出发,验证游戏线协议的端到端往返与拒绝路径。 - fuzz.test.ts——随机字节输入的稳健性探索。
结语与选型建议
zbin 的哲学可以浓缩为三句话:schema 即格式(无版本字节、无字段标签,换取零消息开销);schema 是唯一事实来源(编解码器按实例挂在 WeakMap 旁表,zod 的类型推导、组合、校验能力全部保留);防呆优先于静默优化(编码端拒绝歧义数字与未声明映射、解码端统一错误契约、资源边界按消息设限)。在"客户端与服务器必须同构交付"成立的项目里(OpenFront 正是如此),这种取舍能同时拿到 zod 的开发体验与接近手写二进制协议的紧凑度;一旦多版本共存不可避免,务必在首个 zbin 帧前加入握手版本——这正是 zbin/README.md 反复强调的兼容性红线。若你想在自己的项目里复用它,只需把 zbin 目录整体迁出(保持 zod 唯一依赖),并同步迁移 tests/zbin/ 下的金测与加固测试作为线格式的守护契约。
- 游戏开发
- 后端
【免费下载链接】OpenFrontIO
Online browser-based RTS game
相关推荐
深度解析Thrift协议层:二进制与紧凑协议的抉择
深度解析Thrift协议层:二进制与紧凑协议的抉择 在分布式系统开发中,你是否曾为不同服务间的高效通信而困扰?是否遇到过数据传输量大导致的性能瓶颈?是否在多种编
后端微服务API设计FlatBuffers FlexBuffers 完全指南:零拷贝、无 Schema 的紧凑二进制序列化格式
FlatBuffers FlexBuffers 完全指南:零拷贝、无 Schema 的紧凑二进制序列化格式 导读 FlexBuffers 是 FlatBuffe
序列化跨平台编译器【亲测免费】 MessagePack for Java:高效、紧凑的二进制序列化库
MessagePack for Java:高效、紧凑的二进制序列化库 项目介绍 MessagePack for Java 是一个高性能的二进制序列化格式,旨在提
后端
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考