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:
parent
b671975742
commit
43d7e9ac8e
9
.gitignore
vendored
9
.gitignore
vendored
@ -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
|
||||
|
||||
27
CLAUDE.md
27
CLAUDE.md
@ -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 401–406 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.
|
||||
|
||||
@ -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]));
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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
75
tools/datdump.py
Normal 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()
|
||||
Loading…
Reference in New Issue
Block a user