新增奥日2排障记录:Wwise 线程亲和性故障定位与兼容补丁(文章4篇+补丁包+诊断脚本4个,已脱敏)

This commit is contained in:
SCHOOL
2026-09-23 15:08:27 +08:00
parent 90bcf6176e
commit 6bf9981765
21 changed files with 2815 additions and 0 deletions
+104
View File
@@ -0,0 +1,104 @@
# 诊断脚本说明
这 4 个脚本是 2026-09-16 那轮定位用到的全部工具,用 Python 标准库写的,**不需要装任何第三方包**。
对应的技术报告见 `../文章/奥日2崩溃研究报告.md` 第 5 节(动态实验设计及结果)。
---
## 每个脚本做什么
### 1. `ori_debug.py` — 核心诊断器
用 Windows 调试接口**启动自己的游戏子进程**(不是附加到别的进程,也不装系统钩子),
在音频线程初始化附近的指令上打临时断点(INT3),记录寄存器、参数、返回值和**目标线程自己的** LastError。
这是抓到"`SetThreadAffinityMask` 返回 0、错误码 87"那个关键证据的工具。
```powershell
# 只校验 DLL 哈希,不启动游戏
python ori_debug.py --game "D:\Games\Ori and the Will of the Wisps" --check
# 原始运行:只下断点观察,不做任何修改
python ori_debug.py --game "D:\Games\..." --seconds 40 --output ori-evidence/runtime-original
# 对照实验 A:把掩码截断到进程可用范围(用来否掉"高位越界"假设)
python ori_debug.py --game "D:\Games\..." --clamp-affinity
# 对照实验 B:在进程内把 setter 写入的掩码改成 0(临时验证,不落盘)
python ori_debug.py --game "D:\Games\..." --automatic-affinity
```
**两道安全检查:**
- 启动前先算 `AkSoundEngine.dll` 的 SHA-256,不符就拒绝下断点
- 关闭时只终止**自己创建的那个**进程,不会碰任何其它游戏会话
### 2. `ori_launch.py` — 普通启动器
不带调试器、不改内存,用来验证"**普通启动也通过**"——这才排除了"只有调试器下才成功"的可能。
```powershell
python ori_launch.py --game "D:\Games\Ori and the Will of the Wisps" --label normal-patched-1
```
它会先确认补丁已安装(校验**补丁后**的哈希),没装就直接退出。输出目录是相对于当前目录的
`ori-evidence/<label>/`,不会写到别处。
> 依赖 `ori_debug.py` 里的常量定义,两个文件要放在同一目录。
### 3. `ori_audio_probe.py` — 音频输出验证
只读指定进程的 Windows 音频会话状态和电平,采样 3 秒取峰值。
```powershell
python ori_audio_probe.py <游戏进程 PID>
```
它**不录音、不访问麦克风、不改音量设置**。它证明的是"游戏确实往 Windows 音频管线里送了声音信号"
——比"看到菜单了"强得多。
### 4. `probe_affinity.py` — 独立对照探针
在 Python 自己的短命线程上试一组掩码(`0xFFFFC` 到 `0xFFFFFFFC`)。
```powershell
python probe_affinity.py
```
这个脚本看着不起眼,但它是**否掉两个假设的关键对照**:如果游戏线程设不上这些掩码、
而一个普通线程却能设上,那"核心太多""CPU 坏了""掩码越界"就都解释不通了。
---
## 使用前提
| 项目 | 要求 |
| --- | --- |
| 系统 | Windows x64 |
| Python | 3.x(只用标准库) |
| 权限 | 无需要求管理员,但要能启动游戏 |
| 游戏目录 | **必须自己用 `--game` 指定**,脚本不带任何默认路径 |
## 注意事项
- 脚本里的断点地址(`0x5B2B9`、`0x5B2D8`、`0x121F0` 等)是**针对特定 DLL 构建**的 RVA。
换一个 DLL 版本这些地址就失效了,`ori_debug.py` 的哈希校验会拦住这种情况——**不要去掉那道校验**。
- `--automatic-affinity` 只在进程内存里改,退出即失效,**不会写盘**。磁盘补丁请用补丁包里的安装器。
- 这些脚本是**排查工具**,不是"一键修复"。它们观察和验证,不负责修好你的游戏。
---
## 发布说明
从原工作区复制过来时,改了 2 处:
| 文件 | 改动 |
| --- | --- |
| `ori_debug.py:239` | 原来硬编码了原作者的本地游戏目录,改成 `required=True` |
| `ori_launch.py:10` | 同上 |
原因是这两行硬编码了原作者的本地游戏路径(具体路径已在本说明中略去)。
改成必填参数后,使用者必须自己指定目录——
和补丁包 `build_ori_share.py` 里"公开版不采用研究机器默认路径"的做法一致。
改完已通过 `python -m py_compile` 语法检查。
@@ -0,0 +1,84 @@
"""Read only the target game's Windows audio-session state and volume meter.
Does not record audio, access microphones, or change device/volume settings.
"""
import argparse
import ctypes as c
import json
import time
import uuid
p=argparse.ArgumentParser()
p.add_argument("pid",type=int)
a=p.parse_args()
ole=c.OleDLL("ole32")
P=c.c_void_p
I=c.c_int32
U=c.c_uint32
F=c.c_float
def guid(s): return (c.c_ubyte*16).from_buffer_copy(uuid.UUID(s).bytes_le)
def call(obj,index,types=(),args=()):
vt=c.cast(obj,c.POINTER(c.POINTER(P))).contents
return c.WINFUNCTYPE(I,P,*types)(vt[index])(obj,*args)
def check(hr):
if hr<0: raise OSError(f"COM HRESULT 0x{hr&0xffffffff:08x}")
def qi(obj,iid):
out=P()
check(call(obj,0,(P,c.POINTER(P)),(guid(iid),c.byref(out))))
return out
refs=[]
def keep(obj):
refs.append(obj)
return obj
ole.CoInitializeEx.argtypes=[P,U]
ole.CoCreateInstance.argtypes=[P,P,U,P,c.POINTER(P)]
ole.CoTaskMemFree.argtypes=[P]
check(ole.CoInitializeEx(None,0))
try:
enum=P()
check(ole.CoCreateInstance(guid("BCDE0395-E52F-467C-8E3D-C4579291692E"),None,23,guid("A95664D2-9614-4F35-A746-DE8DB63617E6"),c.byref(enum)))
keep(enum)
devices=P()
check(call(enum,3,(I,U,c.POINTER(P)),(0,1,c.byref(devices))))
keep(devices)
count=U()
check(call(devices,3,(c.POINTER(U),),(c.byref(count),)))
found=0
for i in range(count.value):
dev=P()
check(call(devices,4,(U,c.POINTER(P)),(i,c.byref(dev))))
keep(dev)
manager=P()
check(call(dev,3,(P,U,P,c.POINTER(P)),(guid("77AA99A0-1BD6-484F-8BC7-2C654C9A9B6F"),23,None,c.byref(manager))))
keep(manager)
sessions=P()
check(call(manager,5,(c.POINTER(P),),(c.byref(sessions),)))
keep(sessions)
sc=I()
check(call(sessions,3,(c.POINTER(I),),(c.byref(sc),)))
for j in range(sc.value):
session=P()
check(call(sessions,4,(I,c.POINTER(P)),(j,c.byref(session))))
keep(session)
sc2=keep(qi(session,"BFB7FF88-7239-4FC9-8FA2-07C950BE9C6D"))
pid=U()
check(call(sc2,14,(c.POINTER(U),),(c.byref(pid),)))
if pid.value!=a.pid: continue
found+=1
meter=keep(qi(session,"C02216F6-8C67-4B5B-9D00-D008E73E0064"))
volume=keep(qi(session,"87CE5498-68D6-44E5-9215-6DA47EF883D8"))
state,muted=I(),I()
vol,peak=F(),F()
check(call(session,3,(c.POINTER(I),),(c.byref(state),)))
check(call(volume,4,(c.POINTER(F),),(c.byref(vol),)))
check(call(volume,6,(c.POINTER(I),),(c.byref(muted),)))
maximum=0.0
for _ in range(12):
check(call(meter,3,(c.POINTER(F),),(c.byref(peak),)))
maximum=max(maximum,peak.value)
time.sleep(0.25)
print(json.dumps({"pid":a.pid,"active":state.value==1,"muted":bool(muted.value),"volume":vol.value,"peak_over_3_seconds":maximum}))
if not found: print(json.dumps({"pid":a.pid,"audio_sessions_found":0}))
finally:
for ref in reversed(refs): call(ref,2)
ole.CoUninitialize()
@@ -0,0 +1,245 @@
"""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())
@@ -0,0 +1,33 @@
"""Launch the patched game normally, without debugging or memory changes."""
import argparse
import ctypes as C
import hashlib
import json
from pathlib import Path
from ori_debug import STARTUPINFO, PROCESSINFO, CreateProcess, Close
p=argparse.ArgumentParser()
p.add_argument("--game",required=True,help="game root directory containing oriwotw.exe")
p.add_argument("--label",default="normal-patched-1")
a=p.parse_args()
if not a.label.replace("-","").replace("_","").isalnum():
raise SystemExit("Label must contain only letters, numbers, hyphens or underscores")
root=Path(a.game).resolve()
dll=root / "oriwotw_Data/Plugins/AkSoundEngine.dll"
if hashlib.sha256(dll.read_bytes()).hexdigest()!="62b2079a988d23eb251d4e83366ac04068a6f47e0d38efe92a76df022826f9f0":
raise SystemExit("The verified patch is not installed")
out=Path("ori-evidence") / a.label
out.mkdir(parents=True,exist_ok=True)
si,pi=STARTUPINFO(),PROCESSINFO()
si.cb=C.sizeof(si)
si.desktop="winsta0\\default"
si.flags=1
si.show=1
cmd=C.create_unicode_buffer('"'+str(root / "oriwotw.exe")+'" -screen-fullscreen 0 -screen-width 1280 -screen-height 720 -logFile "'+str((out / "game.log").resolve())+'"')
if not CreateProcess(str(root / "oriwotw.exe"),cmd,None,None,False,0,None,str(root),C.byref(si),C.byref(pi)):
raise C.WinError(C.get_last_error())
result={"pid":pi.pid,"game":str(root),"log":str((out / "game.log").resolve()),"debugger":False,"auto_close":False}
(out / "launch.json").write_text(json.dumps(result,ensure_ascii=False,indent=2),encoding="utf-8")
print(json.dumps(result,ensure_ascii=False))
Close(pi.thread)
Close(pi.process)
@@ -0,0 +1,26 @@
"""Test affinity only on this short-lived Python worker thread."""
import ctypes as c
from ctypes import wintypes as w
import json
import threading
k=c.WinDLL("kernel32",use_last_error=True)
k.GetCurrentThread.restype=w.HANDLE
k.GetCurrentProcess.restype=w.HANDLE
k.GetProcessAffinityMask.argtypes=[w.HANDLE,c.POINTER(c.c_size_t),c.POINTER(c.c_size_t)]
k.SetThreadAffinityMask.argtypes=[w.HANDLE,c.c_size_t]
k.SetThreadAffinityMask.restype=c.c_size_t
def worker():
pm,sm=c.c_size_t(),c.c_size_t()
k.GetProcessAffinityMask(k.GetCurrentProcess(),c.byref(pm),c.byref(sm))
print(json.dumps({"process_mask":hex(pm.value),"system_mask":hex(sm.value)}))
for mask in [1,2,3,4,8,0xc,0xf,0xfc,0xfff,0xffffc,0xfffff,0xfffffffc]:
c.set_last_error(0)
old=k.SetThreadAffinityMask(k.GetCurrentThread(),mask)
error=c.get_last_error()
print(json.dumps({"requested":hex(mask),"previous":hex(old),"error":error}))
if old:
if not k.SetThreadAffinityMask(k.GetCurrentThread(),old):
raise c.WinError(c.get_last_error())
t=threading.Thread(target=worker)
t.start()
t.join()