246 lines
11 KiB
Python
246 lines
11 KiB
Python
"""Observe only the launched Ori process; trace Wwise thread setup failures.
|
|
|
|
Uses Windows debugging APIs and Python's standard library. No DLLs are
|
|
installed, and no game files are changed. INT3 breakpoints are confined to
|
|
the child process and restored before resuming each instruction.
|
|
"""
|
|
import argparse
|
|
import ctypes as C
|
|
from ctypes import wintypes as W
|
|
import hashlib
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import struct
|
|
import sys
|
|
import time
|
|
|
|
sys.stdout.reconfigure(encoding="utf-8", errors="replace", line_buffering=True)
|
|
K = C.WinDLL("kernel32", use_last_error=True)
|
|
HANDLE = C.c_void_p
|
|
DWORD = C.c_uint32
|
|
U64 = C.c_uint64
|
|
|
|
class STARTUPINFO(C.Structure):
|
|
_fields_ = [("cb", DWORD), ("reserved", W.LPWSTR), ("desktop", W.LPWSTR),
|
|
("title", W.LPWSTR), ("x", DWORD), ("y", DWORD),
|
|
("xs", DWORD), ("ys", DWORD), ("xc", DWORD), ("yc", DWORD),
|
|
("fill", DWORD), ("flags", DWORD), ("show", W.WORD),
|
|
("cb2", W.WORD), ("reserved2", HANDLE),
|
|
("stdin", HANDLE), ("stdout", HANDLE), ("stderr", HANDLE)]
|
|
|
|
class PROCESSINFO(C.Structure):
|
|
_fields_ = [("process", HANDLE), ("thread", HANDLE),
|
|
("pid", DWORD), ("tid", DWORD)]
|
|
|
|
class DEBUGEVENT(C.Structure):
|
|
_fields_ = [("code", DWORD), ("pid", DWORD), ("tid", DWORD),
|
|
("payload", C.c_ubyte * 164)]
|
|
|
|
def api(name, restype, *argtypes):
|
|
fn = getattr(K, name)
|
|
fn.restype, fn.argtypes = restype, argtypes
|
|
return fn
|
|
|
|
CreateProcess = api("CreateProcessW", W.BOOL, W.LPCWSTR, W.LPWSTR, HANDLE,
|
|
HANDLE, W.BOOL, DWORD, HANDLE, W.LPCWSTR,
|
|
C.POINTER(STARTUPINFO), C.POINTER(PROCESSINFO))
|
|
Read = api("ReadProcessMemory", W.BOOL, HANDLE, HANDLE, HANDLE, C.c_size_t, HANDLE)
|
|
Write = api("WriteProcessMemory", W.BOOL, HANDLE, HANDLE, HANDLE, C.c_size_t, HANDLE)
|
|
Protect = api("VirtualProtectEx", W.BOOL, HANDLE, HANDLE, C.c_size_t, DWORD, C.POINTER(DWORD))
|
|
Flush = api("FlushInstructionCache", W.BOOL, HANDLE, HANDLE, C.c_size_t)
|
|
Wait = api("WaitForDebugEvent", W.BOOL, C.POINTER(DEBUGEVENT), DWORD)
|
|
Continue = api("ContinueDebugEvent", W.BOOL, DWORD, DWORD, DWORD)
|
|
OpenThread = api("OpenThread", HANDLE, DWORD, W.BOOL, DWORD)
|
|
GetContext = api("GetThreadContext", W.BOOL, HANDLE, HANDLE)
|
|
SetContext = api("SetThreadContext", W.BOOL, HANDLE, HANDLE)
|
|
Close = api("CloseHandle", W.BOOL, HANDLE)
|
|
Terminate = api("TerminateProcess", W.BOOL, HANDLE, DWORD)
|
|
Detach = api("DebugActiveProcessStop", W.BOOL, DWORD)
|
|
SetKill = api("DebugSetProcessKillOnExit", W.BOOL, W.BOOL)
|
|
GetAffinity = api("GetProcessAffinityMask", W.BOOL, HANDLE, C.POINTER(U64), C.POINTER(U64))
|
|
|
|
REGS = {"rax":120,"rcx":128,"rdx":136,"rbx":144,"rsp":152,
|
|
"rbp":160,"rsi":168,"rdi":176,"r8":184,"r9":192,
|
|
"r10":200,"r11":208,"r12":216,"r13":224,"r14":232,
|
|
"r15":240,"rip":248}
|
|
BREAKS = {0x5B2B9:"before_priority", 0x5B2BF:"after_priority",
|
|
0x5B2D8:"before_affinity", 0x5B2DE:"after_affinity",
|
|
0xAD473:"before_affinity_audio", 0xAD479:"after_affinity_audio",
|
|
0xBB212:"before_affinity_bank", 0xBB218:"after_affinity_bank",
|
|
0xC3364:"before_affinity_monitor", 0xC336A:"after_affinity_monitor"}
|
|
EXPECTED_HASH = "8f90a4a3856540cb97d03ca07b4bb10b7a08f7b4ab3c524be2bec1afdd29feca"
|
|
|
|
def run(args):
|
|
game = Path(args.game).resolve()
|
|
dll = game / "oriwotw_Data/Plugins/AkSoundEngine.dll"
|
|
digest = hashlib.sha256(dll.read_bytes()).hexdigest()
|
|
if digest != EXPECTED_HASH:
|
|
raise RuntimeError("Unexpected AkSoundEngine.dll hash; refusing breakpoints")
|
|
assert C.sizeof(STARTUPINFO)==104 and C.sizeof(PROCESSINFO)==24
|
|
assert C.sizeof(DEBUGEVENT)==176
|
|
print(json.dumps({"game":str(game),"dll_sha256":digest,"check_only":args.check},ensure_ascii=False))
|
|
if args.check:
|
|
return
|
|
out = Path(args.output).resolve()
|
|
out.mkdir(parents=True, exist_ok=True)
|
|
log = (out / "trace.jsonl").open("w",encoding="utf-8")
|
|
def emit(**kw):
|
|
s = json.dumps({"time":time.time(),**kw},ensure_ascii=False)
|
|
print(s)
|
|
log.write(s+"\n")
|
|
log.flush()
|
|
pi, si = PROCESSINFO(), STARTUPINFO()
|
|
si.cb = C.sizeof(si)
|
|
# The command runner uses a private desktop. An explicitly authorized
|
|
# game test must use the user's interactive desktop, or Unity may pause
|
|
# before audio initialization while its window is invisible.
|
|
si.desktop = "winsta0\\default"
|
|
si.flags = 1
|
|
si.show = 1
|
|
cmd = C.create_unicode_buffer('"'+str(game / "oriwotw.exe")+'" -screen-fullscreen 0 -screen-width 1280 -screen-height 720 -logFile "'+str(out / "game.log")+'"')
|
|
if not CreateProcess(str(game / "oriwotw.exe"),cmd,None,None,False,2,None,str(game),C.byref(si),C.byref(pi)):
|
|
raise C.WinError(C.get_last_error())
|
|
emit(event="launched",pid=pi.pid)
|
|
process_mask, system_mask = U64(), U64()
|
|
if not GetAffinity(pi.process,C.byref(process_mask),C.byref(system_mask)):
|
|
raise C.WinError(C.get_last_error())
|
|
emit(event="affinity_limits",process_mask=hex(process_mask.value),system_mask=hex(system_mask.value))
|
|
threads, bps = {}, {}
|
|
pending = None
|
|
alive = True
|
|
start = time.monotonic()
|
|
def read(addr,n):
|
|
buf = C.create_string_buffer(n)
|
|
if not Read(pi.process,addr,buf,n,None):
|
|
return b""
|
|
return buf.raw
|
|
def patch(addr,data):
|
|
old = DWORD()
|
|
if not Protect(pi.process,addr,len(data),0x40,C.byref(old)):
|
|
raise C.WinError(C.get_last_error())
|
|
try:
|
|
if not Write(pi.process,addr,data,len(data),None):
|
|
raise C.WinError(C.get_last_error())
|
|
Flush(pi.process,addr,len(data))
|
|
finally:
|
|
unused = DWORD()
|
|
Protect(pi.process,addr,len(data),old.value,C.byref(unused))
|
|
def context(tid):
|
|
h = OpenThread(0x1A,False,tid)
|
|
if not h:
|
|
raise C.WinError(C.get_last_error())
|
|
raw = C.create_string_buffer(1248)
|
|
addr = (C.addressof(raw)+15)&~15
|
|
C.memset(addr,0,1232)
|
|
C.c_uint32.from_address(addr+48).value=0x10000B
|
|
if not GetContext(h,addr):
|
|
Close(h)
|
|
raise C.WinError(C.get_last_error())
|
|
return h,raw,addr
|
|
try:
|
|
while time.monotonic()-start < args.seconds:
|
|
ev = DEBUGEVENT()
|
|
if not Wait(C.byref(ev),500):
|
|
err=C.get_last_error()
|
|
if err==121:
|
|
continue
|
|
raise C.WinError(err)
|
|
raw=bytes(ev)
|
|
status=0x10002
|
|
if ev.code==3:
|
|
fh,_,th,_,_,_,teb=struct.unpack_from("<QQQQIIQ",raw,16)
|
|
threads[ev.tid]=teb
|
|
if fh: Close(fh)
|
|
elif ev.code==2:
|
|
th,teb,_=struct.unpack_from("<QQQ",raw,16)
|
|
threads[ev.tid]=teb
|
|
if th: Close(th)
|
|
elif ev.code==6:
|
|
fh,base=struct.unpack_from("<QQ",raw,16)
|
|
# Read the PE export directory name to identify the module.
|
|
hdr=read(base,4096)
|
|
name=""
|
|
if hdr[:2]==b"MZ":
|
|
pe=struct.unpack_from("<I",hdr,60)[0]
|
|
export=struct.unpack_from("<I",hdr,pe+24+112)[0]
|
|
ed=read(base+export,40) if export else b""
|
|
if ed:
|
|
nr=struct.unpack_from("<I",ed,12)[0]
|
|
name=read(base+nr,128).split(b"\0")[0].decode("ascii","replace")
|
|
if fh: Close(fh)
|
|
if name.lower()=="aksoundengine.dll":
|
|
emit(event="wwise_loaded",base=hex(base))
|
|
if args.automatic_affinity:
|
|
addr=base+0x121F0
|
|
original=bytes.fromhex("48 85 c9 74 03 89 51 04 c3 cc cc cc cc cc cc cc")
|
|
if read(addr,16)!=original:
|
|
raise RuntimeError("Affinity setter bytes differ; refusing memory patch")
|
|
# Preserve the null check; assign the documented zero
|
|
# mask, meaning inherit Windows scheduling, then return.
|
|
patch(addr,bytes.fromhex("48 85 c9 74 07 c7 41 04 00 00 00 00 c3 cc cc cc"))
|
|
emit(event="automatic_affinity_memory_patch",rva="0x121f0")
|
|
for rva,label in BREAKS.items():
|
|
addr=base+rva
|
|
original=read(addr,1)
|
|
bps[addr]=(original,label)
|
|
patch(addr,b"\xcc")
|
|
elif ev.code==1:
|
|
code=struct.unpack_from("<I",raw,16)[0]
|
|
addr=struct.unpack_from("<Q",raw,32)[0]
|
|
first=struct.unpack_from("<I",raw,168)[0]
|
|
if code==0x80000003 and addr in bps:
|
|
original,label=bps.pop(addr)
|
|
h,buf,ctx=context(ev.tid)
|
|
try:
|
|
regs={k:C.c_uint64.from_address(ctx+v).value for k,v in REGS.items()}
|
|
teb=threads.get(ev.tid,0)
|
|
er=read(teb+0x68,4) if teb else b""
|
|
props=read(regs["rdi"],12)
|
|
emit(event=label,tid=ev.tid,registers={k:hex(v) for k,v in regs.items()},last_error=struct.unpack("<I",er)[0] if er else None,thread_properties=list(struct.unpack("<iII",props)) if props else None)
|
|
if label.startswith("before_affinity") and args.clamp_affinity:
|
|
fixed=(regs["rdx"] & process_mask.value) or process_mask.value
|
|
C.c_uint64.from_address(ctx+REGS["rdx"]).value=fixed
|
|
emit(event="clamped_affinity",old=hex(regs["rdx"]),new=hex(fixed))
|
|
patch(addr,original)
|
|
C.c_uint64.from_address(ctx+248).value=addr
|
|
if not SetContext(h,ctx): raise C.WinError(C.get_last_error())
|
|
finally:
|
|
Close(h)
|
|
elif code==0x80000003:
|
|
pass
|
|
else:
|
|
status=0x80010001
|
|
if code not in (0x406D1388,0xE06D7363):
|
|
emit(event="exception",code=hex(code),address=hex(addr),first_chance=bool(first),tid=ev.tid)
|
|
elif ev.code==5:
|
|
emit(event="exit",exit_code=struct.unpack_from("<I",raw,16)[0])
|
|
alive=False
|
|
Continue(ev.pid,ev.tid,status)
|
|
if not alive: break
|
|
if alive:
|
|
emit(event="time_limit",seconds=args.seconds)
|
|
finally:
|
|
if alive:
|
|
# Stop only the exact process created above; never attach to or stop
|
|
# any unrelated game session.
|
|
Terminate(pi.process,0)
|
|
for _ in range(100):
|
|
ev=DEBUGEVENT()
|
|
if not Wait(C.byref(ev),100): break
|
|
Continue(ev.pid,ev.tid,0x10002)
|
|
if ev.code==5: break
|
|
Close(pi.thread)
|
|
Close(pi.process)
|
|
log.close()
|
|
|
|
if __name__=="__main__":
|
|
p=argparse.ArgumentParser()
|
|
p.add_argument("--game",required=True,help="game root directory containing oriwotw.exe")
|
|
p.add_argument("--output",default="ori-evidence/runtime-original")
|
|
p.add_argument("--seconds",type=int,default=40)
|
|
p.add_argument("--check",action="store_true")
|
|
p.add_argument("--clamp-affinity",action="store_true")
|
|
p.add_argument("--automatic-affinity",action="store_true")
|
|
run(p.parse_args())
|