3.2 导入期校验:尺寸、格式、命名

上一节判的是「字段对不对」,这一节判「值合不合规」。尺寸、格式、命名三类判据被写成一张全整数与字符串的规则表,再用一份真跑的 Python 规则引擎与 TS 内核在 9 条导入记录上跑出 8 条违例,八个规则码各命中一次;两侧违例集合逐位一致,且每条规则都配了一条能触发的负例。

3.2 导入期校验:尺寸、格式、命名

3.1 判的是字段对不对(该不该存、缺没缺、漂没漂),这一节判值合不合规:这张图能不能是这个尺寸、能不能是这个格式、文件名叫得对不对。判据不是我们拍脑袋定的——尺寸上限来自预算,站内 移动端与 PC 的美术性能预算 那篇把「为什么移动端要压到 2048」讲到了方向层面,本节做的是把它变成导入期的一条可执行判据:不是文档里的一句建议,而是一段会 exit 1 的代码。命名文法出自卷一 5.1 命名规则:前缀/类型/变体/版本 ,本节只负责在导入那一刻执行它。

核心主张:导入期校验必须是一个规则引擎,而不是一张检查清单。 清单要靠人逐条对;引擎对一批记录跑一遍,输出的是可枚举、可比较、可回归的违例集合。

3.2.1 校验为什么必须落在导入期

同一套判据可以放在三个地方,效果完全不同:写进需求书,是人读到交付时才发现;放进评审会,是人看到评审当天才拦;落在导入期,是机器在提交那一刻就拦下。三者的差别不在「严不严」,而在反馈延迟:一张 4096×4096 的 UI 图,评审时看是「有点大」,导入时看是「超上限 2048 一倍」——后者带着确切数字、确切文件、确切修法。把判据放在最早能自动执行的位置,返工成本最低。

另一个理由是可枚举:评审的结论是「这几张要改」,规则引擎的结论是「E_SIZE_MAX 命中 1 次,命中者是 ui_panel-wood_v01」。后者能被写进测试、能被 diff、能被回归。

3.2.2 三类判据,一张规则表

三类判据落成 config/ta03_import_rules.json。它和 3.1 的 schema 一样是 D 层:不产出数字,只定义判据;区别是 schema 判「字段」,这张表判「值」。

{
  "ruleSet": "import",
  "specVersion": 1,
  "maxDimension": 2048,
  "minDimension": 8,
  "potRequired": true,
  "aspectMax100": 400,
  "kindPrefix": { "furniture": "furn", "character": "char", "ui": "ui", "tile": "tile", "vfx": "vfx", "icon": "icon" },
  "allowedFormats": { "furniture": ["png", "webp"], "character": ["png", "webp"], "ui": ["png", "webp"], "tile": ["png"], "vfx": ["png", "ktx2"], "icon": ["png"] },
  "namePattern": "^[a-z][a-z0-9]*_[a-z][a-z0-9-]*(_[a-z][a-z0-9-]*)?_v[0-9]{2}$"
}

逐个字段说清楚:

字段判据对应规则码
maxDimension长边不得超过 2048E_SIZE_MAX
minDimension短边不得小于 8E_SIZE_MIN
potRequired两边都必须是 2 的幂E_SIZE_POT
aspectMax100长宽比不得超过 4(整数比较 hi*100 <= lo*400)E_ASPECT
allowedFormats该类型的格式必须在枚举内E_FORMAT
文件扩展名声明的格式必须与扩展名一致E_FORMAT_EXT
namePattern文件名必须匹配命名文法E_NAME
kindPrefix前缀必须与 kind 对应E_PREFIX

注意 E_FORMAT 与 E_FORMAT_EXT 是两条不同的规则:前者判「这种格式这个类型收不收」,后者判「你声明的格式和文件本身对不对得上」。vfx 允许 ktx2,所以一张声明 ktx2 的 vfx 图不会踩 E_FORMAT;但如果它的文件扩展名是 .png,就踩 E_FORMAT_EXT——设置与文件自相矛盾,导入器不知道该听谁的。

3.2.3 S 层:规则引擎

下面这段是 S 层脚本,读规则表与 9 条记录,逐条跑八条规则,落 out/ta03_rules.json:

#!/usr/bin/env python3
# file: sim/ta03_rules.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/ta03_rules.py
"""3.2 的 S 层规则引擎:对一批导入记录跑尺寸 / 格式 / 命名三类规则,逐条报违例。
判据全部是整数与字符串(上下界、2 的幂位运算、长宽比 ×100、格式枚举、命名正则),
故与 P 层 TS 内核的违例集合逐位一致。落 out/ta03_rules.json。"""
import hashlib
import json
import os
import re
import sys

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
CFG = os.path.join(ROOT, "config")
OUT = os.path.join(ROOT, "out")


