"""Read-only lookup of the build-pinned, original commodity market UI model.

No native calls, hooks, input, transactions, process writes or screen access.
Only exact class candidates and their bounded model headers are exported.
"""
import argparse,ctypes as c,ctypes.wintypes as w,hashlib,json,struct,time
from pathlib import Path

p=argparse.ArgumentParser(description=__doc__)
p.add_argument('--pid',type=int,required=True)
p.add_argument('--output',type=Path,required=True)
p.add_argument('--max-megabytes',type=int,default=6144)
a=p.parse_args()
if not 1<=a.max_megabytes<=8192:raise ValueError('Bounded read budget required')
image=Path(r'D:/SteamLibrary/steamapps/common/Elite Dangerous/Products/elite-dangerous-odyssey-64/EliteDangerous64.exe')
if hashlib.sha256(image.read_bytes()).hexdigest()!='6891f70a021fa9f3a0e00458c284f48d1226e68ae7fc02ad48081aed47857e93':raise ValueError('Game build changed')
k=c.WinDLL('kernel32',use_last_error=True)
k.OpenProcess.argtypes=[w.DWORD,w.BOOL,w.DWORD];k.OpenProcess.restype=w.HANDLE
k.CloseHandle.argtypes=[w.HANDLE]
k.QueryFullProcessImageNameW.argtypes=[w.HANDLE,w.DWORD,w.LPWSTR,c.POINTER(w.DWORD)]
k.ReadProcessMemory.argtypes=[w.HANDLE,c.c_void_p,c.c_void_p,c.c_size_t,c.POINTER(c.c_size_t)]
class Region(c.Structure):
    _fields_=[('BaseAddress',c.c_void_p),('AllocationBase',c.c_void_p),('AllocationProtect',w.DWORD),('PartitionId',w.WORD),('RegionSize',c.c_size_t),('State',w.DWORD),('Protect',w.DWORD),('Type',w.DWORD)]
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)]
k.VirtualQueryEx.argtypes=[w.HANDLE,c.c_void_p,c.POINTER(Region),c.c_size_t];k.VirtualQueryEx.restype=c.c_size_t
k.CreateToolhelp32Snapshot.argtypes=[w.DWORD,w.DWORD];k.CreateToolhelp32Snapshot.restype=w.HANDLE
k.Module32FirstW.argtypes=[w.HANDLE,c.POINTER(Module)];k.Module32NextW.argtypes=[w.HANDLE,c.POINTER(Module)]
process=k.OpenProcess(0x410,False,a.pid)
if not process:raise c.WinError(c.get_last_error())
def read(at,size):
    buf=c.create_string_buffer(size);done=c.c_size_t()
    if not k.ReadProcessMemory(process,at,buf,size,c.byref(done)):return b''
    return buf.raw[:done.value]
try:
    name=c.create_unicode_buffer(32768);length=w.DWORD(len(name))
    if not k.QueryFullProcessImageNameW(process,0,name,c.byref(length)) or Path(name.value).resolve()!=image.resolve():raise ValueError('Unexpected process image')
    snap=k.CreateToolhelp32Snapshot(0x18,a.pid);m=Module();m.dwSize=c.sizeof(m);found=k.Module32FirstW(snap,c.byref(m));base=0
    while found:
        if m.szModule.lower()=='elitedangerous64.exe':base=m.modBaseAddr;break
        found=k.Module32NextW(snap,c.byref(m))
    k.CloseHandle(snap)
    if not base:raise ValueError('Game module absent')
    patterns={struct.pack('<Q',base+0x524A160):'commodity-market',struct.pack('<Q',base+0x5240AE8):'station-maintenance'}
    at=0x10000;scanned=0;regions=0;rows=[];seen=set();start=time.monotonic();budget=a.max_megabytes*1024*1024
    while at<0x7fffffffffff and scanned<budget and time.monotonic()-start<45:
        r=Region()
        if not k.VirtualQueryEx(process,at,c.byref(r),c.sizeof(r)):break
        end=r.BaseAddress+r.RegionSize;regions+=1
        if r.State==0x1000 and r.Type==0x20000 and r.Protect in (0x04,0x40):
            for pos in range(r.BaseAddress,end,1024*1024):
                if scanned>=budget or time.monotonic()-start>=45:break
                data=read(pos,min(1024*1024,end-pos,budget-scanned));scanned+=len(data)
                for pattern,label in patterns.items():
                    offset=0
                    while (offset:=data.find(pattern,offset))>=0:
                        candidate=pos+offset;offset+=8
                        if candidate%8 or candidate in seen:continue
                        seen.add(candidate);header=read(candidate,0x500)
                        if len(header)!=0x500:continue
                        pointer=lambda x:struct.unpack_from('<Q',header,x)[0]
                        if label=='station-maintenance':
                            parent=read(candidate-0x58,0x58)
                            if len(parent)==0x58 and struct.unpack_from('<Q',parent)[0]==base+0x52409B8 and struct.unpack_from('<Q',parent,8)[0]==base+0x52409E8:
                                rows.append({'kind':label,'address':hex(candidate),'header':header.hex(),'parentHeader':parent.hex(),'methods':[hex(struct.unpack('<Q',read(base+0x5240AE8+i*8,8))[0]-base) for i in range(32)]})
                            continue
                        if pointer(0x20)!=base+0x524A1E8 or pointer(0xC8)!=base+0x524A238:continue
                        rows.append({'kind':label,'address':hex(candidate),'station':hex(pointer(0x370)),
                                     'normalMarket':header[0x3C8:0x3F8].hex(),'otherMarket':header[0x3F8:0x428].hex(),
                                     'quotes':header[0x378:0x3A8].hex(),'pending':hex(pointer(0x478)),
                                     'controller':hex(pointer(0x270)),'header':header.hex()})
        at=end
    result={'pid':a.pid,'base':hex(base),'readBytes':scanned,'regions':regions,'seconds':time.monotonic()-start,'models':rows,'gameInputsSent':0,'transactionsSent':0,'processAccess':'QUERY_INFORMATION | VM_READ'}
    with a.output.open('x',encoding='utf-8') as out:json.dump(result,out,indent=2)
    print(json.dumps({key:value for key,value in result.items() if key!='models'}))
    print(json.dumps({'models':[{key:value for key,value in row.items() if key!='header'} for row in rows]}))
finally:k.CloseHandle(process)
