TH123 回放结构与观战协议

lzusa 发布于 7 小时前 40 次阅读


本地 .rep 回放

游戏从 Replay 菜单读取 replay/ 目录下的 .rep。回放记录对局配置、随机种子与双方输入,播放时由游戏引擎逐帧重演。复现依赖相容的游戏版本、初始状态和完整输入。

文件布局

以下布局适用于 1.10 系列的网络对战回放。每个玩家块占 49 字节。表中偏移从文件首字节起算。输入区边界另按实测文件长度核对。 石塔《非想天则REP文件结构解析》

绝对偏移长度内容
0x002D2 00
0x026头部保留/待确认字段
0x081模式相关字节,网络对战式样本中见到 06,其他观察见下文
0x095头部其余字段,样本和录制器常量可不同
0x0E491P 块
0x3F492P 块
0x701两个玩家块之后的字节,样本与写出器为 03
0x711stage
0x721music
0x734random seed,u32 LE
0x774输入字数,u32 LE
0x7B2 × 字数输入,按时间正序写入,通常每帧先 1P 后 2P

玩家块内部为角色 u8、颜色 u8、卡数 u32 LE、固定 20 个 u16 LE 卡位,最后三个字节依次为侧别标记(1P 为 0、2P 为 1)和两个含义待确认的字节。物理长度为 123 + 2 × 输入字数。KO 结尾可能只剩一个未配对的 1P 输入字,读取时应保留这个字的位置,仅将完整的双人帧用于逐帧分析。

0x08 的 02、03、04、05 分别对应 VS COM、本地双人、网战 1P、网战 2P 的工具显示。单独改动这个字节时,游戏读键方式未见变化。改动 0x73–0x76 的随机种子会改变符卡与天气顺序。

输入区边界。 记为 0x77–0x78, 0x79 标作暂定的输入起点。本文所述的 1.10 网络对战式样本按 0x77 起的四字节计数和 0x7B 起的输入区读取,文件总长度与声明的字数一致。

角色编码

角色字段位于每个玩家块的首字节,1P 为 0x0E,2P 为 0x3F。表中编码采用十六进制,角色以全名列出。

编码角色
0x00博丽灵梦
0x01雾雨魔理沙
0x02十六夜咲夜
0x03爱丽丝·玛格特洛依德
0x04帕秋莉·诺蕾姬
0x05魂魄妖梦
0x06蕾米莉亚·斯卡蕾特
0x07西行寺幽幽子
0x08八云紫
0x09伊吹萃香
0x0A铃仙·优昙华院·因幡
0x0B射命丸文
0x0C小野塚小町
0x0D永江衣玖
0x0E比那名居天子
0x0F东风谷早苗
0x10琪露诺
0x11红美铃
0x12灵乌路空
0x13洩矢诹访子

场地编码

场地字段位于 0x71。

编码场地
0x00倒塌的博丽神社
0x01魔法森林
0x02玄武之泽
0x03妖怪之山
0x04玄云海
0x05有顶天
0x0A博丽神社
0x0B雾雨魔法店
0x0C红魔馆钟楼
0x0D人偶森林
0x0E红魔馆地下图书馆
0x0F冥界
0x10红魔馆大堂
0x11白玉楼雪庭
0x12迷途竹林
0x1E雾之湖湖畔
0x1F守矢神社
0x20间歇泉入口
0x21间歇泉地下通道
0x22聚变反应炉核心

输入字

每个输入字为 u16 LE,可同时设置多个按键位。下表用从 0 开始的位号。VS COM 回放每帧仅保存玩家的输入,并观察到高位可触发 VS COM 专用操作。

位号数值掩码动作
0–30x0001、0x0002、0x0004、0x0008上、下、左、右
4–70x0010、0x0020、0x0040、0x0080A、B、C、D(擦弹)
8–90x0100、0x0200切换符卡、使用符卡
10–150x0400 至 0x8000VS COM 中依次对应 F7、F6、F5、F3、F2、F1

