12.1 规则引擎:命名/尺寸/引用/预算

前面各章的判据各自能跑,却没有一次检查能同时拦住命名、引用与预算。本节把第 3 章的命名文法、第 4 章的建图与环检测、第 5 章的 mip 字节账收进一台规则引擎,用一份 15 个资产的样本跑出 7 条 finding,其中 5 条硬失败即判门禁阻断,并给出跨语言逐位一致的 digest。

12.1 规则引擎:命名/尺寸/引用/预算

前面几章各修了一段管线:第 3 章管导入、第 4 章管引用图、第 5 章管图集与字节账。它们各自能跑,但各自跑意味着没有任何一次检查能同时拦住「命名错了 + 引用悬空 + 包超预算」。CI 门禁要的不是三段脚本,而是一台引擎:把散落的判据收进一张规则表,用一份资产样本跑一遍,给出一个布尔结论。产能与包体的整体账属于生产优化,本节外链 《游戏性能优化与生产》 ,只做可判定规则。

本节有一条硬约束:不另起一套。命名文法与保留字沿用第 3 章与卷一,引用完整性直接 import 第 4 章的建图器与环 / 悬空检测,字节账直接 import 第 5 章的 mip 公式。引擎自己只做一件事——把它们的输出汇成一份带规则码、可跨语言对拍的清单。

12.1.1 一台引擎,四类规则

四类规则的判据都不在本节新造,而是各章已有口径的消费方:

类别判据来源规则码硬失败
naming第 3 章 namePattern + 卷一保留字E_NAME / E_RESERVED / E_PREFIXE_NAME、E_RESERVED
size第 3 章 maxDimension / potRequiredE_SIZE_MAX / E_SIZE_POT否
ref第 4 章建图 + 环 / 悬空E_REF_CYCLE / E_REF_DANGLING是
budget第 5 章 mip 链字节E_BUDGET_OVER是

硬失败的判据是后果的不可逆性,不是数值大小:命名错了引用会断(见 5.3 保留字与反模式 )、保留字会撞工具链、悬空引用打包时找不到源、成环则无法拓扑排序、超预算则整包发不出去。这五类一旦放过,下游一定崩。

12.1.2 规则表:级别与是否硬失败都写死

规则表把每个规则码映射到类别、级别与 hardFail。severity 沿用第 3 章的 block / warn / info 三级(见 3.2 导入期校验 );hardFail 是布尔,任一条命中即门禁失败:

{
  "ruleSet": "ci-gate",
  "specVersion": 1,
  "levels": ["block", "warn", "info"],
  "note": "卷二 12.1 CI 规则引擎的规则表。每条规则声明 category(naming/size/ref/budget)、severity(block/warn/info)与 hardFail。hardFail=true 的五条即命名违例 / 保留字 / 引用悬空 / 依赖成环 / 预算超限,任一条命中即门禁失败。命名文法、尺寸上限、保留字、依赖图与字节账全部沿用第 3/4/5 章与卷一既有口径,不另起一套。",
  "rules": [
    { "code": "E_NAME", "category": "naming", "severity": "block", "hardFail": true, "detail": "文件名不匹配命名文法" },
    { "code": "E_RESERVED", "category": "naming", "severity": "block", "hardFail": true, "detail": "文件名含保留字 / 临时态词" },
    { "code": "E_PREFIX", "category": "naming", "severity": "warn", "hardFail": false, "detail": "前缀与 kind 不对齐" },
    { "code": "E_SIZE_MAX", "category": "size", "severity": "warn", "hardFail": false, "detail": "边长超过导入上限" },
    { "code": "E_SIZE_POT", "category": "size", "severity": "warn", "hardFail": false, "detail": "边长不是 2 的幂" },
    { "code": "E_REF_DANGLING", "category": "ref", "severity": "block", "hardFail": true, "detail": "引用指向不存在的 id" },
    { "code": "E_REF_CYCLE", "category": "ref", "severity": "block", "hardFail": true, "detail": "依赖成环,无法拓扑排序" },
    { "code": "E_BUDGET_OVER", "category": "budget", "severity": "block", "hardFail": true, "detail": "整包纹理全链字节超预算" }
  ]
}

12.1.3 资产样本:结构对齐第 4 章的图

样本与第 4 章的 config/ta04_graph.json 同构(assets / dirOwners / roots),因此可直接喂给第 4 章的建图器。15 个资产里埋了 5 条硬失败与 2 条警告:

