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
*.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
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));
RampPlane = reinterpret_cast<ramp_plane_type*>(floatArr3Plane + 1);
auto wall0Arr = loader::query_float_attribute(groupIndex, 0, 1303);
auto wall0CollisionGroup = 1 << static_cast<int>(floor(wall0Arr[0]));
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);
// Full Tilt's Dragon's Keep and Pirate's Cove ramps omit wall0; 3DPB/Cadet always have it.
auto wall0Arr = loader::query_float_attribute_opt(groupIndex, 0, 1303);
Line1 = nullptr;
if (wall0Arr)
{
auto wall0CollisionGroup = 1 << static_cast<int>(floor(wall0Arr[0]));
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);
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)
{
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 (optional)
return nullptr;
error(0, 22);
return nullptr;
}
@ -249,6 +261,8 @@ float* loader::query_float_attribute(int groupIndex, int groupIndexOffset, int f
int stateId = state_id(groupIndex, groupIndexOffset);
if (stateId < 0)
{
if (optional)
return nullptr;
error(16, 22);
return nullptr;
}
@ -263,6 +277,8 @@ float* loader::query_float_attribute(int groupIndex, int groupIndexOffset, int f
return floatArr + 1;
}
if (optional)
return nullptr;
error(13, 22);
return nullptr;
}

View File

@ -102,7 +102,16 @@ public:
static int query_visual(int groupIndex, int groupIndexOffset, visualStruct* visual);
static char* query_name(int groupIndex);
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);
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 float play_sound(int soundIndex, TPinballComponent *soundSource, const char* info);
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()