例如同时按右和 A,输入字为 0x0018,写入文件为 18 00。

原生 UDP 观战

在网络菜单中对房间地址发起观战连接。1P、2P 是对战席位,parent、child 是观战树上相邻节点的上下游关系。典型路径是房主 1P 重定向到已参战的 2P,由 2P 接入首位观众。首位观众还可以接入其他观众。各级观众都向自己的 parent 请求输入,再用收到的对局配置和双人输入在本机重演比赛。

  1. 观众向目标发送 37 字节 HELLO。直接发现时两个嵌入地址相同,目标回复 OLLEH。需要上级协助时两个地址不同,上级向第二地址发 PUNCH,接收 PUNCH 的节点向其中携带的地址回 OLLEH。HELLO 偏移 33 的 u16 LE 回程端口提示,低于 1024 时加到观察到的源端口,否则是绝对端口。
  2. 观众发 INIT_REQUEST,携带 16 字节 game ID 与角色类型。空位可接入时收到 INIT_SUCCESS;繁忙入口或满载节点可发 REDIRECT。对新候选再做发现、打洞和初始化,直到取得实际 parent。
  3. 建链后以 CHAIN 通告可用子树容量,以 GAME_REPLAY_REQUEST 拉取输入。观众可接受下游;同一条树连接可跨选人、加载及下一局。QUIT、链路沉默及特定退场路径各有自己的状态转换。
opcode长度布局及用途
0x01 HELLO37opcode + 两个 16 字节 IPv4 sockaddr + 4 字节尾部
0x02 PUNCH21opcode + 一个 sockaddr + 4 字节尾部
0x03 OLLEH1发现/打洞回包
0x04 CHAIN5opcode + u32 LE 可用子树容量
0x05 INIT_REQUEST变长game ID @1–16,payload 长度 u16 LE @21,payload @25,首字节为请求类型;总长度 25 + length
0x06 INIT_SUCCESS81(该路径)名称区 1P @13、2P @45,各 32 字节,末尾含联动相关字段
0x07 INIT_ERROR5u32 LE reason;0 为关闭观战,1 为游戏状态不合适
0x08 REDIRECT69count u32 LE @1;最多 4 个 sockaddr,首个 @5
0x0B QUIT1退出/关闭连接
0x0D HOST_GAME变长下行游戏事件,观战使用 MATCH 与 REPLAY
0x0E CLIENT_GAME变长上行游戏事件,观战使用 REPLAY_REQUEST

字节序与地址字段

本节偏移从 UDP 载荷首字节起算。HELLO、PUNCH、REDIRECT 里嵌入的地址是游戏载荷的一部分。同一个包可以同时包含大端和小端字段,例如 REDIRECT 的候选数量是小端,每个候选的端口是大端。

字段字节序或编码方式字节示例
IPv4 sockaddr 内的地址族u16 LE,AF_INET = 202 00
IPv4 sockaddr 内的端口u16 BE(网络字节序)10800 = 0x2A30,写成 2A 30
IPv4 地址4 个字节按点分十进制的顺序192.0.2.20 写成 C0 00 02 14
HELLO 回程端口提示、INIT 载荷长度u16 LE40 写成 28 00
CHAIN 容量、REDIRECT 数量、INIT_ERROR 原因、回放游标、随机种子u32 LE256 写成 00 01 00 00
卡 ID、回放输入字u16 LE0x1234 写成 34 12
opcode、事件类型、请求类型、matchId单字节无需交换
game ID、名称、zlib 数据原样读取的字节串game ID 按发包顺序逐字节比较

16 字节 IPv4 sockaddr 的内部偏移依次为地址族 @0、端口 @2、IPv4 @4、填充 @8。HELLO 的两个 sockaddr 从 UDP 偏移 1、17 开始,分别表示正在联系的节点和最终目标。PUNCH 的 sockaddr 从偏移 1 开始,指定 OLLEH 应回送到哪里。REDIRECT 的四个候选槽从偏移 5、21、37、53 开始,只读 count 指定的有效槽。以 HELLO 端口提示为例,观察到的源端口为 20000,提示为 03 00 时,计算出的回程端口为 20003。

