148 lines
6.9 KiB
Python
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()
|