602 lines
32 KiB
Python
602 lines
32 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 = 'hrsys_knoxbuildworksviewpoint'
|
|
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 mod_info(versioned=False):
|
|
prefix='../' if versioned else ''
|
|
art=f'{prefix}common/media/textures/preview.png'
|
|
lines=['name=Knox Buildworks - Viewpoint Compatibility',f'id={MOD_ID}',f'poster={art}','',
|
|
'description=3D military crate models for Knox Buildworks in Project Viewpoint.',
|
|
'author=Riggs0','category=building']
|
|
if versioned:
|
|
lines += [f'icon={art}','url=https://hudsonriggs.systems','modversion=2.1.0','versionMin=42.20.0']
|
|
else:
|
|
lines += ['versionMin=42.20.0','url=https://hudsonriggs.systems','modversion=2.1.0',f'icon={art}']
|
|
lines += ['require=KnoxBuildworks,Viewpoint,PZVoxelStudioViewpoint',
|
|
'loadAfter=KnoxBuildworks,PZVoxelStudioViewpoint,Viewpoint','']
|
|
return '\n'.join(lines)
|
|
|
|
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')
|
|
(output/VERSION_FOLDER).mkdir(parents=True,exist_ok=True)
|
|
(output/VERSION_FOLDER/'mod.info').write_text(mod_info(versioned=True),encoding='utf-8')
|
|
(output/'mod.info').write_text(mod_info(),encoding='utf-8')
|
|
# Keep the published preview and mod-list art consistent on future rebuilds.
|
|
preview=output/'preview.png'
|
|
if not preview.exists():
|
|
preview=REPO_ROOT/'preview.png'
|
|
if preview.exists():
|
|
art=media/'textures/preview.png'
|
|
art.parent.mkdir(parents=True,exist_ok=True)
|
|
shutil.copy2(preview,art)
|
|
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,
|
|
"%MOD_ID%", "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('%MOD_ID%',MOD_ID).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()
|