Files
animation/clothing/gates/g8_catalog_lint.py
T
jeremy 3ba86b2ea8 feat: clothing lane, character sources, and DCC bridges
Bulk import of the working lanes that were living untracked on the PC.

Content:
- characters/  Lena/male body lanes, bakes, texture work, run logs
- clothing/    garment pipeline, configs, gates, contract docs
- garments/    MD-authored garment sources (.zprj/.zpac)
- UAL-Lib/     Universal Animation Library 2 source (.blend/.fbx/.glb)
- tools/       blender_bridge, iclone_bridge, md_bridge, tailor, glm_agent
- docs/, plans/, dev/, .agents/plans/

Repo hygiene:
- .gitattributes: LFS now covers .blend, .zprj, .zpac, .obj, .npy and the
  Reallusion .iAvatar/.ccAvatar/.ccRestore containers. Without this the
  ~3.8 GB in this commit would land as raw blobs. .png/.jpg are left out
  on purpose — ~250 are already tracked raw and converting them would
  rewrite every one without shrinking history.
- .gitignore: exclude /accurig/ (~1 GB AccuRig program files, redistributable
  from Reallusion, nothing authored here) and /dev/null/ (git-lfs hook copies
  dropped by a `>/dev/null` redirect on Windows).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 15:55:43 -07:00

677 lines
26 KiB
Python

