Files
KnoxBuildWorksViewpoint/tools/Match-KnoxViewpoint.py
T
2026-10-27 17:12:43 -04:00

583 lines
31 KiB
Python

"""Build a private local Viewpoint compatibility pack and an evidence-based catalogue audit.
Python 3.10+ and Pillow. Never changes Workshop files, saves, recipes or sprite IDs.
"""
from __future__ import annotations
import argparse
import collections
import copy
import hashlib
import io
import json
import re
import shutil
import struct
from pathlib import Path
from PIL import Image
MOD_ID = 'KnoxBuildworksViewpointCrates' # Retain the ID of the previously installed patch.
HERE = Path(__file__).resolve().parent
REPO_ROOT = HERE.parent
VERSION_FOLDER = '42.20'
SPRITE = re.compile(r'^[A-Za-z][A-Za-z0-9_-]*_\d+$')
SEMANTICS = ('CustomName', 'GroupName', 'MoveType', 'container', 'WindowShape')
SHAPES = re.compile(r'\b(box|polygon|cylinder)\s*\{')
def read_json(path):
return json.loads(Path(path).read_text(encoding='utf-8-sig'))
def write_json(path, value):
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(value, indent=2, ensure_ascii=False)+'\n', encoding='utf-8')
def family(sprite):
return sprite.removeprefix('kbw_').rsplit('_', 1)[0]
def merge(parent, child):
if isinstance(parent, dict) and isinstance(child, dict):
out = copy.deepcopy(parent)
for key, value in child.items():
out[key] = merge(out.get(key), value)
return out
return copy.deepcopy(child)
def tile_properties(path):
"""Read the installed game's tdef v1 format, including custom tiles and patches."""
out = {}
with path.open('rb') as stream:
def integer():
return struct.unpack('<i', stream.read(4))[0]
def string():
return stream.readline().decode('latin1').strip()
if stream.read(4) != b'tdef' or integer() != 1:
raise ValueError(f'Unsupported tile format: {path}')
count = integer()
if not 0 <= count <= 10000:
raise ValueError('Invalid tileset count')
for _ in range(count):
name, _image = string(), string()
width, height, number = integer(), integer(), integer()
for index in range(integer()):
properties = {}
for _ in range(integer()):
key, value = string(), string()
properties[key] = value
out[f'{name}_{index}'] = properties
if stream.read():
raise ValueError(f'Unparsed tile data: {path}')
return out
def geometry(path):
text = path.read_text(encoding='utf-8-sig')
# Canonical comments identify tiles; do not mistake a properties-only block for geometry.
blocks = {}
for m in re.finditer(r'/\*\s*(\w+_\d+)\s*\*/\s*tile\s*\{(.*?)(?=/\*|\Z)', text, re.S):
if SHAPES.search(m[2]):
body = re.sub(r'properties\s*\{[^{}]*\}', '', m[2])
body = re.sub(r'xy\s*=\s*[^,]+,', '', body)
# Remove closing tileset/root braces from the final tile in a sheet.
body = body[:body.rfind('}')].strip()
blocks[m[1]] = re.sub(r'\s+', '', body).rstrip('}')
return blocks
def load_bindings(workshop):
mod = workshop/'3810302175/mods/PZVoxelStudioViewpoint'
root = mod/'common/media/voxel-studio'
registration = (mod/'common/media/lua/client/PZVoxelStudioViewpoint.lua').read_text(encoding='utf-8-sig')
manifests = re.findall(r'"media/voxel-studio/([^"\r\n]+\.properties)"', registration)
if not manifests:
raise ValueError('No registered model manifests found')
bindings = {}
for name in manifests:
entries = {}
for line in (root/name).read_text(encoding='utf-8-sig').splitlines():
if line.strip() and not line.lstrip().startswith('#') and '=' in line:
key, value = line.split('=', 1)
entries[key.strip()] = value.strip()
models = {k[6:]:v for k,v in entries.items() if k.startswith('model.')}
for key, value in entries.items():
if not key.startswith('bind.'):
continue
fields = value.split(',')
if len(fields) != 6 or fields[0] not in models:
raise ValueError(f'Invalid binding {key} in {name}')
[float(v) for v in fields[1:]]
bindings[key[5:]] = dict(value=value, model=fields[0], path=models[fields[0]], manifest=name)
return root, bindings
def load_catalogue(workshop):
templates, raw, sources, patches = {}, {}, {}, {}
for folder in ('KnoxBuildworks', 'KnoxBuildworks_Vanilla_Expanded'):
root = workshop/'3772269882/mods'/folder/'42'
manifest = read_json(root/'media/KnoxBuildworks/manifest.json')
for relative in manifest['definitions']:
data = read_json(root/relative)
templates.update(data.get('templates', {}))
for key, value in data.get('patches', {}).items():
patches[key] = merge(patches.get(key,{}), value)
for item in data.get('buildables', []):
raw[item['id']] = item
sources[item['id']] = f'{folder}/{relative}'
def resolve(value, seen=()):
parent = value.get('extends')
if not parent:
return copy.deepcopy(value)
if parent in seen or parent not in templates:
raise ValueError(f'Unknown or cyclic template: {parent}')
return merge(resolve(templates[parent], (*seen, parent)), value)
out = []
for key,value in raw.items():
item = resolve(value)
item = merge(item, patches.get(key,{}))
for stage in item.get('stages',[]):
if stage.get('sprites'):
continue
directional = stage.get('directionalSprites',item.get('directionalSprites'))
if directional:
stage['sprites'] = copy.deepcopy(directional)
continue
pattern = stage.get('spritePattern',item.get('spritePattern'))
if pattern:
prefix = pattern.get('prefix',item.get('spritePrefix',''))
suffix = pattern.get('suffix',item.get('spriteSuffix',''))
expanded={d:prefix+str(token)+suffix for d,token in pattern.get('directions',{}).items()}
if 'start' in pattern:
directions=stage.get('rotations',item.get('rotations',['W','N','E','S']))
expanded.update({d:prefix+str(pattern['start']+i*pattern.get('step',1))+suffix for i,d in enumerate(directions)})
stage['sprites']=expanded
item['_source'] = sources[key]
out.append(item)
return out
def sprite_strings(value):
if isinstance(value, str) and SPRITE.fullmatch(value):
yield value
elif isinstance(value, dict):
for key, child in value.items():
if key in ('id','name','displayName','translationKey','tooltipKey','requirements','construction','tags'):
continue
yield from sprite_strings(child)
elif isinstance(value, list):
for child in value:
yield from sprite_strings(child)
def braced_body(text, start):
depth=1
for index in range(start,len(text)):
if text[index]=='{': depth+=1
elif text[index]=='}':
depth-=1
if depth==0: return text[start:index]
raise ValueError('Unbalanced entity script block')
def load_entity_sprites(game, workshop):
entities={}
roots=[game/'scripts', workshop/'3772269882/mods/KnoxBuildworks/common/media/scripts',
workshop/'3772269882/mods/KnoxBuildworks/42/media/scripts']
for root in roots:
if not root.exists(): continue
for path in sorted(root.rglob('*.txt')):
text=path.read_text(encoding='utf-8-sig',errors='replace')
if not re.search(r'\bentity\s+',text): continue
text=re.sub(r'/\*.*?\*/|//[^\n]*','',text,flags=re.S)
module=re.search(r'\bmodule\s+([\w.]+)\s*\{',text)
if not module: continue
for match in re.finditer(r'\bentity\s+([\w.]+)\s*\{',text):
body=braced_body(text,match.end())
entities[f'{module[1]}.{match[1]}']=set()
sprite_config=re.search(r'\bcomponent\s+SpriteConfig\s*\{',body)
if sprite_config:
config=braced_body(body,sprite_config.end())
sprites=set()
for row in re.findall(r'\brow\s*=\s*([^,\n]+)',config):
sprites.update(s for s in row.split() if SPRITE.fullmatch(s))
entities[f'{module[1]}.{match[1]}']=sprites
return entities
def entity_references(value):
if isinstance(value,dict):
ref=value.get('entityCompat')
if isinstance(ref,dict) and ref.get('entity'):
yield ref.get('module','Base')+'.'+ref['entity']
for child in value.values(): yield from entity_references(child)
elif isinstance(value,list):
for child in value: yield from entity_references(child)
class Textures:
"""Read pack metadata without loading the game; keep at most two atlas pages in memory."""
def __init__(self, paths):
self.index = {}
self.pages = collections.OrderedDict()
for path in paths:
with path.open('rb') as stream:
def integer(): return struct.unpack('<I', stream.read(4))[0]
def string(): return stream.read(integer()).decode('latin1')
if stream.read(4) != b'PZPK' or integer() != 1:
raise ValueError(f'Unsupported texture pack: {path}')
for _ in range(integer()):
page, count, alpha = string(), integer(), integer()
subs = []
for _ in range(count):
name = string()
subs.append((name, struct.unpack('<8i', stream.read(32))))
length = integer()
position = stream.tell()
stream.seek(length, 1)
for name, rect in subs:
self.index[name] = dict(file=path, position=position, length=length, rect=rect)
if stream.read():
raise ValueError(f'Unparsed texture data: {path}')
def sprite(self, name):
row = self.index[name]
key = row['file'], row['position']
if key not in self.pages:
with row['file'].open('rb') as stream:
stream.seek(row['position'])
self.pages[key] = Image.open(io.BytesIO(stream.read(row['length']))).convert('RGBA')
while len(self.pages) > 2:
self.pages.popitem(last=False)
self.pages.move_to_end(key)
x,y,w,h,*_ = row['rect']
return self.pages[key].crop((x,y,x+w,y+h))
def fingerprints(self, names):
groups = collections.defaultdict(list)
out = {}
for name in set(names) & self.index.keys():
row = self.index[name]
groups[(row['file'], row['position'])].append(name)
for group in groups.values():
for name in group:
image = self.sprite(name)
box = image.getbbox()
if not box:
continue
image = image.crop(box)
# Ignore invisible RGB bytes, while retaining alpha and object anchor.
clean = Image.new('RGBA', image.size)
clean.paste(image, mask=image.getchannel('A').point(lambda a: 255 if a else 0))
x,y,w,h,ox,oy,fx,fy = self.index[name]['rect']
out[name] = (image.size, ox+box[0], oy+box[1], fx, fy, hashlib.sha256(clean.tobytes()).hexdigest())
return out
def semantic_conflict(a, b):
return any(a.get(k) and b.get(k) and a[k] != b[k] for k in SEMANTICS)
def alias_matches(sprite, bindings, properties):
if not sprite.startswith('kbw_'):
return None
source = sprite[4:]
if source not in bindings:
return None
a,b = properties.get(sprite,{}), properties.get(source,{})
# Same index is not sufficient. In particular Knox white crates reuse vanilla sign indices.
if semantic_conflict(a,b):
return None
if not a.get('CustomName') or a.get('CustomName') != b.get('CustomName'):
return None
if a.get('container') != b.get('container'):
return None
return source
def native_route(sprite, properties, shapes):
p = properties.get(sprite,{})
if 'solidfloor' in p:
return 'native-floor', 'Game solidfloor flag; Viewpoint floor renderer'
if sprite.startswith(('roofs_', 'roofs_accents_')):
return 'native-roof-candidate', 'Roof tile family; uses Viewpoint roof system rather than a furniture binding; visual check needed'
if sprite in shapes:
return 'native-geometry', 'Installed tileGeometry.txt contains box/polygon/cylinder geometry'
if any(k in p for k in ('FloorOverlay','attachedFloor')):
return 'native-floor-overlay', 'Game floor overlay metadata; Viewpoint floor/decal renderer'
edge_flags = ('cutN','cutW','collideN','collideW','HoppableN','HoppableW','TallHoppableN','TallHoppableW',
'transparentN','transparentW','WallN','WallW','WallNW','WallNTrans','WallWTrans','WallNWTrans',
'DoorWallN','DoorWallW','WindowN','WindowW','doorN','doorW','windowN','windowW')
if any(k in p for k in edge_flags):
return 'native-edge', 'Game wall/door/window/fence edge flags; keep Viewpoint edge rendering'
return None
def candidates(sprite, bindings, properties, shapes):
p = properties.get(sprite,{})
out = []
for source, binding in bindings.items():
q = properties.get(source,{})
same_family = family(sprite) == family(source)
named = p.get('CustomName') and p.get('CustomName') == q.get('CustomName')
if not (same_family or named) or semantic_conflict(p,q):
continue
reasons, score = [],0
if same_family: score += 20; reasons.append('same tile family')
if named: score += 25; reasons.append('same CustomName')
if p.get('GroupName') and p.get('GroupName') == q.get('GroupName'):
score += 20; reasons.append('same GroupName')
if p.get('Facing') and p.get('Facing') == q.get('Facing'):
score += 10; reasons.append('same facing')
if p.get('MaterialType') and p.get('MaterialType') == q.get('MaterialType'):
score += 5; reasons.append('same material')
if sprite in shapes and shapes.get(sprite) == shapes.get(source):
score += 20; reasons.append('same native geometry')
if score >= 25:
out.append(dict(source=source, score=score, reasons=reasons))
# This deliberately ranks suggestions rather than applying shape/color guesses.
return sorted(out, key=lambda r:(-r['score'],r['source']))[:3]
def tint_texture(image, color):
"""Set the dominant painted surface to the requested RGB; preserve relative luminance and alpha."""
if not re.fullmatch(r'#[0-9a-fA-F]{6}',color):
raise ValueError(f'Invalid texture tint: {color}')
target=tuple(int(color[i:i+2],16) for i in (1,3,5))
image=image.convert('RGBA')
colors=collections.Counter()
raw=image.tobytes()
for pixel,count in collections.Counter(raw[i:i+4] for i in range(0,len(raw),4)).items():
r,g,b,a=pixel
if a>=128 and g>r and g>b and max(r,g,b)>25:
colors[(r,g,b)]+=count
if not colors:
raise ValueError('The requested green source atlas has no painted green surface')
base=colors.most_common(1)[0][0]
reference=Image.new('RGB',(1,1),base).convert('L').getpixel((0,0))
luminance=image.convert('L')
channels=[luminance.point([min(255,round(channel*value/reference)) for value in range(256)]) for channel in target]
return Image.merge('RGBA',(*channels,image.getchannel('A')))
def copy_asset(source_root, binding, media, white=False, tint=None):
source = (source_root/binding['path']).resolve()
source.relative_to(source_root.resolve())
tag = ('white-' if white else ('tint-'+tint[1:].lower()+'-' if tint else '')) + hashlib.sha256(str(source).encode()).hexdigest()[:16]
target = media/'knox-crates-assets'/tag/source.name
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(source,target)
for line in source.read_text(encoding='utf-8-sig').splitlines():
if not line.startswith('mtllib '):
continue
name = line.split(None,1)[1].strip()
material = (source.parent/name).resolve()
material.relative_to(source_root.resolve())
destination = target.parent/name
destination.parent.mkdir(parents=True,exist_ok=True)
shutil.copy2(material,destination)
for ml in material.read_text(encoding='utf-8-sig').splitlines():
if re.match(r'^map_\w+\s+',ml):
texture_name = ml.split(None,1)[1].strip()
texture = (material.parent/texture_name).resolve()
texture.relative_to(source_root.resolve())
texture_out = destination.parent/texture_name
texture_out.parent.mkdir(parents=True,exist_ok=True)
if tint:
tint_texture(Image.open(texture),tint).save(texture_out)
elif white:
# Reviewed white crate adaptation: reuse crate geometry and UVs, retain atlas alpha/detail.
image = Image.open(texture).convert('RGBA')
alpha = image.getchannel('A')
luminance = image.convert('L').point(lambda v: min(245, int(115 + v*0.65)))
Image.merge('RGBA',(luminance,luminance,luminance,alpha)).save(texture_out)
else:
shutil.copy2(texture,texture_out)
return target.relative_to(media).as_posix(), tag
def build_pack(output, source_root, bindings, assignments):
media=output/'common/media'
media.mkdir(parents=True,exist_ok=True)
lines=['id=knox-buildworks-viewpoint-crates','name=Knox Buildworks - Viewpoint Compatibility','version=2']
models={}
for target, assignment in sorted(assignments.items()):
binding=bindings[assignment['source']]
white=assignment.get('adaptation')=='white-crate'
tint=assignment.get('tint')
key=(binding['path'],white,tint)
if key not in models:
relative,tag=copy_asset(source_root,binding,media,white,tint)
model='kbw-'+tag
models[key]=model
lines.append(f'model.{model}={relative}')
_,*transform=binding['value'].split(',')
lines.append(f"bind.{target}={models[key]},{','.join(transform)}")
(media/'knox-viewpoint-crates.properties').write_text('\n'.join(lines)+'\n',encoding='utf-8')
info='\n'.join(['name=Knox Buildworks - Viewpoint Compatibility',f'id={MOD_ID}','author=Local compatibility patch',
'modversion=2.1.0','description=Green, gray-green (#626763) and white military crates. Gray crates use retextured green crate meshes.',
'require=KnoxBuildworks,Viewpoint,PZVoxelStudioViewpoint','loadAfter=KnoxBuildworks,PZVoxelStudioViewpoint,Viewpoint','versionMin=42.0',''])
(output/VERSION_FOLDER).mkdir(parents=True,exist_ok=True)
(output/VERSION_FOLDER/'mod.info').write_text(info,encoding='utf-8')
(output/'mod.info').write_text(info,encoding='utf-8')
lua='''-- Render bindings only. No recipe, object, inventory or save changes.
local registered = false
local warned = false
local function register()
if registered or not Viewpoint or not Viewpoint.ModelPacks then return end
local ok, result = pcall(Viewpoint.ModelPacks.register,
"KnoxBuildworksViewpointCrates", "media/knox-viewpoint-crates.properties")
if ok and result then
registered = true
print("[Knox Viewpoint Crates] Registered %COUNT% reviewed compatibility sprite bindings.")
elseif not warned then
warned = true
print("[Knox Viewpoint Crates] Model pack registration failed: " .. tostring(result))
end
end
Events.OnGameBoot.Add(register)
Events.OnGameStart.Add(register)
register()
'''.replace('%COUNT%',str(len(assignments)))
(media/'lua/client').mkdir(parents=True,exist_ok=True)
(media/'lua/client/ZZ_KnoxViewpointCrates.lua').write_text(lua,encoding='utf-8')
return len(models)
def main():
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('--workshop-root',type=Path,default=Path('D:/SteamLibrary/steamapps/workshop/content/108600'))
parser.add_argument('--game-directory',type=Path,default=Path('D:/SteamLibrary/steamapps/common/ProjectZomboid'))
parser.add_argument('--output-directory',type=Path,default=REPO_ROOT)
parser.add_argument('--reports-directory',type=Path,default=REPO_ROOT/'reports')
parser.add_argument('--install',action='store_true')
parser.add_argument('--local-mods-directory',type=Path,default=Path.home()/'Zomboid/mods')
args=parser.parse_args()
game=args.game_directory/'media'
source_root,bindings=load_bindings(args.workshop_root)
catalogue=load_catalogue(args.workshop_root)
entities=load_entity_sprites(game,args.workshop_root)
properties={}
for path in [game/'newtiledefinitions.tiles',game/'tiledefinitions_erosion.tiles',game/'tiledefinitions_noiseworks.patch.tiles',
args.workshop_root/'3772269882/mods/KnoxBuildworks/common/media/knox_buildworks_pack.tiles']:
for name,p in tile_properties(path).items(): properties.setdefault(name,{}).update(p)
shapes=geometry(game/'tileGeometry.txt')
shapes.update(geometry(args.workshop_root/'3772269882/mods/KnoxBuildworks/common/media/tileGeometry.txt'))
all_sprites=set()
object_sprites={}
unresolved_entities={}
for item in catalogue:
# Include geometry/states/finishes/variants/materials inherited from templates; exclude mere icons.
relevant={k:item[k] for k in ('stages','geometry','sprites','finishes','variants','materials') if k in item}
object_sprites[item['id']]=set(sprite_strings(relevant))
for entity in set(entity_references(item)):
if entity in entities:
object_sprites[item['id']].update(entities[entity])
else:
unresolved_entities.setdefault(item['id'],[]).append(entity)
all_sprites.update(object_sprites[item['id']])
textures=Textures([game/'texturepacks/Tiles2x.pack',game/'texturepacks/Tiles2x.floor.pack',
args.workshop_root/'3772269882/mods/KnoxBuildworks/common/media/texturepacks/knox_buildworks_pack.pack'])
print(f'Read {len(catalogue)} catalogue entries, {len(all_sprites)} sprites and {len(bindings)} authored bindings.',flush=True)
fingerprints=textures.fingerprints(all_sprites | bindings.keys())
exact=collections.defaultdict(list)
for name in bindings:
if name in fingerprints: exact[fingerprints[name]].append(name)
assignments={}
decisions={}
manual=read_json(HERE/'reviewed-matches.json')
for name in sorted(all_sprites):
p=properties.get(name,{})
if name in bindings:
decisions[name]=dict(status='already-bound',source=name,evidence='Exact authored sprite binding')
continue
# Explicit reviewed choices override generic matching (including user-requested recolors).
alias=alias_matches(name,bindings,properties) if name not in manual['matches'] else None
if alias:
assignments[name]=dict(source=alias,method='automatic-alias')
decisions[name]=dict(status='automatic-match',source=alias,evidence='Prefix alias with matching object/container semantics')
continue
identical=[s for s in exact.get(fingerprints.get(name),[]) if not semantic_conflict(p,properties.get(s,{})) and family(name)==family(s)]
identities={(bindings[s]['path'],bindings[s]['value'].split(',',1)[1]) for s in identical}
if identical and len(identities)==1 and name not in manual['matches']:
source=sorted(identical)[0]
assignments[name]=dict(source=source,method='automatic-identical-art')
decisions[name]=dict(status='automatic-match',source=source,evidence='Identical anchored RGBA artwork, same tile family and compatible metadata')
continue
if name in manual['matches']:
review=manual['matches'][name]
if review['source'] not in bindings:
raise ValueError(f"Reviewed source disappeared: {review['source']}")
# Abort when either target/source meaning changes after a Workshop update.
expected=review.get('expectedTargetCustomName')
if expected and p.get('CustomName')!=expected:
raise ValueError(f'Reviewed target changed: {name}')
expected_source=review.get('expectedSourceCustomName')
if expected_source and properties.get(review['source'],{}).get('CustomName')!=expected_source:
raise ValueError(f'Reviewed source changed: {name}')
assignments[name]=dict(source=review['source'],method='manual-adaptation',adaptation=review.get('adaptation'),tint=review.get('tint'))
decisions[name]=dict(status='manual-adaptation',**review)
continue
native=native_route(name,properties,shapes)
if native:
decisions[name]=dict(status=native[0],evidence=native[1])
continue
suggestions=candidates(name,bindings,properties,shapes)
rule=next((r for r in manual['unmatchedReviews'] if re.fullmatch(r['pattern'],name)),None)
decisions[name]=dict(status='no-compatible-authored-model',candidates=suggestions,
evidence=rule['reason'] if rule else 'No exact or reviewed compatible asset; do not force a different object model',
reviewed=bool(rule))
# Include custom stacked sprites which are managed by the game's stack system, not just recipes.
for name,review in manual.get('extraSprites',{}).items():
if name not in properties: raise ValueError(f'Missing reviewed stack sprite: {name}')
if review['source'] not in bindings: raise ValueError(f'Missing reviewed stack model: {review["source"]}')
if properties[name].get('CustomName') != 'Crate' or properties.get(review['source'],{}).get('CustomName') != 'Crate':
raise ValueError(f'Reviewed stack meaning changed: {name}')
assignments[name]=dict(source=review['source'],method=review['method'],adaptation=review.get('adaptation'),tint=review.get('tint'))
decisions[name]=dict(status=review['method'],**review)
model_count=build_pack(args.output_directory,source_root,bindings,assignments)
counts=dict(collections.Counter(d['status'] for d in decisions.values()))
objects=[]
for item in catalogue:
sprites=sorted(object_sprites[item['id']])
counts_for_object=collections.Counter(decisions[s]['status'] for s in sprites)
if not sprites: counts_for_object['no-static-sprite-reference']=1
objects.append(dict(id=item['id'],name=item.get('displayName',item['id']),category=item.get('category',''),
source=item['_source'],statuses=dict(counts_for_object),sprites=sprites,
unresolvedEntityReferences=unresolved_entities.get(item['id'],[])))
summary=dict(catalogueEntries=len(catalogue),entriesWithResolvedSprites=sum(bool(o['sprites']) for o in objects),uniqueSprites=len(decisions),
authoredPackBindings=len(bindings),patchBindings=len(assignments),copiedModels=model_count,statusCounts=counts,
note='Native routes describe installed geometry/metadata, not verified in-game appearance. Roof family matches require visual checks. No-compatible-model entries have no accepted model assignment.')
summary['unresolvedEntityReferences']=unresolved_entities
gap_objects=[o for o in objects if o['statuses'].get('no-compatible-authored-model',0)]
summary['buildablesWithReviewedModelGaps']=len(gap_objects)
summary['unreviewedGapSprites']=sum(d['status']=='no-compatible-authored-model' and not d.get('reviewed') for d in decisions.values())
write_json(args.reports_directory/'summary.json',summary)
write_json(args.reports_directory/'sprite-decisions.json',decisions)
write_json(args.reports_directory/'buildable-coverage.json',objects)
write_json(args.reports_directory/'applied-matches.json',assignments)
lines=['# Matching results','',f"Scanned {len(catalogue)} catalogue entries; added {len(assignments)} sprite bindings.",'',
'Native geometry is recorded separately from authored model matches. File analysis is not an in-game visual test.','',
'## Applied matches','']
for target,a in sorted(assignments.items()): lines.append(f"- `{target}` -> `{a['source']}` ({a['method']}{'; '+a['adaptation'] if a.get('adaptation') else ''})")
lines+=['','## Buildables still without a complete authored model match','',
'The reasons below identify native rendering routes and reviewed gaps. A native route is not an invisible object.','']
for item in sorted(objects,key=lambda a:(a['name'],a['id'])):
if not item['sprites']:
lines.append(f"- **{item['name']}** — `{item['id']}`: no static sprite reference; game action/runtime entity; not assigned a model")
continue
other=[s for s in item['sprites'] if decisions[s]['status'] not in ('already-bound','automatic-match','manual-adaptation')]
if not other: continue
statuses=collections.Counter(decisions[s]['status'] for s in other)
lines.append(f"- **{item['name']}** — `{item['id']}`: "+', '.join(f'{k}: {v}' for k,v in sorted(statuses.items())))
for sprite in other:
if decisions[sprite]['status']=='no-compatible-authored-model':
lines.append(f" - `{sprite}`: {decisions[sprite]['evidence']}")
(args.reports_directory/'results.md').write_text('\n'.join(lines)+'\n',encoding='utf-8')
gaps=['# Buildables without a complete accepted authored model match','',
f'{len(gap_objects)} buildables have one or more unresolved authored-model parts. These are reviewed gaps, not confirmed invisible objects. Native edge/depth/overlay rendering may still display them.','',
'The earlier audit counted ordinary native walls/floors/roofs as unmatched; those native routes are now recorded separately in results.md.','']
for item in sorted(gap_objects,key=lambda o:(o['name'],o['id'])):
gaps.append(f"## {item['name']}")
gaps.extend(['',f"`{item['id']}`",''])
for sprite in item['sprites']:
if decisions[sprite]['status']=='no-compatible-authored-model':
gaps.append(f"- `{sprite}`: {decisions[sprite]['evidence']}")
gaps.append('')
(args.reports_directory/'unmatched.md').write_text('\n'.join(gaps)+'\n',encoding='utf-8')
write_json(args.output_directory/'match-summary.json',summary)
if args.install:
destination=args.local_mods_directory/MOD_ID
if args.output_directory.resolve()!=destination.resolve():
# The output can be a Git repository. Install runtime files only, never .git/tools/reports.
destination.mkdir(parents=True,exist_ok=True)
for directory in ('common',VERSION_FOLDER):
shutil.copytree(args.output_directory/directory,destination/directory,dirs_exist_ok=True)
for filename in ('mod.info','match-summary.json'):
shutil.copy2(args.output_directory/filename,destination/filename)
print(f'Installed {destination}',flush=True)
print(json.dumps(summary,indent=2),flush=True)
if __name__=='__main__':
main()