15.3 资产 diff 与回滚

回滚不是「把文件覆盖回去」:一次改版牵动新增/修改/删除三态,还会沿着引用关系把没直接变的资产一起拖下水。本节把两份资产快照做三态 diff,再用反向闭包生成回滚计划,用一份真跑的 S 层脚本和 P 层 TS 内核给出逐位一致的计划与违例,并用漏掉依赖者的负例证明门禁真的会 fail。

15.3.1 回滚不是「把文件覆盖回去」

15.1 管版本号、15.2 管大文件,这一节管改出问题之后怎么退回去。很多人以为回滚就是「把某个文件恢复成上一版」,这在单文件场景成立,在有依赖的资产集合里不成立:把 furn_chair_01 退回旧版,引用它的 scene_lobby 如果还在用新版坐标,画面就错位了;把一张图集退回旧版,所有引用该图集的 UI 都得跟着回。回滚的单位不是文件,是一组有依赖关系的资产。

所以要做两件事:一是把两份快照的差异算成三态(新增 / 修改 / 删除),二是沿引用关系做反向闭包——凡「引用了被改资产」的资产,即使它自己没变,也必须纳入回滚计划。本节只做这两件事的静态计算,不执行任何真实的版本库操作。

15.3.2 D 层:两份快照与引用表

config/sp15_snapshots.json 是静态输入:base 与 head 两份 (id, hash) 清单,外加 references(谁引用了谁)。样本里 scene_lobby 自己没变,却引用了三个被改动的资产:

{
  "project": "stardust-inn",
  "note": "《星屑旅店》资产快照样本:base 与 head 两份 (id, hash) 清单,外加 references(谁引用了谁)。diff 得新增/修改/删除三态;回滚计划要把三态对应的动作列全,并沿 references 反向闭包把「引用了被改资产」的资产一并纳入。",
  "base": [
    { "id": "atlas_ui", "hash": "h_ui_1" },
    { "id": "furn_chair_01", "hash": "h_chair_2" },
    { "id": "ui_frame_01", "hash": "h_frame_1" },
    { "id": "scene_lobby", "hash": "h_lobby_1" },
    { "id": "tile_floor_01", "hash": "h_floor_1" }
  ],
  "head": [
    { "id": "atlas_ui", "hash": "h_ui_1" },
    { "id": "furn_chair_01", "hash": "h_chair_3" },
    { "id": "ui_frame_01", "hash": "h_frame_2" },
    { "id": "scene_lobby", "hash": "h_lobby_1" },
    { "id": "vfx_steam_01", "hash": "h_steam_1" }
  ],
  "references": {
    "ui_frame_01": ["atlas_ui"],
    "furn_chair_01": ["atlas_ui"],
    "scene_lobby": ["furn_chair_01", "ui_frame_01", "tile_floor_01"]
  }
}

三态一眼可见:vfx_steam_01 只在 head(新增)、tile_floor_01 只在 base(删除)、furn_chair_01 与 ui_frame_01 两边 hash 不同(修改);atlas_ui 与 scene_lobby 未变。

15.3.3 S 层:三态 diff 与回滚计划

下面这段是 S 层脚本。diff 算三态,closure 做反向闭包,build_plan 给每个纳入闭包的资产定动作(remove/restore/revert/rebuild),verify_plan 校验计划是否覆盖完整闭包:

#!/usr/bin/env python3
# file: sim/sp15_snapshots.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/sp15_snapshots.py
"""15.3 资产 diff 与回滚的 S 层脚本。读 config/sp15_snapshots.json:
  1) diff(base, head) → 三态:
       ADDED    head 有、base 无
       REMOVED  base 有、head 无
       MODIFIED 两边都有但 hash 不同
  2) 回滚计划:把 head 退回 base。三态各对应一个动作
       ADDED   -> remove   删掉
       REMOVED -> restore  找回
       MODIFIED-> revert   退回 base 版本
     再沿 references 做**反向闭包**:凡「引用了被改资产」的资产,其呈现也会变,必须
     一并纳入,动作记为 rebuild(重出)。闭包缺失即计划不完整。
  3) verify_plan 检查计划是否覆盖了完整闭包,漏一个依赖者即产出 MISSING_DEP:<id>。
违例是字符串数组,排序后落 out/sp15_snapshots.json,供 P 层跨语言对照。全整数 → 逐位一致。
"""
import json
import os
import sys

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))