#!/usr/bin/env python3
"""G8 — catalog <-> asset lint (design doc §3 "G8", §1.3 item 2).
`ariki-game/src/Character/OutfitCatalog.cs` resolves every item's asset path as
`{BaseDirFor(set)}/{Gender}_{Set}_{SlotFileName}.gltf`, and a missing file is
**warn-only** at runtime (`OutfitCatalog.cs:71-78`) — the character just stays
naked. Worse, `BaseDirFor`'s `_ =>` arm silently resolves an unknown/typo'd set
to the Fantasy pack folder. This gate makes both failures loud and pre-game.
Two modes:
# lint the whole live catalog, no config needed
python clothing/gates/g8_catalog_lint.py --all [--out report.json]
# pipeline mode: whole-catalog lint + "is THIS garment registered?"
python clothing/gates/g8_catalog_lint.py --config work/<name>/resolved.json \\
--work work/<name> [--require-registered]
Exit codes: 0 pass · 2 fail (lint violations) · 3 error (could not evaluate).
`--config` mode writes `<work>/qc/g8.json`; `--all` prints the report to stdout
(and to `--out` if given). Stdlib only.
Checks
------
forward every catalog entry's resolved .gltf exists on disk (case-sensitively —
Windows dev boxes are case-insensitive, Godot exports need not be)
fallback an entry whose `set:` is NOT a named `BaseDirFor` arm *and* whose file
is missing from the Fantasy default folder — the silent-typo case
buffers each existing .gltf's `buffers[].uri` sidecar (`.bin`) exists
duplicate two `Add()` calls sharing an id (the C# dict assignment silently wins)
reverse every `{Gender}_{Set}_{Slot}.gltf` under assets/quaternius/outfits/ has
a catalog entry that resolves to it
config (--config only) `export.out_dir` agrees with `BaseDirFor(export.set)`,
and each part slot has a catalog entry
Reverse-check scope decision (verified 2026-07-31, do not "fix" without re-checking):
the Fantasy pack ships exactly 20 `.gltf` files under
`Modular Character Outfits - Fantasy[Standard]/.../Modular Parts` and the catalog
registers exactly those 20 (Peasant m/f x 4 slots = 8, Ranger m/f x 6 = 12). There
are therefore **no legitimately-unregistered pack parts today**, so the reverse
check treats every outfit folder — Fantasy included — as a hard failure. If a
future pack drop adds parts nobody intends to register, downgrade that folder
with `--info-dir "Fantasy[Standard]"` (repeatable, substring match) rather than
silently narrowing the scan.
`expect` block: this gate reads no thresholds — per the contract it runs after
`register` unconditionally.
"""
from __future__ import annotations
import argparse
import json
import os
import re
import sys
GATE_ID = "g8"
STAGE = "register"
EXIT_PASS, EXIT_FAIL, EXIT_ERROR = 0, 2, 3
RES_PREFIX = "res://"
OUTFITS_REL = "assets/quaternius/outfits"
# Mirrors OutfitCatalog.SlotToFileName's plain arms (OutfitCatalog.cs:101-113).
SLOT_DEFAULT_FILENAME = {
"Body": "Body",
"Arms": "Arms",
"Legs": "Legs",
"Feet": "Feet",
"HeadGear": "Head_Hood",
"Accessories": "Acc_Pauldron",
}
# ── report plumbing ──────────────────────────────────────────────────────────
class Report:
def __init__(self):
self.metrics = {}
self.failures = []
self.warnings = []
self.notes = []
self.artifacts = []
def fail(self, part, detail, **extra):
e = {"part": part, "detail": detail}
e.update(extra)
self.failures.append(e)
def warn(self, part, detail, **extra):
e = {"part": part, "detail": detail}
e.update(extra)
self.warnings.append(e)
def to_dict(self):
return {
"gate": GATE_ID,
"pass": not self.failures,
"checked_at_stage": STAGE,
"metrics": self.metrics,
"failures": self.failures,
"warnings": self.warnings,
"notes": self.notes,
"artifacts": self.artifacts,
}
def emit(report, work, out, all_mode):
path = out
if not path and work and not all_mode:
path = os.path.join(work, "qc", "%s.json" % GATE_ID)
if path:
try:
os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
with open(path, "w", encoding="utf-8") as fh:
json.dump(report, fh, indent=2)
fh.write("\n")
report = dict(report, report_path=path)
except OSError as exc: # pragma: no cover
sys.stderr.write("[g8] WARNING could not write %s: %s\n" % (path, exc))
print(json.dumps(report, indent=2))
return report
def die(detail, work, out, all_mode):
rep = Report()
rep.fail("catalog", detail, code="gate_error")
doc = rep.to_dict()
doc["error"] = detail
sys.stderr.write("[g8] ERROR %s\n" % detail)
emit(doc, work, out, all_mode)
return EXIT_ERROR
# ── OutfitCatalog.cs parsing ─────────────────────────────────────────────────
STRING_LIT = re.compile(r'"((?:[^"\\]|\\.)*)"')
def _unescape(s):
return (s.replace('\\"', '"').replace("\\\\", "\\")
.replace("\\n", "\n").replace("\\t", "\t"))
def _strip_comments(src):
"""Blank out // and /* */ so commented-out Add() lines are not parsed.
String literals are preserved (a `//` inside one would otherwise eat the rest
of the line). Newlines are kept so reported line numbers stay correct.
"""
out = []
i, n = 0, len(src)
while i < n:
c = src[i]
if c == '"':
j = i + 1
while j < n:
if src[j] == "\\":
j += 2
continue
if src[j] == '"':
j += 1
break
j += 1
out.append(src[i:j])
i = j
elif src.startswith("//", i):
j = src.find("\n", i)
j = n if j < 0 else j
out.append(" " * (j - i))
i = j
elif src.startswith("/*", i):
j = src.find("*/", i + 2)
j = n if j < 0 else j + 2
out.append("".join(ch if ch == "\n" else " " for ch in src[i:j]))
i = j
else:
out.append(c)
i += 1
return "".join(out)
def parse_base_dirs(src):
"""-> ({setId: baseDir}, defaultBaseDir) from the BaseDirFor switch expression."""
m = re.search(r"BaseDirFor\s*\(\s*string\s+\w+\s*\)\s*=>\s*\w+\s+switch\s*\{",
src, re.S)
if not m:
raise ValueError("could not locate the BaseDirFor switch expression")
body_start = m.end()
depth = 1
i = body_start
while i < len(src) and depth:
if src[i] == "{":
depth += 1
elif src[i] == "}":
depth -= 1
i += 1
body = src[body_start:i - 1]
arms, default = {}, None
for raw_arm in body.split(","):
arm = raw_arm.strip()
if not arm or "=>" not in arm:
continue
lhs, rhs = arm.split("=>", 1)
literals = [_unescape(x) for x in STRING_LIT.findall(rhs)]
if not literals:
continue
value = "".join(literals) # C# folds `"a" + "b"` across lines
lhs = lhs.strip()
if lhs == "_":
default = value
else:
key = STRING_LIT.findall(lhs)
if key:
arms[_unescape(key[0])] = value
if default is None:
raise ValueError("BaseDirFor has no `_ =>` default arm")
return arms, default
def split_args(arglist):
"""Split a C# argument list on top-level commas, respecting strings/nesting."""
args, buf, depth, i, n = [], [], 0, 0, len(arglist)
while i < n:
c = arglist[i]
if c == '"':
j = i + 1
while j < n:
if arglist[j] == "\\":
j += 2
continue
if arglist[j] == '"':
j += 1
break
j += 1
buf.append(arglist[i:j])
i = j
continue
if c in "([{":
depth += 1
elif c in ")]}":
depth -= 1
if c == "," and depth == 0:
args.append("".join(buf).strip())
buf = []
else:
buf.append(c)
i += 1
tail = "".join(buf).strip()
if tail:
args.append(tail)
return args
# `Add(` but not `AddDefault(` — tolerant of the file's multi-space column alignment
# and of entries spread over several lines.
ADD_CALL = re.compile(r"(?<![A-Za-z0-9_])Add\s*\(")
def parse_add_calls(src):
"""-> list of dicts: id, displayName, slot, slotSuffix, category, set, gender, line."""
entries = []
for m in ADD_CALL.finditer(src):
start = m.end()
depth, i, n = 1, start, len(src)
while i < n and depth:
c = src[i]
if c == '"':
j = i + 1
while j < n:
if src[j] == "\\":
j += 2
continue
if src[j] == '"':
j += 1
break
j += 1
i = j
continue
if c == "(":
depth += 1
elif c == ")":
depth -= 1
i += 1
args = split_args(src[start:i - 1])
if len(args) < 4:
continue # not the Add(id, name, slot, suffix, ...) overload
if not STRING_LIT.match(args[0].strip()):
continue # the `static void Add(string id, ...)` declaration itself
entry = {"line": src.count("\n", 0, m.start()) + 1,
"set": None, "gender": -1, "category": None, "slotSuffix": None}
positional = []
for arg in args:
named = re.match(r"^([A-Za-z_]\w*)\s*:\s*(.+)$", arg, re.S)
if named and not arg.startswith('"'):
entry["_" + named.group(1)] = named.group(2).strip()
else:
positional.append(arg)
def lit(idx):
if idx >= len(positional):
return None
v = positional[idx].strip()
if v == "null":
return None
s = STRING_LIT.match(v)
return _unescape(s.group(1)) if s else v
entry["id"] = lit(0)
entry["displayName"] = lit(1)
slot = positional[2].strip() if len(positional) > 2 else ""
entry["slot"] = slot.split(".")[-1] if slot.startswith("OutfitSlot.") else slot
entry["slotSuffix"] = lit(3)
entry["category"] = lit(6)
if "_set" in entry:
s = STRING_LIT.match(entry.pop("_set"))
entry["set"] = _unescape(s.group(1)) if s else None
if "_gender" in entry:
g = entry.pop("_gender").strip()
try:
entry["gender"] = int(g)
except ValueError:
entry["gender"] = -1
for stale in [k for k in entry if k.startswith("_")]:
entry.pop(stale)
if not entry["id"] or not entry["slot"]:
continue
entries.append(entry)
return entries
def slot_to_file_name(slot, set_id, gender):
"""Replicates OutfitCatalog.SlotToFileName incl. the two Ranger exceptions."""
if slot == "Feet":
return "Feet_Boots" if (set_id == "Ranger" and gender == 0) else "Feet"
if slot == "Accessories":
return "Acc_Pauldrons" if (set_id == "Ranger" and gender == 1) else "Acc_Pauldron"
return SLOT_DEFAULT_FILENAME.get(slot)
def resolve_asset_path(base_dirs, default_dir, set_id, slot, gender, slot_suffix):
"""Replicates OutfitCatalog.ResolveAssetPath. -> (res_path, used_default_arm)."""
if not set_id or set_id == "none":
return None, False
g = "Female" if gender == 1 else "Male"
slot_name = slot_suffix or slot_to_file_name(slot, set_id, gender)
if not slot_name:
return None, False
used_default = set_id not in base_dirs
base = base_dirs.get(set_id, default_dir)
return "%s/%s_%s_%s.gltf" % (base, g, set_id, slot_name), used_default
# ── filesystem ───────────────────────────────────────────────────────────────
_LISTING = {}
def _listing(d):
key = os.path.normcase(os.path.abspath(d))
if key not in _LISTING:
try:
_LISTING[key] = set(os.listdir(d))
except OSError:
_LISTING[key] = None
return _LISTING[key]
def exists_exact(path):
"""os.path.exists + exact-case name match (Windows FS is case-insensitive)."""
if not os.path.exists(path):
return False, False
d, name = os.path.split(path)
names = _listing(d)
if names is None:
return True, True
return True, name in names
def res_to_disk(res_path, game_root):
if res_path.startswith(RES_PREFIX):
return os.path.join(game_root, res_path[len(RES_PREFIX):].replace("/", os.sep))
return res_path.replace("/", os.sep)
def disk_to_res(disk_path, game_root):
rel = os.path.relpath(disk_path, game_root).replace("\\", "/")
return RES_PREFIX + rel
def gltf_buffer_uris(path):
"""-> list of sidecar file names referenced by the .gltf (data: URIs skipped)."""
try:
with open(path, "r", encoding="utf-8") as fh:
doc = json.load(fh)
except (OSError, ValueError):
return None
uris = []
for buf in doc.get("buffers", []) or []:
uri = buf.get("uri")
if uri and not uri.startswith("data:"):
uris.append(uri)
return uris
# ── lint ─────────────────────────────────────────────────────────────────────
ASSET_NAME = re.compile(r"^(Male|Female)_([^_]+)_(.+)\.gltf$")
def lint_catalog(rep, entries, base_dirs, default_dir, game_root, info_dirs):
expected_disk = {} # normcase disk path -> entry id
seen_ids = {}
for e in entries:
eid = e["id"]
if eid in seen_ids:
rep.fail(eid, "duplicate catalog id (also at line %d) — the later Add() "
"silently overwrites the earlier one" % seen_ids[eid],
code="duplicate_id", line=e["line"])
seen_ids[eid] = e["line"]
if not e["set"]:
rep.warn(eid, "no set: argument — AssetPath stays null (defaults-only entry)",
code="no_set", line=e["line"])
continue
res_path, used_default = resolve_asset_path(
base_dirs, default_dir, e["set"], e["slot"], e["gender"], e["slotSuffix"])
if not res_path:
rep.fail(eid, "slot %r has no file-name mapping and no explicit slotSuffix — "
"ResolveAssetPath returns null" % e["slot"],
code="unmappable_slot", line=e["line"])
continue
disk = res_to_disk(res_path, game_root)
expected_disk[os.path.normcase(os.path.abspath(disk))] = eid
present, exact = exists_exact(disk)
if not present:
if used_default:
rep.fail(eid, "set %r is not a named BaseDirFor arm, so it fell through "
"`_ =>` to the Fantasy pack folder and %s does not exist "
"there — typo'd set name or a missing BaseDirFor case"
% (e["set"], res_path),
code="fallback_missing", line=e["line"], path=res_path,
set=e["set"])
else:
rep.fail(eid, "missing asset %s (runtime is warn-only: the character just "
"stays naked)" % res_path,
code="missing_asset", line=e["line"], path=res_path, set=e["set"])
continue
if not exact:
rep.fail(eid, "asset %s exists only under different letter case on disk — "
"breaks on a case-sensitive filesystem" % res_path,
code="case_mismatch", line=e["line"], path=res_path)
continue
uris = gltf_buffer_uris(disk)
if uris is None:
rep.warn(eid, "could not parse %s as glTF JSON" % res_path,
code="unparsable_gltf", path=res_path)
else:
for uri in uris:
side = os.path.join(os.path.dirname(disk),
uri.replace("/", os.sep))
ok, exact_side = exists_exact(side)
if not ok or not exact_side:
rep.fail(eid, "%s references buffer %r which is missing on disk — "
"the mesh will not load" % (res_path, uri),
code="missing_buffer", path=res_path, buffer=uri)
# ── reverse: assets on disk with no catalog entry ───────────────────────
outfits_root = os.path.join(game_root, OUTFITS_REL.replace("/", os.sep))
scanned = 0
if not os.path.isdir(outfits_root):
rep.warn("catalog", "outfits root not found: %s (reverse check skipped)"
% outfits_root, code="no_outfits_root")
else:
for dirpath, _dirnames, filenames in os.walk(outfits_root):
for fn in filenames:
if not fn.endswith(".gltf") or not ASSET_NAME.match(fn):
continue
scanned += 1
disk = os.path.join(dirpath, fn)
if os.path.normcase(os.path.abspath(disk)) in expected_disk:
continue
res_path = disk_to_res(disk, game_root)
downgrade = any(sub in res_path for sub in info_dirs)
detail = ("asset %s has no OutfitCatalog entry — unreachable in game"
% res_path)
if downgrade:
rep.warn("catalog", detail + " (folder downgraded via --info-dir)",
code="unregistered_asset", path=res_path)
else:
rep.fail("catalog", detail, code="unregistered_asset", path=res_path)
rep.metrics["assets_scanned"] = scanned
rep.metrics["catalog_entries"] = len(entries)
rep.metrics["entries_with_set"] = sum(1 for e in entries if e["set"])
rep.metrics["sets"] = sorted({e["set"] for e in entries if e["set"]})
rep.metrics["fallback_sets"] = sorted({e["set"] for e in entries
if e["set"] and e["set"] not in base_dirs})
return expected_disk
def lint_config(rep, cfg, entries, base_dirs, default_dir, game_root, require_registered):
"""--config mode: does this garment's export line up with the catalog?"""
export = cfg.get("export") or {}
set_id = export.get("set")
gender_name = export.get("gender")
out_dir = export.get("out_dir")
if not set_id or not gender_name:
rep.fail("config", "export.set / export.gender missing — cannot check registration",
code="bad_config")
return
gender = 1 if str(gender_name).lower().startswith("f") else 0
rep.metrics["config_set"] = set_id
rep.metrics["config_gender"] = gender_name
# 1. does BaseDirFor know this set, and does it agree with export.out_dir?
if set_id not in base_dirs:
rep.fail("config", "export.set %r has no BaseDirFor arm — the game will look for "
"it in the Fantasy pack folder (%s). Add a case to "
"OutfitCatalog.BaseDirFor." % (set_id, default_dir),
code="missing_basedir", set=set_id)
elif out_dir:
want = os.path.normcase(os.path.abspath(res_to_disk(base_dirs[set_id], game_root)))
got = os.path.normcase(os.path.abspath(out_dir))
if want != got:
rep.fail("config", "export.out_dir (%s) != BaseDirFor(%r) (%s) — the pipeline "
"writes where the game will not look"
% (out_dir, set_id, base_dirs[set_id]),
code="outdir_mismatch", set=set_id)
# 2. one catalog entry per exported slot
by_slot = {}
for e in entries:
if e["set"] == set_id and e["gender"] == gender:
by_slot.setdefault(e["slotSuffix"] or slot_to_file_name(
e["slot"], e["set"], e["gender"]), []).append(e)
slots = sorted({(p or {}).get("slot") for p in (cfg.get("parts") or {}).values()
if isinstance(p, dict) and p.get("slot")})
rep.metrics["config_slots"] = slots
registered = 0
for slot in slots:
fname = "%s_%s_%s.gltf" % (gender_name, set_id, slot)
# exported file present?
if out_dir:
present, exact = exists_exact(os.path.join(out_dir, fname))
if not present:
rep.warn("config", "%s not found in export.out_dir — run the export stage"
% fname, code="not_exported", path=fname)
elif not exact:
rep.fail("config", "%s exists under different letter case in export.out_dir"
% fname, code="case_mismatch", path=fname)
hits = by_slot.get(slot) or []
if hits:
registered += 1
continue
detail = ("no OutfitCatalog entry resolves to %s (need an Add(..., OutfitSlot.%s, "
"%r, ..., set: %r, gender: %d) line — see work/<name>/register.cs.txt)"
% (fname, slot, slot, set_id, gender))
if require_registered:
rep.fail("config", detail, code="not_registered", path=fname)
else:
rep.warn("config", detail + " [warn: register runs before paste-in; pass "
"--require-registered to fail]",
code="not_registered", path=fname)
rep.metrics["config_slots_registered"] = registered
# ── main ─────────────────────────────────────────────────────────────────────
def default_game_root(cfg):
"""ariki-game repo root: env, then the sibling of this repo, then the config."""
env = os.environ.get("ARIKI_GAME_ROOT")
if env and os.path.isdir(env):
return os.path.abspath(env)
here = os.path.dirname(os.path.abspath(__file__)) # clothing/gates
sibling = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(here))),
"ariki-game") # <parent>/ariki-game
if os.path.isdir(sibling):
return os.path.abspath(sibling)
out_dir = ((cfg or {}).get("export") or {}).get("out_dir") or ""
marker = out_dir.replace("\\", "/").find("/" + OUTFITS_REL)
if marker > 0:
return os.path.abspath(out_dir.replace("\\", "/")[:marker])
return None
def main(argv=None):
ap = argparse.ArgumentParser(description="G8 catalog <-> asset lint")
ap.add_argument("--config", help="resolved config JSON (pipeline mode)")
ap.add_argument("--work", help="work dir (default: the config's directory)")
ap.add_argument("--all", action="store_true",
help="standalone: lint the whole catalog, no config")
ap.add_argument("--game-root", help="ariki-game repo root (default: env ARIKI_GAME_ROOT "
"or the sibling checkout)")
ap.add_argument("--catalog", help="OutfitCatalog.cs override")
ap.add_argument("--require-registered", action="store_true",
help="--config mode: unregistered export slots fail instead of warn")
ap.add_argument("--info-dir", action="append", default=[], metavar="SUBSTR",
help="downgrade unregistered-asset findings whose res:// path contains "
"SUBSTR to warnings (repeatable)")
ap.add_argument("--out", help="report path override")
args = ap.parse_args(argv)
if not args.all and not args.config:
ap.error("pass --config <resolved.json> or --all")
work = args.work
cfg = None
if args.config:
try:
with open(args.config, "r", encoding="utf-8") as fh:
cfg = json.load(fh)
except (OSError, ValueError) as exc:
return die("cannot read config %s: %s" % (args.config, exc), work, args.out, args.all)
if not work:
work = os.path.dirname(os.path.abspath(args.config))
game_root = args.game_root or default_game_root(cfg)
if not game_root or not os.path.isdir(game_root):
return die("ariki-game repo root not found (%r) — pass --game-root or set "
"ARIKI_GAME_ROOT" % game_root, work, args.out, args.all)
catalog_path = args.catalog or os.path.join(
game_root, "src", "Character", "OutfitCatalog.cs")
try:
with open(catalog_path, "r", encoding="utf-8-sig") as fh:
src = _strip_comments(fh.read())
except OSError as exc:
return die("cannot read %s: %s" % (catalog_path, exc), work, args.out, args.all)
try:
base_dirs, default_dir = parse_base_dirs(src)
except ValueError as exc:
return die("OutfitCatalog.cs parse failed: %s" % exc, work, args.out, args.all)
entries = parse_add_calls(src)
if not entries:
return die("parsed 0 Add() entries from %s — the file's shape changed, fix the "
"parser before trusting this gate" % catalog_path, work, args.out, args.all)
rep = Report()
rep.notes.append("catalog: %s" % catalog_path.replace("\\", "/"))
rep.notes.append("game root: %s" % game_root.replace("\\", "/"))
rep.metrics["base_dir_arms"] = sorted(base_dirs)
lint_catalog(rep, entries, base_dirs, default_dir, game_root, args.info_dir)
if cfg is not None:
lint_config(rep, cfg, entries, base_dirs, default_dir, game_root,
args.require_registered)
doc = rep.to_dict()
emit(doc, work, args.out, args.all)
if rep.failures:
sys.stderr.write("[g8] FAIL %d finding(s), %d warning(s)\n"
% (len(rep.failures), len(rep.warnings)))
return EXIT_FAIL
sys.stderr.write("[g8] PASS %d catalog entries, %d assets scanned, %d warning(s)\n"
% (len(entries), rep.metrics.get("assets_scanned", 0), len(rep.warnings)))
return EXIT_PASS
if __name__ == "__main__":
sys.exit(main())