def is_pot(n: int) -> bool:
    return n > 0 and (n & (n - 1)) == 0


def lint(rec: dict, rules: dict) -> list:
    out = []
    base = rec["file"].rsplit("/", 1)[-1]
    stem, _, ext = base.rpartition(".")
    if rec["format"] != ext:
        out.append({"id": rec["id"], "code": "E_FORMAT_EXT", "detail": f"{rec['format']}!={ext}"})
    allowed = rules["allowedFormats"].get(rec["kind"], [])
    if rec["format"] not in allowed:
        out.append({"id": rec["id"], "code": "E_FORMAT",
                    "detail": f"{rec['format']} not in {','.join(allowed)}"})
    if not re.match(rules["namePattern"], stem):
        out.append({"id": rec["id"], "code": "E_NAME", "detail": stem})
    want = rules["kindPrefix"].get(rec["kind"], "")
    got = stem.split("_")[0]
    if got != want:
        out.append({"id": rec["id"], "code": "E_PREFIX", "detail": f"{got}!={want}"})
    w, h = rec["source"]["width"], rec["source"]["height"]
    lo, hi = min(w, h), max(w, h)
    if hi > rules["maxDimension"]:
        out.append({"id": rec["id"], "code": "E_SIZE_MAX", "detail": str(hi)})
    if lo < rules["minDimension"]:
        out.append({"id": rec["id"], "code": "E_SIZE_MIN", "detail": str(lo)})
    if rules["potRequired"] and not (is_pot(w) and is_pot(h)):
        out.append({"id": rec["id"], "code": "E_SIZE_POT", "detail": f"{w}x{h}"})
    if hi * 100 > lo * rules["aspectMax100"]:
        out.append({"id": rec["id"], "code": "E_ASPECT", "detail": f"{hi}:{lo}"})
    return sorted(out, key=lambda v: v["code"])


def main() -> int:
    rules = json.load(open(f"{CFG}/ta03_import_rules.json", encoding="utf-8"))
    records = json.load(open(f"{CFG}/ta03_import_records.json", encoding="utf-8"))["records"]
    violations = []
    for r in records:
        violations.extend(lint(r, rules))
    violations.sort(key=lambda v: (v["id"], v["code"]))
    counts = {}
    for v in violations:
        counts[v["code"]] = counts.get(v["code"], 0) + 1
    digest = hashlib.sha256(
        "\n".join(f"{v['id']}:{v['code']}:{v['detail']}" for v in violations).encode("utf-8")
    ).hexdigest()[:16]
    report = {"project": "stardust-inn", "ruleSet": rules["ruleSet"], "recordCount": len(records),
              "violationCount": len(violations), "flaggedRecords": len({v["id"] for v in violations}),
              "counts": counts, "digest": digest, "violations": violations}
    os.makedirs(OUT, exist_ok=True)
    with open(f"{OUT}/ta03_rules.json", "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
        f.write("\n")
    print(f"recordCount={len(records)} violations={len(violations)} "
          f"flaggedRecords={report['flaggedRecords']}")
    for code in sorted(counts):
        print(f"  {code}={counts[code]}")
    print(f"digest={digest}")
    print("wrote out/ta03_rules.json")
    return 0


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

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

recordCount=9 violations=8 flaggedRecords=7
  E_ASPECT=1
  E_FORMAT=1
  E_FORMAT_EXT=1
  E_NAME=1
  E_PREFIX=1
  E_SIZE_MAX=1
  E_SIZE_MIN=1
  E_SIZE_POT=1
digest=ac696ab9d5cd0233
wrote out/ta03_rules.json

八条规则各命中一次,共 8 条违例,落在 7 条记录上。逐条对上:

记录违例触发的是哪条判据
furn_chair-base_v01E_SIZE_POT 300x300300 不是 2 的幂
ui_panel-wood_v01E_SIZE_MAX 4096长边超过 2048
icon_dot-bullet_v01E_SIZE_MIN 4短边小于 8,该并进图集
furn_rug-round_v01E_ASPECT 1024:128、E_PREFIX furn!=tile长宽比 8 超过 4;地毯是 tile 却用了 furn_ 前缀
icon_Icon-star_v01E_NAME icon_Icon-star_v01subject 里出现大写 Icon
tile_grass-dark_v01E_FORMAT jpg not in png地块类型只收 png
vfx_steam-puff_v01E_FORMAT_EXT ktx2!=png声明 ktx2,文件却是 .png

furn_lamp-base_v01 与 char_innkeeper-idle_v01 一条都不报——正例同样重要:一个对什么都报警的规则引擎等于没报,所以引擎必须证明它在干净输入上是安静的。

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

TS 侧独立实现一遍。它不 import Python 的任何东西,只读同一份 config/ta03_import_rules.json:

// file: core/src/ta03/rules.ts
// tier: P
// verify: node --test 'core/test/ta03_*.test.ts'
// 3.2 的 P 层规则引擎:与 sim/ta03_rules.py 共用同一份 config/ta03_import_rules.json。
// 尺寸用整数、2 的幂用位运算、长宽比用 ×100 整数比较、格式用枚举、命名用正则,
// 没有一处浮点,故两侧违例集合逐位一致。只允许可擦除 TS 语法。

export type ImportRules = {
  maxDimension: number; minDimension: number; potRequired: boolean; aspectMax100: number;
  kindPrefix: Record<string, string>; allowedFormats: Record<string, string[]>; namePattern: string;
};
export type ImportRecord = { id: string; kind: string; file: string; format: string;
  source: { width: number; height: number } };
export type Violation = { id: string; code: string; detail: string };

const cmp = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0);

