macrosoft3dpinball/tools/datdump.py
type-two 43d7e9ac8e Add DAT inspector; scope the other two Full Tilt tables
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>
2026-07-28 14:27:52 +10:00

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