Package Apple Silicon VM exports and cached runtime switching with local cabinet controls and conagent provisioning. Preserve experimental SPIKE 2 emulation. Improve preparation concurrency, Ghidra checkpoints, signature importing, sound indexing and client-side spectrograms. Include regression tests and validation notes.
154 lines
8.1 KiB
Python
154 lines
8.1 KiB
Python
"""Private per-session launcher. No arbitrary browser commands or paths."""
|
|
import importlib.util
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import resource
|
|
import re
|
|
import subprocess
|
|
import shutil
|
|
import sys
|
|
|
|
|
|
def main():
|
|
workspace = Path(sys.argv[1]).resolve()
|
|
target = sys.argv[2]
|
|
if target not in ('game', 'boot-display', 'spike-menu'):
|
|
raise ValueError('Unsupported target')
|
|
runtime = json.loads((workspace / 'runtime.json').read_text())
|
|
arch = runtime['architecture']
|
|
triplet = {'aarch64':'aarch64-linux-gnu', 'armhf':'arm-linux-gnueabihf'}[arch]
|
|
loader_name = {'aarch64':'ld-linux-aarch64.so.1', 'armhf':'ld-linux-armhf.so.3'}[arch]
|
|
# Limit diagnostic logs; media is streamed rather than recorded.
|
|
resource.setrlimit(resource.RLIMIT_FSIZE, (256 * 1024**2, 256 * 1024**2))
|
|
# The retained SD extractor omitted symlinks. Reconstruct library SONAME
|
|
# aliases in a session-local view without changing archived files.
|
|
aliases = []
|
|
for name in ('lib', 'usr'):
|
|
view = workspace / name
|
|
original = view.resolve()
|
|
view.unlink()
|
|
view.mkdir()
|
|
for path in original.rglob('*'):
|
|
destination = view / path.relative_to(original)
|
|
if path.is_dir():
|
|
destination.mkdir(parents=True, exist_ok=True)
|
|
elif path.is_file():
|
|
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
destination.symlink_to(path)
|
|
for path in list(view.rglob('*')):
|
|
if not path.is_file() or '.so' not in path.name:
|
|
continue
|
|
result = subprocess.run([triplet + '-readelf', '-d', str(path)], capture_output=True, text=True)
|
|
match = re.search(r'\(SONAME\).*\[([^\]]+)\]', result.stdout)
|
|
if match and '/' not in match[1] and match[1] not in ('.', '..'):
|
|
alias = path.parent / match[1]
|
|
if not alias.exists():
|
|
alias.symlink_to(path.name)
|
|
aliases.append({'path': str(alias.relative_to(workspace)), 'target': path.name})
|
|
(workspace / 'library-aliases.json').write_text(json.dumps(aliases))
|
|
loader = workspace / 'lib' / loader_name
|
|
loader_source = loader.resolve()
|
|
loader.unlink()
|
|
shutil.copy2(loader_source, loader)
|
|
loader.chmod(0o555)
|
|
print(f'Restored {len(aliases)} library SONAME aliases in the private runtime view', flush=True)
|
|
source = workspace / 'emulation/process/spike3_emu.py'
|
|
spec = importlib.util.spec_from_file_location('capsule', source)
|
|
capsule = importlib.util.module_from_spec(spec)
|
|
sys.modules[spec.name] = capsule
|
|
spec.loader.exec_module(capsule)
|
|
capsule.ARCH = capsule.ARCHITECTURES[arch]
|
|
capsule.HASHED_ASSETS['loader'] = capsule.ARCH['loader']
|
|
capsule.TARGETS = {target: '/games/title/' + runtime['targets'][target]}
|
|
capsule.HASHED_ASSETS = {k:v for k,v in capsule.HASHED_ASSETS.items() if k in ('game', 'loader')}
|
|
original_base = capsule.bwrap_base_command
|
|
runtime_root = Path(runtime['directory']).resolve()
|
|
(workspace / 'bin').symlink_to(runtime_root / runtime['system_root'] / 'bin')
|
|
for logical in {'games/title/game', 'games/title/' + runtime['targets'][target]}:
|
|
destination = workspace / logical
|
|
original = destination.resolve()
|
|
destination.unlink()
|
|
shutil.copy2(original, destination)
|
|
destination.chmod(0o555)
|
|
sysfs = workspace / 'virtual-sys'
|
|
if arch == 'armhf':
|
|
# Isolated i.MX6 board model, matching the supplied SPIKE 2 rig.
|
|
# Q selects an unavailable VPU firmware so hardware decoding fails
|
|
# promptly instead of waiting forever for an unimplemented VPU.
|
|
attributes = {
|
|
'devices/soc0/soc_id': 'i.MX6Q', 'devices/soc0/revision': '1.2',
|
|
'devices/soc0/machine': 'Freescale i.MX6 Quad/DualLite (Device Tree)',
|
|
'fsl_otp/HW_OCOTP_CFG0': '0x12345678', 'fsl_otp/HW_OCOTP_CFG1': '0x9abcdef0',
|
|
'fsl_otp/HW_OCOTP_MAC0': '0x00001122', 'fsl_otp/HW_OCOTP_MAC1': '0x33445566',
|
|
'class/backlight/backlight_lvds.28/brightness': '7',
|
|
'class/backlight/backlight_lvds.28/max_brightness': '7',
|
|
'class/backlight/backlight_lvds.28/actual_brightness': '7',
|
|
'bus/iio/devices/iio:device0/in_power_frequency': '6000',
|
|
'bus/iio/devices/iio:device0/in_power_input': '0', 'class/gpio/export': '',
|
|
}
|
|
for name, value in attributes.items():
|
|
path = sysfs / name
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(value + '\n')
|
|
def runtime_base(*args, **kwargs):
|
|
# Session-local library links point at the shared immutable files.
|
|
command = original_base(*args, **kwargs) + ['--ro-bind', str(runtime_root), str(runtime_root)]
|
|
if sysfs.is_dir():
|
|
command += ['--ro-bind', str(sysfs), '/sys']
|
|
game_name = Path(runtime['game_root']).name
|
|
if game_name not in ('title', 'game', 'data', 'spike3', 'conagent'):
|
|
command += ['--symlink', 'title', '/games/' + game_name]
|
|
return command
|
|
capsule.bwrap_base_command = runtime_base
|
|
original_environment = capsule.target_environment
|
|
def runtime_environment(*args, **kwargs):
|
|
env = original_environment(*args, **kwargs)
|
|
# This worker has no macOS VideoToolbox service. Keep the donor's
|
|
# bridge available to its own launcher, but never probe it here.
|
|
env['SPIKE3_VIDEO_BRIDGE'] = '0'
|
|
if arch == 'armhf':
|
|
env['SPIKE3_EXPERIMENTAL_SPIKE2_UART'] = '1'
|
|
return env
|
|
capsule.target_environment = runtime_environment
|
|
if target == 'boot-display':
|
|
candidates = [Path(runtime['targets'][target]).parent, Path('system/usr/local/spike')]
|
|
font = next((p / 'VeraMono.ttf' for p in candidates if (workspace / 'games/title' / p / 'VeraMono.ttf').is_file()), None)
|
|
image = next((p / name for p in candidates for name in ('SternLogo_1360x768.png','SternLogo.png') if (workspace / 'games/title' / p / name).is_file()), None)
|
|
capsule.TARGET_DEFAULT_ARGS[target] = ['-message', runtime['label']]
|
|
if font: capsule.TARGET_DEFAULT_ARGS[target] += ['-font', '/games/title/' + str(font), '-font_height', '48']
|
|
if image: capsule.TARGET_DEFAULT_ARGS[target] += ['-background_image', '/games/title/' + str(image)]
|
|
config = capsule.load_configuration(workspace, None)
|
|
config['runtime']['extra_read_only_paths'].append('/usr/local/spike')
|
|
version_file = workspace / 'games/title/spike3/powerdist/version.txt'
|
|
if version_file.is_file():
|
|
version = version_file.read_text().strip().split('.')
|
|
if len(version) == 3 and all(v.isdigit() and 0 <= int(v) <= 255 for v in version):
|
|
profile = workspace / config['machine']['profile']
|
|
model = json.loads(profile.read_text())
|
|
model.setdefault('netbridge', {})['powerdist_version'] = [int(v) for v in version]
|
|
profile.write_text(json.dumps(model))
|
|
config['ghidra_offsets'] = {}
|
|
config['display']['enabled'] = False
|
|
config['dashboard']['enabled'] = False
|
|
config['machine']['tcp_host'] = '127.0.0.1'
|
|
config['machine']['tcp_port'] = 0
|
|
config['runtime'].update(architecture=arch, bypass_country_lock=False, force_free_play=False,
|
|
software_video_decoder=False)
|
|
# The web worker is the single paced FIFO consumer. No raw recording grows.
|
|
def audio_capture(workspace, config, run_dir):
|
|
fifo = capsule.work_paths(workspace)['writable'] / 'run/spike3-emu/audio.pipe'
|
|
fifo.parent.mkdir(parents=True, exist_ok=True)
|
|
os.mkfifo(fifo, 0o600)
|
|
return None
|
|
capsule.start_audio_capture = audio_capture
|
|
capsule.finalize_audio_capture = lambda *args: {'backend': 'live-pcm', 'recorded': False}
|
|
capsule.prepare_capsule(workspace, config)
|
|
(workspace / 'prepared.json').write_text(json.dumps({'target': target, 'patches': 'disabled'}))
|
|
raise SystemExit(capsule.execute_process_mode(workspace, config, 'run', target,
|
|
'qemu-user', 'sim', [], 1234))
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|