#!/usr/bin/env python3 """Independent VT + physical controls; requires opt-in production frame metadata. No parsing renderer state or importing rendering code. Metadata is a diagnostic side channel only; all actions enter the real executable through its PTY. """ import errno import fcntl import json import os import pathlib import selectors import signal import struct import subprocess import sys import termios import time from vt import VT, export class Driver: def __init__(self, binary, data, content, style, color): self.vt = VT(100,24) self.total = 0 self.metadata = pathlib.Path(data).parent / ('interface-'+style+'-'+color+'.jsonl') self.metadata.unlink(missing_ok=True) self.last = None self.frames = [] env = os.environ.copy() for name in ('HEXWORK_FORCE_TC','HEXWORK_FORCE_256','HEXWORK_NO_COLOR','HEXWORK_FORCE_ASCII','NO_COLOR','HEXWORK_CONTENT_ROOT'): env.pop(name, None) env.update(TERM='xterm-256color', COLORTERM='truecolor', LANG='C.UTF-8', HEXWORK_INTERFACE_METADATA=str(self.metadata)) env[{'truecolor':'HEXWORK_FORCE_TC','indexed':'HEXWORK_FORCE_256','none':'HEXWORK_NO_COLOR'}[color]]='1' if os.environ.get('HEXWORK_PTY_ASCII') == '1': env['HEXWORK_FORCE_ASCII']='1' args=[binary,'--data-dir',data,'--content-root',content,'--seed',os.environ.get('HEXWORK_PTY_SEED','223'), '--name','Interface evidence','--ancestry','1','--calling','1'] if style in ('story','legacy','controls'): args.append('--story') if style == 'controls': env['HEXWORK_MOUSE']='1' if style == 'legacy': pathlib.Path(data).mkdir(parents=True,exist_ok=True) (pathlib.Path(data)/'keymap.toml').write_bytes(pathlib.Path(__file__).with_name('legacy-interface-keymap.toml').read_bytes()) if style == 'resume': runs=sorted(path.name for path in (pathlib.Path(data)/'runs').iterdir() if path.is_dir()) assert len(runs)==1, runs args=[binary,'--data-dir',data,'--content-root',content,'--load',runs[0]] self.master, slave = os.openpty() fcntl.ioctl(slave,termios.TIOCSWINSZ,struct.pack('HHHH',24,100,0,0)) try: self.child=subprocess.Popen(args,stdin=slave,stdout=slave,stderr=slave,env=env, close_fds=True,start_new_session=True) finally: os.close(slave) os.set_blocking(self.master,False) self.selector=selectors.DefaultSelector() self.selector.register(self.master,selectors.EVENT_READ) self.style,self.color=style,color def pump(self, timeout=0.05): for _, _ in self.selector.select(timeout): while True: try: chunk=os.read(self.master,65536) except OSError as error: if error.errno in (errno.EAGAIN,errno.EWOULDBLOCK): break if error.errno == errno.EIO: return raise if not chunk: return self.total+=len(chunk) if self.total > 32*1024*1024: raise RuntimeError('PTY exceeds 32 MiB') self.vt.feed(chunk) def records(self): if not self.metadata.exists(): return [] lines=self.metadata.read_bytes().split(b'\n')[:-1] return [json.loads(line) for line in lines if line] def wait(self, predicate, label, previous=-1): deadline=time.monotonic()+180 while time.monotonic() previous and predicate(value): # Metadata is written after flushing the terminal frame. Drain # already available bytes before inspecting the reconstructed grid. self.pump(0) self.last=value self.frames.append(label) artifact_name=self.style+'-'+self.color+'-seed'+str(value['seed'])+('-ascii' if value.get('ascii_only') else '-unicode')+'-'+label export(self.vt,artifact_name,value) return value if self.child.poll() is not None: raise RuntimeError('child exited before '+label+': '+self.vt.text()) raise RuntimeError('deadline waiting for '+label+'; metadata adapter present='+str(self.metadata.exists())+'; screen='+self.vt.text()) def key(self, wire, predicate, label): before=self.last['frame'] os.write(self.master,wire) return self.wait(predicate,label,before) def resize(self,width,height): self.vt.resize(width,height) fcntl.ioctl(self.master,termios.TIOCSWINSZ,struct.pack('HHHH',height,width,0,0)) os.kill(self.child.pid,signal.SIGWINCH) def close(self): self.selector.close() os.close(self.master) if self.child.poll() is None: self.child.kill() self.child.wait(timeout=5) def canonical(value): return tuple(value[key] for key in ('tick','diagnostic_hash','history_len')) def main(): binary,data,content,style,color=sys.argv[1:] driver=Driver(binary,data,content,style,color) try: initial=driver.wait(lambda m:not m['look_active'] and m['mode']=='first-person','play') assert 'Esc' in driver.vt.text(), driver.vt.text() assert initial['seed']==int(os.environ.get('HEXWORK_PTY_SEED','223')) assert initial['build_identifier'].startswith('fnv1a64:') if os.environ.get('HEXWORK_PTY_ASCII') == '1': assert initial['ascii_only'] is True assert all(ord(cell[0])<128 for row in driver.vt.cells[:16] for cell in row) if style=='resume': prior=json.loads(driver.metadata.with_name('interface-story-'+color+'.jsonl').read_text().splitlines()[-1]) assert initial['run_id']==prior['run_id'] and initial['build_identifier']==prior['build_identifier'] assert initial['history_len']==prior['history_len'] and initial['player_location']==prior['player_location'] driver.key(b'\t',lambda m:m['mode']=='chase','resume-camera') tactical=driver.key(b'm',lambda m:m['mode']=='tactical','resume-map') sliced=driver.key(b'\x1b[5~',lambda m:m['selected_z']!=m['player_z'],'resume-slice') home=driver.key(b'\x1b[H',lambda m:m['selected_z']==m['player_z'],'resume-home') assert canonical(tactical)==canonical(sliced)==canonical(home) driver.key(b'\x1b',lambda m:m['mode']=='chase','resume-return') assert driver.last['history_len']==initial['history_len'] os.write(driver.master,b'\x1b') deadline=time.monotonic()+180 while driver.child.poll() is None and time.monotonic()initial['history_len'],'camera-relative-forward') old=initial['player_location']; new=moved['player_location'] assert new==[old[0]+1,old[1],old[2]], (old,new,moved) os.write(driver.master,b'\x1b') deadline=time.monotonic()+180 while driver.child.poll() is None and time.monotonic()moved['tick'],'legacy-zero-wait') assert waited['history_len']>moved['history_len'], (moved,waited) driver.key(b'\x1b',lambda m:m['mode']=='first-person','legacy-map-exit') os.write(driver.master,b'\x1b') deadline=time.monotonic()+180 while driver.child.poll() is None and time.monotonic()