{
  "project": "stardust-inn",
  "chapter": 12,
  "note": "卷二 12.1 CI 规则引擎的资产样本。结构与 config/ta04_graph.json 同构(assets/dirOwners/roots),可直接喂给第 4 章的建图器;尺寸与命名文法沿用第 3 章的导入规则;纹理全链字节按第 5 章的 mip 公式算。样本里埋了 5 条硬失败(E_NAME / E_RESERVED / E_REF_DANGLING / E_REF_CYCLE / E_BUDGET_OVER)与 2 条警告。",
  "roots": ["scene_inn_lobby", "scene_inn_room"],
  "dirOwners": { "ui/icons/": "atlas_ui" },
  "packageBudgetBytes": { "ui": 1500000, "world": 12000000 },
  "assets": [
    { "id": "scene_inn_lobby", "kind": "scene", "name": "scene_inn-lobby_v01", "dir": "scenes/", "dependsOn": ["ui_hud", "furn_lamp_01"] },
    { "id": "scene_inn_room", "kind": "scene", "name": "scene_inn-room_v01", "dir": "scenes/", "dependsOn": ["ui_hud"] },
    { "id": "ui_hud", "kind": "ui", "name": "ui_hud_v01", "dir": "ui/", "dependsOn": ["atlas_ui"] },
    { "id": "atlas_ui", "kind": "ui", "name": "ui_atlas_v01", "dir": "ui/" },
    { "id": "icon_coin", "kind": "icon", "name": "icon_coin-gold_v01", "dir": "ui/icons/" },
    { "id": "icon_coin_copy", "kind": "icon", "name": "icon_coin_copy_v01", "dir": "ui/icons/" },
    { "id": "furn_lamp_01", "kind": "furniture", "name": "furn_lamp-01_v01", "dir": "furniture/", "dependsOn": ["furn_lamp_01_v2", "mat_lamp_brass"] },
    { "id": "furn_lamp_01_v2", "kind": "furniture", "name": "furn_lamp-01_v02", "dir": "furniture/" },
    { "id": "mat_lamp_brass", "kind": "material", "name": "mat_lamp-brass_v01", "dir": "materials/", "slots": { "baseColor": "tex_lamp_brass" } },
    { "id": "mat_counter_wood", "kind": "material", "name": "mat_counter-wood_v01", "dir": "materials/", "slots": { "baseColor": "tex_missing_wood" } },
    { "id": "tex_lamp_brass", "kind": "texture", "name": "tex_lamp-brass_v01", "dir": "textures/", "w": 512, "h": 512, "format": "bc1", "package": "ui" },
    { "id": "tex_ui_atlas", "kind": "texture", "name": "tex_ui-atlas_v01", "dir": "textures/", "w": 1024, "h": 1024, "format": "bc1", "package": "ui" },
    { "id": "tex_banner_hd", "kind": "texture", "name": "tex_banner-hd_v01", "dir": "textures/", "w": 1024, "h": 1536, "format": "bc1", "package": "ui" },
    { "id": "tex_legacy_upper", "kind": "texture", "name": "Furn_Lamp_Bad_v01", "dir": "textures/", "w": 256, "h": 256, "format": "bc1", "package": "ui" },
    { "id": "tex_huge_bg", "kind": "texture", "name": "tex_huge-bg_v01", "dir": "textures/", "w": 4096, "h": 4096, "format": "bc1", "package": "world" }
  ]
}

12.1.4 S 层:四类规则共用一份样本

下面这段是 S 层脚本,读规则表与样本,ref 与 budget 两类直接 import 第 4 章与第 5 章的模块,跑完按 code:id:detail 排序取 sha256 前 16 位作 digest(字符串类,跨语言逐位一致):

#!/usr/bin/env python3
# file: sim/ta12_engine.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/ta12_engine.py
"""卷二 12.1 CI 规则引擎(S 层)。一份资产样本 + 一张规则表,跑四类规则:

* naming —— 命名文法(沿用第 3 章 namePattern)、保留字(沿用卷一 sp05_reserved 的 tempMarkers)、
  前缀与 kind 对齐;
* size   —— 边长上限与 2 的幂(沿用第 3 章的 maxDimension / potRequired);
* ref    —— **直接复用第 4 章**的建图器与环 / 悬空检测(sim/ta04_graph.py + sim/ta04_cycle.py);
* budget —— **直接复用第 5 章**的 mip 链字节公式(sim/ta05_mip.py),按包累加纹理全链字节。

规则码的 severity / hardFail 由 config/ta12_rules.json 声明;硬失败即命名违例 / 保留字 /
引用悬空 / 依赖成环 / 预算超限五类。finding 只含字符串,落 out/ta12_engine.json 供 P 层对照。
"""
import hashlib
import json
import os
import re
import sys

