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>
This commit is contained in:
@@ -0,0 +1,676 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user