INIT_REQUEST 与 game ID

固定头长 25 字节。偏移 0 为 05,1–16 为 game ID,17–20 与 23–24 为其他初始化字段,21–22 为 u16 LE 载荷长度。载荷从 25 开始,首字节 00 表示观战,01 表示参战。参战类型后还有名称长度、名称字节和 NUL,观战请求没有玩家名称字段。常见的 65 字节包在偏移 21 写 28 00,表示之后有 40 字节载荷,包括请求类型和其余填充。收到 OLLEH 说明发现阶段可继续。版本不匹配时,INIT 请求可能一直收不到回应。

game ID 是握手使用的 16 字节版本标识,可能受游戏版本、角色解锁、与《东方绯想天》(SWR)的联动和模组影响。下面按实际发包顺序列出字节。每场比赛的 matchId 另占 1 字节,出现在 MATCH、REPLAY 和 REPLAY_REQUEST 中。

版本或配置16 字节 game ID(1.10ac、全角色解锁示例)
原版,联动 SWR6E 73 65 D9 FF C4 6E 48 8D 7C A1 92 31 34 72 95
SokuRoll 1.3,联动 SWR64 73 65 D9 FF C4 6E 48 8D 7C A1 92 31 34 72 95
原版或 SokuRoll 1.3,未联动 SWR46 C9 67 C8 AC F2 44 4D B8 B1 EC EE D4 D5 40 4A
Giuroll 0.6.18,联动 SWR,60 FPS6B 73 65 D9 FF C4 6E 48 8D 7C A1 92 31 34 72 95
Giuroll 0.6.18,联动 SWR,62 FPS6C 73 65 D9 FF C4 6E 48 8D 7C A1 92 31 34 72 95

原版联动 SWR 的请求开头是 05 6E 73 65 D9 ...。表中的联动版本共享后 15 字节,首字节随模组和配置变化。所检验的旧版 Giuroll 0.5.3b 使用 69(60 FPS)和 6A(62 FPS)。未联动 SWR 时,原版与 SokuRoll 1.3 可以报出相同 ID,但对战双方的后续协议仍可能不相容。

下面是一份 65 字节原版联动 SWR 观战请求示例。每行左侧是十六进制偏移。偏移 0x19(十进制 25)的 00 表示观战,偏移 0x15 的 28 00 表示载荷长度,其余头字段仅展示示例取值。

00  05 6E 73 65 D9 FF C4 6E 48 8D 7C A1 92 31 34 72
10  95 3B AA 01 6E 28 00 FC 30 00 00 00 00 00 00 00
20  00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
30  00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
40  00

INIT_SUCCESS 与观战资格

常见完整响应长 81 字节。偏移 5–8 的 u32 LE 观战状态常见为 0x00(不允许观战)、0x10(允许观战的参战响应)或 0x11(观战响应)。偏移 9–10 是 u16 LE 资料长度,完整响应为 68(44 00)。偏移 13 和 45 各有 32 字节名称槽,分别保存 1P、2P 的名称字节,可在槽内遇 NUL 停止。偏移 77–80 是 u32 LE 的 SWR 禁用标志,0 表示本场使用双方联动的 SWR,1 表示未使用。后续成功响应可能省略名称等资料,解析前应检查包长。在等待初始化时,收到有效成功响应才确定本次的 parent。

连接与保活

观众可以把已接收的回放输入提供给下一级观众。同一观战连接可跨过选人、加载并继续接收下一局。有效 CHAIN 或对应节点的有效游戏数据可以刷新活跃状态。

