Files
Verstack Local f73167de85 Add downloadable VMs and improve catalog processing
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.
2026-09-14 20:03:59 -05:00

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()