from ta04_graph import build_graph
from ta04_cycle import dangling, find_cycles
from ta05_mip import chain_block

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


def load(rel):
    return json.load(open(os.path.join(ROOT, rel), encoding="utf-8"))


def run_rules(assets, naming, reserved, rules, formats):
    """返回已定级、已排序的 findings 列表。四类规则共用同一份样本。"""
    fmt = {f["id"]: f for f in formats["formats"]}
    name_re = re.compile(naming["namePattern"])
    kind_prefix, temp = naming["kindPrefix"], set(reserved["tempMarkers"])
    max_dim, pot_required = naming["maxDimension"], naming["potRequired"]
    out = []
    add = lambda code, rid, detail: out.append({"code": code, "id": rid, "detail": detail})

    for a in assets["assets"]:                       # naming / size:逐资产
        name = a.get("name", a["id"])
        if not name_re.match(name):
            add("E_NAME", a["id"], name)
        hit = sorted(set(name.lower().split("_")) & temp)
        if hit:
            add("E_RESERVED", a["id"], hit[0])
        kp = kind_prefix.get(a.get("kind"))
        if kp and name.split("_")[0] != kp:
            add("E_PREFIX", a["id"], f"prefix={name.split('_')[0]} expected={kp}")
        if "w" in a and "h" in a:
            w, h = a["w"], a["h"]
            if max(w, h) > max_dim:
                add("E_SIZE_MAX", a["id"], f"{w}x{h}")
            if pot_required and ((w & (w - 1)) or (h & (h - 1))):
                add("E_SIZE_POT", a["id"], f"{w}x{h}")

    ids, edges = build_graph(assets)                 # ref:直接复用第 4 章
    for c in find_cycles(ids, edges):
        add("E_REF_CYCLE", c[6:].split("->")[0], c)
    for d in dangling(ids, edges):
        add("E_REF_DANGLING", d[9:].split("->")[0], d)

    budgets = assets.get("packageBudgetBytes", {})   # budget:直接复用第 5 章
    totals = {}
    for a in assets["assets"]:
        if a.get("kind") == "texture" and a.get("format") in fmt:
            f = fmt[a["format"]]
            totals[a["package"]] = totals.get(a["package"], 0) + chain_block(
                a["w"], a["h"], f["block"], f["bytes"])
    for pkg in sorted(totals):
        if pkg in budgets and totals[pkg] > budgets[pkg]:
            add("E_BUDGET_OVER", pkg, f"bytes={totals[pkg]} budget={budgets[pkg]}")

    sev = {r["code"]: r["severity"] for r in rules["rules"]}
    hard = {r["code"]: r["hardFail"] for r in rules["rules"]}
    for f in out:
        if f["code"] not in sev:
            raise SystemExit(f"FAIL 规则码 {f['code']} 未在规则表中声明")
        f["severity"], f["hardFail"] = sev[f["code"]], bool(hard[f["code"]])
    out.sort(key=lambda f: (f["code"], f["id"], f["detail"]))
    return out


def main():
    rules, assets = load("config/ta12_rules.json"), load("config/ta12_assets.json")
    naming, reserved = load("config/ta03_import_rules.json"), load("config/sp05_reserved.json")
    formats = load("config/ta05_formats.json")
    findings = run_rules(assets, naming, reserved, rules, formats)
    levels = {lv: 0 for lv in rules["levels"]}
    for f in findings:
        levels[f["severity"]] += 1
    hard_fail = [f for f in findings if f["hardFail"]]
    lines = [f"{f['code']}:{f['id']}:{f['detail']}" for f in findings]
    digest = hashlib.sha256("\n".join(lines).encode("utf-8")).hexdigest()[:16]
    rep = {"project": assets["project"], "ruleSet": rules["ruleSet"],
           "assetCount": len(assets["assets"]), "ruleCount": len(rules["rules"]),
           "findingCount": len(findings), "bySeverity": levels,
           "hardFailCount": len(hard_fail), "gate": "block" if hard_fail else "pass",
           "findings": findings, "digest": digest}
    with open(os.path.join(ROOT, "out/ta12_engine.json"), "w", encoding="utf-8") as f:
        json.dump(rep, f, ensure_ascii=False, indent=2)
        f.write("\n")
    print(f"assets={rep['assetCount']} rules={rep['ruleCount']} findings={rep['findingCount']} "
          f"block={levels['block']} warn={levels['warn']} info={levels['info']}")
    print(f"hardFail={rep['hardFailCount']} gate={rep['gate']} digest={digest}")
    for f in findings:
        print(f"  {f['severity']:5s} {f['code']:16s} {f['id']:18s} {f['detail']}")
    print("wrote out/ta12_engine.json")
    return 0


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

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

