Skip to content

录制与回放

基于 rts-server-golang replay/ 模块解析 关键词: 录制, 回放, 确定性验证, 作弊检测, RPLY1格式

概述

录制与回放系统将游戏过程完整记录下来,用于:

  • 观战:让其他人观看已结束的对局
  • 调试:回放任意历史帧,定位 desync 根因
  • 作弊检测:对比录制与实际执行结果,检测篡改
  • AI 训练:用真实对局数据训练 AI

帧同步天然适合录制——只需要记录每帧的输入(命令),不需要记录状态。回放时用相同 seed 重跑即可。

文件格式 (RPLY1)

Header (固定长度 + 变长 snapshot):
  magic[6]       "RPLY1\0"      — 格式标识
  proto_ver(2)   uint16          — wire 协议版本
  seed(8)        uint64          — 世界 seed
  tick_rate(2)   uint16          — 每秒帧数
  start_ts(8)    int64           — 开始时间戳
  players(1)     uint8           — 玩家数量
  mapW(4)        int32           — 地图宽(原始值)
  mapH(4)        int32           — 地图高(原始值)
  snap_len(4)    uint32          — 初始快照长度
  snapshot[]     []byte          — 初始世界序列化

Tick Stream (每帧重复):
  tick(4)        uint32          — 帧号
  cmd_count(2)   uint16          — 命令数量
  cmds[]         []Cmd           — 每条 22 字节
  hash(8)        uint64          — 该帧执行后 world hash

魔数 RPLY1\0

文件头 6 字节唯一标识格式,解析前先校验魔数:

go
if data[0] != 'R' || data[1] != 'P' || data[2] != 'L' ||
   data[3] != 'Y' || data[4] != '1' || data[5] != 0 {
    return nil, 0, ErrBadMagic
}

Cmd 固定 22 字节

player(1) + op(1) + unitid(4) + tx(4) + ty(4) + targetid(4) + tick(4) = 22

设计:命令定长,无需长度前缀,方便随机访问和流式处理。

TickRecord

go
type TickRecord struct {
    Tick uint32
    Cmds []wire.Cmd
    Hash uint64
}

Hash 字段用于回放时校验——重跑结果与录制 hash 不一致说明有问题。

Writer — 录制

go
type Writer struct {
    w       io.Writer
    written bool  // header 只能写一次
}

// 写 Header(游戏开始时调用一次)
func (rw *Writer) WriteHeader(h *Header) error {
    data := MarshalHeader(h)
    _, err := rw.w.Write(data)
    if err == nil {
        rw.written = true
    }
    return err
}

// 写单帧(每帧调用一次)
func (rw *Writer) WriteTick(tick uint32, cmds []wire.Cmd, hash uint64) error {
    tr := &TickRecord{Tick: tick, Cmds: cmds, Hash: hash}
    data := MarshalTick(tr)
    _, err := rw.w.Write(data)
    return err
}

调用时机在 Room.sealTick() 里:

go
replayWriter.WriteTick(r.tick, cmds, sim.Hash(r.world))

Reader — 回放

go
type Reader struct {
    data   []byte
    offset int
    Header *Header
}

// 从头到尾逐帧读取
func (r *Reader) NextTick() (*TickRecord, error) {
    if r.offset >= len(r.data) {
        return nil, nil  // EOF
    }
    tr, n, err := UnmarshalTick(r.data[r.offset:])
    r.offset += n
    return tr, err
}

// 一次性读完全部帧
func (r *Reader) ReadAll() ([]TickRecord, error)

回放流程

go
// 1. 创建 Reader
reader, err := replay.NewReader(data)

// 2. 重建初始世界
world, err := sim.Unmarshal(reader.Header.Snapshot)

// 3. 逐帧重放
for {
    tr, err := reader.NextTick()
    if tr == nil { break }
    sim.Step(world, tr.Cmds)
    if sim.Hash(world) != tr.Hash {
        return ErrDesync  // 校验失败
    }
}

在 Server 中的集成

go
// cmd/server/main.go
registry := room.NewRegistry(defaults, log)
listener, _ := transport.Listen(transport.ListenerConfig{...})

// Room 创建时附带 replay writer
rm := registry.GetOrCreate(roomID)
rm.SetReplayWriter(replayWriter)  // 写入文件

录制文件通常写到 /var/replays/room-<id>-<timestamp>.rply

用途场景

场景怎么用
观战下载 replay 文件,客户端独立回放(不连服务器)
Desync 调试找到 hash 不一致的帧,对比两端的 world 差异
作弊检测服务器录制 + 客户端上报 hash,服务器对比
AI 训练用 replay 数据训练决策模型

相关

撰写