Accumulated uncommitted work: GHOST Web backend, WebGL/GPU web layer, web workflow engine and protocol, tooling, docs and task records. Large generated evidence, user-testing archives, build snapshots and imported test assets remain untracked. Co-Authored-By: Claude Code <noreply@anthropic.com>
68 lines
4.6 KiB
Python
68 lines
4.6 KiB
Python
"""Compare serialized ID fields using Blender's reader; not a Main-equivalence verdict."""
|
|
import argparse
|
|
import collections
|
|
import hashlib
|
|
import json
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
def read_ids(blend):
|
|
result = {}
|
|
for block in blend.blocks:
|
|
if block.code in (b'DATA', b'DNA1', b'ENDB'): continue
|
|
struct = blend.structs[block.sdna_index]
|
|
if not any(f.dna_name.name_only == b'id' and f.dna_type.dna_type_id == b'ID' for f in struct.fields): continue
|
|
name = block.get((b'id', b'name'))
|
|
if not isinstance(name, str): raise TypeError('ID name must be decoded text')
|
|
key = block.code.decode('ascii') + ':' + name
|
|
if key in result: raise ValueError('Duplicate ID identity needs library disambiguation: ' + key)
|
|
blend.handle.seek(block.file_offset); result[key] = (struct, blend.handle.read(block.size))
|
|
return result
|
|
|
|
def changed_fields(struct, before, after, prefix='', base=0):
|
|
for field in struct.fields:
|
|
if not hasattr(field.dna_type, 'fields'): raise TypeError('DNA field type must be a DNAStruct')
|
|
lo, hi = base + field.dna_offset, base + field.dna_offset + field.dna_size
|
|
if not (0 <= lo <= hi <= len(before) and hi <= len(after)): raise ValueError('DNA field outside block')
|
|
if before[lo:hi] == after[lo:hi]: continue
|
|
name = prefix + field.dna_name.name_full.decode('ascii')
|
|
if field.dna_type.fields and not field.dna_name.is_pointer and field.dna_name.array_size == 1:
|
|
yield from changed_fields(field.dna_type, before, after, name + '.', lo)
|
|
else:
|
|
yield {'field': name, 'dnaType': field.dna_type.dna_type_id.decode('ascii'), 'bytes': field.dna_size,
|
|
'beforeHex': before[lo:hi][:32].hex(), 'afterHex': after[lo:hi][:32].hex(), 'previewTruncated': field.dna_size > 32}
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument('before', type=Path); parser.add_argument('after', type=Path)
|
|
parser.add_argument('--output', required=True, type=Path); parser.add_argument('--dependency-path', type=Path)
|
|
args = parser.parse_args()
|
|
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / 'blender-5.2.0/tools/modules'))
|
|
if args.dependency_path: sys.path.insert(0, str(args.dependency_path.resolve()))
|
|
try: import blendfile
|
|
except ModuleNotFoundError as error: raise SystemExit(f'Reader dependency missing: {error.name}; provide --dependency-path (python3-zstandard).')
|
|
with blendfile.open_blend(str(args.before)) as before, blendfile.open_blend(str(args.after)) as after:
|
|
def dna_hash(blend):
|
|
block = next(block for block in blend.blocks if block.code == b'DNA1')
|
|
blend.handle.seek(block.file_offset); return hashlib.sha256(blend.handle.read(block.size)).hexdigest()
|
|
if dna_hash(before) != dna_hash(after): raise ValueError('DNA schemas differ; use snapshots from one candidate')
|
|
left, right = read_ids(before), read_ids(after)
|
|
changes = []
|
|
for key in sorted(left.keys() & right.keys()):
|
|
struct, old = left[key]; other, new = right[key]
|
|
if struct.dna_type_id != other.dna_type_id or len(old) != len(new): raise ValueError('ID layout changed: ' + key)
|
|
fields = list(changed_fields(struct, old, new))
|
|
if fields: changes.append({'id': key, 'fields': fields})
|
|
inventory = lambda blend: dict(sorted(collections.Counter(block.code.decode('ascii') + ':' + blend.structs[block.sdna_index].dna_type_id.decode('ascii') for block in blend.blocks if block.code != b'ENDB').items()))
|
|
result = {'schemaVersion': 1, 'scope': 'Serialized ID fields and block counts; DATA contents are not a semantic equality verdict',
|
|
'changedIds': changes, 'addedIds': sorted(right.keys() - left.keys()), 'removedIds': sorted(left.keys() - right.keys()),
|
|
'blockCountsBefore': inventory(before), 'blockCountsAfter': inventory(after), 'dnaSha256': dna_hash(before)}
|
|
for name in ['before', 'after']:
|
|
file = getattr(args, name)
|
|
with file.open('rb') as stream: result[name] = {'path': str(file), 'sha256': hashlib.file_digest(stream, 'sha256').hexdigest()}
|
|
args.output.parent.mkdir(parents=True, exist_ok=True)
|
|
temporary = args.output.with_name(args.output.name + '.tmp'); temporary.write_text(json.dumps(result, indent=2) + '\n'); temporary.replace(args.output)
|
|
print(json.dumps({'output': str(args.output), 'changedIds': len(changes), 'addedIds': len(result['addedIds']), 'removedIds': len(result['removedIds'])}))
|
|
|
|
if __name__ == '__main__': main()
|