docs/tracks.html joins the roster page: all 18 tracks with a plan view, what the console calls them, which scenarios offer them, and how big they are. There are no track maps in the game's files. The console had pictures of them and those pictures did not survive - RPConfig.xml still points at "images/red planet maps/Wiseguy's Wake.bmp" and the folder is gone. So the plans are drawn from the tracks themselves. A map's instance stream places its scenery: 76-byte records carrying a position at +48 and a unit quaternion at +60, a few of them longer, so the reader resyncs on an unexpected class id rather than trusting the stride. The quaternion doubles as a checksum - a mis-read almost never yields a unit one - and 17 of the 18 decode every instance the header promises. Trough gives up 631 of 633 and the card says so. Seen this way the tracks have obvious shapes: Brewer's Bane turns two corners, Tour De Mars is one 23,000-unit run, and both arenas are a regular lattice of obstacles rather than a route at all. The eras come from the resource-file archaeology rather than a guess: 9 tracks shipped in the 4.10 cabinets, headoff and headmf arrived with 4.11, and 7 were built by the community afterwards. Scenario legality is read out of the front end's own kMaps and kFootballMaps, so the page cannot claim a track is offered when the menu does not offer it. tools/pages carries the generators for both reference pages, with a README covering the two formats they read and the id-alignment the listing is needed for. They were scratch scripts until now, which made a committed page harder to regenerate than to rebuild by hand. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
247 lines
10 KiB
Python
247 lines
10 KiB
Python
"""Decode every VTV's loadout and six-preset control table from RPL4.RES.
|
|
|
|
Everything here is resolved by resource id rather than by guesswork:
|
|
|
|
* RPL4TOOL -l gives the authoritative id -> description table.
|
|
* The resource directory is walked in file order (an 8-byte prefix, a
|
|
32-byte name, addr/size at +0x30, data at +0x38, next descriptor after
|
|
the data) and aligned against that listing, skipping the ids the
|
|
listing marks "Not Used". Sizes are cross-checked on every row.
|
|
* A vehicle's "ControlsMappings List of 2 elements" holds the resource
|
|
ids of its Thrustmaster and L4 streams, so each stream is attributed
|
|
to its owner exactly - no nearest-name proximity, which quietly
|
|
mis-attributed the community vehicles.
|
|
* Subsystem names come from the vehicle's own "Stream of N Subsystems".
|
|
VTV::BasicSubsystemCount = 9, so list index i is subsystem id 9+i.
|
|
|
|
Usage: extract_presets.py <out.json> <rpl4tool-listing.txt>
|
|
"""
|
|
import json, re, struct, sys
|
|
|
|
ROOT = 'c:/VWE/RP412'
|
|
RES = open(ROOT + '/assets/RP411/RPL4.RES', 'rb').read()
|
|
CFG = open(ROOT + '/assets/RP411/GAUGE/L4GAUGE.CFG', 'r', errors='replace').read()
|
|
NRES = len(RES)
|
|
OUT, LISTING = sys.argv[1], sys.argv[2]
|
|
|
|
BASIC_SUBSYSTEM_COUNT = 9 # VTV::BasicSubsystemCount
|
|
PRESET_BITS = 0x1F8 # ModePreset1..6
|
|
STICK = {0x40: 'trigger', 0x45: 'pinky', 0x46: 'thumb low', 0x47: 'thumb high'}
|
|
CM_ATTR = {2: 'stick', 3: 'throttle', 4: 'pedals', 5: 'reverse thrust',
|
|
6: 'LIFT CUT', 7: 'SIDESLIP'}
|
|
CM_MSG = {10: 'HORN', 14: 'reticle'}
|
|
# Authored bindings whose hardware never shipped past prototype cockpits.
|
|
NO_HARDWARE = {13} # VTVControlsMapper ActivatePTT
|
|
SILHOUETTE = {'l': 'lepton', 'p': 'puck', 's': 'speck', 'm': 'mule', 'b': 'bull'}
|
|
|
|
# ------------------------------------------------------------------ the CFG
|
|
blocks = dict(re.findall(r'^([A-Za-z0-9_]+)\s*\n\{(.*?)^\}', CFG, re.S | re.M))
|
|
|
|
hull_art = {}
|
|
for name, body in blocks.items():
|
|
if len(name) == 2 and name[0] in 'blpsm':
|
|
pccs = re.findall(r'([a-z0-9]+\.pcc)', body)
|
|
if pccs:
|
|
hull_art[name] = pccs[0]
|
|
|
|
# ------------------------------------------------- the console's own names
|
|
# TeslaConsole's RPConfig.xml is the only place the vehicles and their hull
|
|
# classes are named in words. The class comes from the picture the console
|
|
# showed for each machine ("images/red planet vehicles/Bull.bmp"), so every
|
|
# vehicle sharing a picture shares a class.
|
|
CONSOLE = {}
|
|
xml = open(ROOT + '/tools/console-config/RPConfig.xml', encoding='utf-8-sig').read()
|
|
for m in re.finditer(r'<vehicle\s+key="([^"]+)"\s+name="([^"]+)"'
|
|
r'(?:\s+image="[^"]*/([^"/]+)\.bmp")?', xml):
|
|
CONSOLE[m.group(1)] = {'display': m.group(2), 'consoleClass': m.group(3)}
|
|
|
|
cfg_veh = {}
|
|
for name, body in blocks.items():
|
|
if not name.endswith('Init'):
|
|
continue
|
|
refs = re.findall(r'^\s*([A-Za-z0-9_]+)\s*;', body, re.M)
|
|
if not any(r in ('common_setup', 'uncommon_setup') for r in refs):
|
|
continue
|
|
art = next((r for r in refs if len(r) == 2 and r[0] in SILHOUETTE), None)
|
|
cfg_veh[name[:-4]] = {
|
|
'silhouette': art,
|
|
'hullArt': hull_art.get(art),
|
|
'components': [r for r in refs if r not in ('common_setup', 'uncommon_setup')
|
|
and r != art],
|
|
}
|
|
|
|
# ------------------------------------------------- the resource id table
|
|
def resource_table():
|
|
rows = []
|
|
for line in open(LISTING, encoding='latin1'):
|
|
m = re.match(r'\s*(\d+)\s+(\d+)?\s*(.*)$', line.rstrip('\n'))
|
|
if m and m.group(3).strip():
|
|
rows.append((int(m.group(1)), m.group(2), m.group(3).strip()))
|
|
|
|
walk = []
|
|
o = RES.find(b'StaticAudioStream\x00') - 8
|
|
while o + 0x38 <= NRES:
|
|
name = RES[o + 8:o + 0x28].split(b'\x00')[0].decode('latin1', 'replace')
|
|
addr, size = struct.unpack_from('<II', RES, o + 0x30)
|
|
if addr != o + 0x38 or addr + size > NRES:
|
|
break
|
|
walk.append((name, addr, size))
|
|
o = addr + size
|
|
|
|
table, i, mismatched = {}, 0, 0
|
|
for rid, rsize, desc in rows:
|
|
if desc == 'Not Used':
|
|
continue # an id the file never assigned
|
|
if i >= len(walk):
|
|
break
|
|
name, addr, size = walk[i]
|
|
if rsize is not None and int(rsize) != size:
|
|
mismatched += 1
|
|
table[rid] = {'name': name, 'addr': addr, 'size': size, 'desc': desc}
|
|
i += 1
|
|
if mismatched:
|
|
print('!! %d id/size mismatches - the walk and the listing disagree'
|
|
% mismatched, file=sys.stderr)
|
|
return table
|
|
|
|
|
|
TABLE = resource_table()
|
|
|
|
# ------------------------------------------------------- subsystem names
|
|
RAWNAME = re.compile(rb'([A-Za-z][A-Za-z0-9_]{2,23})\x00')
|
|
SUBNAME = re.compile(r'(Booster\d*|Chute\d*|RivetGun\d*|LaserGun\d*|'
|
|
r'Demo[Pp]ackDropper\d*|Slaver\d*|Stinger\d*|Eject\d*)$')
|
|
|
|
|
|
def clean(raw):
|
|
"""Records are variable length; a float tail can leave printable junk
|
|
glued to the front of the next name. Longest valid suffix wins."""
|
|
for i in range(len(raw)):
|
|
if SUBNAME.fullmatch(raw[i:]):
|
|
return raw[i:]
|
|
return None
|
|
|
|
|
|
def subsystems(veh, warn):
|
|
want = re.compile(r'^%s: Stream of (\d+) Subsystems$' % re.escape(veh))
|
|
for res in TABLE.values():
|
|
m = want.match(res['desc'])
|
|
if not m:
|
|
continue
|
|
expect = int(m.group(1))
|
|
body = RES[res['addr'] + 4:res['addr'] + res['size']]
|
|
names = [clean(x.group(1).decode()) for x in RAWNAME.finditer(body)]
|
|
names = [n for n in names if n]
|
|
if len(names) != expect:
|
|
warn.append('subsystem stream lists %d names, header says %d'
|
|
% (len(names), expect))
|
|
return names
|
|
return []
|
|
|
|
|
|
# ------------------------------------------------------- mapping streams
|
|
def control_streams(veh):
|
|
"""The vehicle's ControlsMappings List names its streams by id."""
|
|
want = '%s: ControlsMappings List of' % veh
|
|
found = {}
|
|
for res in TABLE.values():
|
|
if not res['desc'].startswith(want):
|
|
continue
|
|
count = struct.unpack_from('<I', RES, res['addr'])[0]
|
|
for k in range(count):
|
|
rid = struct.unpack_from('<I', RES, res['addr'] + 4 + k * 4)[0]
|
|
target = TABLE.get(rid)
|
|
if target is None:
|
|
continue
|
|
m = re.match(r'^(L4|Thrustmaster)(?:: Stream of (\d+) ControlsMappings)?$',
|
|
target['desc'])
|
|
if m and m.group(2):
|
|
found[m.group(1)] = (target['addr'], int(m.group(2)))
|
|
return found
|
|
|
|
|
|
def decode(addr, count, subs, warn):
|
|
recs = [struct.unpack_from('<IIIIII', RES, addr + 4 + i * 24)
|
|
for i in range(count)]
|
|
|
|
def label(t, s, x):
|
|
if s == 0:
|
|
return CM_ATTR.get(x, 'attr%d' % x) if t == 0 else CM_MSG.get(x, 'msg%d' % x)
|
|
i = s - BASIC_SUBSYSTEM_COUNT
|
|
if 0 <= i < len(subs):
|
|
return subs[i]
|
|
warn.append('subsystem id %d outside the %d-entry list' % (s, len(subs)))
|
|
return 'subsystem %d' % s
|
|
|
|
def live(t, s, x):
|
|
return not (s == 0 and t == 1 and x in NO_HARDWARE)
|
|
|
|
presets = []
|
|
for p in range(6):
|
|
row = {}
|
|
for g, e, t, s, m, x in recs:
|
|
if (m & (8 << p)) and e in STICK and live(t, s, x):
|
|
row[STICK[e]] = label(t, s, x)
|
|
presets.append(row)
|
|
used = {label(t, s, x) for g, e, t, s, m, x in recs
|
|
if (m & PRESET_BITS) and e in STICK and live(t, s, x)}
|
|
return {'presets': presets, 'records': count, 'onStick': sorted(used)}
|
|
|
|
|
|
# ------------------------------------------------------------------ build
|
|
out, warnings = {}, {}
|
|
for veh in sorted(cfg_veh):
|
|
streams = control_streams(veh)
|
|
if 'L4' not in streams:
|
|
continue # no vehicle resource in this file
|
|
warn = warnings.setdefault(veh, [])
|
|
subs = subsystems(veh, warn)
|
|
if not subs:
|
|
warn.append('no subsystem stream found')
|
|
addr, count = streams['L4']
|
|
out[veh] = {
|
|
'name': veh, 'subsystems': subs, **cfg_veh[veh],
|
|
'boosters': sum(1 for s in subs if s.startswith('Booster')),
|
|
'chute': any(s.startswith('Chute') for s in subs),
|
|
'weapons': [s for s in subs if not s.startswith(('Booster', 'Chute'))],
|
|
'armed': any(not s.startswith(('Booster', 'Chute')) for s in subs),
|
|
'L4': decode(addr, count, subs, warn),
|
|
}
|
|
|
|
# --------------------------------------------- name the hulls, not the codes
|
|
# The silhouette code is what the game draws; the console names it. Decide
|
|
# each hull's name by majority vote of the vehicles drawn with it, so one
|
|
# mis-set picture in the config cannot rename a whole class.
|
|
votes = {}
|
|
for veh, entry in out.items():
|
|
cls = CONSOLE.get(veh, {}).get('consoleClass')
|
|
if cls:
|
|
votes.setdefault(entry.get('silhouette'), []).append(cls)
|
|
hull_name = {}
|
|
for sil, names in votes.items():
|
|
hull_name[sil] = max(set(names), key=names.count)
|
|
|
|
for veh, entry in out.items():
|
|
console = CONSOLE.get(veh, {})
|
|
entry['display'] = console.get('display', veh)
|
|
entry['consoleClass'] = console.get('consoleClass')
|
|
entry['hullClass'] = hull_name.get(entry.get('silhouette'))
|
|
# The console picture and the hull the game actually draws disagree for
|
|
# two community vehicles - worth showing rather than smoothing over.
|
|
entry['hullMismatch'] = bool(
|
|
entry['consoleClass'] and entry['hullClass'] and
|
|
entry['consoleClass'] != entry['hullClass'])
|
|
if entry['hullMismatch']:
|
|
print('!! %s: console shows a %s picture, the game draws the %s hull'
|
|
% (veh, entry['consoleClass'], entry['hullClass']), file=sys.stderr)
|
|
|
|
for veh, w in sorted(warnings.items()):
|
|
for line in sorted(set(w)):
|
|
print('!! %s: %s' % (veh, line), file=sys.stderr)
|
|
print('vehicles with preset tables: %d' % len(out), file=sys.stderr)
|
|
print('in the gauge config but not this resource file: %s'
|
|
% sorted(set(cfg_veh) - set(out)), file=sys.stderr)
|
|
|
|
json.dump(out, open(OUT, 'w'), indent=1)
|
|
print('wrote %s' % OUT, file=sys.stderr)
|