"""Optional bounded host presentation-call timing. No per-frame bridge traffic.""" import contextlib import csv import fcntl import hashlib import json import math import mmap import os from pathlib import Path import secrets import struct import subprocess import sys import time from . import support from . import native ROOT = Path(__file__).resolve().parent.parent ACTIVE = support.STATE_ROOT / 'performance-active.json' LAUNCH = support.STATE_ROOT / 'performance-launch.json' LAYER = ROOT / 'presentation/build' HEADER = struct.Struct('<16Q') RECORD = struct.Struct('<6QiIQ') MAGIC = 0x31544e4553455250 CAPACITY = 65536 MARKER_LIMIT = 2*1024*1024 SEMANTICS = 'Host CLOCK_MONOTONIC intervals between vkQueuePresentKHR entries, at this layer in the Proton Vulkan chain; not GPU completion, compositor/display timing or input latency.' def digest(p): return hashlib.sha256(Path(p).read_bytes()).hexdigest() def write(p,v): support.atomic_json(Path(p),v) def read(p): return json.loads(Path(p).read_text()) @contextlib.contextmanager def lock(): with (support.STATE_ROOT/'.performance.lock').open('a') as f: fcntl.flock(f,fcntl.LOCK_EX) yield def validate_number(value,low,high,name): if not math.isfinite(value) or not low <= value <= high:raise ValueError(f'{name} must be finite, {low}..{high}') def launch(proton): """Temporary app-scoped Proton environment, with exact-byte rollback journal.""" from . import lifecycle lifecycle.verify();support.manage.ensure_game_closed() if LAUNCH.exists() and not read(LAUNCH).get('restored'):raise RuntimeError('Capture launch configuration exists; run perf-restore first') receipt=read(LAYER/'receipt.json') if digest(LAYER/'libPathPresent.so')!=receipt['library_sha256'] or digest(ROOT/'presentation/layer.cpp')!=receipt['source_sha256']: raise RuntimeError('Presentation library/source differs from its build receipt; rebuild with presentation/build.py') proton=Path(proton).resolve() if not (proton/'proton').is_file():raise ValueError('Expected the active Steam Proton directory containing proton') target=proton/'user_settings.py' if target.is_symlink():raise RuntimeError('Refusing symlinked Proton user_settings.py') before=target.read_bytes() if target.exists() else None folder=support.ARTIFACTS/('performance-launch-'+str(time.time_ns()));folder.mkdir() buffers=folder/'buffers';buffers.mkdir() env={'VK_LAYER_PATH':str(LAYER),'VK_INSTANCE_LAYERS':'VK_LAYER_PATHDEVKIT_present','PATHDEVKIT_PRESENT_DIR':str(buffers)} # Proton does not override an existing environment variable. Runtime discovery # verifies that this layer actually loaded, rather than treating launch as capture. addition='\n# PathDevkit temporary presentation capture\nimport os as _pd_os\nif _pd_os.environ.get("SteamAppId") == "292030":\n user_settings.update('+repr(env)+')\n' installed=(before or b'user_settings = {}\n')+addition.encode() if before is not None:(folder/'user_settings.before').write_bytes(before) (folder/'user_settings.installed').write_bytes(installed) journal=dict(path=str(target),folder=str(folder),before_exists=before is not None,installed_sha256=hashlib.sha256(installed).hexdigest(),env=env,restored=False) write(LAUNCH,journal);target.write_bytes(installed) try: from . import game_settings launched=game_settings.launch() except Exception: restore();raise return dict(ok=True,state='launch_requested',lab_mode=launched['lab_mode'],journal=str(LAUNCH),next='Wait for the real window; attach; perf-status must confirm a buffer owned by the game process. stop restores personal settings and capture configuration after exit.') def restore(): with lock(): if not LAUNCH.exists():return dict(ok=True,restored=True,reason='no_owned_configuration') j=read(LAUNCH) if j.get('restored'):return dict(ok=True,restored=True,journal=str(LAUNCH)) p=Path(j['path']) if not p.exists() or digest(p)!=j['installed_sha256']:raise RuntimeError('Capture Proton configuration changed externally; refusing overwrite. Inspect performance-launch.json') if j['before_exists']:p.write_bytes((Path(j['folder'])/'user_settings.before').read_bytes()) else:p.unlink() j['restored']=True;j['restored_at']=support.now();write(LAUNCH,j);write(Path(j['folder'])/'cleanup.json',j) return dict(ok=True,restored=True,journal=str(LAUNCH),settings_bytes_restored=True) def capability(): result=dict(ok=False,available=False,provider='PathPresent',version=1,semantics=SEMANTICS,clock='CLOCK_MONOTONIC',units='nanoseconds',capacity=CAPACITY,attribution={'gpu_time':False,'script_cpu_time':False,'displayed_frames':False}) try: identity=support.process();pid=identity['pid'];proc=Path('/proc')/str(pid) env=dict(x.split(b'=',1) for x in (proc/'environ').read_bytes().split(b'\0') if b'=' in x) directory=env.get(b'PATHDEVKIT_PRESENT_DIR') if not directory:raise RuntimeError('Game launched without capture environment; close it and use perf-launch') source=Path(os.fsdecode(directory))/f'{pid}.bin' maps=(proc/'maps').read_text() if str(LAYER/'libPathPresent.so') not in maps:raise RuntimeError('Presentation layer not mapped in game process') if not source.exists():raise RuntimeError('Layer mapped but no presentation buffer yet; wait for the real game window') header=HEADER.unpack(source.open('rb').read(HEADER.size)) if header[:5]!=(MAGIC,1,pid,CAPACITY,RECORD.size):raise RuntimeError('Invalid buffer identity/layout') same_clock=os.readlink(proc/'ns/time')==os.readlink('/proc/self/ns/time') if not same_clock:raise RuntimeError('Game and collector use different time namespaces; clock alignment unqualified') receipt=read(LAYER/'receipt.json') if digest(LAYER/'libPathPresent.so')!=receipt['library_sha256'] or digest(ROOT/'presentation/layer.cpp')!=receipt['source_sha256']:raise RuntimeError('Provider build receipt does not match source/library') if not any(int(line.split()[4])==(LAYER/'libPathPresent.so').stat().st_ino for line in maps.splitlines() if str(LAYER/'libPathPresent.so') in line):raise RuntimeError('Mapped provider inode differs from current library') result.update(ok=True,available=True,process=identity,buffer=str(source),presents_seen=header[11],active=bool(header[5]),same_time_namespace=True,clock_resolution_ns=int(time.get_clock_info("monotonic").resolution*1e9),alignment_offset_ns=0,measurement_uncertainty_ns=None,clock_alignment_note='Same kernel CLOCK_MONOTONIC domain; timestamps stored as integer ns. 1 ns is representation resolution, not clock accuracy or observer-effect bound.',build=receipt,runtime_libraries=sorted(set(line.split()[-1] for line in maps.splitlines() if any(n in line.lower() for n in ('vkd3d','winevulkan','libvulkan','libpathpresent'))))) except (OSError,RuntimeError,ValueError) as e:result['reason']=str(e) return result def marker(kind,details=None): """Called by host control path only. Never queries or mutates the game.""" if not ACTIVE.exists():return state=read(ACTIVE) if state.get('finished'):return item=dict(monotonic_ns=time.monotonic_ns(),kind=kind,details=details or {}) path=Path(state['folder'])/'markers.jsonl' encoded=(json.dumps(item,separators=(',',':'))+'\n').encode() with path.open('ab') as f: fcntl.flock(f,fcntl.LOCK_EX) size=path.stat().st_size if size+len(encoded)>MARKER_LIMIT: write(path.parent/'marker-overflow.json',dict(limit_bytes=MARKER_LIMIT,retained_bytes=size,rejected_bytes=len(encoded),kind=str(kind)[:128])) raise RuntimeError('Performance marker buffer limit reached; perf-stop will retain a failed partial report') f.write(encoded) def retained_markers(folder): """Read a bounded complete-line prefix, even for an externally overfilled log.""" path=folder/'markers.jsonl' with path.open('rb') as f: fcntl.flock(f,fcntl.LOCK_SH) size=os.fstat(f.fileno()).st_size;prefix=f.read(MARKER_LIMIT) complete=prefix[:prefix.rfind(b'\n')+1] markers=[];invalid=0 for line in complete.splitlines(): try: item=json.loads(line) if not isinstance(item,dict) or not isinstance(item.get('monotonic_ns'),int) or not isinstance(item.get('kind'),str) or not isinstance(item.get('details'),dict):raise ValueError('Invalid marker') markers.append(item) except (ValueError,UnicodeError):invalid+=1 return markers,dict(limit_bytes=MARKER_LIMIT,file_bytes=size,parsed_prefix_bytes=len(complete),omitted_bytes=size-len(complete),invalid_lines=invalid,overflow_recorded=(folder/'marker-overflow.json').exists()) def start(seconds,label,thresholds,budget_ms=None): validate_number(seconds,.1,120,'seconds') if not label or len(label)>128:raise ValueError('arm label required, <=128 characters') if not thresholds:raise ValueError('At least one explicit --threshold-ms is required') for t in thresholds:validate_number(t,.01,10000,'threshold-ms') if budget_ms is not None:validate_number(budget_ms,.01,10000,'budget-ms') with lock(): if ACTIVE.exists() and not read(ACTIVE).get('finished'):raise RuntimeError('Capture already exists; perf-stop preserves it before another start') cap=capability() if not cap['available']:raise RuntimeError(cap['reason']) session=native.state();snapshot=native.call('snapshot') if cap['process']!=session['process']:raise RuntimeError('Capture process differs from attached game') folder=support.ARTIFACTS/('performance-'+str(time.time_ns()));folder.mkdir() (folder/'markers.jsonl').touch() graphics={n:dict(sha256=digest(support.manage.DOCS/n),file=n) for n in ('user.settings','dx12user.settings')} for n in graphics:(folder/n).write_bytes((support.manage.DOCS/n).read_bytes()) state=dict(version=1,folder=str(folder),buffer=cap['buffer'],capability=cap,session=session['session'],generation=snapshot['generation'],bridge_build=session['build'],loaded_fixture=session.get('loaded_fixture'),process=cap['process'],label=label,thresholds_ms=sorted(set(thresholds)),budget_ms=budget_ms,seconds=seconds,snapshot_before=snapshot,graphics=graphics,finished=False,epoch=secrets.randbits(63),created=support.now(),collector_sources={n:digest(ROOT/'witcher_devkit'/n) for n in ('performance.py','native.py','cli_app.py','catalog.py')}) state['clock_identity']=dict(boot_id=Path('/proc/sys/kernel/random/boot_id').read_text().strip(),time_namespace=os.readlink('/proc/self/ns/time')) with open(cap['buffer'],'r+b') as f, mmap.mmap(f.fileno(),0) as mem: struct.pack_into('threshold:run.append(v) elif run:runs.append(run);run=[] spikes.append(dict(threshold_ms=threshold,comparison='strictly_greater',frame_count=sum(len(r) for r in runs),episode_count=len(runs),total_frame_time_ms=sum(sum(r) for r in runs),total_excess_ms=sum(sum(v-threshold for v in r) for r in runs),longest_episode_ms=max([sum(r) for r in runs],default=0))) return dict(interval_count=n,duration_ms=sum(values),median_ms=percentile(values,.5),p95_ms=percentile(values,.95),p99_ms=percentile(values,.99),maximum_ms=max(values,default=None),percentile_method='nearest_rank: sorted[ceil(p*n)-1]',tail_estimates_qualified=n>=1000,tail_note='Require >=1000 intervals for this workflow (at least 10 observations in the top 1%); not a statistical confidence guarantee.',spikes=spikes,budget_ms=budget_ms,budget_exceeded_frames=sum(v>budget_ms for v in values) if budget_ms is not None else None) def decode(raw,epoch): if len(raw)CAPACITY:raise ValueError('Invalid trace layout') if len(raw)!=HEADER.size+h[6]*RECORD.size:raise ValueError('Trace length mismatch') rows=[];previous=None for i in range(h[6]): seq,begin,end,swap,tid,ep,result,count,overhead=RECORD.unpack_from(raw,HEADER.size+i*RECORD.size) if seq!=i or ep!=epoch or begin>end or (previous and begincursor:gaps.append((cursor,lo)) known+=hi-lo;cursor=hi if left>=begin and right<=end:selected.append(row) else:boundary.append(dict(begin_ns=left,end_ns=right,interval_ns=interval,overlap_ns=hi-lo,crosses_begin=leftend,exceeds_thresholds_ms=[t for t in thresholds if interval>t*1e6])) if cursorrows[-1]['begin_ns'],note='Uncovered time inside the window, not a completed frame duration.')) duration=end-begin;completed=sum(r['interval_ns'] for r in selected) limit=min(thresholds) reasons=[] if known!=duration:reasons.append('uncovered_or_censored_time') if any(b['interval_ns']>limit*1e6 for b in boundary):reasons.append('boundary_interval_exceeds_threshold') if not selected:reasons.append('no_completed_intervals') coverage=dict(version=1,window_ns=duration,completed_interval_ns=completed,known_interval_overlap_ns=known,boundary_overlap_ns=sum(b['overlap_ns'] for b in boundary),uncovered_ns=duration-known,uncovered_leading_ns=next((hi-lo for lo,hi in gaps if lo==begin),0),uncovered_trailing_ns=next((hi-lo for lo,hi in gaps if hi==end),0),completed_fraction=completed/duration,known_fraction=known/duration,boundary_intervals=boundary,censored_gaps=censored,acceptance_rule=dict(name='full_known_coverage_and_bounded_boundary_intervals_v1',required_known_fraction=1.0,max_boundary_interval_ms=limit,threshold_source='smallest declared spike threshold; not a universal performance budget',require_completed_intervals=True),acceptance_eligible=not reasons,rejection_reasons=reasons) return dict(summary=summarize(selected,thresholds,budget_ms),coverage=coverage) def stop(reason='manual',expected_folder=None): with lock(): if not ACTIVE.exists():raise RuntimeError('No capture; use perf-start') state=read(ACTIVE);folder=Path(state['folder']) if expected_folder and str(folder)!=expected_folder:return dict(ok=False,reason='capture_replaced') if state.get('finished'):return read(folder/'report.json') with open(state['buffer'],'r+b') as f,mmap.mmap(f.fileno(),0) as mem: struct.pack_into('1:errors.append('swapchain_changed_intervals_segmented') if any(r['present_result'] not in (0,1000001003) for r in rows):errors.append('present_returned_error') live=support.process_identity(state['process']['pid'])==state['process'] if not live:errors.append('game_exited_partial_capture') with (folder/'frames.csv').open('w') as f: cols=list(rows[0]) if rows else ['sequence','begin_ns','end_ns','interval_ns'] writer=csv.DictWriter(f,fieldnames=cols);writer.writeheader();writer.writerows(rows) windows=[] for m in markers: if m['kind']=='window_begin': ends=[e for e in markers if e['kind']=='window_end' and e['details'].get('name')==m['details'].get('name') and e['monotonic_ns']>=m['monotonic_ns']] if not ends:continue e=ends[0] if e['monotonic_ns']<=m['monotonic_ns']:continue windows.append(dict(name=m['details']['name'],kind=m['details'].get('kind'),begin_ns=m['monotonic_ns'],end_ns=e['monotonic_ns'],**window_analysis(rows,m['monotonic_ns'],e['monotonic_ns'],state['thresholds_ms'],state['budget_ms']))) report=dict(loaded_fixture=state.get('loaded_fixture'),ok=not errors,provider='PathPresent',semantics=SEMANTICS,folder=str(folder),label=state['label'],session=state['session'],generation=state['generation'],process=state['process'],stop_reason=reason,complete=not errors,errors=errors,dropped_samples=h[7],uncommitted_present=in_flight,capacity=CAPACITY,retained_records=len(rows),raw_sha256=digest(folder/'trace.bin'),units='ns in raw/CSV; ms in summaries',clock='CLOCK_MONOTONIC',clock_alignment=state['capability']['clock_alignment_note'],requested_seconds=state['seconds'],observed_span_ns=rows[-1]['begin_ns']-rows[0]['begin_ns'] if len(rows)>1 else 0,summary=summarize(rows,state['thresholds_ms'],state['budget_ms']),windows=windows,markers=markers,graphics=state['graphics'],observer_bookkeeping_ns=summarize([dict(interval_ns=r['observer_ns']) for r in rows],[],None),observer_note='Bookkeeping brackets exclude downstream Present; they also exclude dispatch lookup, initial enabled check, two clock reads, atomic commit and disabled-layer cost. Not total observer overhead.',exclusion_rule='Raw trace keeps all samples. Window summaries retain only intervals wholly inside explicit begin/end markers. Coverage separately exposes boundary-crossing intervals and censored gaps. complete is capture integrity, not coverage acceptance. Whole-trace summary includes pauses/menus/loading unless separately marked.',attribution={'gpu_time':None,'script_cpu_time':None,'displayed_frames':None,'input_latency':None}) report.update(coverage_version=1,marker_log=marker_log) write(folder/'report.json',report);state.update(finished=True,report=str(folder/'report.json'));write(folder/'capture.json',state);write(ACTIVE,state) return report def watch(folder): state=read(Path(folder)/'capture.json') while time.monotonic_ns()1 and len({v['swapchain'] for v in rows})==1: if sum(v['interval_ns'] or 0 for v in rows)!=rows[-1]['begin_ns']-rows[0]['begin_ns']:raise ValueError('Trace duration mismatch') return dict(ok=True,report=str(path),raw_hash=r['raw_sha256'],records=len(rows),windows_checked=len(r['windows']),window_coverage=coverage,csv_matches_raw=True,summary_matches_raw=True,scope='Content and arithmetic integrity, not proof of gameplay validity or causal attribution. Legacy report coverage is recomputed without rewriting evidence; comparison enforces the current coverage rule.')