Acceleration, top speed, impact speed, armor, boosts, chutes and each tool's charges, decoded from the resource file rather than transcribed. GameModel is a fixed 180-byte block: mass at +0, drag at +36, acceleration at +64, impact speed at +100. Top speed is NOT stored - it is terminal velocity, acceleration over drag, which is why it lands on the round numbers the arcade quoted: 6.0/0.060 is Mule's 360 kph, 5.5/0.060 is Bull's 330. Armor is a float in the DamageZones record past the "dz_vtv" name, at +35. Boosts and chutes come from the subsystem stream, which is now walked properly: a record is name[32], a type id, its own length, and the charge count sixteen bytes on. That replaces a regex that hunted for printable names in the float tails and guessed where each one started - the new walk matches every vehicle's declared subsystem count exactly. Neutrino's two derived figures are withheld and the card says why. Its drag is 0.008 against 0.052 on every other Lepton and its impact speed is uninitialised, so the engine would give it a 2880 kph top speed. The data is wrong, not the reading.
292 lines
12 KiB
Python
292 lines
12 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()
|
|
|
|
# ------------------------------------------------------------- subsystems
|
|
# A record is name[32], then an int type id, the record's own length in
|
|
# bytes, and sixteen bytes on, how many charges it carries. Walking by the
|
|
# length reads the stream exactly, which beats hunting for printable names
|
|
# in the float tails and then guessing where each one really starts.
|
|
SUB_TYPE = {2004: 'Booster', 2005: 'Rivet Gun', 2006: 'Laser Drill',
|
|
2007: 'Demopacks', 2008: 'Chute'}
|
|
|
|
|
|
def subsystems(veh, warn):
|
|
"""-> [(name, type id, charges)] in stream order."""
|
|
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']:res['addr'] + res['size']]
|
|
out, o = [], 4
|
|
while len(out) < expect and o + 52 <= len(body):
|
|
length = struct.unpack_from('<i', body, o + 36)[0]
|
|
if not (40 <= length <= 512) or o + length > len(body):
|
|
break
|
|
out.append((body[o:o + 32].split(b'\x00')[0].decode('latin1'),
|
|
struct.unpack_from('<i', body, o + 32)[0],
|
|
struct.unpack_from('<i', body, o + 48)[0]))
|
|
o += length
|
|
if len(out) != expect:
|
|
warn.append('subsystem stream walked %d records, header says %d'
|
|
% (len(out), expect))
|
|
return out
|
|
return []
|
|
|
|
|
|
# --------------------------------------------------------------- the specs
|
|
# GameModel is a fixed 180-byte block. Top speed is NOT stored: it is
|
|
# terminal velocity, acceleration over drag, which is why it comes out as
|
|
# the round numbers the arcade quoted. Armor is a float in the DamageZones
|
|
# record, past the "dz_vtv" name.
|
|
GM_MASS, GM_DRAG, GM_ACCEL, GM_IMPACT = 0, 36, 64, 100
|
|
DZ_ARMOR = 35
|
|
|
|
|
|
def specs(veh, warn):
|
|
model = next((r for r in TABLE.values()
|
|
if r['desc'] == '%s: GameModel' % veh), None)
|
|
if model is None:
|
|
return {}
|
|
mass, drag, accel, impact = (
|
|
struct.unpack_from('<f', RES, model['addr'] + o)[0]
|
|
for o in (GM_MASS, GM_DRAG, GM_ACCEL, GM_IMPACT))
|
|
zone = next((r for r in TABLE.values()
|
|
if re.match(r'^%s: Stream of \d+ DamageZones$' % re.escape(veh),
|
|
r['desc'])), None)
|
|
armor = struct.unpack_from('<f', RES, zone['addr'] + DZ_ARMOR)[0] if zone else 0
|
|
top = accel / drag * 3.6 if drag else 0
|
|
# Neutrino's drag is 0.008 against 0.052 on every sibling, and its
|
|
# impact speed is uninitialised. Say so rather than print it.
|
|
sane = 0 < impact < 2000 and 0 < top < 1200
|
|
if not sane:
|
|
warn.append('physics out of range: top %.0f kph, impact %.3g' % (top, impact))
|
|
return {'mass': round(mass), 'accel': round(accel * 3.6, 2),
|
|
'topSpeed': round(top), 'impact': round(impact),
|
|
'armor': round(armor), 'physicsSane': sane}
|
|
|
|
|
|
# ------------------------------------------------------- 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, [])
|
|
records = subsystems(veh, warn)
|
|
if not records:
|
|
warn.append('no subsystem stream found')
|
|
subs = [name for name, _, _ in records] # preset tables index by these
|
|
boosts = [c for _, t, c in records if t == 2004]
|
|
addr, count = streams['L4']
|
|
out[veh] = {
|
|
'name': veh, 'subsystems': subs, **cfg_veh[veh],
|
|
'boosters': len(boosts),
|
|
# "2x6" is two boosters of six charges each. A vehicle whose
|
|
# boosters disagree is listed as the sum instead.
|
|
'boostCharges': boosts[0] if boosts and len(set(boosts)) == 1 else None,
|
|
'boostTotal': sum(boosts),
|
|
'chute': any(t == 2008 for _, t, _ in records),
|
|
'chuteCharges': next((c for _, t, c in records if t == 2008), 0),
|
|
'tools': [{'name': 'Slaver' if n.startswith('Slaver') else SUB_TYPE[t],
|
|
'charges': c}
|
|
for n, t, c in records if t in (2005, 2006, 2007)],
|
|
'weapons': [s for s in subs if not s.startswith(('Booster', 'Chute'))],
|
|
'armed': any(not s.startswith(('Booster', 'Chute')) for s in subs),
|
|
**specs(veh, warn),
|
|
'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)
|