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 三个被改资产。这就是反向闭包的意义:
| 动作 | 触发 | 含义 |
|---|---|---|
remove | ADDED | 删掉这个版本新增的资产 |
restore | REMOVED | 把被删的资产找回来 |
revert | MODIFIED | 退回 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 成文方法与模板 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。