CHAIN 通告直接空位与各活跃子节点可用容量的总和。所观察到的原版满载路径会从容量最小且大于零的活跃子节点开始,循环检查最多四个槽位,把可用目标写入 REDIRECT。REDIRECT 公布的地址用于请求上级协助打洞,实际 UDP 回包端口可能因 NAT 映射而改变。

所观察到的原版连接分支中,HELLO 每候选最多 34 轮,间隔 128 ms,每轮发送 3 或 5 个包。INIT 无回应分支发送 102 次。CHAIN 一轮约 500 ms,第 11 个未回应轮次进入断线处理。回放请求每第四次相关更新调用入发送队列,32 ms 为队列有效期。若每秒更新 60 次,请求间隔约 67 ms。parent 沉默会触发超时,主动 QUIT 的部分路径允许在有候选时切换 parent。

观战游戏事件

事件前两个字节是外层 opcode 和事件类型。HOST_GAME(0D)沿树向下发,CLIENT_GAME(0E)沿树向上发。发出 0D 的 parent 可以是 2P 或另一个观众。单字节 0B 是 QUIT,0E 0B ... 中的 0B 位于事件类型位置,表示回放请求。

对战双方用事件 01/02 确认场景加载,用 03(GAME_INPUT)交换近期输入,并用 08/04/05 请求、传递和确认比赛配置。事件里的场景值 03 是选人,05 是战斗。观战侧主要看 0E 0B 的请求及 0D 04、0D 09 两种回复。向观众发的 MATCH 带双方配置,发给参战者的 MATCH 可省略接收方已有的卡组。解析时应结合端点角色。

GAME_MATCH(0D 04)

偏移 2 起依次为 1P 块、2P 块与比赛尾部。每个玩家块由角色 u8、颜色 u8、deckId u8、卡数 u8、相应数量的 u16 LE 卡 ID 和 simultaneous-buttons 禁用标志 u8 组成,长度为 5 + 2 × 卡数。尾部依次为场地 u8、音乐 u8、随机种子 u32 LE、matchId u8。matchId 随新局变化,并受 8 位字段的取值范围限制。

若双方都带 20 张卡,每块长 45 字节,1P 块从偏移 2 开始,2P 块从 47 开始,比赛尾部从 92 开始,标准包长为 99 字节。若一方卡数变化,后续字段的偏移也随之变化。卡组由卡 ID 列表给出,deckId 单独无法还原卡组。

GAME_REPLAY_REQUEST(0E 0B)

固定 7 字节,格式为 0E 0B | frameId:u32le | matchId:u8。观众用它请求某局的输入位置。上游可在比赛代次不匹配等情况下先回 GAME_MATCH,再提供回放输入。

请求中的位置是观众希望补取的游标,回复中的位置是所返回数据的末端。一次回复可以覆盖一段位置,重复请求也可能拿到重叠的输入。

GAME_REPLAY(0D 09)

外层为 0D 09 | compressedSize:u8 | zlib 数据。解压后按以下布局读取。

解压偏移长度含义
04frameId,u32 LE,包内最新输入位置
44endFrameId,u32 LE,已知的比赛结束位置;0 表示尚未公告
81matchId
91输入字数 N
102Nu16 LE 输入字;成对时为 2P 后 1P,输入对从新到旧

压缩长度只数 zlib 部分。标准解压布局长 10 + 2 × N 字节,N=4 表示两对双人输入。若 N=0,该包可以携带游标或结束标记,但没有输入可供游戏推进。

输入位置、输入字数与双人帧号是不同的单位。在成对输入布局中,包内最新输入对的逻辑帧号为 (frameId + 1) // 2(整数除法),其余输入对按帧号递减。重建连续回放时按比赛代次和输入位置合并重叠包,并继续请求缺失位置。结束位置可以先于最后一段输入到达。

转换为本地 .rep 时,逐帧改写为时间正序,每帧先写 1P 再写 2P。缺失输入会影响重演结果。

示例 A:直接加入一个有空位的观战节点