def diff(base: list, head: list):
    bmap = {a["id"]: a["hash"] for a in base}
    hmap = {a["id"]: a["hash"] for a in head}
    added = sorted(i for i in hmap if i not in bmap)
    removed = sorted(i for i in bmap if i not in hmap)
    modified = sorted(i for i in hmap if i in bmap and hmap[i] != bmap[i])
    return added, removed, modified


def closure(seed: set, references: dict):
    """从 seed 出发,反复加入「引用了闭包内任一资产」的资产,直到不动点。"""
    cur = set(seed)
    while True:
        grown = set(cur)
        for src, deps in references.items():
            if any(d in cur for d in deps):
                grown.add(src)
        if grown == cur:
            return cur
        cur = grown


def build_plan(base: list, head: list, references: dict):
    added, removed, modified = diff(base, head)
    changed = set(added) | set(removed) | set(modified)
    full = closure(changed, references)
    action = {}
    for i in sorted(full):
        if i in added:
            action[i] = "remove"
        elif i in removed:
            action[i] = "restore"
        elif i in modified:
            action[i] = "revert"
        else:
            action[i] = "rebuild"  # 未直接变,被闭包拉进来的引用者
    plan = [{"id": i, "action": action[i]} for i in sorted(full)]
    return {"added": added, "removed": removed, "modified": modified,
            "changed": sorted(changed), "plan": plan, "planIds": sorted(full)}


def verify_plan(plan_ids: list, base: list, head: list, references: dict):
    """计划必须覆盖完整闭包,漏掉的依赖者逐个报 MISSING_DEP。"""
    added, removed, modified = diff(base, head)
    full = closure(set(added) | set(removed) | set(modified), references)
    got = set(plan_ids)
    return sorted(f"MISSING_DEP:{i}" for i in full if i not in got)


