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