观众 V 已知节点 P 的地址 192.0.2.20:10800。在 HELLO 的两个地址槽中都填 P。这个 IPv4 sockaddr 可以写成 02 00 2A 30 C0 00 02 14 00 00 00 00 00 00 00 00,其中 02 00 是地址族,2A 30 是网络字节序的 10800,后面四个字节是 IPv4 地址。以下时序每行只画一个代表性的包。

方向报文接收方动作
V → PHELLO,两个地址槽均为 PP 向 V 的 UDP 源地址回 OLLEH
P → V03V 确认发现阶段成功
V → PINIT_REQUEST,偏移 25 为 00P 检查 game ID 与观战请求
P → VINIT_SUCCESSV 取得双方资料,P 成为 V 的 parent
V ↔ PCHAIN双方维护这条树边并通告可用容量
V → PGAME_REPLAY_REQUESTP 根据局号和游标返回配置或输入

例如 V 作为无子节点的观众,直接容量为 4,发送 04 04 00 00 00。P 若也有 4 个直接槽且只接了 V,剩余 3 个直接槽,加上 V 通告的 4 个子树空位,合计通告 04 07 00 00 00。

示例 B:通过入口重定向和协助打洞

观众 V 先接触入口 H,H 在初始化阶段返回 REDIRECT。若只有候选 P,报文头是 08 01 00 00 00,接 P 的 16 字节地址,其余三个槽可填零,合计 69 字节。一个可能的协助建链过程如下。

  1. V 向 H 发协助 HELLO。两个地址槽填不同地址,第二槽为 H 公布的候选 P。V 也可向 P 直接发两个地址槽相同的 HELLO,尝试建立双向可达性。
  2. H 向 P 发 PUNCH,嵌入 H 观察到的 V 的 UDP 源地址。P 向该地址回 OLLEH,V 因此可能在 P 的 NAT 映射端口收到响应。
  3. V 向可达的 P 发送观战 INIT_REQUEST。收到 INIT_SUCCESS 后,后续 CHAIN 和回放请求走 V 与 P 之间的树边。如果 P 再次重定向,V 对新候选重复这个过程。

例如 H 公布 P 的端口为 10800,V 实际收到 P 的 OLLEH 却来自同一 IP 的 21800。下一次请 H 协助时,HELLO 第二槽仍使用 H 公布的 10800;向已确认的传输地址直接发包时可使用 21800。

示例 C:取得配置并解码两帧

V 初始发 0E 0B 00 00 00 00 00,表示请求第 0 局的位置 0。如果当前局号为 2,P 可先回 0D 04,让 V 取得第 2 局的角色、卡组、场地、音乐与种子。V 加载该局配置后,用 0E 0B 00 00 00 00 02 请求第 2 局的位置 0。下面是完整的示例 UDP 载荷,第三字节 14 表示后面有 20 字节 zlib 数据。

0D 09 14 78 9C 63 61 80 00 26 16 06 06 01 06 0E 20 0B 00 01 12 00 23

解压从 78 9C 开始的数据,得到以下 18 字节。

04 00 00 00   00 00 00 00   02 04   00 00 10 00   08 00 00 00
最新位置 4     结束位置未知    局号 2   第 2 帧        第 1 帧
                             4 个字   2P=0 1P=0x10  2P=8 1P=0

这里最新输入对对应逻辑帧 2。第 2 帧是 1P 按 A,第 1 帧是 2P 按右。游戏按第 1 帧、第 2 帧的次序推进。若写入磁盘回放,这两帧的 8 个输入字节为 00 00 08 00 10 00 00 00。

示例 D:乱序、补取与跨局

假设观众已连续收到第 1、2 帧,随后先收到了包含第 5、6 帧的包。它可以先缓存这两帧,并继续从连续边界之后请求,等第 3、4 帧到齐再按顺序推进。按示例 C 的编号方式,已连续收到的输入止于位置 4,第 5、6 帧占位置 9–12。缺口从位置 5 开始,请求可写为 0E 0B 05 00 00 00 02。如果补包同时带有第 5 帧,根据局号和帧位置合并重叠输入。

