DS2Revival/tools/bw_cpuid_sites.py
2026-10-23 15:37:43 -04:00

130 lines
6.1 KiB
Python

"""Generate mods/brokenworld/cpuid_sites.h: the CPUID sites in Broken World's crack-rebuilt .rld section
(SecuROM virtual-machine dispatchers; see mods/brokenworld/brokenworld.cpp, FastCpuid).
usage: python -I tools/bw_cpuid_sites.py [BW_EXE] [--check PID]
A site is a CPUID followed, within 14 instructions (unconditional jumps followed), by "mov eax, 0xf41" and
"pop ebx" (the dispatcher shape). To make room for a 5-byte jump the straight-line instructions after it are
copied to the stub, or an unconditional jump right after it (whose skipped bytes cover the patch) becomes the
stub's exit. Each entry has a CRC32 of the overwritten bytes, checked before patching (other builds are left
alone). --check PID samples a running game and reports which
CPUID sites its main thread is waiting on and whether they are in the table.
"""
import re, struct, sys, zlib
from capstone import Cs, CS_ARCH_X86, CS_MODE_32, CS_GRP_JUMP, CS_GRP_CALL, CS_GRP_RET
EXE = 'DungeonSiege2.exe' # Broken World v2.30.0.4277 (or pass its path as the first argument)
def follow(md, d, off_of, va, limit=14):
"""Instructions from va, following unconditional direct jumps (junk is hidden behind them)."""
out = []
while len(out) < limit:
o = off_of(va)
ins = list(md.disasm(d[o:o + 16], va))[:1]
if not ins:
break
i = ins[0]
out.append(i)
if i.mnemonic == 'jmp' and i.op_str.startswith('0x'):
va = int(i.op_str, 16)
elif i.group(CS_GRP_JUMP) or i.group(CS_GRP_CALL) or i.group(CS_GRP_RET):
break
else:
va = i.address + i.size
return out
def sites(exe):
d = open(exe, 'rb').read()
pe = struct.unpack_from('<I', d, 0x3c)[0]
n = struct.unpack_from('<H', d, pe + 6)[0]
so = struct.unpack_from('<H', d, pe + 20)[0]
secs = {s[0].rstrip(b'\0').decode(): s for s in (struct.unpack_from('<8sIIII', d, pe + 24 + so + i * 40) for i in range(n))}
_, vs, v, rs, ra = secs['.rld']
off_of = lambda a: ra + a - 0x400000 - v
md = Cs(CS_ARCH_X86, CS_MODE_32)
md.detail = True
out, skipped = [], []
for m in re.finditer(rb'\x0f\xa2', d[ra:ra + rs]):
off = ra + m.start()
va = 0x400000 + v + m.start()
flow = follow(md, d, off_of, va + 2)
txt = ['%s %s' % (i.mnemonic, i.op_str) for i in flow]
if not ('mov eax, 0xf41' in txt and 'pop ebx' in txt):
continue
# Make room for a 5-byte jump: copy the straight-line instructions after the CPUID (>= 3 bytes), or stop at
# an unconditional jump whose target lies beyond the patch (the bytes it skips are never executed).
copy, target, ok = 0, 0, False
a = va + 2
while True:
i = list(md.disasm(d[off_of(a):off_of(a) + 16], a))[:1]
if not i:
break
i = i[0]
if i.mnemonic == 'jmp' and i.op_str.startswith('0x'):
target = int(i.op_str, 16)
ok = target >= va + 5 and target >= i.address + i.size
end = va + 2 + copy + i.size
break
if i.group(CS_GRP_JUMP) or i.group(CS_GRP_CALL) or i.group(CS_GRP_RET) or i.mnemonic.startswith('loop'):
break
copy += i.size
a += i.size
if copy >= 3:
ok, end = True, va + 2 + copy
break
if not ok:
skipped.append(va)
continue
span = max(5, end - va)
out.append((va, copy, span, target, zlib.crc32(d[off:off + span]) & 0xffffffff))
return out, skipped
def main():
a = [x for x in sys.argv[1:] if not x.startswith('--')]
exe = a[0] if a and not a[0].isdigit() else EXE
table, skipped = sites(exe)
if '--check' in sys.argv:
import collections, ctypes, time
sys.path.insert(0, 'tools')
import cpuprof as cp
pid = int(sys.argv[sys.argv.index('--check') + 1])
proc = cp.k32.OpenProcess(cp.PROCESS_ACCESS, False, pid)
best, mh = -1, None
for tid in cp.thread_ids(pid):
h = cp.k32.OpenThread(cp.THREAD_ACCESS, False, tid)
t = cp.cpu_time(h)
if t > best:
best, mh = t, h
hits, total, ctx = collections.Counter(), 0, cp.WOW64_CONTEXT()
for i in range(400):
cp.k32.Wow64SuspendThread(mh)
ctx.ContextFlags = 0x10001
cp.k32.Wow64GetThreadContext(mh, ctypes.byref(ctx))
cp.k32.ResumeThread(mh)
total += 1
if cp.read(proc, ctx.Eip - 2, 2) == b'\x0f\xa2':
hits[ctx.Eip - 2] += 1
time.sleep(0.01)
known = {e[0] for e in table}
print('%d of %d samples right after a CPUID' % (sum(hits.values()), total))
for va, c in hits.most_common():
print(' %08X %3d %s' % (va, c, 'in table' if va in known else ('SKIPPED (branch)' if va in skipped else 'NOT IN TABLE')))
return
lines = ['// Generated by tools/bw_cpuid_sites.py from Broken World DungeonSiege2.exe v2.30.0.4277 (PE timestamp',
'// 0x449B02B3): CPUID sites in the crack-rebuilt .rld section. {VA of the CPUID, bytes after it copied to',
'// the stub, bytes overwritten by the patch, where the stub continues (0 = right after the copied bytes),',
'// CRC32 of the overwritten bytes}. No code bytes are stored; they are copied from the running exe.',
'#pragma once', '', 'struct CpuidSite {', ' unsigned long va;', ' unsigned char copy;',
' unsigned char span;', ' unsigned long next;', ' unsigned long crc;', '};', '',
'constexpr CpuidSite kCpuidSites[] = {']
for va, copy, span, target, crc in table:
lines.append(' {0x%08X, %d, %d, 0x%08X, 0x%08X},' % (va, copy, span, target, crc))
lines.append('};')
open('mods/brokenworld/cpuid_sites.h', 'w', newline='\r\n').write('\n'.join(lines) + '\n')
print('%d sites written, %d skipped (branch right after the CPUID): %s' % (len(table), len(skipped), ' '.join('%08X' % s for s in skipped)))
if __name__ == '__main__':
main()