#!/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//resolved.json \\ --work work/ [--require-registered] Exit codes: 0 pass · 2 fail (lint violations) · 3 error (could not evaluate). `--config` mode writes `/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"(? 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//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") # /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 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())