若第 2 局的包先公告结束位置 1200,观众仍需补齐该位置之前的输入。随后收到第 3 局的 GAME_MATCH 时,以新配置建立独立的输入序列,再从新局起始位置请求。例如 0E 0B 00 00 00 00 03 表示请求第 3 局的位置 0。仍在路上的第 2 局包应按旧局处理。只要 parent 连接仍有效,换局可以沿用原来的 UDP 会话。

SokuRoll 与 Giuroll 的观战差异

SokuRoll 1.3

SokuRoll 的回滚同步发生在 1P 与 2P 之间。观战仍按 game ID 规则初始化,并用 0E 0B 请求、0D 09 接收双人回放输入。以下 SokuRoll 专用 opcode 用于对战双方。

UDP 首字节名称用途和载荷
10TIME序号 u32 LE、时间戳 u32 LE,用于估计往返延迟
11TIME_ACK回显 TIME 的序号和时间戳
12STATE调试用同步状态,已知的帧号与角色坐标字段为 i32 LE
13SETTINGS最大回滚量 u8、输入延迟 u8,每个单位对应 2 帧
14SETTINGS_ACK单字节,确认设置

例如 13 03 02 表示最大回滚 6 帧、输入延迟 4 帧。在 SokuRoll 的参战流中,战斗阶段由 1P 发送的 0D 03 会在普通输入字前放两份相同的 u16 LE 输入延迟值,包内的字数包含这两个附加字。该布局属于 GAME_INPUT。观战的 0D 09 解压后仍按 REPLAY 布局读取。

Giuroll 的观战接入与输入缓冲

所核对的 Giuroll 版本收到观战 INIT_REQUEST 时,会根据本机 60/62 FPS 配置,把收到包的 game ID 首字节改成自身的版本字节,再交给游戏原有的握手校验。其余 15 字节仍参与校验。这个行为发生在接收侧,所以交换 parent 与 child 的位置会改变结果。例如 Giuroll 60 FPS 的 parent 收到首字节为 6C 的观战请求时可在本机改为 6B,原版 parent 收到首字节为 6B 的请求时则走自己的原版校验路径。原版、同版 Giuroll 60 FPS、同版 Giuroll 62 FPS 各自在受控环境中完成过跨局观战。混合版本要看每一条树边上的接收方以及实际对局表现。

Giuroll 在对战开始后才提供满足缓冲条件的观战输入。所核对版本的条件是待发送位置加 16 小于当前计数,并且战斗已开始。若对战刚开始或卡在很早的位置,观众可能已经完成初始化,却暂时收不到能推进画面的输入。这里的 16 是该实现检查输入缓冲用的计数差,实际观战延迟还受请求间隔、传输和加载影响。

抓包中看到 05 6B ... 时,6B 是 INIT 的 game ID 首字节。一个直接以 6B 开头的 UDP 载荷则是 Giuroll 自定义战斗包,单凭这个 opcode 无法判断当前使用 60 还是 62 FPS。

从抓包判断观战停在哪一步

已观察到的进展接着检查
HELLO 发出,未收到 OLLEH目标地址和端口、UDP 可达性、重定向后的协助打洞
OLLEH 已到,反复发 INIT完整的 16 字节 game ID、偏移 25 的请求类型、长度,以及是否已有 REDIRECT 或 INIT_ERROR
收到 INIT_SUCCESS,但没有比赛配置是否向实际 parent 发送 0E 0B,当前是否已产生可用对局配置
收到 GAME_MATCH,但缺少连续的非空 GAME_REPLAYmatchId、回放请求位置、解压后的输入字数和上游战斗进度
输入位置持续推进,但画面仍卡在加载或出现不同步本地加载进度、联动数据、模组及帧率配置、同局输入完整性
看烟花已落,你我仍是陌路人
最后更新于 2026-11-13