"""Bounded external ReadProcessMemory observation of an explicitly receipt-owned offline copy.

No injection, native calls, hooks, memory writes, input, exits or process termination.
The known transform layout is pinned to one offline build; it is never reused on the online build.
"""
import argparse, ctypes as c, hashlib, json, math, pathlib, struct, sys, time
from ctypes import wintypes as w

PIN='e6be8bbe04e6a7ae226d4318945af7f367de13dc5a007a261964d9ba8144e988'
parser=argparse.ArgumentParser()
parser.add_argument('--launch',required=True,type=pathlib.Path)
parser.add_argument('--output',required=True,type=pathlib.Path)
args=parser.parse_args()
receipt=json.loads(args.launch.read_text(encoding='utf-8-sig'))
if receipt.get('mode')!='in-process-offline' or receipt.get('zeroNetworkCapabilities') is not True or receipt.get('imageSha256')!=PIN:
    raise SystemExit('Requires a matching receipt-owned offline build')
capture_scripts=args.launch.resolve().parents[2]/'scripts'/'capture'
sys.path.insert(0,str(capture_scripts))
from owned_diagnostic_lock import acquire_native_lease
lease=acquire_native_lease(args.launch)
kernel=c.WinDLL('kernel32',use_last_error=True)
kernel.OpenProcess.argtypes=[w.DWORD,w.BOOL,w.DWORD];kernel.OpenProcess.restype=w.HANDLE
kernel.CloseHandle.argtypes=[w.HANDLE]
kernel.ReadProcessMemory.argtypes=[w.HANDLE,c.c_void_p,c.c_void_p,c.c_size_t,c.POINTER(c.c_size_t)]
kernel.QueryFullProcessImageNameW.argtypes=[w.HANDLE,w.DWORD,w.LPWSTR,c.POINTER(w.DWORD)]
kernel.CreateToolhelp32Snapshot.argtypes=[w.DWORD,w.DWORD];kernel.CreateToolhelp32Snapshot.restype=w.HANDLE
class Module(c.Structure):
    _fields_=[('dwSize',w.DWORD),('th32ModuleID',w.DWORD),('th32ProcessID',w.DWORD),('GlblcntUsage',w.DWORD),('ProccntUsage',w.DWORD),('modBaseAddr',c.c_void_p),('modBaseSize',w.DWORD),('hModule',w.HMODULE),('szModule',w.WCHAR*256),('szExePath',w.WCHAR*260)]
kernel.Module32FirstW.argtypes=[w.HANDLE,c.POINTER(Module)];kernel.Module32NextW.argtypes=[w.HANDLE,c.POINTER(Module)]
pid=int(receipt['ProcessId']);process=kernel.OpenProcess(0x410,False,pid)
if not process:raise OSError('Cannot open the owned process for external read-only observation')
def read(address,size):
    if address<0x10000 or size>8192:raise ValueError('Invalid bounded read')
    buffer=c.create_string_buffer(size);done=c.c_size_t()
    if not kernel.ReadProcessMemory(process,address,buffer,size,c.byref(done)) or done.value!=size:raise OSError('Unreadable native object')
    return buffer.raw