assets=15 rules=8 findings=7 block=5 warn=2 info=0
hardFail=5 gate=block digest=a81e513ef1f6583d
  block E_BUDGET_OVER    ui                 bytes=1966144 budget=1500000
  block E_NAME           tex_legacy_upper   Furn_Lamp_Bad_v01
  block E_REF_CYCLE      furn_lamp_01       CYCLE:furn_lamp_01->furn_lamp_01_v2
  block E_REF_DANGLING   mat_counter_wood   DANGLING:mat_counter_wood->tex_missing_wood
  block E_RESERVED       icon_coin_copy     copy
  warn  E_SIZE_MAX       tex_huge_bg        4096x4096
  warn  E_SIZE_POT       tex_banner_hd      1024x1536
wrote out/ta12_engine.json

7 条 finding 里 5 条是硬失败。三条来自命名与引用(Furn_Lamp_Bad_v01 大写违例、icon_coin_copy_v01 含临时态词 copy、mat_counter_wood 的槽位指向不存在的 tex_missing_wood),一条是第 4 章埋的那个混合环(furn_lamp_01 → furn_lamp_01_v2),一条是 ui 包纹理全链 1966144 字节超了 1500000 预算。这五条正是「命名 / 保留字 / 引用悬空 / 依赖成环 / 预算超限」的完整清单。

12.1.5 P 层:TS 内核与跨语言 digest

下面这段是 P 层 TS 内核,buildGraph / findCycles / danglingRefs 来自第 4 章,chainBlock 来自第 5 章——规则逻辑与 Python 逐条对应,因此 finding 清单与 digest 必须逐位一致:

// file: core/src/ta12/engine.ts
// tier: P
// verify: node --test 'core/test/ta12_*.test.ts'
// 卷二 12.1 CI 规则引擎的 TS 侧实现。与 sim/ta12_engine.py 同一套口径:命名文法与保留字沿用
// 第 3 章 / 卷一配置,引用规则**直接复用第 4 章**的建图与环 / 悬空检测,预算**直接复用第 5 章**
// 的 mip 链字节公式。finding 只含字符串 → 两侧逐位一致。只允许可擦除 TS 语法。
import { buildGraph } from "../ta04/graph.ts";
import { danglingRefs, findCycles } from "../ta04/cycle.ts";
import { chainBlock } from "../ta05/mip.ts";

export type Rule = { code: string; category: string; severity: string; hardFail: boolean; detail: string };
export type Finding = { code: string; id: string; detail: string; severity: string; hardFail: boolean };
const cmp = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0);

