TeamViewRelay 的共享协议源。
proto/teamviewer/v1/teamviewer.proto:唯一的 ProtoBuf 真相源buf.yaml:协议模块 lint / breaking 配置
- 本仓库只负责共享
.proto定义与buf规则。 - 本仓库不直接向消费仓库写入生成代码。
- Python / TypeScript / Java 生成物都由消费仓库在本地或 CI 中自行生成。
- 协议版本使用
proto/vX.Y.Z命名,例如proto/v0.6.0。 - 这里的 tag 表示“协议发布版本”,不是 Mod、后端或前端脚本的应用版本。
- 消费仓库必须锚定具体 submodule commit,不允许依赖本仓库 branch head 作为构建输入。
以下变更默认需要创建新的协议 tag:
- wire shape 变化,例如字段新增、删除、改名、类型变化、
oneof结构变化 - 兼容性基线变化,例如最低兼容协议版本变化
- 需要三端同步升级的协议语义变化
- 在本仓库修改
proto/teamviewer/v1/teamviewer.proto - 运行
buf lint - 在三个消费仓库验证生成与构建
- 创建并 push 协议 tag,例如
proto/v0.6.0 - 在消费仓库把
third_party/TeamViewRelay-Protocol升级到该 tag 对应 commit - 重新生成代码、运行测试并提交 submodule 指针更新
消费仓库通过 third_party/TeamViewRelay-Protocol 这一 git submodule 锚定本仓库的具体 commit:
Minecraft-TeamViewer-BackendMinecraft-TeamViewer-Web-ScriptMinecraft_TeamViewer
不要依赖本仓库的 branch head;应先发布协议 tag,再在消费仓库中更新 submodule 指针。
0.7.0 为每个房间维护“每个玩家 UUID 最新一次被观察到的 Tab 标签”,用于离线玩家的城镇归属分析。 它不是标签变更审计日志;实时 Tab 数据始终应覆盖历史缓存。
TabHistorySubscribeRequest/TabHistoryDigest:订阅数据集 head,digest 变化后再发起同步。TabHistorySyncRequest/TabHistorySyncChunk:支持完整镜像和从已知 revision 开始的增量镜像。TabHistoryLookupRequest/TabHistoryLookupChunk:按 UUID 或玩家名查询,不要求客户端下载整个房间。- 握手中的
TabHistoryCapabilities声明单消息的条目数、编码字节数和查询 selector 上限。max_chunk_entries不是房间玩家总数上限;数千条记录会拆成多个 chunk。 - 所有请求都隐式绑定当前 WebSocket 握手确定的
room_code,请求消息不接受另一个房间号。 TabPlayerEntry.scoreboard_team_id是原版计分板 Team 的内部名称;prefix、suffix、Team color 和解析后的 格式 span 分别透传。旧客户端继续读取无格式的display_name/prefixed_name。- digest 与 entry ETag 使用 SHA-256;完整/增量响应中的所有 chunk 使用同一个固定
TabHistoryHead。
0.7.1 统一将公开的 battle chunk 状态表示为结构化条目列表。坐标只出现在 ref 中,data 不再重复
dimension、chunkX、chunkZ,也不公开 dimension|chunkX|chunkZ 形式的合成 ID:
[
{
"ref": {
"dimension": "minecraft:overworld",
"chunkX": 12,
"chunkZ": 34
},
"data": {
"colorRaw": "#ff0000",
"colorMode": "raw_observed",
"mode": "simmc"
}
}
]实现内部可以用合成字符串或结构体作为索引,但 snapshot、patch、管理 API 和 0.7.1 摘要必须使用
{ref,data},不得把内部索引暴露为公开合同。ProtoBuf wire 消息原本已经使用
BattleChunkRef + BattleChunkValue,因此 0.7.1 不增加、删除或重编号字段。
Digest.battle_chunks 按连接协商出的协议版本选择合同:
< 0.7.1使用旧合同:按dimension|chunkX|chunkZ排序,每行是canonicalJson(key) + ":" + canonicalJson(flattenedValue) + "\n"。flattenedValue必须包含dimension、chunkX、chunkZ和业务字段。这保持与 0.7.0 Mod 及旧 Python Backend 一致。>= 0.7.1使用新合同:按(dimension, chunkX, chunkZ)排序,每行只写canonicalJson({"ref":ref,"data":data}) + "\n",不添加合成 key 前缀。- 两种合同都对完整字节串计算 SHA-1,使用小写十六进制的前 16 位。空集合摘要为
da39a3ee5e6b4b0d。
这里的 canonicalJson 递归按 JSON object key 的 Unicode 字典序排列;数组保持顺序;字符串使用标准
JSON 转义;null 字段先删除。Battle chunk 摘要不包含 observedAt、positionSampledAt、
alignmentSource、reporterId。缺失的 colorMode 按 raw_observed 参与摘要,以兼容旧数据。
固定测试向量如下(roomCode 为 default):
{
"ref": {"dimension":"minecraft:overworld","chunkX":1,"chunkZ":2},
"data": {"colorRaw":"#112233","colorMode":"raw_observed","mode":"simmc","roomCode":"default"}
}- 0.7.0 官方旧合同:
d6fccb4a1bd18438 - 0.7.0 Rust 历史缺陷投影(仅用于新 Mod 兼容旧服务端):
9cecf13aa4592a1c - 0.7.1
{ref,data}合同:31c63cd6e92bbc39
0.7.0 Rust 历史缺陷会从 flattenedValue 漏掉三个坐标字段。服务端不得继续生成该摘要;0.7.1 Mod
连接 0.7.0 服务端时可以同时接受官方旧合同和这个已知缺陷值,避免无休止全量重同步。
0.8.0 允许外部来源发布完整玩家目录、组织节点和组织关系边。关系图不进入实时 SnapshotFull,而是通过
独立查询消息按需读取,避免把玩家关系展开为 O(n²) 的玩家对。
ExternalSourceCapabilities在来源握手时声明数据集格式、覆盖范围和 stale 阈值。ExternalDatasetPublish分块发布关系快照或 patch;完整目录与实时在线 Tab 是不同数据集。PlayerDirectoryLookupRequest支持 UUID、稳定 player ID 和名字查询;名字可以返回多个同名玩家。PlayerRelationQueryRequest既可查询指定目标,也可在目标为空时筛选当前玩家的全部关系。PlayerReportPolicy是服务端建议,不是强制命令。只有健康且完整的外部在线目录才能建议抑制 Tab; 位置信息默认继续上报。- 数据过期后仍可查询,但响应必须设置
stale,由客户端决定如何展示。
teamviewer.v1 包名保持不变,0.8.0 只追加字段和消息。0.7.1 及更早的客户端会忽略新增字段,继续使用
实时 Tab、Tab 历史和本地手工关系标记。
0.9.0 把 0.8.1 引入的 WebMapHandshakeRequest.accepts_unreliable_positions 布尔声明泛化为通道列表:
- 新增
enum UnreliableChannel(UNRELIABLE_CHANNEL_MOVEMENT = 1起,后续按需扩枚举); WebMapHandshakeRequest.accepts_channels(repeated UnreliableChannel,字段号 6)声明客户端可经 不可靠 datagram 消费的通道;服务端必须忽略未知枚举值(proto3 保留为 unknown fields);- 旧字段
accepts_unreliable_positions(字段号 5)标记deprecated:true语义等价于accepts_channels = [UNRELIABLE_CHANNEL_MOVEMENT],服务端以此映射兼容 0.8.1 客户端。
本版本为纯增量:0.8.1 客户端/服务端互操作行为不变。
- 0.8.1 tag 撤销重发:原 tag
proto/v0.8.1忘加 alpha 后缀,已从本地与远端撤销, 原地(同一提交 f9060f4,树内容一字不改)重发为proto/v0.8.1-alpha.1。协议版本号 0.8.1 本身继续有效,只修正发布属性。 - 0.9.0-alpha.4 传输层/应用层分层:
DoorControlFrame系消息从teamviewer.proto迁出至teamviewer/door/v1/door.proto(字段号逐一不变,线上零变化),并新增BulkTransferStart与 bulk 传输通道约定;door-control 下行流全部压缩套 按需实体化(首帧写出才过线,流序不变)。 - 0.9.0-alpha.5 上行位置 datagram:
PlayerHandshakeRequest.unreliable_channels(13)+HandshakeAck.unreliable_channels_accepted(19),Player 上报对称化接收路径。 - 0.9.0-alpha.6 下行消费对称化:
PlayerHandshakeRequest.accepts_channels(14)+HandshakeAck.downlink_channels_accepted(20)——Player 通道获得与 WebMap 相同的 movement datagram 消费声明/回执;回执字段对两类连接统一填写。纯增量,wire 版本串 仍为 0.9.0。
TeamViewRelay 服务端并行开放多扇“传输门”,所有门共享同一套应用层(WireEnvelope)语义,仅线路封装不同。
下列约定是各门实现的共同合同,任何一端的实现副本都不得漂移;变更必须先改本节再改代码。
| 门 | 端口 | 客户端 | 会话识别 |
|---|---|---|---|
| WebSocket | 8765/tcp(/web-map/ws) |
浏览器脚本、Java mod | URL path + 握手消息 |
| WebTransport | 8766/udp(/web-map/wt) |
浏览器脚本 | URL path + 握手消息 |
| 裸 QUIC | 8767/udp |
Java mod | 无 path,由首个握手消息的通道/载荷类型自识别 |
- 帧格式为
[varint 长度][payload],varint 采用 LEB128(与 protobuf 同款);长度只计 payload。 - 帧上限 8 MiB(后续启用压缩的协议版本中,上限以解压后大小计);帧头最长 4 字节。
- 非法帧头(varint 超 4 字节或长度越限)必须显式断开连接,不得静默等待或丢弃。
- 历史:后端
1.2.0-alpha.4及更早使用 4 字节大端长度前缀,该格式已废弃。 - WebSocket 门不使用本分帧:WS 帧自带消息边界,应用消息整帧直传。
- 自后端
1.2.0-alpha.5起,全协议版本 datagram 一律无长度前缀:datagram 自带报文边界。 - plain 版本:payload 即裸 protobuf
WireEnvelope。 - 历史:
1.2.0-alpha.4及更早的[4 字节大端长度 + envelope]datagram 格式已废弃。 - datagram 为尽力投递:载荷必须自包含、可独立解析,丢失由下一 tick 或低频保底全量自愈。
- 上行对称化(0.9.0-alpha.5):Player 通道客户端可在
PlayerHandshakeRequest.unreliable_channels(字段 13)声明"我将以 datagram 上送"的通道(首值UNRELIABLE_CHANNEL_MOVEMENT= 逐 tick 玩家位置 upsert);服务端在HandshakeAck.unreliable_channels_accepted(字段 19)回执实际 启用子集(需会话具备 datagram 能力),未回执 = 全部走可靠流。上行 datagram 载荷 = 裸WireEnvelope{PLAYER, PlayerReportBundle},仅 players_patch 有值(带 submit_player_id), 无长度前缀、恒 plain;未声明会话发来的 datagram 直接丢弃(尽力投递语义,不算违规)。 丢失零重传:由 digest 核对 + refresh_req + 周期全量自愈。 - 下行消费声明(0.9.0-alpha.6):Player 通道客户端可在
PlayerHandshakeRequest.accepts_channels(字段 14,语义镜像WebMapHandshakeRequest.accepts_channels)声明"我可经 datagram 消费" 的通道;WebMap 通道继续经字段 6(或 deprecated 布尔)声明。服务端统一在HandshakeAck.downlink_channels_accepted(字段 20)回执实际启用的下行消费子集 (需会话具备 datagram 能力);未回执 = 位置全部由可靠流承载。启用后服务端在可靠流 patch 中剥离逐 tick 位置(低频保底刷新除外),movement datagram 载荷 = 裸WireEnvelope{WEB_MAP, Patch{players}},与消费端连接角色无关(共享批缓存不按 角色分裂,消费端按载荷类型识别),压缩由协商套决定(plain/zstd 单帧/字典),无长度前缀。 声明了消费但无法解 datagram 的客户端自担损失——低频保底全量最终收敛。
- 连接建立后 10 秒内,可靠流上的首帧必须是合法握手消息,否则服务端断开连接。这是应用层策略, 适用于所有门(含裸 QUIC 无 path 的自识别场景)。
| 协议版本 | WS 子协议 | WT URL query ?suite= |
QUIC ALPN |
|---|---|---|---|
| plain | teamviewrelay.plain.v1 |
plain |
teamviewrelay/v1 |
| +zstd | teamviewrelay.zstd.v1 |
zstd |
teamviewrelay/v1+zstd |
| +zstd-dict | teamviewrelay.zstd-dict.v1 |
zstd-dict |
teamviewrelay/v1+zstd-dict |
- WS 子协议与 QUIC ALPN 按上表;WT 门以 extended CONNECT 目标 URL 的 query 参数
?suite=<短名>为协商唯一权威。浏览器不把WT-Protocol响应回执暴露给页面脚本 (Chromium issue 435589295),响应头协商通道在浏览器侧断裂,故改走两端共享的 URL: 客户端把选定套的短名拼进 query,服务端从同一字符串解析,两端必然一致。未携带suite、值为空或无法识别时一律取zstd-dict(压缩率最高)。 - 客户端按偏好序提供候选,服务端在门原生协商载体(WS 101 回显 / TLS ALPN / WT query) 中确定其一;协商全程零额外 RTT,压缩不在 protobuf 层协商。
- WS 门的服务端回显必须取自客户端实际提供的候选列表(RFC 6455 硬性要求,严格客户端拒绝
列表外的回显);因此任何套都原样回显,不做套间折叠。WS 门无 datagram,
+zstd-dict在 WS 门的流行为与+zstd完全一致,客户端按"收到 dict 回显即 zstd 流语义"解释。 WT 门的服务端仍以裸 token 回显WT-Protocol响应头,仅作未来浏览器实现协商语义后 的前向兼容。 - 裸 QUIC 门 ALPN 未匹配任何候选时握手必须失败。
- 压缩为单向语义:zstd 压缩版本(
+zstd/+zstd-dict)仅作用于下行 (服务端→客户端)的可靠流与 datagram;上行(客户端→服务端)恒为 plain—— 可靠流按[varint 长度][payload]原样分帧(WS 门为 plain 消息),datagram 为裸 protobuf envelope。上行载荷小(握手、命令、回执),且浏览器端解压库 fzstd 仅解压;客户端不得对上行启用压缩——服务端一律按 plain 解读, 压缩字节过不了 10 秒首帧握手或 envelope 解码。
门级控制帧自 0.9.0-alpha.4 起独立在传输层协议文件 teamviewer/door/v1/door.proto
(package teamviewer.door.v1,含 DoorControlFrame),应用层文件
teamviewer/v1/teamviewer.proto 只描述 WireEnvelope 承载的应用层消息。
迁移保持字段号逐一不变,线上字节零变化(proto package 改名对 wire 不可见)。
QUIC/WT 门适配层在全部压缩套(plain/+zstd/+zstd-dict)为门级控制帧
预留各一条额外单向流:下行门控流按需实体化(QUIC 流首帧写出才过线,
服务端在首帧需要时才开流,流序仍恒为服务端第 2 条;空闲零开销;字典载荷只在
+zstd-dict 套出现):
- 分层铁律:door-control 流不是应用层信道,绝不允许承载
WireEnvelope; 应用流也绝不承载DoorControlFrame。两类载荷都是 protobuf,跨层混用时首字段 wire type 必然冲突(WireEnvelope.channel是 varint,DoorControlFrame.payload是 length-delimited),解析当场报错;解析失败即协议违规,必须显式断开连接, 不得静默等待或丢弃(DoorControlFrame内未知 oneof 成员仍前向兼容容忍); 已知许可例外:浏览器脚本端(fzstd 纯 JS 解压)对门控流损坏选择静默降级 不断连——丢弃字典帧、回退独立压缩语义;Java mod 与服务端仍严格执行断连; - 流识别按开启序约定:客户端第 1 条双向流 = 应用层上行;服务端第 1 条单向流 = 应用层 下行;服务端第 2 条单向流 = door-control 下行(服务端→客户端);客户端第 1 条单向流 = door-control 上行(客户端→服务端);合同外流(客户端第 2 条起双向流/单向流)一律 违规断连;
- door-control 流使用与流通道相同的
[varint][payload]分帧,payload 为序列化的DoorControlFrame;WS 门(无独立传输流语义)不涉及门控流; - datagram 字典模式(
+zstd-dict套):- 服务端用近期 movement 批训练字典(推荐 4096 字节;接收端应拒绝异常超大内容,建议
上限 16 KiB),经 door-control 下行流发送
DoorControlFrame{dict_offer: {dictionary_id, content}};dictionary_id为连接内不透明的短标识(建议内容哈希前缀),字典内容按 连接独立,不得跨连接共享或缓存;content是不透明 zstd 训练字节(压缩状态), 外层帧本身是标准 protobuf; - 客户端完整接收并安装字典后,经 door-control 上行流回
DoorControlFrame{dict_ready: {dictionary_id}}; - 服务端仅在收到
dict-ready(ID)后才用该字典压缩 datagram(激活);激活前按独立 (无字典)zstd 压缩。字典内容与使用它的数据分别走可靠有序流与 datagram:ready 只能 在 offer 完整安装后发出,故字典字节必然先于用它压缩的任何 datagram 过线; - 客户端按连接只保留 current + previous 两个字典(旧 ID 是乱序在途帧的宽限,收到新 字典压缩的帧后即可释放更旧);服务端同款 current + previous 生命周期,新 ID 激活即 逐出更旧;连接关闭全部释放;
- 重训设最小间隔(建议 ≥60 秒)加玩家集变化阈值,防重训风暴;
- 唯一理论竞态是客户端 door-control 安装任务滞后于 datagram 接收:zstd 字典不匹配会 响亮报错(非静默错位),按普通丢包丢帧即可——movement 下一 tick 自愈 + 低频保底 全量刷新兜底,代价与 UDP 丢包同级。
- 服务端用近期 movement 批训练字典(推荐 4096 字节;接收端应拒绝异常超大内容,建议
上限 16 KiB),经 door-control 下行流发送
战区地图等大内容(10 MiB 级)走服务端第 3 条起的专用单向流,与应用下行流、 door-control 流互不混用,实现"大内容不阻塞小消息"的传输层分层:
- 服务端先经 door-control 下行流发送
DoorControlFrame{bulk_transfer_start: {transfer_id, total_len, content_type}}通告,再开启 bulk 流; - bulk 流自标识:载荷以
[varint len][transfer_id UTF-8]前缀开头,其后是total_len字节的原始内容(不做 protobuf 封装,零逐块开销)。QUIC 不保证跨流顺序,接收端按transfer_id把流匹配到通告:先于通告到达的字节进有界缓冲(建议上限 4 MiB), 超上限或超时仍无匹配通告 = 协议违规断连; - 低优先级的实现优先用传输栈的本地发送优先级(quinn
set_priority: 数值越大越先发送,bulk 流取负值,门控/应用流恒先出队);辅以小块写入 (每次 ≤64 KiB)与拥塞控制运行时反馈(cwnd/RTT)自节流,防止灌满 连接发送队列把高优流队头阻塞在 bulk 字节后面;不得按已知链路预算 调参——拥塞控制自行探测路径; - 完整性与验收由接收方核对
total_len与内容自校验(如内容图案 checksum)完成; bulk 传输失败不拖垮连接,应用层流与 datagram 照常。
- 不要在消费仓库中复制、重建或手改
.proto副本。 - 修改协议后,必须明确说明目标 tag、兼容性影响,以及需要更新哪些消费仓库。
- 任何破坏兼容的协议变更都必须显式记录,不允许“静默升级”。