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
|
*.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
|
||||||
|
|||||||
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
|
- `~/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 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));
|
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]));
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
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