export function isPot(n: number): boolean {
  return n > 0 && (n & (n - 1)) === 0;
}

/** 对齐 Python 的 str.rpartition("."):按最后一个点切 stem / ext。 */
export function splitExt(path: string): { stem: string; ext: string } {
  const base = path.slice(path.lastIndexOf("/") + 1);
  const dot = base.lastIndexOf(".");
  return dot < 0 ? { stem: "", ext: base } : { stem: base.slice(0, dot), ext: base.slice(dot + 1) };
}

export function lintImport(rec: ImportRecord, rules: ImportRules): Violation[] {
  const out: Violation[] = [];
  const { stem, ext } = splitExt(rec.file);
  if (rec.format !== ext) out.push({ id: rec.id, code: "E_FORMAT_EXT", detail: `${rec.format}!=${ext}` });
  const allowed = rules.allowedFormats[rec.kind] ?? [];
  if (!allowed.includes(rec.format)) {
    out.push({ id: rec.id, code: "E_FORMAT", detail: `${rec.format} not in ${allowed.join(",")}` });
  }
  if (!new RegExp(rules.namePattern).test(stem)) out.push({ id: rec.id, code: "E_NAME", detail: stem });
  const want = rules.kindPrefix[rec.kind] ?? "";
  const got = stem.split("_")[0];
  if (got !== want) out.push({ id: rec.id, code: "E_PREFIX", detail: `${got}!=${want}` });
  const w = rec.source.width;
  const h = rec.source.height;
  const lo = Math.min(w, h);
  const hi = Math.max(w, h);
  if (hi > rules.maxDimension) out.push({ id: rec.id, code: "E_SIZE_MAX", detail: String(hi) });
  if (lo < rules.minDimension) out.push({ id: rec.id, code: "E_SIZE_MIN", detail: String(lo) });
  if (rules.potRequired && !(isPot(w) && isPot(h))) {
    out.push({ id: rec.id, code: "E_SIZE_POT", detail: `${w}x${h}` });
  }
  if (hi * 100 > lo * rules.aspectMax100) {
    out.push({ id: rec.id, code: "E_ASPECT", detail: `${hi}:${lo}` });
  }
  return out.sort((x, y) => cmp(x.code, y.code));
}

export function lintAll(records: ImportRecord[], rules: ImportRules): Violation[] {
  const all: Violation[] = [];
  for (const r of records) all.push(...lintImport(r, rules));
  return all.sort((x, y) => cmp(x.id, y.id) || cmp(x.code, y.code));
}

测试读 Python 落盘的 JSON,断言违例集合逐条相同;四条测试里有三条是负例——每条规则都必须有一条能触发它的用例:

// file: core/test/ta03_rules.test.ts
// tier: P
// verify: node --test 'core/test/ta03_*.test.ts'
// 跨语言对照:读 sim/ta03_rules.py 落盘的 out/ta03_rules.json,断言 TS 侧规则引擎的违例集合
// 与之一致(整数/字符串类,逐位一致)。负例:尺寸、格式、命名三类规则各有一条能触发的用例。
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { createHash } from "node:crypto";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { lintAll, lintImport, isPot, splitExt } from "../src/ta03/rules.ts";

const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
const rules = JSON.parse(readFileSync(resolve(ROOT, "config/ta03_import_rules.json"), "utf8"));
const records = JSON.parse(readFileSync(resolve(ROOT, "config/ta03_import_records.json"), "utf8")).records;
const py = JSON.parse(readFileSync(resolve(ROOT, "out/ta03_rules.json"), "utf8"));
const key = (v: { id: string; code: string; detail: string }): string => `${v.id}:${v.code}:${v.detail}`;
const digest = (ls: string[]): string => createHash("sha256").update(ls.join("\n")).digest("hex").slice(0, 16);
const got = (): Set<string> => new Set(lintAll(records, rules).map(key));

