Files
EscapistsReintroduced/compatibility/rhs-1.9/prepare.py
T
jakub 9caed3f12d
continuous-integration/drone/push Build is passing
Embed RHS 1.9 compatibility; record Workshop 0.0.141
2026-11-05 16:39:35 +01:00

148 lines
6.9 KiB
Python

#!/usr/bin/env python3
"""Generate a local RHS 0.16.5237 compatibility addon from installed scripts.
Only two pinned scripts are read; installed Workshop files are never modified.
The generated addon replaces scripts at their original paths before compilation.
"""
import argparse
import hashlib
import json
from pathlib import Path
import shutil
import struct
import sys
import zlib
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE.parents[1] / 'tools'))
from pak_inventory import Limits, parse_tree
CAS = 'Scripts/Game/Editor/Components/EditableEntity/RHS_CAS_EditableEntityComponent.c'
TIRE = 'Scripts/Game/Vehicle/RHS_DestructionTireComponent.c'
EXPECTED = {
CAS: '1b4c5c8d3078f60f617cab94457bd0d9c96a048ad05117353e67e22b9972af37',
TIRE: '9fc9fffa3e63a1115e174e51faa222504038e57790f95c57ef691da8745039c9',
}
def replace_once(text, old, new):
if text.count(old) != 1:
raise ValueError('Expected exactly one patch location: ' + old)
return text.replace(old, new)
def patched(path, raw):
if hashlib.sha256(raw).hexdigest() != EXPECTED[path]:
raise ValueError('RHS script changed; review compatibility before patching: ' + path)
text = raw.decode('utf-8').replace('\r\n', '\n')
if path == CAS:
text = replace_once(text, 'override void SetAuthor(int playerID)',
'override void SetAuthor(int playerID, bool isGM = false)')
text = replace_once(text, 'super.SetAuthor(playerID);\n\n\t\tRHS_CAS_',
'super.SetAuthor(playerID, isGM);\n\n\t\tRHS_CAS_')
elif path == TIRE:
text = replace_once(text, 'return m_iWheelIndex;', 'return GetWheelIndex();')
return text.encode('utf-8')
def read_scripts(folder):
project = folder / 'addon.gproj'
expected_project = '51fda0afde5ebcd4dc88c6c9451d960b67aceaee437f02f1cc80aef724e3779c'
if hashlib.sha256(project.read_bytes()).hexdigest() != expected_project:
raise ValueError('Expected the audited RHS Status Quo 0.16.5237 project')
found = {}
for pak in sorted(folder.glob('*.pak')):
with pak.open('rb') as stream:
header = stream.read(12)
if header[:4] != b'FORM' or header[8:] != b'PAC1':
raise ValueError('Unsupported archive: ' + str(pak))
offset, chunks = 12, {}
for expected in (b'HEAD', b'DATA', b'FILE'):
stream.seek(offset)
tag, size = struct.unpack('>4sI', stream.read(8))
if tag != expected or offset + 8 + size > pak.stat().st_size:
raise ValueError('Invalid archive chunks')
chunks[tag] = (offset + 8, size)
offset += 8 + size
start, size = chunks[b'FILE']
if size > 32 * 1024**2:
raise ValueError('Archive tree too large')
stream.seek(start)
data_start, data_size = chunks[b'DATA']
entries, _, _ = parse_tree(stream.read(size), data_start,
data_start + data_size, Limits(total_bytes=32 * 1024**3))
for entry in entries:
path = entry['path']
if path not in EXPECTED:
continue
if path in found or entry['content_bytes'] > 1024**2:
raise ValueError('Duplicate or oversized target script')
stream.seek(entry['offset'])
raw = stream.read(entry['stored_bytes'])
if entry['compression'] == 'zlib':
raw = zlib.decompress(raw)
found[path] = raw
if set(found) != set(EXPECTED):
raise ValueError('Missing expected RHS scripts')
return found
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--rhs', type=Path, required=True, help='Installed RHS Status Quo directory')
destination = parser.add_mutually_exclusive_group(required=True)
destination.add_argument('--output', type=Path, help='New standalone addon directory')
destination.add_argument('--mission', type=Path, help='Existing Escape addon directory to embed the fixes in')
args = parser.parse_args()
if args.mission:
mission = args.mission.resolve()
if mission.is_relative_to(args.rhs.resolve()):
parser.error('Mission must be outside the installed RHS directory')
project = (mission / 'addon.gproj').read_text()
if 'GUID "2528466DE220D91C"' not in project or '"595F2BF2F44836FB"' not in project:
parser.error('Expected the Escape project with its original RHS dependency')
if '"E119F1C05A274B83"' in project:
parser.error('Remove the standalone RHS fix dependency before embedding it')
originals = read_scripts(args.rhs)
outputs = {path: patched(path, raw) for path, raw in originals.items()}
# Validate all destinations before writing; never overwrite a local edit
# or follow a link into another addon. Repeating the same patch is safe.
for path, raw in outputs.items():
target = mission / path
if any(parent.is_symlink() for parent in [target, *target.parents] if parent != mission.parent):
parser.error('Symlink in mission destination: ' + path)
if target.exists() and (not target.is_file() or target.read_bytes() != raw):
parser.error('Existing mission script differs; preserve and review it: ' + path)
for path, raw in outputs.items():
target = mission / path
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(raw)
print('Embedded both pinned RHS fixes in ' + str(mission))
return
if args.output.exists():
parser.error('Output already exists; choose a new directory to preserve prior work')
if args.output.resolve().is_relative_to(args.rhs.resolve()):
parser.error('Output must be outside the installed RHS directory')
report_path = args.output.with_name(args.output.name + '-patch-inputs.json')
if report_path.exists():
parser.error('Output provenance already exists; choose a new directory')
# Validate every input and patch before creating any output.
originals = read_scripts(args.rhs)
outputs = {path: patched(path, raw) for path, raw in originals.items()}
args.output.mkdir(parents=True)
shutil.copy2(HERE / 'addon.gproj', args.output / 'addon.gproj')
report = {'rhs_version': '0.16.5237', 'target_game_version': '1.9.0.37',
'native_validation': 'required', 'scripts': {}}
for path, raw in outputs.items():
target = args.output / path
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(raw)
report['scripts'][path] = {'original_sha256': EXPECTED[path],
'patched_sha256': hashlib.sha256(raw).hexdigest()}
report_path.write_text(json.dumps(report, indent=2) + '\n')
print(args.output.resolve())
if __name__ == '__main__':
main()