回放文件能打开不等于能重演:逻辑版本与表现资源必须分层

一段四帧回放包含 Tick 420 到 423 的移动与开火输入。文件能够解析,角色模型也能找到,但这还不能证明它能重演:若录像来自逻辑版本 8,当前运行时只理解版本 7,同一组输入可能进入不同状态;若输入中间缺了 Tick 422,后面的每一步都失去时间前提。反过来,录像引用的渲染包版本 99 不存在,未必需要拒绝整段历史——只要玩法状态仍可确定重建,画面可以换成兼容表现。

同一条回放输入驱动稳定模拟与不同表现输出

这要求回放加载器回答两个不同问题:这份输入能否在当前逻辑中得到可信状态,以及用哪套资源把状态画出来。把二者压成一个“版本匹配”开关,要么会让旧皮肤阻断可用录像,要么会让不兼容逻辑悄悄进入播放。

能重演的身份不包括模型和材质

本地探针把确定性身份限制为三项:LogicVersion、按 Tick 排序的 ReplayInput,以及这些输入规范化编码后的 SHA-256 摘要。RenderVersion 仍记录在包内,却只选择表现配置。逻辑版本匹配而渲染版本未知时,加载器提交回放状态,并将 RenderProfile 设为 compatibility-render

入口位于 $LAB_ROOT/ReplayLoader.csReplayLoader.TryLoad。活动回放由加载器独占,候选必须完成全部检查才能替换它:

public sealed class ReplayLoader(
    int supportedLogicVersion,
    IReadOnlySet<int> renderVersions)
{
    public ActiveReplay? Active { get; private set; }

    public LoadResult TryLoad(ReplayPackage candidate)
    {
        if (candidate.LogicVersion != supportedLogicVersion)
            return LoadResult.UnsupportedLogic;

        if (!HasContiguousTicks(candidate.Inputs))
            return LoadResult.InvalidTickSequence;

        var digest = ComputeDigest(candidate.Inputs);
        if (!CryptographicOperations.FixedTimeEquals(
                Encoding.ASCII.GetBytes(digest),
                Encoding.ASCII.GetBytes(candidate.InputDigest)))
            return LoadResult.DigestMismatch;

        var profile = renderVersions.Contains(candidate.RenderVersion)
            ? $"render-v{candidate.RenderVersion}"
            : "compatibility-render";
        Active = new ActiveReplay(
            candidate.Id,
            candidate.LogicVersion,
            candidate.RenderVersion,
            profile,
            candidate.Inputs.ToArray(),
            digest);
        return LoadResult.Loaded;
    }

    public static string ComputeDigest(IEnumerable<ReplayInput> inputs)
    {
        var canonical = string.Join(";", inputs.Select(
            input => $"{input.Tick}:{input.MoveX}:{(input.Fire ? 1 : 0)}"));
        return Convert.ToHexString(
            SHA256.HashData(Encoding.ASCII.GetBytes(canonical)));
    }

    private static bool HasContiguousTicks(IReadOnlyList<ReplayInput> inputs)
    {
        if (inputs.Count == 0)
            return false;
        for (var index = 1; index < inputs.Count; index++)
            if (inputs[index].Tick != inputs[index - 1].Tick + 1)
                return false;
        return true;
    }
}

摘要不是用来代替逻辑版本。相同字节交给不同规则仍可能产生不同结果;版本号也不能代替摘要,因为版本正确的文件仍可能被截断或修改。两者分别约束“如何解释”和“实际收到了什么”。这里使用固定长度比较,使摘要检查不依赖首个差异出现的位置;它不是认证机制,若录像需要抵抗恶意篡改,还应在包级加入可信签名。

回放包还不应保存每帧 Transform、粒子位置或最终颜色来冒充确定性结果。那些数据适合快速预览、拖动时间轴或视频导出,却不能成为重新执行玩法的权威输入。权威状态应由版本匹配的模拟器从输入推导;预览缓存即使缺失,也只影响访问速度,不改变能否重演的判断。这样做还给调试器留出一条可靠路径:同一输入在两个逻辑构建中产生差异时,可以定位第一个分歧 Tick,而不是比较两份来源不明的表现快照。

拒绝候选时,旧回放必须完整留下

危险实现通常先写 Active.Inputs,再检查版本或加载表现资源。失败发生后,界面可能仍显示旧回放 ID,模拟器却已经拿到新输入。探针将唯一写入放在所有确定性检查之后,因此摘要损坏、逻辑版本不兼容和 Tick 缺口都不会产生半状态。

回放候选经过逻辑版本、Tick 与摘要门禁后原子提交

固定样本先加载 match-42,随后用未知渲染版本加载 match-43。后续三个坏候选依次到达,活动对象始终保持 match-43

$ dotnet build $LAB_ROOT/ReplayVersionProbe.csproj -c Release
exit_code=0 warnings=0 errors=0 duration=8.72s

$ dotnet run --project $LAB_ROOT/ReplayVersionProbe.csproj -c Release --no-build
exit_code=0 pass=13 fail=0 skip=0 duration=1.98s
normal: id=match-42 logic=7 render=3 ticks=420..423 count=4 digest=037798F800D2 profile=render-v3
render-fallback: id=match-43 logic=7 render=99 result=Loaded profile=compatibility-render
corrupt: id=corrupt result=DigestMismatch active=match-43
unsupported: id=logic-v8 result=UnsupportedLogic active=match-43
tick-gap: id=tick-gap ticks=420,421,423 result=InvalidTickSequence active=match-43

这些耗时只证明当天的小型进程内探针完成构建与断言,不是回放吞吐或加载性能数据。输入摘要目前用文本规范化编码,正式格式需要固定字段宽度、字节序、布尔编码和版本化序列化合同,否则不同语言实现可能对同一语义产生不同字节。

版本演进应从明确的失败开始

逻辑版本不兼容时直接拒绝,比尝试“尽量播放”更容易解释。确实需要长期保存赛事或玩家录像时,可以为少数历史版本保留隔离的模拟器,或提供显式迁移器;迁移产物必须重新计算摘要,并记录来源版本,不能把版本号原地改成 7。只有当历史运行时的维护成本超过回放价值,才应缩短兼容窗口。

表现资源则可以采用更宽松的合同:缺少旧材质、模型或特效时,选择兼容角色和基础效果,只要它们不反馈到碰撞、命中盒或玩法随机数。若所谓“皮肤”改变了骨骼命中点或动画事件,它已经跨入逻辑输入,不能再作为可替换表现处理。

这一边界也决定存档生命周期。短期本地录像可以只支持当前逻辑版本;需要用于客服裁决、赛事复盘或长期内容展示的录像,则必须连同可执行逻辑版本、迁移策略和资源退化规则一起纳入保留计划。单独承诺“回放文件永久兼容”没有工程含义,因为真正需要保留的是解释输入的能力,而不只是字节本身。

四帧文件能否打开只是存储层事实。能否重演取决于当前逻辑是否理解有序输入,并能证明输入没有缺失或改变;画面是否与录制时完全一致是另一条合同。把这两条边界分开后,旧表现资源可以安全退化,而任何会改变模拟结果的版本差异都会在活动状态被触碰前明确失败。