"""One original logical UI input while exactly docked; callbacks stay resident until game exit.
This research probe never buys/sells, launches, moves a ship or changes cargo.
"""
import argparse, ctypes as c, ctypes.wintypes as w, hashlib, json, time
from pathlib import Path
import frida
p=argparse.ArgumentParser(description=__doc__)
p.add_argument('--pid',type=int,required=True)
p.add_argument('--components',type=Path,required=True)
p.add_argument('--bindings',type=Path,required=True)
p.add_argument('--market',type=int,required=True)
p.add_argument('--thread',type=int,required=True)
p.add_argument('--output',type=Path,required=True)
p.add_argument('--command-file',type=Path,required=True)
p.add_argument('--select-once',action='store_true')
a=p.parse_args()
snapshot=json.loads(a.components.read_text(encoding='utf-8-sig'));bindings=json.loads(a.bindings.read_text(encoding='utf-8-sig'))
if snapshot['pid']!=a.pid or bindings['pid']!=a.pid:raise ValueError('Process differs')
image=Path(r'D:/SteamLibrary/steamapps/common/Elite Dangerous/Products/elite-dangerous-odyssey-64/EliteDangerous64.exe')
if hashlib.sha256(image.read_bytes()).hexdigest()!=snapshot['gameSha256']:raise ValueError('Game build differs')
dock=next(x for x in snapshot['components'] if x['name']=='docking')
config={'image':str(image),'service':bindings['service'],'entity':dock['entityId'],'life':dock['lifecycle'],
'dock':hex(int(dock['address'],16)+0x58),'market':str(a.market),'thread':a.thread,
'select':a.select_once,'bindings':{x['name']:x['address'] for x in bindings['bindings'] if x['name'] in ('UI_Select','UI_Back','UI_Up','UI_Down','UI_Left','UI_Right')}}
source=r'''
const C=__CONFIG__,module=Process.getModuleByName('EliteDangerous64.exe'),base=module.base;
if(module.path.toLowerCase()!==C.image.replaceAll('/','\\').toLowerCase())throw new Error('Game image differs');
const service=ptr(C.service),dock=ptr(C.dock),life=ptr(C.life);let pending=C.select?'UI_Select':null,calls=0,inputs=0,last='';
function valid(){return Process.getCurrentThreadId()===C.thread&&service.readPointer().equals(base.add(0x512FD90))
&&dock.readPointer().equals(base.add(0x5315D58))&&[5,7].includes(dock.add(0x4C0).readU32())
&&life.add(0xC0).readU32()===8&&life.add(0xB0).readU64().toString()===C.entity
&&dock.add(0x4E0).readPointer().add(0xB0).readU64().toString()===C.market;}
if(base.add(0x8F0C00).readU8()===0xE9)throw new Error('Input entry is already hooked');
Interceptor.attach(base.add(0x8F0C00),{onEnter(args){this.matches=args[0].equals(service);},onLeave(){
if(!this.matches)return;calls++;
try{if(pending&&valid()){
const name=pending,binding=ptr(C.bindings[name]);pending=null;
// This is the same one-frame logical select flag produced by the
// original mouse-click path at the end of 8F0C00, not a ship control.
binding.add(0x24).writeU8(1);inputs++;send({kind:'original-logical-ui-input',name,inputs,thread:Process.getCurrentThreadId()});
}}catch(error){const e=String(error);if(last!==e){last=e;send({kind:'refused',error:e});}}
}});
// Observe the actual UI-created model. Do not instantiate a model or invoke
// any transaction callback while exploring its legitimate creation path.
Interceptor.attach(base.add(0x1E86000),{onEnter(args){this.model=args[0];this.context=args[1];},onLeave(){
try{if(this.model.readPointer().equals(base.add(0x524A160)))send({kind:'original-market-created',model:this.model.toString(),context:this.context.toString()});}catch{}
}});
rpc.exports={summary(){return {calls,inputs,pending,transactionsSent:0,flightInputsSent:0};},queue(name){
if(!Object.hasOwn(C.bindings,name)||pending!==null)throw new Error('One known UI input at a time');pending=name;return {queued:name};
}};
'''.replace('__CONFIG__',json.dumps(config))
k=c.WinDLL('kernel32',use_last_error=True)
k.OpenProcess.argtypes=[w.DWORD,w.BOOL,w.DWORD];k.OpenProcess.restype=w.HANDLE
k.WaitForSingleObject.argtypes=[w.HANDLE,w.DWORD];k.CloseHandle.argtypes=[w.HANDLE]
handle=k.OpenProcess(0x100000,False,a.pid)
if not handle:raise c.WinError(c.get_last_error())
with a.output.open('x',encoding='utf-8') as out:
session=frida.attach(a.pid);script=session.create_script(source)
def emit(row):out.write(json.dumps(row)+'\n');out.flush();print(json.dumps(row),flush=True)
script.on('message',lambda message,data:emit(message.get('payload',message)));script.load()
emit({'kind':'resident-ui-probe','pid':a.pid,'market':a.market,'transactionsSent':0})
last_command='';last_summary=0
try:
while k.WaitForSingleObject(handle,1000)==258:
try:
if a.command_file.exists():
command=a.command_file.read_text(encoding='utf-8-sig').strip()
if command and command!=last_command:
last_command=command;value=json.loads(command)
if set(value)!={'id','input'}:raise ValueError('Explicit unique input command required')
emit(script.exports_sync.queue(value['input']))
except Exception as error:
# A partial command-file write must never end this resident
# process and unload callbacks while the game is alive.
emit({'kind':'command-refused','error':str(error)})
if time.monotonic()-last_summary>=15:
last_summary=time.monotonic();emit(script.exports_sync.summary())
except (frida.InvalidOperationError,frida.ProcessNotFoundError):pass
finally:
# The only normal detach is after this exact game process has exited.
k.CloseHandle(handle)
"""One original logical UI input while exactly docked; callbacks stay resident until game exit.
This research probe never buys/sells, launches, moves a ship or changes cargo.
"""
import argparse, ctypes as c, ctypes.wintypes as w, hashlib, json, time
from pathlib import Path
import frida
p=argparse.ArgumentParser(description=__doc__)
p.add_argument('--pid',type=int,required=True)
p.add_argument('--components',type=Path,required=True)
p.add_argument('--bindings',type=Path,required=True)
p.add_argument('--market',type=int,required=True)
p.add_argument('--thread',type=int,required=True)
p.add_argument('--output',type=Path,required=True)
p.add_argument('--command-file',type=Path,required=True)
p.add_argument('--select-once',action='store_true')
a=p.parse_args()
snapshot=json.loads(a.components.read_text(encoding='utf-8-sig'));bindings=json.loads(a.bindings.read_text(encoding='utf-8-sig'))
if snapshot['pid']!=a.pid or bindings['pid']!=a.pid:raise ValueError('Process differs')
image=Path(r'D:/SteamLibrary/steamapps/common/Elite Dangerous/Products/elite-dangerous-odyssey-64/EliteDangerous64.exe')
if hashlib.sha256(image.read_bytes()).hexdigest()!=snapshot['gameSha256']:raise ValueError('Game build differs')
dock=next(x for x in snapshot['components'] if x['name']=='docking')
config={'image':str(image),'service':bindings['service'],'entity':dock['entityId'],'life':dock['lifecycle'],
'dock':hex(int(dock['address'],16)+0x58),'market':str(a.market),'thread':a.thread,
'select':a.select_once,'bindings':{x['name']:x['address'] for x in bindings['bindings'] if x['name'] in ('UI_Select','UI_Back','UI_Up','UI_Down','UI_Left','UI_Right')}}
source=r'''
const C=__CONFIG__,module=Process.getModuleByName('EliteDangerous64.exe'),base=module.base;
if(module.path.toLowerCase()!==C.image.replaceAll('/','\\').toLowerCase())throw new Error('Game image differs');
const service=ptr(C.service),dock=ptr(C.dock),life=ptr(C.life);let pending=C.select?'UI_Select':null,calls=0,inputs=0,last='';
function valid(){return Process.getCurrentThreadId()===C.thread&&service.readPointer().equals(base.add(0x512FD90))
&&dock.readPointer().equals(base.add(0x5315D58))&&[5,7].includes(dock.add(0x4C0).readU32())
&&life.add(0xC0).readU32()===8&&life.add(0xB0).readU64().toString()===C.entity
&&dock.add(0x4E0).readPointer().add(0xB0).readU64().toString()===C.market;}
if(base.add(0x8F0C00).readU8()===0xE9)throw new Error('Input entry is already hooked');
Interceptor.attach(base.add(0x8F0C00),{onEnter(args){this.matches=args[0].equals(service);},onLeave(){
if(!this.matches)return;calls++;
try{if(pending&&valid()){
const name=pending,binding=ptr(C.bindings[name]);pending=null;
// This is the same one-frame logical select flag produced by the
// original mouse-click path at the end of 8F0C00, not a ship control.
binding.add(0x24).writeU8(1);inputs++;send({kind:'original-logical-ui-input',name,inputs,thread:Process.getCurrentThreadId()});
}}catch(error){const e=String(error);if(last!==e){last=e;send({kind:'refused',error:e});}}
}});
// Observe the actual UI-created model. Do not instantiate a model or invoke
// any transaction callback while exploring its legitimate creation path.
Interceptor.attach(base.add(0x1E86000),{onEnter(args){this.model=args[0];this.context=args[1];},onLeave(){
try{if(this.model.readPointer().equals(base.add(0x524A160)))send({kind:'original-market-created',model:this.model.toString(),context:this.context.toString()});}catch{}
}});
rpc.exports={summary(){return {calls,inputs,pending,transactionsSent:0,flightInputsSent:0};},queue(name){
if(!Object.hasOwn(C.bindings,name)||pending!==null)throw new Error('One known UI input at a time');pending=name;return {queued:name};
}};
'''.replace('__CONFIG__',json.dumps(config))
k=c.WinDLL('kernel32',use_last_error=True)
k.OpenProcess.argtypes=[w.DWORD,w.BOOL,w.DWORD];k.OpenProcess.restype=w.HANDLE
k.WaitForSingleObject.argtypes=[w.HANDLE,w.DWORD];k.CloseHandle.argtypes=[w.HANDLE]
handle=k.OpenProcess(0x100000,False,a.pid)
if not handle:raise c.WinError(c.get_last_error())
with a.output.open('x',encoding='utf-8') as out:
session=frida.attach(a.pid);script=session.create_script(source)
def emit(row):out.write(json.dumps(row)+'\n');out.flush();print(json.dumps(row),flush=True)
script.on('message',lambda message,data:emit(message.get('payload',message)));script.load()
emit({'kind':'resident-ui-probe','pid':a.pid,'market':a.market,'transactionsSent':0})
last_command='';last_summary=0
try:
while k.WaitForSingleObject(handle,1000)==258:
try:
if a.command_file.exists():
command=a.command_file.read_text(encoding='utf-8-sig').strip()
if command and command!=last_command:
last_command=command;value=json.loads(command)
if set(value)!={'id','input'}:raise ValueError('Explicit unique input command required')
emit(script.exports_sync.queue(value['input']))
except Exception as error:
# A partial command-file write must never end this resident
# process and unload callbacks while the game is alive.
emit({'kind':'command-refused','error':str(error)})
if time.monotonic()-last_summary>=15:
last_summary=time.monotonic();emit(script.exports_sync.summary())
except (frida.InvalidOperationError,frida.ProcessNotFoundError):pass
finally:
# The only normal detach is after this exact game process has exited.
k.CloseHandle(handle)