6.3.1 两个方向,缺一不可
6.1 给了字段、6.2 给了引用,但清单和磁盘目录仍是两份各自演进的数据:清单说「我登记了 13 条」,目录说「我有 13 个文件」——数字一样不代表内容一样。要真正对账,必须做双向差集:
| 方向 | 含义 | 后果 | 谁会踩到 |
|---|---|---|---|
| 目录多出(EXTRA) | 文件在盘上、清单里没有 | 漏登记:不打包、不校验、不被盘点 | 打包器会漏掉它,上线才发现图裂 |
| 清单悬空(MISSING) | 条目在清单里、盘上没有 | 资产已删/改名,条目成了幽灵 | 打包器找不到文件,构建中断 |
只查一个方向,就只治一半的病。实践中漏登记(EXTRA)更隐蔽:新来的美术往目录里丢了一张图就以为完事了,清单里没有它,于是它既不进图集、也不进预算账,直到某天有人发现「怎么这个家具没有图」。而悬空(MISSING)更吵:构建会直接报错,反而容易发现。
本节继续沿用 《渲染引擎》的资产管线入口 的划界——引擎侧如何把清单喂给导入器、如何调度打包,属于实现,外链;本节只做清单 ↔ 目录这一层的对账。
6.3.2 目录快照长什么样
要对比,先得有「目录」这一侧的可比数据。我们不直接扫盘(扫盘依赖文件系统顺序、大小写、软链接,不可复现),而是把目录状态导成一份快照——一份有序的路径清单。真实项目里它由 CI 的一条 find 生成;本套把它固化成 D 层配置 config/sp06_directory.json:
{
"project": "stardust-inn",
"root": "art",
"files": [
"art/atlas/ui_atlas.png",
"art/furniture/chair_01.png",
"art/furniture/table_01.png",
"art/furniture/lamp_01.png",
"art/furniture/chair_01_v2.png",
"art/furniture/sofa_01.png",
"art/portrait/host_01.png",
"art/portrait/guest_01.png",
"art/ui/frame.png",
"art/tiles/floor_01.png",
"art/tiles/wall_01.png",
"art/vfx/steam_01.png",
"art/vfx/spark_01.png"
]
}
这份快照与 6.1 的清单样本故意不完全一致,好让双向差集都非空:快照里有 art/furniture/sofa_01.png(清单没登记),却少了 art/ui/button.png(清单有、盘上没有)。
为什么用「快照」而不是直接扫盘:
- 可复现:CI 与本地跑同一份快照,结果逐位一致,不会因为某人本地多了一个
.DS_Store就飘。 - 可测试:测试可以直接喂一份内存快照,构造出「只有 EXTRA」或「只有 MISSING」的极端用例。
- 可留痕:快照进了版本库,谁在什么时候删了哪张图,一查便知。
6.3.3 S 层:双向差集
下面这段是 S 层脚本,读 config/sp06_manifest.json 的 path 集合与 config/sp06_directory.json 的 files 集合,各算一次差集,落 out/sp06_dir.json:
#!/usr/bin/env python3
# file: sim/sp06_dir.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/sp06_dir.py
"""6.3 清单 ↔ 目录双向一致的 S 层校验器。清单 path 集合与目录 files 集合各算一次差集,
两个方向都报:EXTRA = 目录里有、清单里没有;MISSING = 清单里有、目录里没有。
落 out/sp06_dir.json,供 P 层跨语言对照。"""
import json
import os
import sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
def check(manifest: dict, snapshot: dict):
mpaths = [a["path"] for a in manifest["assets"] if "path" in a]
files = list(snapshot["files"])
mset, dset = set(mpaths), set(files)
extra = sorted(p for p in dset if p not in mset)
missing = sorted(p for p in mset if p not in dset)
return extra, missing
def main() -> int:
mf = json.load(open(os.path.join(ROOT, "config/sp06_manifest.json"), encoding="utf-8"))
snap = json.load(open(os.path.join(ROOT, "config/sp06_directory.json"), encoding="utf-8"))
extra, missing = check(mf, snap)
report = {"project": mf["project"], "root": snap["root"],
"manifestPaths": len([a for a in mf["assets"] if "path" in a]),
"dirFiles": len(snap["files"]), "extra": extra, "missing": missing,
"count": len(extra) + len(missing)}
with open(os.path.join(ROOT, "out/sp06_dir.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2)
f.write("\n")
print(f"manifestPaths={report['manifestPaths']} dirFiles={report['dirFiles']} "
f"extra={len(extra)} missing={len(missing)}")
for v in extra:
print(f" EXTRA {v}")
for v in missing:
print(f" MISSING {v}")
print("wrote out/sp06_dir.json")
return 0
if __name__ == "__main__":
sys.exit(main())
实跑 /opt/homebrew/bin/python3 sim/sp06_dir.py:
manifestPaths=13 dirFiles=13 extra=1 missing=1
EXTRA art/furniture/sofa_01.png
MISSING art/ui/button.png
wrote out/sp06_dir.json
两个方向都非空:sofa_01.png 是漏登记,button.png 是幽灵条目。数字上清单 13 条、目录 13 个,看着「对齐」,实际错了两个——这正是只数个数不查内容会漏掉的。
落盘的 out/sp06_dir.json 把两个方向分字段存,供下游门禁直接读:
{
"project": "stardust-inn",
"root": "art",
"manifestPaths": 13,
"dirFiles": 13,
"extra": [
"art/furniture/sofa_01.png"
],
"missing": [
"art/ui/button.png"
],
"count": 2
}
6.3.4 P 层:同一套差集的 TS 内核
下面这段是 P 层 TS 内核 core/src/sp06/dir.ts。差集运算简单,但两个方向、去重与排序必须与 Python 完全一致,否则跨语言对照会飘:
// file: core/src/sp06/dir.ts
// tier: P
// verify: node --test 'core/test/sp06_*.test.ts'
// 6.3 清单 ↔ 目录双向一致的 P 层内核。与 sim/sp06_dir.py 同一套规则:
// 清单 path 集合与目录 files 集合各算一次差集,两个方向都报。
// extra = 目录里有、清单里没有;missing = 清单里有、目录里没有。
// 只允许可擦除 TS 语法(禁 enum / namespace / 参数属性 / 装饰器)。
export function checkDirConsistency(manifest: any, snapshot: any): { extra: string[]; missing: string[] } {
const manifestPaths: string[] = manifest.assets.filter((a: any) => a.path).map((a: any) => a.path);
const dirFiles: string[] = snapshot.files;
const mset = new Set(manifestPaths);
const dset = new Set(dirFiles);
const extra = dirFiles.filter((p) => !mset.has(p)).sort();
const missing = manifestPaths.filter((p) => !dset.has(p)).sort();
return { extra: [...new Set(extra)], missing: [...new Set(missing)] };
}
6.3.5 负例:两个方向都要能报
core/test/sp06_dir.test.ts 一边读 Python 产物做跨语言对照,一边用内存快照分别构造出「只有 EXTRA」「只有 MISSING」两种用例——两个方向都必须被报出来,缺一不可:
// file: core/test/sp06_dir.test.ts
// tier: P
// verify: node --test 'core/test/sp06_*.test.ts'
// 跨语言对照:读 sim/sp06_dir.py 落盘的 out/sp06_dir.json,断言 TS 侧的双向差集逐位
// 一致。负例覆盖两个方向:目录多出的文件、清单悬空的条目,都必须被报出来。
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 { checkDirConsistency } from "../src/sp06/dir.ts";
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = resolve(HERE, "../..");
const manifest = JSON.parse(readFileSync(resolve(ROOT, "config/sp06_manifest.json"), "utf8"));
const snapshot = JSON.parse(readFileSync(resolve(ROOT, "config/sp06_directory.json"), "utf8"));
const py = JSON.parse(readFileSync(resolve(ROOT, "out/sp06_dir.json"), "utf8"));
test("清单 ↔ 目录双向差集:TS 复算与 Python 产物逐位一致", () => {
const mine = checkDirConsistency(manifest, snapshot);
assert.deepEqual(mine.extra, py.extra);
assert.deepEqual(mine.missing, py.missing);
assert.equal(mine.extra.length + mine.missing.length, py.count);
});
test("负例:目录里多出的文件(漏登记)必须被检出", () => {
const m = { assets: [{ path: "art/a/a.png" }] };
const s = { files: ["art/a/a.png", "art/a/orphan.png"] };
assert.deepEqual(checkDirConsistency(m, s).extra, ["art/a/orphan.png"]);
});
test("负例:清单里悬空的条目(目录已删)必须被检出", () => {
const m = { assets: [{ path: "art/a/a.png" }, { path: "art/a/gone.png" }] };
assert.deepEqual(checkDirConsistency(m, { files: ["art/a/a.png"] }).missing, ["art/a/gone.png"]);
});
test("正例:完全一致时两个方向都为空", () => {
const m = { assets: [{ path: "art/a/a.png" }, { path: "art/a/b.png" }] };
const r = checkDirConsistency(m, { files: ["art/a/b.png", "art/a/a.png"] });
assert.deepEqual(r.extra, []);
assert.deepEqual(r.missing, []);
});
实跑 node --test 'core/test/sp06_*.test.ts'(此处只列本节相关的 4 条;三节合计 13 条全绿):
✔ 清单 ↔ 目录双向差集:TS 复算与 Python 产物逐位一致
✔ 负例:目录里多出的文件(漏登记)必须被检出
✔ 负例:清单里悬空的条目(目录已删)必须被检出
✔ 正例:完全一致时两个方向都为空
ℹ tests 13
ℹ pass 13
ℹ fail 0
6.3.6 什么时候跑、谁来修
对账不是「上线前跑一次」,而是两个触发点:
| 触发点 | 谁跑 | 期望动作 |
|---|---|---|
| 提交前(本地/CI) | 美术或 TA | EXTRA 补登记、MISSING 删条目或找回文件 |
| 合并到主干前 | TA 门禁 | 两个方向都为空才放行;非空即阻断 |
修法也要分清方向:
- EXTRA(漏登记):多半是「图丢了但没登记」。补一条清单条目即可,但必须补全 6.1 的八个字段,不能只写
path蒙混过关——否则 6.1 的校验器会再拦一次。 - MISSING(幽灵条目):要么是资产真被删了(那就删条目),要么是被改名了(那就改
path而不是删条目,否则 6.2 的引用图会断)。先确认是哪一种,再动手。
一个常见的坏味道是:有人为了让门禁变绿,把 MISSING 的条目直接删掉,结果 6.2 的依赖图里多出一堆悬空引用。三个检查必须一起跑——字段(6.1)、引用(6.2)、目录(6.3)是同一份清单的三个切面。
6.3.7 边界与取舍
- 真扫盘 / 真实资产库同步需要真实环境(DCC / 版本控制系统 / 资产服务器),命令行不可复现,标 X;本节只对一份目录快照做静态对账。
- 快照要排除生成物与系统文件(
.DS_Store、缩略图、.import中间文件),否则 EXTRA 会被噪音淹没。本套用「只收录art/下的.png/.webp」这一条白名单口径。 - 两个方向的差集都是字符串数组,逐位一致;本节不涉及色彩派生量,故没有浮点容差问题。
阅读导航:上一节:6.2 引用关系与依赖声明 · 下一节:7.1 按角色分级的面数与骨骼预算 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。