Intial Commit

This commit is contained in:
2026-10-27 17:12:43 -04:00
parent b29ea9faf3
commit 288ce560f3
75 changed files with 120505 additions and 1 deletions
+18
View File
@@ -0,0 +1,18 @@
[CmdletBinding()]
param(
[string]$WorkshopRoot = 'D:\SteamLibrary\steamapps\workshop\content\108600',
[string]$GameDirectory = 'D:\SteamLibrary\steamapps\common\ProjectZomboid',
[string]$OutputDirectory = (Split-Path -Parent $PSScriptRoot),
[switch]$Install,
[string]$LocalModsDirectory = (Join-Path $env:USERPROFILE 'Zomboid\mods'),
[string]$PythonExecutable = (Join-Path $env:USERPROFILE '.cache\codex-runtimes\codex-primary-runtime\dependencies\python\python.exe')
)
$ErrorActionPreference = 'Stop'
if (!(Test-Path -LiteralPath $PythonExecutable)) {
throw 'Pass -PythonExecutable with a Python 3.10+ interpreter containing Pillow.'
}
$scriptArguments = @((Join-Path $PSScriptRoot 'Match-KnoxViewpoint.py'), '--workshop-root', $WorkshopRoot,
'--game-directory', $GameDirectory, '--output-directory', $OutputDirectory, '--local-mods-directory', $LocalModsDirectory)
if ($Install) { $scriptArguments += '--install' }
& $PythonExecutable @scriptArguments
if ($LASTEXITCODE -ne 0) { throw "Matcher failed with exit code $LASTEXITCODE" }
+18
View File
@@ -0,0 +1,18 @@
[CmdletBinding()]
param(
[string]$WorkshopRoot = 'D:\SteamLibrary\steamapps\workshop\content\108600',
[string]$GameDirectory = 'D:\SteamLibrary\steamapps\common\ProjectZomboid',
[string]$OutputDirectory = (Split-Path -Parent $PSScriptRoot),
[switch]$Install,
[string]$LocalModsDirectory = (Join-Path $env:USERPROFILE 'Zomboid\mods'),
[string]$PythonExecutable = (Join-Path $env:USERPROFILE '.cache\codex-runtimes\codex-primary-runtime\dependencies\python\python.exe')
)
$ErrorActionPreference = 'Stop'
if (!(Test-Path -LiteralPath $PythonExecutable)) {
throw 'Pass -PythonExecutable with a Python 3.10+ interpreter containing Pillow.'
}
$scriptArguments = @((Join-Path $PSScriptRoot 'Match-KnoxViewpoint.py'), '--workshop-root', $WorkshopRoot,
'--game-directory', $GameDirectory, '--output-directory', $OutputDirectory, '--local-mods-directory', $LocalModsDirectory)
if ($Install) { $scriptArguments += '--install' }
& $PythonExecutable @scriptArguments
if ($LASTEXITCODE -ne 0) { throw "Matcher failed with exit code $LASTEXITCODE" }
+582
View File
@@ -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()
+106
View File
@@ -0,0 +1,106 @@
"""Meaningful regression checks for the matcher. Run with the same Python as the matcher."""
import importlib.util
import json
import tempfile
import unittest
import sys
from pathlib import Path
from PIL import Image
HERE=Path(__file__).resolve().parent
sys.dont_write_bytecode=True
spec=importlib.util.spec_from_file_location('matcher',HERE/'Match-KnoxViewpoint.py')
matcher=importlib.util.module_from_spec(spec)
spec.loader.exec_module(matcher)
class MatchingTests(unittest.TestCase):
def test_white_crate_is_not_vanilla_military_sign(self):
target='kbw_location_military_generic_01_16'
bindings={'location_military_generic_01_16':{}}
props={target:{'CustomName':'Crate','container':'militarycrate'},
'location_military_generic_01_16':{'CustomName':'Sign'}}
self.assertIsNone(matcher.alias_matches(target,bindings,props))
def test_real_crate_alias_matches(self):
target='kbw_location_military_generic_01_0'
props={target:{'CustomName':'Crate','container':'militarycrate'},
target[4:]:{'CustomName':'Crate','container':'militarycrate'}}
self.assertEqual(matcher.alias_matches(target,{target[4:]:{}},props),target[4:])
def test_properties_only_tile_has_no_geometry(self):
text='''/* fixtures_test_0 */ tile { xy=0x0, properties { Translucent=true, } }
/* fixtures_test_1 */ tile { xy=1x0, cylinder { min=0x0x0, max=1x1x1, } }'''
with tempfile.TemporaryDirectory() as temp:
path=Path(temp)/'tileGeometry.txt';path.write_text(text)
result=matcher.geometry(path)
self.assertNotIn('fixtures_test_0',result)
self.assertIn('fixtures_test_1',result)
def test_variant_ids_are_not_sprites(self):
value={'id':'tile_02','geometry':{'rows':[['walls_interior_house_01_2']]},'requirements':{'id':'tool_03'}}
self.assertEqual(list(matcher.sprite_strings(value)),['walls_interior_house_01_2'])
def test_real_sprite_names_can_have_capitals_and_hyphens(self):
self.assertTrue(matcher.SPRITE.fullmatch('location_shop_bargNclothes_01_24'))
self.assertTrue(matcher.SPRITE.fullmatch('signs_one-off_08_269'))
def test_native_floor_does_not_need_authored_model(self):
result=matcher.native_route('floors_test_0',{'floors_test_0':{'solidfloor':''}}, {})
self.assertEqual(result[0],'native-floor')
def test_native_roof_is_candidate_not_visual_confirmation(self):
self.assertEqual(matcher.native_route('roofs_01_0',{}, {})[0],'native-roof-candidate')
def test_suggestions_never_change_assignments(self):
bindings={'fixtures_test_1':{}}
props={'fixtures_test_0':{'CustomName':'Shelf'},'fixtures_test_1':{'CustomName':'Shelf'}}
result=matcher.candidates('fixtures_test_0',bindings,props,{})
self.assertEqual(result[0]['source'],'fixtures_test_1')
self.assertEqual(bindings,{'fixtures_test_1':{}})
def test_registered_manifest_order_and_source_paths(self):
workshop=Path('D:/SteamLibrary/steamapps/workshop/content/108600')
if not workshop.exists(): self.skipTest('Installed Workshop files not present')
root,bindings=matcher.load_bindings(workshop)
self.assertEqual(bindings['location_military_generic_01_1']['manifest'],'package-12.properties')
self.assertTrue((root/bindings['location_military_generic_01_1']['path']).is_file())
def test_white_adaptation_preserves_mesh_and_alpha_and_source(self):
with tempfile.TemporaryDirectory() as temp:
root=Path(temp)/'source';root.mkdir()
obj=root/'model.obj';obj.write_text('mtllib model.mtl\nv 0 0 0\n')
(root/'model.mtl').write_text('newmtl atlas\nmap_Kd texture.png\n')
image=Image.new('RGBA',(2,1));image.putdata([(20,40,60,255),(80,90,100,0)]);image.save(root/'texture.png')
before=(root/'texture.png').read_bytes()
media=Path(temp)/'patch/media'
relative,tag=matcher.copy_asset(root,{'path':'model.obj'},media,white=True)
self.assertEqual((media/relative).read_bytes(),obj.read_bytes())
after=Image.open((media/relative).parent/'texture.png').convert('RGBA')
self.assertEqual(after.getchannel('A').tobytes(),bytes([255,0]))
r,g,b,a=after.getpixel((0,0));self.assertEqual(r,g);self.assertEqual(g,b);self.assertGreater(r,100)
self.assertEqual((root/'texture.png').read_bytes(),before)
def test_manual_white_sources_are_crates_not_signs(self):
reviews=json.loads((HERE/'reviewed-matches.json').read_text())
self.assertEqual(reviews['matches']['kbw_location_military_generic_01_16']['source'],'location_military_generic_01_8')
self.assertEqual(reviews['matches']['kbw_location_military_generic_01_17']['source'],'location_military_generic_01_9')
def test_gray_variants_use_green_meshes_and_requested_tint(self):
reviews=json.loads((HERE/'reviewed-matches.json').read_text())
rules={**reviews['matches'],**reviews['extraSprites']}
for target,source in zip((8,9,10,11,12,13),(0,1,2,3,4,5)):
rule=rules[f'kbw_location_military_generic_01_{target}']
self.assertEqual(rule['source'],f'location_military_generic_01_{source}')
self.assertEqual(rule['tint'],'#626763')
def test_gray_tint_exact_base_shading_and_alpha(self):
original=Image.new('RGBA',(4,1))
original.putdata([(85,97,74,255),(85,97,74,255),(42,48,37,255),(85,97,74,0)])
tinted=matcher.tint_texture(original,'#626763')
self.assertEqual(tinted.getpixel((0,0)),(98,103,99,255))
self.assertLess(tinted.getpixel((2,0))[0],98)
self.assertEqual(tinted.getchannel('A').tobytes(),original.getchannel('A').tobytes())
self.assertEqual(original.getpixel((0,0)),(85,97,74,255))
if __name__=='__main__':
unittest.main(verbosity=2)
+1
View File
@@ -0,0 +1 @@
Pillow>=10
+62
View File
@@ -0,0 +1,62 @@
{
"reviewBasis": "Reviewed installed tile properties, actual 2D sprite contact sheets, model manifests and Viewpoint renderer bytecode. Approve only object-compatible model reuse. Never infer identity from the tile number alone.",
"matches": {
"kbw_location_military_generic_01_8": {
"source": "location_military_generic_01_0",
"adaptation": "gray-green-crate",
"tint": "#626763",
"expectedTargetCustomName": "Crate",
"expectedSourceCustomName": "Crate",
"evidence": "User-requested gray crate appearance: use the matching green crate mesh, retint its atlas toward #626763, and preserve luminance detail and transparency."
},
"kbw_location_military_generic_01_9": {
"source": "location_military_generic_01_1",
"adaptation": "gray-green-crate",
"tint": "#626763",
"expectedTargetCustomName": "Crate",
"expectedSourceCustomName": "Crate",
"evidence": "Second gray crate orientation uses the second green crate mesh with the same requested gray-green base tint."
},
"kbw_location_military_generic_01_16": {
"source": "location_military_generic_01_8",
"adaptation": "white-crate",
"expectedTargetCustomName": "Crate",
"expectedSourceCustomName": "Crate",
"evidence": "Knox white crate has the same military-crate shape as gray crate orientation 8. Reuse that crate mesh with a neutral white atlas. Vanilla index 16 is a military sign and is explicitly rejected. Color is an approximation; custom paint tint is not supplied by this pack."
},
"kbw_location_military_generic_01_17": {
"source": "location_military_generic_01_9",
"adaptation": "white-crate",
"expectedTargetCustomName": "Crate",
"expectedSourceCustomName": "Crate",
"evidence": "Same reviewed military-crate mesh for the second orientation; neutral white atlas. Vanilla index 17 is a sign, not a crate."
}
},
"extraSprites": {
"kbw_location_military_generic_01_2": {"source":"location_military_generic_01_2","method":"reviewed-stack"},
"kbw_location_military_generic_01_3": {"source":"location_military_generic_01_3","method":"reviewed-stack"},
"kbw_location_military_generic_01_4": {"source":"location_military_generic_01_4","method":"reviewed-stack"},
"kbw_location_military_generic_01_5": {"source":"location_military_generic_01_5","method":"reviewed-stack"},
"kbw_location_military_generic_01_10": {"source":"location_military_generic_01_2","method":"reviewed-stack","adaptation":"gray-green-crate","tint":"#626763"},
"kbw_location_military_generic_01_11": {"source":"location_military_generic_01_3","method":"reviewed-stack","adaptation":"gray-green-crate","tint":"#626763"},
"kbw_location_military_generic_01_12": {"source":"location_military_generic_01_4","method":"reviewed-stack","adaptation":"gray-green-crate","tint":"#626763"},
"kbw_location_military_generic_01_13": {"source":"location_military_generic_01_5","method":"reviewed-stack","adaptation":"gray-green-crate","tint":"#626763"},
"kbw_location_military_generic_01_18": {"source":"location_military_generic_01_10","method":"reviewed-stack","adaptation":"white-crate"},
"kbw_location_military_generic_01_19": {"source":"location_military_generic_01_11","method":"reviewed-stack","adaptation":"white-crate"},
"kbw_location_military_generic_01_20": {"source":"location_military_generic_01_12","method":"reviewed-stack","adaptation":"white-crate"},
"kbw_location_military_generic_01_21": {"source":"location_military_generic_01_13","method":"reviewed-stack","adaptation":"white-crate"}
},
"unmatchedReviews": [
{"pattern":"overlay_graffiti_wall_.*", "reason":"Reviewed wall-paint/sign overlay family. These are surface artwork, not standalone 3D objects. No matching authored mesh is needed to retain the art on its wall, and substituting a furniture mesh would be incorrect."},
{"pattern":"fixtures_bathroom_.*", "reason":"Reviewed attached bathroom fixture/overlay. No accepted authored asset with matching dimensions and artwork; retain native attachment behavior."},
{"pattern":"walls_.*", "reason":"Reviewed architectural wall/trim/pillar family. No accepted authored asset with matching shape and artwork; many variants rely on cut/edge/depth rendering. Do not substitute a furniture mesh or a differently shaped window wall."},
{"pattern":"fixtures_doors_.*", "reason":"Reviewed doors/open states/frame trims. Similar names or adjacent indices do not prove the same door or opening angle. Preserve Viewpoint door rendering; no accepted replacement asset."},
{"pattern":"fixtures_windows_.*", "reason":"Reviewed specialized window shape. Other window meshes are not guaranteed to match this shape, facing or broken/open state. Preserve native rendering; bespoke geometry would be needed for a faithful authored replacement."},
{"pattern":"fixtures_railings_.*", "reason":"Reviewed railing contact sheet. Unmatched sloped stair tips/posts are distinct sections of multi-tile railings, not duplicate full railing models. Substituting a full model here would duplicate or misalign the railing."},
{"pattern":"fixtures_fireplaces_.*", "reason":"Reviewed double-fireplace sprite footprint. Its halves form a two-tile opening; other fireplace/chimney models have different dimensions. Preserve existing edge/depth rendering rather than place a whole fireplace on each half."},
{"pattern":"signs_.*", "reason":"Reviewed sign family. No accepted authored model with the target sign artwork/size. Similar sign geometry alone would display a different business or sign."},
{"pattern":"location_.*", "reason":"Reviewed location-specific facade/sign/fixture family. No accepted equivalent model with this artwork and footprint; no cross-object substitution applied."},
{"pattern":"fencing_.*", "reason":"Reviewed fence and corner sections. Distinct corner/end geometry must remain separate; native edge rendering is retained where metadata exists."},
{"pattern":"street_.*", "reason":"Reviewed road/curb family. These are terrain overlays or curb sections, not furniture. No accepted authored replacement with the same section geometry."}
]
}