John looked at R41's proof shots and named three faults. All three are fixed, at zero draw cost, and every lane corrected somebody's numbers on the way — including its own. E-LOOK (§42.1+§42.2a) — THE PREMISE WAS WRONG. "The pub props have no texture" (my brief, quoting E's own R41 note) is false: all 37 carry COLOR_0 and three.js renders it. The colour was BLEACHED — jukebox at saturation 0.02, 15 assets under 0.05; what the source library called colour was baked AO. So the fix was a palette imposed on colour already present, in place: 46 assets, draw delta +0 and tri delta +0 on every one, proved the honest way — the concatenated POSITION/NORMAL/TEXCOORD/index bytes are sha1-identical to R41, so footprints and origins provably did not move. (A first-pass bbox check ignored node rotations and "found" 3 phantom violations; thrown out for the byte comparison.) The banquette was 1990s office teal and went burgundy; the beer umbrella was white and went brewery green. THE DECK, diagnosed before being touched: 7 greys, chassis 0.05 luminance on a 0.033 bench = 1.21:1, and the one high-contrast feature was INSIDE-OUT (white ring around a dark disc; a turntable is a dark record on a light platter). Now Technics silver at 6.74:1, black vinyl, red start/stop. Node contract re-read from the shipped GLB: 4/4 + 1/1 PASS. Filed to R43, not fixed: deck_1200_rigged has NO PLINTH (bottom 43mm of 119mm lost to R41 join damage). E-CAST (§42.3) — 19 of 24 ship. Rejected: comical_luchador_01 (Mixamo Ch43, purple mask reading EL CHUPACABRA, identified from the file's own material name), a 3-heads-tall cartoon child, a robed wuxia elder, a WW2 infantryman — and dj_phrtt_01, AN UNCLOTHED BODY whose only texture is a bare-skin atlas, which was in the DEFAULT crowd and live on the depot. It is the R41 deny-list's failure class through a different door: an innocent name from another pipeline. Only rendering all 24 and LOOKING found it. Ruling 2 (prefer CC0) was REFUSED ON MEASUREMENT: Quaternius is 25 nodes / 23 joints and ZERO of 64 canonical tracks bind — it would have loaded silently and never animated. Replacements came from Mixamo's Ch## pool, filtered to the contract-clean 65-bone family (candidates ran 65..99 bones; named characters carry facial/hair bones). THE LICENCE GAP WAS UNREAD, NOT MISSING: every source FBX carries ApplicationVendor "Mixamo, Inc." internally. Adobe royalty-free, no attribution owed. B's amber rows are now real. B (§42.5) — MY ASSIGNED FIX WAS USELESS AND B PROVED IT. document.fonts.ready shipped, then measured: the atlas canvases are already byte-identical across boots, the game ships NO webfonts (document.fonts.size === 0), and ready fires 50ms BEFORE the first fillText. The real cause of unreproducible bookmarks is the wall-clock CROWD — 983 changed px of 921,600, ALL in the pedestrian band; 0 px with the crowd out. New DBG.freeze() takes stable bookmarks 2/5 -> 5/5. Also found: ?pop=0 boots 140 citizens (0 is falsy against the default). THE BENCH: D's filing correct on both counts. Front-vs-road 90.0deg -> 0.0deg, min=med=max over 60/60 instances; the streetlight arm now reaches 1.05m over the road where it reached 0.00. Both used a base yaw that pointed +Z down the street while their own comments claimed otherwise — the side term was already right, which is why it survived 41 rounds of reading. Also: on any box with CDN access 9 of 60 benches are the depot GLB, 90deg out from the house law — normalised at the geometry, once, at load. benchStops(plan) exported as one truth. C (§42.2b) — 122 vs 123 WAS THE CAMERA ASPECT, and nobody was wrong. Draw counts are post-frustum-cull and the sweep camera inherits the window aspect: 1.25->116, 1.60->122 (C's harness), 1.778->123 (F's), 2.37->124. Pinned MEASURE_ASPECT=16/9 with a 1.25 control. E's "keep the bench dark" advice was measured and declined with reason (deck-vs-bench was already 4.73:1, over threshold). "Reads as a black box" is a claim about pixels, so C measured pixels: near-black in the booth crop 57.5% -> 26.7%, distinct colours 228 -> 389. Bench rebuilt 3->7 boxes with an open record bay; headphones on the mixer; faceDoor aims booths at the player's entry — alignment within 45deg went 49% -> 92%. Two bugs found by looking: a fitting could be planted INSIDE A WALL (0.77m of a 0.86m half-depth in plaster), and every booth part sat 25mm sunk into its own worktop. D (§42.4) — five swaps IN PLACE at the same index (pickRig indexes these arrays and pedIndex is in the identity signature and the impostor atlas; removal would renumber the town). Identity signature pre- vs post-swap: 0 differing lines of 150 — who each citizen is did not move, only which body they wear. dj_phrtt_01 verified gone AT THE WIRE, not from source: a fresh default boot fetches 32 ped GLBs, none retired. THE CONTROL ARM FOUND A 41-ROUND-OLD BUG: the ped being RETIRED, comical_boy_01, binds only 3,132 of 3,460 tracks — six thumb bones lack the mixamorig prefix. It had been silently dropping six tracks per clip since it shipped. Testing the thing being thrown away found it. Imported B's benchStops mid-round: sitters now 0.0deg to the road, signatures byte-identical across the rewire. Corrected F's filing: the browse smoke was flaky-by-construction (a 16-25s race against a live sim), not deterministically red. F (§42.6) — qa.sh --strict 24 passed / 0 failed / 0 warn / 0 skipped. New Gate 11: period law (0/5 banned; CONTROL: R41's roster trips all five) and roster licence (24/24, 0 unverified; CONTROL: an amber row turns 20 red). r41_shots.py promoted warn -> strict. R41'S BYTE-HASH BAN IS REVERSED AND ITS STATED REASON WITHDRAWN AS WRONG. A gate may pin a hash under five in-run assertions (classic+flags boot, freeze().stillLive all-false, a second freeze removing 0 draws, settle until DBG.info() stops changing, hash with the dbg panel hidden): 5/5 bookmarks now pin to one sha256 over 3 fresh boots. Two findings got it there — the chunk streamer (a fixed settle is a bet on load) and the fps readout, which with everything else frozen was the last thing moving at 53-62 px. ?pop=0 fixed (Number.isFinite) — a no-op for 35 rounds. Three red flags_check gates fixed. Street: noon 282 · NIGHT 291/300 — the nine-draw margin INTACT, every R41 row reproducing to the digit. Interior at pinned 16:9: 123 @ dept/auto, margin 227. selfcheck 157,647/157,647, 0x5f76e76 unmoved. No tag — John's playtest rules the epoch. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
349 lines
16 KiB
Python
349 lines
16 KiB
Python
#!/usr/bin/env python3
|
|
"""Repaint a GLB's baked vertex colour (`COLOR_0`) into a named palette — zero draws, zero triangles.
|
|
|
|
WHY THIS EXISTS (R42 §42.1 + §42.2, "the pub props render as untextured white blobs")
|
|
--------------------------------------------------------------------------------------
|
|
R41's own note said "37 of 39 have no texture at all". Measuring the shipped files says something
|
|
different and more useful: **all 37 DO carry COLOR_0 and it does render** — it is just the wrong
|
|
colour. Mean vertex colour across the set is a bleached near-grey (15 of 37 sit below 0.05
|
|
saturation; `beer_umbrella` is sRGB 238,238,238 and `jukebox` is 175,174,173), because the source
|
|
library is photogrammetry/generative output whose "colour" is really baked ambient occlusion. The
|
|
fault is therefore not a missing map — it is a missing palette.
|
|
|
|
The cheapest correct fix is consequently NOT a texture. Lane C took the interior from 14,140 draws
|
|
to 117 by collapsing multi-material primitives into one `COLOR_0` primitive each (`merge_prims.py`);
|
|
adding per-asset baseColour maps would re-import the exact cost that bought. So this tool rewrites
|
|
the COLOR_0 buffer *in place*, preserving the accessor's component type and element count — same
|
|
byte length, same primitives, same triangles, **same draw count** — and only touches the material
|
|
block to set metallic/roughness/emissive (material count does not affect draw count; three.js issues
|
|
one draw per glTF *primitive*, and `batch.js` never descends into a GLB instance because the loaded
|
|
root is a Group, not a Mesh).
|
|
|
|
Four routes, chosen per asset by what the source colour actually is:
|
|
|
|
ramp luminance -> a 2..5 stop palette gradient. For the bleached-grey majority: the greyscale
|
|
IS the baked shading, so mapping it through a dark->light palette keeps every AO gradient
|
|
and adds hue. Normalisation is per-asset between the source's own 1st/99th luminance
|
|
percentiles, so a flat-grey asset still spans the whole ramp.
|
|
lut exact source-colour -> target, ordered rules, first match wins, optional position
|
|
predicates (`cyl`, `y_min`, `y_max`). For the DJ gear, whose colours are a discrete
|
|
5..12 entry palette where each entry IS a part (chassis / platter / slipmat / arm).
|
|
tint keep the source hue relationship, force saturation and remap value. For the minority that
|
|
already carry meaningful colour (`pokie`'s lit artwork, `planter`'s foliage).
|
|
huesplit two ramps selected by source hue — foliage vs pot, for the planted props.
|
|
factor no COLOR_0 at all (a real texture): tint via `baseColorFactor`, which multiplies the map.
|
|
|
|
python3 pipeline/palette.py --spec pipeline/r42_palette.json --src DIR --out DIR
|
|
python3 pipeline/palette.py --spec pipeline/r42_palette.json --src DIR --out DIR --only jukebox
|
|
python3 pipeline/palette.py --report DIR/*.glb # mean sRGB / saturation / prims / draws
|
|
|
|
RE-RUN SAFETY: `ramp` and `tint` normalise against the source file's own luminance spread, so a
|
|
second pass over an already-painted file is NOT a no-op. Every output is stamped
|
|
`asset.extras.procity_palette`, and a stamped input is skipped unless `--force`. The pristine
|
|
pre-R42 GLBs are kept at `pipeline/.props_orig/r42/` (git-ignored, like `_normalized/` itself),
|
|
which is the directory to point `--src` at when re-running.
|
|
"""
|
|
import json, os, struct, sys, colorsys
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
from merge_prims import read_glb, write_glb, COMPONENT, NCOMP # noqa: E402
|
|
|
|
|
|
# ── colour helpers ───────────────────────────────────────────────────────────────────────────────
|
|
def srgb_to_lin(c):
|
|
return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4
|
|
|
|
|
|
def lin_to_srgb(c):
|
|
c = max(0.0, min(1.0, c))
|
|
return 12.92 * c if c <= 0.0031308 else 1.055 * (c ** (1 / 2.4)) - 0.055
|
|
|
|
|
|
def hex_lin(h):
|
|
h = h.lstrip("#")
|
|
return tuple(srgb_to_lin(int(h[i:i + 2], 16) / 255.0) for i in (0, 2, 4))
|
|
|
|
|
|
def lum(rgb):
|
|
return 0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2]
|
|
|
|
|
|
def lerp3(a, b, t):
|
|
return tuple(a[k] + (b[k] - a[k]) * t for k in range(3))
|
|
|
|
|
|
# ── glb attribute io ─────────────────────────────────────────────────────────────────────────────
|
|
def acc_layout(gltf, ai):
|
|
a = gltf["accessors"][ai]
|
|
fmt, sz = COMPONENT[a["componentType"]]
|
|
n = NCOMP[a["type"]]
|
|
bv = gltf["bufferViews"][a["bufferView"]]
|
|
base = bv.get("byteOffset", 0) + a.get("byteOffset", 0)
|
|
stride = bv.get("byteStride") or sz * n
|
|
return a, fmt, sz, n, base, stride
|
|
|
|
|
|
def read_vec(gltf, binc, ai):
|
|
a, fmt, sz, n, base, stride = acc_layout(gltf, ai)
|
|
return [struct.unpack_from("<" + fmt * n, binc, base + i * stride) for i in range(a["count"])]
|
|
|
|
|
|
def write_colors(gltf, buf, ai, rows):
|
|
"""Write linear RGB back into an existing COLOR_0 accessor. Byte length never changes."""
|
|
a, fmt, sz, n, base, stride = acc_layout(gltf, ai)
|
|
ct = a["componentType"]
|
|
div = 65535.0 if ct == 5123 else (255.0 if ct == 5121 else 1.0)
|
|
for i, rgb in enumerate(rows):
|
|
old = struct.unpack_from("<" + fmt * n, buf, base + i * stride)
|
|
if div == 1.0:
|
|
vals = [float(c) for c in rgb]
|
|
else:
|
|
vals = [int(round(max(0.0, min(1.0, c)) * div)) for c in rgb]
|
|
if n == 4:
|
|
vals.append(old[3])
|
|
struct.pack_into("<" + fmt * n, buf, base + i * stride, *vals)
|
|
|
|
|
|
# ── the four routes ──────────────────────────────────────────────────────────────────────────────
|
|
def route_ramp(cols, spec):
|
|
stops = [(float(t), hex_lin(h)) for t, h in spec["stops"]]
|
|
stops.sort()
|
|
L = sorted(lum(c) for c in cols)
|
|
lo = L[max(0, int(len(L) * 0.01))]
|
|
hi = L[min(len(L) - 1, int(len(L) * 0.99))]
|
|
span = max(hi - lo, 1e-4)
|
|
gamma = float(spec.get("gamma", 1.0))
|
|
out = []
|
|
for c in cols:
|
|
t = min(1.0, max(0.0, (lum(c) - lo) / span)) ** gamma
|
|
for k in range(len(stops) - 1):
|
|
t0, c0 = stops[k]
|
|
t1, c1 = stops[k + 1]
|
|
if t <= t1 or k == len(stops) - 2:
|
|
f = 0.0 if t1 <= t0 else min(1.0, max(0.0, (t - t0) / (t1 - t0)))
|
|
out.append(lerp3(c0, c1, f))
|
|
break
|
|
else:
|
|
out.append(stops[-1][1])
|
|
return out
|
|
|
|
|
|
def route_tint(cols, spec):
|
|
hue = spec.get("hue") # degrees, or None to keep source hue
|
|
hue_rot = float(spec.get("hue_rot", 0.0)) / 360.0
|
|
sat_mul = float(spec.get("sat_mul", 1.0))
|
|
sat_set = spec.get("sat")
|
|
v0, v1 = spec.get("val", [0.0, 1.0])
|
|
S = sorted(max(lin_to_srgb(x) for x in c) for c in cols)
|
|
vlo = S[max(0, int(len(S) * 0.01))]
|
|
vhi = S[min(len(S) - 1, int(len(S) * 0.99))]
|
|
vspan = max(vhi - vlo, 1e-4)
|
|
out = []
|
|
for c in cols:
|
|
r, g, b = (lin_to_srgb(x) for x in c)
|
|
h, s, v = colorsys.rgb_to_hsv(r, g, b)
|
|
if hue is not None:
|
|
h = (float(hue) / 360.0)
|
|
h = (h + hue_rot) % 1.0
|
|
s = float(sat_set) if sat_set is not None else min(1.0, s * sat_mul)
|
|
v = v0 + (min(1.0, max(0.0, (v - vlo) / vspan)) * (v1 - v0))
|
|
r, g, b = colorsys.hsv_to_rgb(h, s, v)
|
|
out.append((srgb_to_lin(r), srgb_to_lin(g), srgb_to_lin(b)))
|
|
return out
|
|
|
|
|
|
def route_huesplit(cols, spec):
|
|
"""Two ramps chosen by SOURCE hue — 'a planter is a pot and a plant, and they are not one ramp'."""
|
|
lo_h, hi_h = spec.get("hue_band", [60, 190])
|
|
idx_a, idx_b = [], []
|
|
for i, c in enumerate(cols):
|
|
r, g, b = (lin_to_srgb(x) for x in c)
|
|
h, s, v = colorsys.rgb_to_hsv(r, g, b)
|
|
deg = h * 360.0
|
|
(idx_a if (lo_h <= deg <= hi_h and s >= spec.get("hue_min_sat", 0.05)) else idx_b).append(i)
|
|
out = [None] * len(cols)
|
|
for idxs, key in ((idx_a, "in_stops"), (idx_b, "out_stops")):
|
|
if not idxs:
|
|
continue
|
|
sub = route_ramp([cols[i] for i in idxs], {"stops": spec[key], "gamma": spec.get("gamma", 1.0)})
|
|
for j, i in enumerate(idxs):
|
|
out[i] = sub[j]
|
|
return [o if o is not None else c for o, c in zip(out, cols)]
|
|
|
|
|
|
def route_lut(cols, pos, spec):
|
|
rules = spec["rules"]
|
|
default = hex_lin(spec["default"]) if spec.get("default") else None
|
|
out = []
|
|
for c, p in zip(cols, pos):
|
|
hit = None
|
|
for rl in rules:
|
|
if "src" in rl and max(abs(c[k] - rl["src"][k]) for k in range(3)) > 2e-3:
|
|
continue
|
|
if "y_min" in rl and p[1] < rl["y_min"]:
|
|
continue
|
|
if "y_max" in rl and p[1] > rl["y_max"]:
|
|
continue
|
|
cyl = rl.get("cyl")
|
|
if cyl:
|
|
cx, cz = cyl["center"]
|
|
r = ((p[0] - cx) ** 2 + (p[2] - cz) ** 2) ** 0.5
|
|
if r > cyl.get("r_max", 1e9) or r < cyl.get("r_min", -1.0):
|
|
continue
|
|
hit = rl
|
|
break
|
|
out.append(hex_lin(hit["to"]) if hit else (default if default else c))
|
|
return out
|
|
|
|
|
|
ROUTES = {"ramp", "lut", "tint", "huesplit", "factor", "none"}
|
|
|
|
|
|
# ── per-asset application ────────────────────────────────────────────────────────────────────────
|
|
def mat_for(gltf, prim, want):
|
|
"""Return a material index carrying `want` (metallic/roughness/emissive), cloning if shared."""
|
|
mi = prim.get("material")
|
|
if mi is None:
|
|
return None
|
|
mats = gltf["materials"]
|
|
cur = mats[mi]
|
|
users = sum(1 for m in gltf.get("meshes", []) for p in m["primitives"] if p.get("material") == mi)
|
|
tgt = json.loads(json.dumps(cur))
|
|
pbr = tgt.setdefault("pbrMetallicRoughness", {})
|
|
if "metallic" in want:
|
|
pbr["metallicFactor"] = float(want["metallic"])
|
|
if "roughness" in want:
|
|
pbr["roughnessFactor"] = float(want["roughness"])
|
|
if "factor" in want:
|
|
pbr["baseColorFactor"] = list(hex_lin(want["factor"])) + [1.0]
|
|
if "emissive" in want:
|
|
tgt["emissiveFactor"] = list(hex_lin(want["emissive"]))
|
|
if tgt == cur:
|
|
return mi
|
|
if users == 1:
|
|
mats[mi] = tgt
|
|
return mi
|
|
mats.append(tgt)
|
|
return len(mats) - 1
|
|
|
|
|
|
STAMP = "procity_palette"
|
|
|
|
|
|
def stamped(gltf):
|
|
"""`ramp` and `tint` normalise against the SOURCE's own luminance spread, so running them twice
|
|
is not a no-op — the second pass re-stretches an already-stretched file. The asset therefore
|
|
carries a stamp and a second run refuses unless --force. (`lut`/`factor` are idempotent; the
|
|
stamp is applied uniformly so one rule covers the whole spec.)"""
|
|
return (gltf.get("asset", {}).get("extras") or {}).get(STAMP)
|
|
|
|
|
|
def apply_asset(path, out_path, spec, force=False):
|
|
gltf, binc = read_glb(path)
|
|
was = stamped(gltf)
|
|
if was and not force:
|
|
return -1
|
|
buf = bytearray(binc)
|
|
touched = 0
|
|
seen_acc = set()
|
|
for mesh in gltf.get("meshes", []):
|
|
mname = mesh.get("name", "")
|
|
for prim in mesh["primitives"]:
|
|
sub = dict(spec)
|
|
sub.pop("meshes", None)
|
|
over = (spec.get("meshes") or {}).get(mname)
|
|
if over:
|
|
sub.update(over)
|
|
route = sub.get("route", "none")
|
|
if route not in ROUTES:
|
|
raise ValueError(f"{path}: unknown route {route!r}")
|
|
want = {k: sub[k] for k in ("metallic", "roughness", "emissive", "factor") if k in sub}
|
|
if want:
|
|
prim["material"] = mat_for(gltf, prim, want)
|
|
ai = prim["attributes"].get("COLOR_0")
|
|
if route in ("none", "factor") or ai is None:
|
|
continue
|
|
if ai in seen_acc:
|
|
print(f" ! {os.path.basename(path)}: COLOR_0 accessor {ai} shared across primitives; "
|
|
f"already written, skipping second pass")
|
|
continue
|
|
seen_acc.add(ai)
|
|
cols = [tuple(v[:3]) for v in read_vec(gltf, binc, ai)]
|
|
if route == "ramp":
|
|
new = route_ramp(cols, sub)
|
|
elif route == "tint":
|
|
new = route_tint(cols, sub)
|
|
elif route == "huesplit":
|
|
new = route_huesplit(cols, sub)
|
|
else:
|
|
new = route_lut(cols, read_vec(gltf, binc, prim["attributes"]["POSITION"]), sub)
|
|
write_colors(gltf, buf, ai, new)
|
|
touched += 1
|
|
gltf.setdefault("asset", {}).setdefault("extras", {})[STAMP] = spec.get("stamp", "r42")
|
|
os.makedirs(os.path.dirname(os.path.abspath(out_path)) or ".", exist_ok=True)
|
|
write_glb(out_path, gltf, bytes(buf))
|
|
return touched
|
|
|
|
|
|
# ── reporting ────────────────────────────────────────────────────────────────────────────────────
|
|
def report(paths):
|
|
print(f"{'file':44} {'prims':>5} {'mats':>4} {'tris':>6} {'meanRGB':16} {'lum':>5} {'sat':>5}")
|
|
for p in paths:
|
|
gltf, binc = read_glb(p)
|
|
prims = tris = 0
|
|
tot = [0.0, 0.0, 0.0]
|
|
n = 0
|
|
for mesh in gltf.get("meshes", []):
|
|
for prim in mesh["primitives"]:
|
|
prims += 1
|
|
if "indices" in prim:
|
|
tris += gltf["accessors"][prim["indices"]]["count"] // 3
|
|
ai = prim["attributes"].get("COLOR_0")
|
|
if ai is None:
|
|
continue
|
|
a, fmt, sz, nc, base, stride = acc_layout(gltf, ai)
|
|
div = 65535.0 if a["componentType"] == 5123 else (255.0 if a["componentType"] == 5121 else 1.0)
|
|
for v in read_vec(gltf, binc, ai):
|
|
for k in range(3):
|
|
tot[k] += v[k] / div
|
|
n += 1
|
|
if n:
|
|
mean = [t / n for t in tot]
|
|
rgb = [round(lin_to_srgb(c) * 255) for c in mean]
|
|
sat = (max(mean) - min(mean)) / max(mean) if max(mean) > 0 else 0.0
|
|
s = f"{str(rgb):16} {lum(mean):5.3f} {sat:5.3f}"
|
|
else:
|
|
s = f"{'(textured)':16} {'-':>5} {'-':>5}"
|
|
print(f"{os.path.basename(p):44} {prims:5d} {len(gltf.get('materials', [])):4d} {tris:6d} {s}")
|
|
|
|
|
|
def main():
|
|
av = sys.argv[1:]
|
|
if "--report" in av:
|
|
report([a for a in av[av.index("--report") + 1:] if a.endswith(".glb")])
|
|
return 0
|
|
spec_path = av[av.index("--spec") + 1]
|
|
src = av[av.index("--src") + 1]
|
|
out = av[av.index("--out") + 1]
|
|
only = av[av.index("--only") + 1].split(",") if "--only" in av else None
|
|
force = "--force" in av
|
|
doc = json.load(open(spec_path))
|
|
n = skipped = 0
|
|
for aid, spec in doc["assets"].items():
|
|
if only and aid not in only:
|
|
continue
|
|
f = spec["file"]
|
|
touched = apply_asset(os.path.join(src, f), os.path.join(out, f), spec, force)
|
|
if touched < 0:
|
|
print(f" {aid:18} SKIP — already stamped {STAMP}; pass --force to repaint a repaint")
|
|
skipped += 1
|
|
continue
|
|
print(f" {aid:18} {spec.get('route', 'none'):9} prims_recoloured={touched} -> {f}")
|
|
n += 1
|
|
print(f"palette: {n} assets repainted, {skipped} skipped")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|