def number(address,format):return struct.unpack(format,read(address,struct.calcsize(format)))[0]
def ptr(address):return number(address,'<Q')
report={'scope':'external read-only receipt-owned offline process; no injection, hooks, native invocation or writes','sha256':PIN,'pid':pid,'samples':[],'heatPercentVerified':False,'exclusionEnvelopeVerified':False,'controlVerified':False}
try:
    name=c.create_unicode_buffer(32768);length=w.DWORD(len(name))
    if not kernel.QueryFullProcessImageNameW(process,0,name,c.byref(length)):raise OSError('Cannot verify process image')
    image=pathlib.Path(name.value)
    if image.name.lower()!='elitedangerous64.exe' or hashlib.sha256(image.read_bytes()).hexdigest()!=PIN:raise ValueError('Process build mismatch')
    snapshot=kernel.CreateToolhelp32Snapshot(0x18,pid)
    if snapshot==c.c_void_p(-1).value:raise OSError('Cannot enumerate owned modules')
    modules=[]
    try:
        item=Module();item.dwSize=c.sizeof(Module);found=kernel.Module32FirstW(snapshot,c.byref(item))
        while found:
            modules.append((item.szModule.lower(),item.szExePath,item.modBaseAddr,item.modBaseSize));found=kernel.Module32NextW(snapshot,c.byref(item))
    finally:kernel.CloseHandle(snapshot)
    if not any(pathlib.Path(path)==pathlib.Path(receipt['ownedNativeRuntime']) for _,path,_,_ in modules):raise ValueError('Receipt-owned runtime is not loaded in the selected process')
    base=next(base for name,_,base,_ in modules if name=='elitedangerous64.exe')
    seeds={}
    for path in sorted(args.launch.parent.glob('entity-*.jsonl')):
        if path.stat().st_size>16*1024*1024:continue
        for line in path.read_text(encoding='utf-8-sig').splitlines():
            try:
                record=json.loads(line)
                if record.get('kind')=='native-entity-observed' and record.get('lifecyclePointer') and len(seeds)<512:
                    seeds[str(record['id'])]=(int(record['lifecyclePointer'],16),str(record.get('type','')))
            except (ValueError,KeyError,TypeError):pass
    type_ids=[number(base+rva,'<I') for rva in [0x5F02CFC,0x5F2DF60]]
    report['seedCount']=len(seeds);report['transformTypeIds']=type_ids
    for sample in range(3):
        positions=[];live=0
        for entity,(life,kind) in seeds.items():
            try:
                if str(ptr(life+0xB0))!=entity or number(life+0xC0,'<I')!=8:continue
                live+=1;component=ptr(life+0xC8)
                if ptr(ptr(component+8))!=base+0x8C6EF0:continue
                root=component-0x30;slots=ptr(ptr(root+0x10)+0x30);components=ptr(root+0x18)
                for type_id in type_ids:
                    if not 0<type_id<100000:continue
                    index=ptr(slots+type_id*16)
                    if index>512:continue
                    transform=ptr(components+index*8);vtable=ptr(transform);method=ptr(vtable+0x28)
                    if vtable==base+0x5355318:matrix=transform+0xD8
                    elif method==base+0x3CB93C0:
                        offset=0xF8 if number(transform+0x410,'<B') or number(ptr(transform-8)+0x10,'<I')==0 else 0x1E8
                        matrix=transform+offset
                    else:continue
                    position=[number(matrix+offset,'<d') for offset in [0x60,0x68,0x70]]
                    rotation=[number(matrix+offset,'<d') for offset in [0,8,0x10,0x20,0x28,0x30,0x40,0x48,0x50]]
                    if not all(math.isfinite(value) for value in position+rotation):continue
                    axes=[rotation[0:3],rotation[3:6],rotation[6:9]]
                    norms=[sum(v*v for v in axis) for axis in axes]
                    orthogonal=all(abs(n-1)<.01 for n in norms) and all(abs(sum(a*b for a,b in zip(axes[i],axes[j])))<.01 for i,j in [(0,1),(0,2),(1,2)])
                    positions.append({'entity':entity,'type':kind,'position':position,'rotation':rotation,'orthonormal':orthogonal,'componentVtableRva':hex(vtable-base)})
                    break
            except (OSError,ValueError,OverflowError):continue
        report['samples'].append({'ordinal':sample+1,'liveEntities':live,'transforms':positions})
        if sample<2:time.sleep(.25)
    report['result']='observed'
finally:
    kernel.CloseHandle(process);lease.close()
args.output.parent.mkdir(parents=True,exist_ok=True)
args.output.write_text(json.dumps(report,indent=2),encoding='utf-8')
print(json.dumps({'result':report['result'],'seedCount':report['seedCount'],'sampleCounts':[len(s['transforms']) for s in report['samples']],'orthonormalCounts':[sum(p['orthonormal'] for p in s['transforms']) for s in report['samples']],'controlVerified':False,'heatPercentVerified':False}))