def main() -> int:
    cfg = json.load(open(os.path.join(ROOT, "config/sp15_snapshots.json"), encoding="utf-8"))
    base, head, refs = cfg["base"], cfg["head"], cfg["references"]
    res = build_plan(base, head, refs)
    viol = verify_plan(res["planIds"], base, head, refs)
    report = {"project": cfg["project"], "baseCount": len(base), "headCount": len(head),
              "added": res["added"], "removed": res["removed"], "modified": res["modified"],
              "plan": res["plan"], "planCount": len(res["plan"]),
              "violations": viol, "count": len(viol)}
    with open(os.path.join(ROOT, "out/sp15_snapshots.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2)
        f.write("\n")

    print(f"base={len(base)} head={len(head)} added={len(res['added'])} "
          f"removed={len(res['removed'])} modified={len(res['modified'])}")
    print(f"  ADDED    {' '.join(res['added']) or '-'}")
    print(f"  REMOVED  {' '.join(res['removed']) or '-'}")
    print(f"  MODIFIED {' '.join(res['modified']) or '-'}")
    print(f"planCount={report['planCount']} violations={report['count']}")
    for p in res["plan"]:
        print(f"  PLAN {p['action']:<8} {p['id']}")
    for v in viol:
        print(f"  VIOLATION {v}")
    print("wrote out/sp15_snapshots.json")
    return 0


if __name__ == "__main__":
    sys.exit(main())

实跑 /opt/homebrew/bin/python3 sim/sp15_snapshots.py:

base=5 head=5 added=1 removed=1 modified=2
  ADDED    vfx_steam_01
  REMOVED  tile_floor_01
  MODIFIED furn_chair_01 ui_frame_01
planCount=5 violations=0
  PLAN revert   furn_chair_01
  PLAN rebuild  scene_lobby
  PLAN restore  tile_floor_01
  PLAN revert   ui_frame_01
  PLAN remove   vfx_steam_01
wrote out/sp15_snapshots.json

15.3.4 读计划:为什么 scene_lobby 在名单里

计划里有 5 个资产,但直接变的只有 4 个(1 新增 + 1 删除 + 2 修改)。多出来的 scene_lobby 自己 hash 没变,却必须回滚——因为它引用了 furn_chair_01、ui_frame_01、tile_floor_01 三个被改资产。这就是反向闭包的意义:

动作触发含义
removeADDED删掉这个版本新增的资产
restoreREMOVED把被删的资产找回来
revertMODIFIED退回 base 的 hash
rebuild闭包拉入自身没变,但引用的东西变了,必须重出

漏掉 rebuild 这一类,是回滚最常见的事故:文件都「退回去了」,画面却还是错的,因为没人重出那些引用了旧版的场景。所以门禁必须把闭包算全,而不是只回滚三态里的那几条。

15.3.5 P 层:同一套规则的 TS 内核

下面这段是 P 层 TS 内核 core/src/sp15/snapshots.ts,与 Python 逐条对应——同一套 diff、同一套闭包不动点、同一套动作映射:

// file: core/src/sp15/snapshots.ts
// tier: P
// verify: node --test 'core/test/sp15_*.test.ts'
// 15.3 资产 diff 与回滚的 P 层内核。与 sim/sp15_snapshots.py 同一套规则:三态 diff、
// 反向闭包、回滚计划生成、计划完整性校验(漏依赖者报 MISSING_DEP)。
// 只允许可擦除 TS 语法(禁 enum / namespace / 参数属性 / 装饰器)。

export function diff(base: any[], head: any[]): { added: string[]; removed: string[]; modified: string[] } {
  const bmap: Record<string, string> = {};
  for (const a of base) bmap[a.id] = a.hash;
  const hmap: Record<string, string> = {};
  for (const a of head) hmap[a.id] = a.hash;
  const added = Object.keys(hmap).filter((i) => !(i in bmap)).sort();
  const removed = Object.keys(bmap).filter((i) => !(i in hmap)).sort();
  const modified = Object.keys(hmap).filter((i) => i in bmap && hmap[i] !== bmap[i]).sort();
  return { added, removed, modified };
}

export function closure(seed: Set<string>, references: Record<string, string[]>): Set<string> {
  let cur = new Set(seed);
  for (;;) {
    const grown = new Set(cur);
    for (const src of Object.keys(references)) {
      if (references[src].some((d) => cur.has(d))) grown.add(src);
    }
    if (grown.size === cur.size) return cur;
    cur = grown;
  }
}

export function buildPlan(base: any[], head: any[], references: Record<string, string[]>): any {
  const { added, removed, modified } = diff(base, head);
  const changed = new Set([...added, ...removed, ...modified]);
  const full = closure(changed, references);
  const ids = [...full].sort();
  const plan = ids.map((i) => {
    let action: string;
    if (added.includes(i)) action = "remove";
    else if (removed.includes(i)) action = "restore";
    else if (modified.includes(i)) action = "revert";
    else action = "rebuild";
    return { id: i, action };
  });
  return { added, removed, modified, plan, planIds: ids, planCount: plan.length };
}

export function verifyPlan(planIds: string[], base: any[], head: any[], references: Record<string, string[]>): string[] {
  const { added, removed, modified } = diff(base, head);
  const full = closure(new Set([...added, ...removed, ...modified]), references);
  const got = new Set(planIds);
  return [...full].filter((i) => !got.has(i)).map((i) => `MISSING_DEP:${i}`).sort();
}

15.3.6 负例:漏掉依赖者必须 fail

core/test/sp15_snapshots.test.ts 一边读 Python 产物做跨语言对照,一边构造一条负例——计划漏掉 scene_lobby 这个依赖者,必须 fail:

// file: core/test/sp15_snapshots.test.ts
// tier: P
// verify: node --test 'core/test/sp15_*.test.ts'
// 跨语言对照:读 sim/sp15_snapshots.py 落盘的 out/sp15_snapshots.json,断言 TS 侧复算的
// 三态 diff、回滚计划、计划完整性逐位一致。负例:漏掉依赖者的计划必须 fail。
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { diff, buildPlan, verifyPlan } from "../src/sp15/snapshots.ts";

const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = resolve(HERE, "../..");
const cfg = JSON.parse(readFileSync(resolve(ROOT, "config/sp15_snapshots.json"), "utf8"));
const py = JSON.parse(readFileSync(resolve(ROOT, "out/sp15_snapshots.json"), "utf8"));

test("资产 diff:TS 复算三态与 Python 产物逐位一致", () => {
  const d = diff(cfg.base, cfg.head);
  assert.deepEqual(d.added, py.added);
  assert.deepEqual(d.removed, py.removed);
  assert.deepEqual(d.modified, py.modified);
});

test("回滚计划:TS 复算与 Python 产物逐位一致", () => {
  const p = buildPlan(cfg.base, cfg.head, cfg.references);
  assert.deepEqual(p.plan, py.plan);
  assert.equal(p.planCount, py.planCount);
});

test("正例:未变的 scene_lobby 因引用被改资产被闭包拉进计划", () => {
  const p = buildPlan(cfg.base, cfg.head, cfg.references);
  const lobby = p.plan.find((x: any) => x.id === "scene_lobby");
  assert.deepEqual(lobby, { id: "scene_lobby", action: "rebuild" });
  assert.equal(p.planCount, 5);
});

test("负例:计划漏掉依赖者必须 fail", () => {
  const planIds = ["furn_chair_01", "ui_frame_01", "tile_floor_01", "vfx_steam_01"];
  const v = verifyPlan(planIds, cfg.base, cfg.head, cfg.references);
  assert.deepEqual(v, ["MISSING_DEP:scene_lobby"]);
});

test("正例:完整计划无 MISSING_DEP", () => {
  const p = buildPlan(cfg.base, cfg.head, cfg.references);
  assert.deepEqual(verifyPlan(p.planIds, cfg.base, cfg.head, cfg.references), []);
});

实跑 node --test 'core/test/sp15_*.test.ts'(本节 5 条;三节合计 12 条全绿):

✔ 资产 diff:TS 复算三态与 Python 产物逐位一致 (0.928208ms)
✔ 回滚计划:TS 复算与 Python 产物逐位一致 (0.231834ms)
✔ 正例:未变的 scene_lobby 因引用被改资产被闭包拉进计划 (0.115833ms)
✔ 负例:计划漏掉依赖者必须 fail (0.12375ms)
✔ 正例:完整计划无 MISSING_DEP (0.5755ms)
ℹ tests 12
ℹ pass 12
ℹ fail 0

15.3.7 边界与取舍

  • 真实版本库 diff / 真回滚执行(Git revert / Perforce 回滚 / 制品库回退)需要真实环境,命令行不可复现,标 X;本节只对两份快照做静态集合运算与计划生成,不触网、不执行任何回滚。
  • 三态集合、计划动作、违例都是字符串/枚举数组,逐位一致;闭包是不动点迭代,两侧用同一套「引用了闭包内任一资产即纳入」的规则,结果逐位一致。本节不涉及色彩派生量,故无浮点容差问题。
  • 闭包基于显式声明的引用表;隐式依赖(如命名约定推导出的变体关系)不在本节范围——那属于引用完整性检查(6.2)与依赖图(卷二第 4 章)的职责。
  • 回滚计划只算「要动哪些资产」,不算「怎么动」:真正的执行顺序、锁、以及回滚后如何重跑校验,属于管线工程,留给卷二。

阅读导航:上一节:15.2 大文件与二进制资产的协作 · 下一节:16.1 Art Bible 成文方法与模板 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「gameart」更多文章

  1. 附录 A:风格参数表、量化规则与数据集索引
  2. 9.3 等距资产的制作约束
  3. 9.2 等距 tile 与遮挡规则