test("跨语言:TS 违例集合 == Python 违例集合(逐位一致)", () => {
  const mine = lintAll(records, rules).map(key);
  assert.deepEqual(mine, (py.violations as { id: string; code: string; detail: string }[]).map(key));
  assert.equal(digest(mine), py.digest);
  console.log(`rules digest = ${py.digest}`);
});

test("负例:尺寸规则(超上限 / 非 2 的幂 / 过小 / 比例越界)", () => {
  const cases: [string, string, string][] = [
    ["ui_panel-wood_v01", "E_SIZE_MAX", "4096"],
    ["furn_chair-base_v01", "E_SIZE_POT", "300x300"],
    ["icon_dot-bullet_v01", "E_SIZE_MIN", "4"],
    ["furn_rug-round_v01", "E_ASPECT", "1024:128"],
  ];
  for (const [id, code, detail] of cases) {
    assert.ok(got().has(`${id}:${code}:${detail}`), `${id} 应报 ${code}:${detail}`);
  }
  assert.ok(isPot(512) && !isPot(300) && isPot(4));
});

test("负例:格式规则(枚举外格式 / 声明与扩展名矛盾)", () => {
  assert.ok(got().has("tile_grass-dark_v01:E_FORMAT:jpg not in png"), "枚举外格式未报");
  assert.ok(got().has("vfx_steam-puff_v01:E_FORMAT_EXT:ktx2!=png"), "格式与扩展名矛盾未报");
  assert.deepEqual(splitExt("art/ui/icon_Icon-star_v01.png"), { stem: "icon_Icon-star_v01", ext: "png" });
});

test("负例:命名规则(大小写 / 前缀与 kind 不符);正例不报", () => {
  assert.ok(got().has("icon_Icon-star_v01:E_NAME:icon_Icon-star_v01"), "大写 subject 未报");
  assert.ok(got().has("furn_rug-round_v01:E_PREFIX:furn!=tile"), "前缀与 kind 不符未报");
  const clean = lintImport({ id: "x", kind: "icon", file: "a/b/icon_star_v01.png", format: "png",
    source: { width: 64, height: 64 } }, rules);
  assert.deepEqual(clean, []);
});

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

rules digest = ac696ab9d5cd0233
✔ 跨语言:TS 违例集合 == Python 违例集合(逐位一致)
✔ 负例:尺寸规则(超上限 / 非 2 的幂 / 过小 / 比例越界)
✔ 负例:格式规则(枚举外格式 / 声明与扩展名矛盾)
✔ 负例:命名规则(大小写 / 前缀与 kind 不符);正例不报
ℹ pass 12
ℹ fail 0

两侧 digest 都是 ac696ab9d5cd0233。这一节同样没有浮点,所以是逐位一致。

3.2.5 整数判据:为什么长宽比要写成 ×100

判据里最容易写飘的是长宽比。直觉写法是 max/min > 4.0——这是浮点比较,Python 与 JS 在边界上(比如正好 4.0)可能给出不同结论,而且 1024/128 这种整数除在两侧的舍入规则也不同。

正确写法是把比例放大成整数:hi * 100 > lo * aspectMax100,也就是 1024*100 > 128*400 → 102400 > 51200 → 真,判违例。全程整数乘法,没有除法、没有浮点,两侧逐位一致。

同一套纪律在另外三条判据上也能看到:2 的幂用位运算(n & (n-1) == 0)而不是 log2(n) % 1 == 0;尺寸比较用整数;格式与命名用字符串枚举与正则。这条纪律直接来自本套的三条跨语言纪律之一——预算与阈值一律整数,除法一律整数地板除。它不是为了好看,而是为了让「两侧一致」这件事可以被断言,而不是被祈祷。

3.2.6 每条规则都要有一条能触发的负例

规则引擎最常见的失效方式不是写错判据,而是写了判据却从没触发过:E_SIZE_MIN 存在,但样本里最小的图是 64×64,于是这条规则永远是死代码,某天阈值被改错也没人知道。

所以本节的 9 条样本是故意构造的:八条规则各有一条正好踩中的记录,另外两条记录(furn_lamp-base_v01、char_innkeeper-idle_v01)是干净的,用来证明引擎不会误报。测试里的三条负例测试则更进一步——它们不依赖样本,而是直接断言某个具体的 id:code:detail 必须出现在结果集里,把「规则可触发」变成一条会失败的断言。

这也是本节与 3.1 的分工:3.1 说清「哪些字段是契约」,本节说清「契约的值该长什么样」。至于报出来的这 8 条(连同 3.1 的 8 条)该怎么处置——哪些必须拦、哪些只警告、哪些只是提示,谁能自动修、谁必须人来改——是下一节的分级表要解决的事。

阅读导航:上一节:3.1 导入设置与 .meta 语义 · 下一节:3.3 导入失败分级与修复 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gameart」更多文章

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