6.3 清单与目录的双向一致

清单与目录必须互相对账:目录里多出的文件是漏登记,清单里悬空的条目是资产已被删。本节把《星屑旅店》的目录快照与清单各算一次差集,两个方向都报,用一份真跑的 S 层脚本和 P 层 TS 内核给出逐位一致的违例集合,并说明为什么只查一个方向会漏掉一半问题。

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)美术或 TAEXTRA 补登记、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 按角色分级的面数与骨骼预算 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gameart」更多文章

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