Investigating "make the engine play all the .dat types". Answer: the container parses for all three tables, but playability is a per-table reverse-engineering project, not a loader fix. Evidence recorded in CLAUDE.md so it isn't re-derived. - tools/datdump.py: parse and diff PARTOUT(4.0) DATs — groups, component type ids, float attribute ids. Asserts its own entry walk stayed in sync, since a desync silently turns every later field into garbage. - loader: add query_float_attribute_opt for genuinely optional attributes. Dragon/Pirate ramps omit wall0 (1303), which Cadet always carries; treat a missing wall as no wall instead of a fatal load error. Cadet and Full Tilt Cadet verified unchanged after this. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
76 lines
3.4 KiB
Python
76 lines
3.4 KiB
Python
#!/usr/bin/env python3
|
|
"""Dump PARTOUT(4.0) pinball .DAT structure: groups, component type ids, float attribute ids."""
|
|
import struct, sys, collections
|
|
|
|
FIXED = {0: 2, 2: 2, 13: 0} # partman::_field_size; everything else has a uint32 length prefix
|
|
|
|
def parse(path):
|
|
d = open(path, 'rb').read()
|
|
sig = d[0:21].split(b'\0')[0].decode()
|
|
app = d[21:71].split(b'\0')[0].decode()
|
|
desc = d[71:171].split(b'\0')[0].decode()
|
|
filesize, ngroups, sizeofbody, unknown = struct.unpack_from('<iHiH', d, 171)
|
|
full_tilt = (app == 'FullTilt')
|
|
o = 183 + unknown
|
|
groups = []
|
|
for gi in range(ngroups):
|
|
n = d[o]; o += 1
|
|
g = {'i': gi, 'name': None, 'sv': None, 'attrs': [], 'types': []}
|
|
for _ in range(n):
|
|
t = d[o]; o += 1
|
|
if t in FIXED:
|
|
sz = FIXED[t]
|
|
else:
|
|
sz = struct.unpack_from('<I', d, o)[0]; o += 4
|
|
payload = d[o:o+sz]
|
|
g['types'].append(t)
|
|
if t == 0 and sz == 2:
|
|
g['sv'] = struct.unpack_from('<h', payload, 0)[0]
|
|
elif t == 3:
|
|
g['name'] = payload.split(b'\0')[0].decode('latin-1')
|
|
elif t == 11 and sz >= 4:
|
|
g['attrs'].append(int(struct.unpack_from('<f', payload, 0)[0]))
|
|
o += sz
|
|
groups.append(g)
|
|
# If the walk desynced, every field after it is garbage. Catch it here, not in the output.
|
|
assert sig == 'PARTOUT(4.0)RESOURCE', f'{path}: not a PARTOUT dat ({sig!r})'
|
|
assert len(groups) == ngroups, f'{path}: parsed {len(groups)} groups, header says {ngroups}'
|
|
assert o == len(d), f'{path}: ended at {o} of {len(d)} bytes — entry walk desynced'
|
|
return {'sig': sig, 'app': app, 'desc': desc, 'ngroups': ngroups,
|
|
'full_tilt': full_tilt, 'groups': groups}
|
|
|
|
def summarize(path):
|
|
f = parse(path)
|
|
svs = collections.Counter(g['sv'] for g in f['groups'] if g['sv'] is not None)
|
|
attrs = collections.Counter(a for g in f['groups'] for a in g['attrs'])
|
|
named = sum(1 for g in f['groups'] if g['name'])
|
|
return f, svs, attrs, named
|
|
|
|
if __name__ == '__main__':
|
|
results = {}
|
|
for path in sys.argv[1:]:
|
|
name = path.split('/')[-1]
|
|
f, svs, attrs, named = summarize(path)
|
|
results[name] = (f, svs, attrs)
|
|
print(f"=== {name} app={f['app']!r} desc={f['desc']!r} groups={f['ngroups']} named={named}")
|
|
print(f" component type ids ({len(svs)}): {sorted(svs)}")
|
|
print(f" float attr ids ({len(attrs)}): {sorted(attrs)}")
|
|
print()
|
|
|
|
if len(results) > 1:
|
|
base = 'CADET.DAT' if 'CADET.DAT' in results else list(results)[0]
|
|
bf, bsv, battr = results[base]
|
|
for name, (f, svs, attrs) in results.items():
|
|
if name == base:
|
|
continue
|
|
print(f"--- {name} vs {base}")
|
|
print(f" component types only in {name}: {sorted(set(svs) - set(bsv))}")
|
|
print(f" component types missing from {name}: {sorted(set(bsv) - set(svs))}")
|
|
print(f" attrs only in {name}: {sorted(set(attrs) - set(battr))}")
|
|
print(f" attrs missing from {name}: {sorted(set(battr) - set(attrs))}")
|
|
bn = {g['name'] for g in bf['groups'] if g['name']}
|
|
fn = {g['name'] for g in f['groups'] if g['name']}
|
|
print(f" shared component names: {len(bn & fn)} / {len(fn)} in {name}")
|
|
print(f" sample names only in {name}: {sorted(fn - bn)[:25]}")
|
|
print()
|