export function runRules(assets: any, naming: any, reserved: any, rules: Rule[], formats: any): Finding[] {
  const fmt: Record<string, any> = {};
  for (const f of formats.formats) fmt[f.id] = f;
  const nameRe = new RegExp(naming.namePattern);
  const kindPrefix: Record<string, string> = naming.kindPrefix;
  const temp = new Set<string>(reserved.tempMarkers);
  const maxDim: number = naming.maxDimension;
  const potRequired: boolean = naming.potRequired;
  const out: { code: string; id: string; detail: string }[] = [];
  const add = (code: string, id: string, detail: string): void => { out.push({ code, id, detail }); };

  for (const a of assets.assets) {                 // naming / size:逐资产
    const name: string = a.name ?? a.id;
    if (!nameRe.test(name)) add("E_NAME", a.id, name);
    const hit = [...new Set(name.toLowerCase().split("_"))].filter((s) => temp.has(s)).sort();
    if (hit.length) add("E_RESERVED", a.id, hit[0]);
    const kp = kindPrefix[a.kind];
    if (kp !== undefined && name.split("_")[0] !== kp) add("E_PREFIX", a.id, `prefix=${name.split("_")[0]} expected=${kp}`);
    if (a.w !== undefined && a.h !== undefined) {
      const w: number = a.w, h: number = a.h;
      if (Math.max(w, h) > maxDim) add("E_SIZE_MAX", a.id, `${w}x${h}`);
      if (potRequired && ((w & (w - 1)) !== 0 || (h & (h - 1)) !== 0)) add("E_SIZE_POT", a.id, `${w}x${h}`);
    }
  }
  const { ids, edges } = buildGraph(assets);       // ref:直接复用第 4 章
  for (const c of findCycles(ids, edges)) add("E_REF_CYCLE", c.slice(6).split("->")[0], c);
  for (const d of danglingRefs(ids, edges)) add("E_REF_DANGLING", d.slice(9).split("->")[0], d);
  const budgets: Record<string, number> = assets.packageBudgetBytes ?? {};  // budget:复用第 5 章
  const totals: Record<string, number> = {};
  for (const a of assets.assets) {
    if (a.kind === "texture" && a.format in fmt) {
      const f = fmt[a.format];
      totals[a.package] = (totals[a.package] ?? 0) + chainBlock(a.w, a.h, f.block, f.bytes);
    }
  }
  for (const pkg of Object.keys(totals).sort()) {
    if (pkg in budgets && totals[pkg] > budgets[pkg]) add("E_BUDGET_OVER", pkg, `bytes=${totals[pkg]} budget=${budgets[pkg]}`);
  }
  const sev: Record<string, string> = {}, hard: Record<string, boolean> = {};
  for (const r of rules) { sev[r.code] = r.severity; hard[r.code] = r.hardFail; }
  const findings: Finding[] = out.map((f) => {
    if (!(f.code in sev)) throw new Error(`规则码 ${f.code} 未在规则表中声明`);
    return { ...f, severity: sev[f.code], hardFail: hard[f.code] };
  });
  findings.sort((x, y) => cmp(x.code, y.code) || cmp(x.id, y.id) || cmp(x.detail, y.detail));
  return findings;
}

export function summarize(findings: Finding[], levels: string[]) {
  const bySeverity: Record<string, number> = {};
  for (const lv of levels) bySeverity[lv] = 0;
  for (const f of findings) bySeverity[f.severity] = (bySeverity[f.severity] ?? 0) + 1;
  const hardFailCount = findings.filter((f) => f.hardFail).length;
  return { bySeverity, hardFailCount, gate: hardFailCount > 0 ? "block" : "pass" };
}

实跑 node --test 'core/test/ta12_*.test.ts'(本节 4 条,本章三节合计 12 条):

engine digest = a81e513ef1f6583d
✔ 跨语言:TS finding 清单 == Python finding 清单(逐位一致)
✔ 样本:7 条 finding = block 5 + warn 2,硬失败恰好是那五类,门禁判阻断
✔ 负例:干净样本零 finding,门禁必须 pass
✔ 负例:五类硬失败各自可被单独触发(检测器是活的,不是空转)
ℹ pass 12
ℹ fail 0

两侧 digest 都是 a81e513ef1f6583d——字符串类,逐位一致。测试里两条负例是本节的核心:一条喂一个干净样本(零 finding、门禁 pass),一条把五类硬失败各自单独触发一遍(改名 → 只报 E_NAME;加 _copy_ → 只报 E_RESERVED;槽位指向幽灵 → 只报 E_REF_DANGLING;加一条反向边 → 只报 E_REF_CYCLE;砍预算 → 只报 E_BUDGET_OVER)。负例证明的不是「测试通过」,而是断言是活的——检测器坏了、恒返回空,负例立刻打红。

12.1.6 五条硬失败为什么必须是硬的

E_SIZE_MAX / E_SIZE_POT 在本节只是 warn,而在第 3 章的导入分级表里 E_SIZE_MAX 是 block——同一个规则码,两处级别不同,这不是矛盾。导入期面对的是单个文件的导入动作,尺寸超限会让导入器当场失败;CI 面对的是整批已入库资产,一张超尺寸的图只要没进包,就不该拦住整条流水线。级别的判据永远是**「后果有多不可逆」**,而不是「这个数超了多少」。

反过来,命名与引用类问题在任何阶段都不可逆:命名错了,引用它的清单、图集收编规则、材质槽会一起断,且断得静默(第 4 章的三类隐式边正是这种静默断裂的来源);依赖成环则让拓扑排序无解。所以这五类在任何门禁里都只能是硬失败。下一节要回答的是另一个问题:硬失败之外的那些,门禁到底拦不拦、拿什么当阻断条件。

阅读导航:上一节:11.3 版本一致性与缓存失效 · 下一节:12.2 门禁的分级与阻断策略 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gameart」更多文章

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