"""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(' 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()