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>
This commit is contained in:
type-two 2026-07-28 14:27:52 +10:00
parent b671975742
commit 43d7e9ac8e
6 changed files with 148 additions and 7 deletions

9
.gitignore vendored
View File

@ -306,3 +306,12 @@ build.ninja
*.dat *.dat
*.MID *.MID
*.mid *.mid
.DS_Store
# Ripped game media — copyrighted, large, and not ours to redistribute
Full_Tilt_Pinball_ISO/
*.img
*.iso
*.ccd
*.sub
*.cue

View File

@ -40,3 +40,30 @@ Drop them in any of these; first match wins in the order `CADET.DAT`, `PINBALL.D
- `~/Library/Application Support/SpaceCadetPinball/` ← preferred, keeps the repo clean - `~/Library/Application Support/SpaceCadetPinball/` ← preferred, keeps the repo clean
Without them the binary exits with "Could not load game data" and lists these paths. Without them the binary exits with "Could not load game data" and lists these paths.
## The other two Full Tilt tables (Dragon's Keep, Pirate's Cove)
The retail CD carries `DRAGON.DAT` and `PIRATES.DAT` alongside `CADET.DAT`. **They do not play, and
making them play is a months-long reverse-engineering project per table — not a loader fix.**
Investigated 2026-07-28; evidence, so nobody re-derives it:
- The container format is shared (`PARTOUT(4.0)RESOURCE`) and all three tables use the same five
component type ids (200, 201, 202, 300, 400). So the DAT *parses* fine. That is the easy half.
- Only ~35 of Dragon's 278 named components and ~36 of Pirate's 355 match Cadet's names. The
88-entry `control::score_components` table in `control.cpp` binds behaviour to those names, and
`control.cpp` is 4600 lines of Cadet-specific rules (missions, ranks, wormholes, Gravity Well).
Each table needs its own equivalent written from scratch.
- Both tables use float attributes the engine has no semantics for: `409`, `1306`, `1406`,
`1600``1604` (plus `604`, `704``706` for Dragon, `1405` for Pirate). These are hard errors, not
missing-value errors — e.g. `loader::kicker` accepts only 401406 and calls `error(10, 20)` on
anything else. Dragon's kickers carry `409`, meaning unknown.
- Upstream will not do it. Maintainer k4zmu2a, issue #148: the effort is "comparable to doing the
whole process all over again, once per table", and issue #20: "I most likely will not be adding
support for DRAGON and PIRATE tables." Cadet was reversed with the aid of the *public PDB symbols*
shipped for XP's `pinball.exe`; no equivalent symbols exist for the Full Tilt table binaries.
Four issues have requested it (#20, #148, #160, #256); none has ever produced a PR.
If someone does take it on, `tools/datdump.py` dumps and diffs DAT structure, and
[DatPartoutExplorer](https://github.com/belaw/DatPartoutExplorer) has a `Resource Identifiers.txt`
mapping attribute ids to object types — including entries flagged as unused in Cadet, which is the
obvious first place to look up what `1600``1604` mean.

View File

@ -28,13 +28,18 @@ TRamp::TRamp(TPinballTable* table, int groupIndex) : TCollisionComponent(table,
RampPlaneCount = static_cast<int>(floor(*floatArr3Plane)); RampPlaneCount = static_cast<int>(floor(*floatArr3Plane));
RampPlane = reinterpret_cast<ramp_plane_type*>(floatArr3Plane + 1); RampPlane = reinterpret_cast<ramp_plane_type*>(floatArr3Plane + 1);
auto wall0Arr = loader::query_float_attribute(groupIndex, 0, 1303); // Full Tilt's Dragon's Keep and Pirate's Cove ramps omit wall0; 3DPB/Cadet always have it.
auto wall0CollisionGroup = 1 << static_cast<int>(floor(wall0Arr[0])); auto wall0Arr = loader::query_float_attribute_opt(groupIndex, 0, 1303);
auto wall0Pts = reinterpret_cast<wall_point_type*>(wall0Arr + 2); Line1 = nullptr;
Line1 = new TLine(this, &ActiveFlag, wall0CollisionGroup, wall0Pts->Pt1, wall0Pts->Pt0); if (wall0Arr)
Line1->WallValue = nullptr; {
Line1->place_in_grid(&AABB); auto wall0CollisionGroup = 1 << static_cast<int>(floor(wall0Arr[0]));
EdgeList.push_back(Line1); auto wall0Pts = reinterpret_cast<wall_point_type*>(wall0Arr + 2);
Line1 = new TLine(this, &ActiveFlag, wall0CollisionGroup, wall0Pts->Pt1, wall0Pts->Pt0);
Line1->WallValue = nullptr;
Line1->place_in_grid(&AABB);
EdgeList.push_back(Line1);
}
auto wall1Arr = loader::query_float_attribute(groupIndex, 0, 1301); auto wall1Arr = loader::query_float_attribute(groupIndex, 0, 1301);
Wall1CollisionGroup = 1 << static_cast<int>(floor(wall1Arr[0])); Wall1CollisionGroup = 1 << static_cast<int>(floor(wall1Arr[0]));

View File

@ -239,9 +239,21 @@ int16_t* loader::query_iattribute(int groupIndex, int firstValue, int* arraySize
} }
float* loader::query_float_attribute(int groupIndex, int groupIndexOffset, int firstValue) float* loader::query_float_attribute(int groupIndex, int groupIndexOffset, int firstValue)
{
return query_float_attribute_impl(groupIndex, groupIndexOffset, firstValue, false);
}
float* loader::query_float_attribute_opt(int groupIndex, int groupIndexOffset, int firstValue)
{
return query_float_attribute_impl(groupIndex, groupIndexOffset, firstValue, true);
}
float* loader::query_float_attribute_impl(int groupIndex, int groupIndexOffset, int firstValue, bool optional)
{ {
if (groupIndex < 0) if (groupIndex < 0)
{ {
if (optional)
return nullptr;
error(0, 22); error(0, 22);
return nullptr; return nullptr;
} }
@ -249,6 +261,8 @@ float* loader::query_float_attribute(int groupIndex, int groupIndexOffset, int f
int stateId = state_id(groupIndex, groupIndexOffset); int stateId = state_id(groupIndex, groupIndexOffset);
if (stateId < 0) if (stateId < 0)
{ {
if (optional)
return nullptr;
error(16, 22); error(16, 22);
return nullptr; return nullptr;
} }
@ -263,6 +277,8 @@ float* loader::query_float_attribute(int groupIndex, int groupIndexOffset, int f
return floatArr + 1; return floatArr + 1;
} }
if (optional)
return nullptr;
error(13, 22); error(13, 22);
return nullptr; return nullptr;
} }

View File

@ -102,7 +102,16 @@ public:
static int query_visual(int groupIndex, int groupIndexOffset, visualStruct* visual); static int query_visual(int groupIndex, int groupIndexOffset, visualStruct* visual);
static char* query_name(int groupIndex); static char* query_name(int groupIndex);
static float* query_float_attribute(int groupIndex, int groupIndexOffset, int firstValue); static float* query_float_attribute(int groupIndex, int groupIndexOffset, int firstValue);
/* Returns nullptr instead of raising a load error when the attribute is absent.
Full Tilt's Dragon's Keep / Pirate's Cove omit attributes that Cadet always has. */
static float* query_float_attribute_opt(int groupIndex, int groupIndexOffset, int firstValue);
static float query_float_attribute(int groupIndex, int groupIndexOffset, int firstValue, float defVal); static float query_float_attribute(int groupIndex, int groupIndexOffset, int firstValue, float defVal);
private:
static float* query_float_attribute_impl(int groupIndex, int groupIndexOffset, int firstValue, bool optional);
public:
static int16_t* query_iattribute(int groupIndex, int firstValue, int* arraySize); static int16_t* query_iattribute(int groupIndex, int firstValue, int* arraySize);
static float play_sound(int soundIndex, TPinballComponent *soundSource, const char* info); static float play_sound(int soundIndex, TPinballComponent *soundSource, const char* info);
static DatFile* loader_table; static DatFile* loader_table;

75
tools/datdump.py Normal file
View File

@ -0,0 +1,75 @@
#!/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()