Intial Commit
This commit is contained in:
@@ -0,0 +1,582 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user