SE2Revival/tools/bwcompat_gen.py
2026-10-23 20:47:44 -04:00

207 lines
9.2 KiB
Python

"""Generate mods/bwcompat/bwcompat.cpp: a FuBi extension DLL for Siege Editor 2 with Broken World's script functions.
usage: python -I tools/bwcompat_gen.py BROKEN_WORLD_EXE SIEGEEDITOR2_EXE
Broken World's skrits call ~40 C++ functions its exe added (GoMind::GetHomeLeashDistance, GoAttack::GetChanceToPenetrate,
...). Siege Editor 2 (2005, base game) cannot compile those skrits ("'GetHomeLeashDistance': undefined function", a
retry prompt per skrit). Siege Editor 2 loads %ds2dll_path%/*.ds2dll and FuBi imports each one's export table into its
function database (FuBi.cpp: "Imported library: '%s'"). This DLL exports, under the same decorated names, every function
Broken World's exe exports and Siege Editor 2 does not. Each export is a stub: it returns 0 (fldz for float/double;
a pointer to zeroed memory for references) and removes its arguments from the stack the way its calling convention
requires, computed from the decorated name. The editor does not run game logic, so the skrits only need to compile.
Names whose argument sizes cannot be derived (a struct or class passed by value) are left out and listed.
"""
import os, re, sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from ds2exports import exports
SIZE4 = set('CDEFGHIJKM') # char..long, float: one stack slot each
class Unsupported(Exception):
pass
def read_name(s, i):
"""Skip a qualified name starting at i: fragments 'name@', back-reference digits, or templates '?$name@<args>@',
up to the terminating '@'. Return the index after it."""
while True:
if s[i] == '@':
return i + 1
if s[i].isdigit():
i += 1
elif s.startswith('?$', i): # template: ?$name@ then template arguments up to '@'
i = s.index('@', i) + 1
while s[i] != '@':
i = skip_type(s, i)
i += 1
else:
i = s.index('@', i) + 1
def skip_type(s, i):
"""Index after one type at s[i] (template arguments: sizes do not matter)."""
c = s[i]
if c.isdigit() or c in SIZE4 or c in 'NX':
return i + 1
if c == '_':
return i + 2
if c in 'PQRSA':
j = i + 2 # pointer kind + pointee cv
return skip_type(s, j)
if c in 'UVTW':
return read_name(s, i + (2 if c == 'W' else 1))
if c == '$': # template value argument ($0<number>)
j = i + 2
while s[j] not in '@':
j += 1
return j + 1
if c == '?':
return skip_type(s, i + 2)
raise Unsupported('template arg ' + c)
def read_type(s, i, backrefs):
"""Parse one type at s[i]; return (stack bytes, kind, next index). kind: 'f' float/double, 'v' void, 'i' other."""
c = s[i]
if c.isdigit(): # back-reference to an earlier argument type
k = int(c)
if k >= len(backrefs):
raise Unsupported('backref')
return backrefs[k][0], backrefs[k][1], i + 1
if c == 'X':
return 0, 'v', i + 1
if c in SIZE4:
return 4, 'f' if c == 'M' else 'i', i + 1
if c == 'N':
return 8, 'f', i + 1
if c == '_':
c2 = s[i + 1]
if c2 in 'NDEFGHIW': # bool, __int8.. __int32, wchar_t
return 4, 'i', i + 2
if c2 in 'JK': # __int64
return 8, 'i', i + 2
raise Unsupported('_' + c2)
if c in 'PQRSA': # pointer / reference: one slot; skip the pointee
j = i + 1
if s[j] == 'E': # __ptr64 marker (not in x86 names, but harmless)
j += 1
j += 1 # cv qualifier of the pointee (A/B/C/D)
if s[j] == '6': # function pointer
raise Unsupported('function pointer')
return 4, 'p' if c == 'A' else 'i', skip_type(s, j) # any pointee: the pointer is one slot
if c == 'W': # enum: W4Name@@
return 4, 'i', read_name(s, i + 2)
if c in 'UVT': # struct/class/union by value: size unknown
raise Unsupported('by value')
if c == '?': # return type with storage class (?A / ?B ...)
return read_type(s, i + 2, backrefs)
raise Unsupported('type ' + c)
def signature(name):
"""(convention, argument bytes, return kind) of a decorated function name, or raise Unsupported."""
if not name.startswith('?') or name.startswith('??'):
raise Unsupported('special')
i = read_name(name, 1)
code = name[i]
i += 1
if code in 'QAIUEM': # member functions (public/private/protected, virtual): this-cv follows
member = True
i += 1
elif code in 'SCK': # static member functions
member = False
elif code == 'Y': # global function
member = False
else:
raise Unsupported('not a function (' + code + ')')
conv = name[i]
i += 1
if conv not in 'AEG':
raise Unsupported('convention ' + conv)
ret_size, ret_kind, i = read_type(name, i, [])
args, backrefs = 0, []
if name[i] == 'X':
i += 1
else:
while name[i] not in '@Z':
size, kind, j = read_type(name, i, backrefs)
if j - i > 1 and len(backrefs) < 10:
backrefs.append((size, kind))
args += size
i = j
callee_pops = conv in 'EG' # thiscall, stdcall
return ('thiscall' if conv == 'E' else 'stdcall' if conv == 'G' else 'cdecl'), (args if callee_pops else 0), ret_kind
def overload_key(name):
"""(qualified name, parameter list) of a decorated function name - what FuBi compares. FuBi refuses a whole DLL
("Critical error! Separate functions are nearly identical", "ImportBindings() failure") when an export differs from
an existing function only in return type or const-ness, e.g. Broken World's void WorldFXMgr::SetEffectTexture(...)
against Siege Editor 2's unsigned int version."""
try:
i = read_name(name, 1)
code = name[i]
i += 2 if code in 'QAIUEM' else 1
i += 1 # calling convention
_, _, i = read_type(name, i, []) # return type
return name[1:read_name(name, 1)], name[i:]
except (Unsupported, ValueError, IndexError):
return None
def main():
bw, se = exports(sys.argv[1]), exports(sys.argv[2])
missing = sorted(set(bw) - set(se))
stubs, exported, skipped = {}, [], []
se_keys = {k for k in (overload_key(n) for n in se) if k}
for n in missing:
if overload_key(n) in se_keys:
skipped.append((n, 'same function in Siege Editor 2 with another return type'))
continue
try:
conv, pops, ret = signature(n)
except (Unsupported, ValueError, IndexError) as e:
skipped.append((n, str(e)))
continue
key = 'Stub_%s_%d' % ({'f': 'float', 'p': 'ref'}.get(ret, 'int'), pops)
stubs[key] = (ret, pops)
exported.append((n, key))
out = ['// GENERATED by tools/bwcompat_gen.py - do not edit. Broken World script functions for Siege Editor 2 (stubs).',
'// %d exports (functions Broken World\'s exe exports and SiegeEditor2.exe does not); %d left out:' % (len(exported), len(skipped))]
out += ['// %s (%s)' % s for s in skipped]
out += ['#include <windows.h>', '', 'namespace {', 'alignas(16) char g_zero[4096]; // what reference-returning stubs point at', '}', '',
'extern "C" {']
for key, (ret, pops) in sorted(stubs.items()):
if ret == 'f':
body = 'fldz'
elif ret == 'p':
body = 'lea eax, g_zero'
else:
body = 'sub eax, eax' # not xor: an alternative C++ token under /permissive-
ret_insn = 'ret %d' % pops if pops else 'ret'
out.append('__declspec(naked) void %s() {\n __asm {\n %s\n %s\n }\n}' % (key, body, ret_insn))
out.append('}')
out.append('')
out += ['', 'BOOL WINAPI DllMain(HINSTANCE, DWORD, LPVOID) { return TRUE; }', '']
dest = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'mods', 'bwcompat', 'bwcompat.cpp')
os.makedirs(os.path.dirname(dest), exist_ok=True)
with open(dest, 'w', newline='\n') as f:
f.write('\n'.join(out))
# Exports: MSVC's linker cuts an aliased export name at its first '@', so the DLL is linked with placeholder names
# (E0000=Stub_...) and tools/pe_rename_exports.py then writes the decorated names (bwcompat.map: placeholder, name).
with open(dest[:-4] + '.def', 'w', newline='\n') as f:
f.write('; GENERATED by tools/bwcompat_gen.py - placeholder exports, renamed after linking (bwcompat.map)\n')
f.write('EXPORTS\n')
for i, (n, key) in enumerate(exported):
f.write(' E%04d=%s\n' % (i, key))
with open(dest[:-4] + '.map', 'w', newline='\n') as f:
for i, (n, key) in enumerate(exported):
f.write('E%04d\t%s\n' % (i, n))
print('%d missing exports: %d stubbed (%d stub kinds), %d left out' % (len(missing), len(exported), len(stubs), len(skipped)))
for n, why in skipped[:15]:
print(' left out %s (%s)' % (n, why))
if __name__ == '__main__':
main()