Matching decomp scaffold for EverQuest Titanium eqgame.exe
MSVC 7.1 (cl 13.10.3077) under Wine, PE/COFF matcher with relocation verification, side-by-side diff, progress, Ghidra-seeded symbol map (15,323 functions), and the first three matched functions (/O1). Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
commit
bbc4bbfdd5
11 files changed
+16149
No files matched your search
+15
@@ -0,0 +1,15 @@
|
||||
# Copyrighted originals: never commit. Users supply their own client.
|
||||
/orig/*
|
||||
!/orig/README.md
|
||||
*.exe
|
||||
*.dll
|
||||
*.s3d
|
||||
*.eqg
|
||||
|
||||
# Toolchain (downloaded by tools/setup.sh)
|
||||
/tools/msvc7.1/
|
||||
|
||||
# Build output, Ghidra project
|
||||
/build/
|
||||
__pycache__/
|
||||
.ninja_*
|
||||
@@ -0,0 +1,85 @@
|
||||
# EverQuest Decomp/Recomp
|
||||
|
||||
A byte-for-byte matching decompilation of the EverQuest **Titanium** client
|
||||
(`eqgame.exe`, linked 2005-10-31 18:36:26 UTC). The goal is C++ source that
|
||||
recompiles with the original toolchain to the exact same machine code, which
|
||||
can then be ported, fixed and recompiled freely.
|
||||
|
||||
This repository contains **no game code or data**. You need your own copy of the
|
||||
client; the tools check it against the expected hash.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Target | `eqgame.exe`, SHA1 `e8fdc68b46df69dde0094d57fd7ba2cb33aef1c5` |
|
||||
| Compiler | MSVC 7.1 (Visual Studio .NET 2003), `cl` 13.10.3077, run under Wine |
|
||||
| Flags so far | `/O1 /GX /Gy` (size-optimised; confirmed by matching code) |
|
||||
| Functions | ~15.3k found by Ghidra: ~8.4k game code, ~6.9k CRT/STL/library |
|
||||
|
||||
## Setup
|
||||
|
||||
Requirements: Linux (or WSL/macOS) with `wine`, `python3` + PyYAML, `ninja`,
|
||||
`i686-w64-mingw32-objdump` (binutils for mingw), and optionally Ghidra.
|
||||
|
||||
```sh
|
||||
tools/setup.sh /path/to/EverQuest/eqgame.exe # links orig/, fetches MSVC 7.1, verifies both
|
||||
python3 tools/eq.py build # compile src/ -> build/obj/
|
||||
python3 tools/eq.py check # compare against the original
|
||||
python3 tools/eq.py progress
|
||||
```
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Pick a function: `python3 tools/eq.py todo` lists unmatched game functions,
|
||||
smallest first. Look at it with `python3 tools/eq.py diff 0x00401050`.
|
||||
2. Write C++ for it under `src/` and mark it with `// FUNCTION: EQGAME 0xADDR`.
|
||||
3. `python3 tools/eq.py build`, then map the compiled (mangled) name to the address:
|
||||
`python3 tools/eq.py map 0x00401050 '?Append@CList@@QAEXPAUCListNode@@@Z'`.
|
||||
`check` lists compiled functions that aren't mapped yet, with their mangled names.
|
||||
4. Iterate with `diff` until `check` says `MATCH`.
|
||||
5. `python3 tools/eq.py check --learn` adds the globals and callees that a
|
||||
matching function proves (e.g. `operator new` at `0x0060d071`) to the map.
|
||||
|
||||
### Check statuses
|
||||
|
||||
| Status | Meaning |
|
||||
|---|---|
|
||||
| `MATCH` | every byte equal, every relocation points at the right symbol |
|
||||
| `MATCH?` | bytes equal, but some referenced symbols aren't in the map yet (`--learn` fills them) |
|
||||
| `BADREF` | bytes equal, but a call/global resolves to a different address than the map says |
|
||||
| `DIFF` | code differs |
|
||||
|
||||
Relocated fields (calls, globals, string literals) are masked when comparing
|
||||
bytes and then verified separately: against the symbol map for named symbols,
|
||||
and by content for string literals and float constants.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
config/eqgame/config.yaml target hash, compiler flags
|
||||
config/eqgame/symbols.txt ADDR SIZE KIND NAME for every known function/global
|
||||
src/ decompiled source (one .cpp per original translation unit, eventually)
|
||||
include/ shared headers
|
||||
tools/eq.py build / check / diff / progress / map
|
||||
tools/binfmt.py PE + COFF readers
|
||||
tools/cl.sh MSVC 7.1 under Wine
|
||||
tools/ghidra/ headless export used to seed symbols.txt
|
||||
orig/ your eqgame.exe (git-ignored)
|
||||
```
|
||||
|
||||
Per-file compiler flags: put `// CFLAGS: /O2` (etc.) near the top of a source file.
|
||||
|
||||
### Re-seeding the symbol map from Ghidra
|
||||
|
||||
```sh
|
||||
/opt/ghidra/support/analyzeHeadless build/ghidra eqgame -import orig/eqgame.exe \
|
||||
-scriptPath tools/ghidra -postScript ExportFunctions.java "$PWD/build/ghidra_functions.txt"
|
||||
python3 tools/eq.py import-ghidra build/ghidra_functions.txt # keeps existing names
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- The Rich header shows ~640 C++ objects from cl 13.10.3077, plus libraries built
|
||||
with VC6 (build 8447/9178), likely Miles Sound System, DirectX helpers, etc.
|
||||
Those are classified as `lib` and excluded from progress.
|
||||
- Names are placeholders until identified. The EQEmu project and MacroQuest's
|
||||
Titanium-era offsets are good references for structures and function names.
|
||||
@@ -0,0 +1,7 @@
|
||||
# Target: EverQuest Titanium client, eqgame.exe linked 2005-10-31 18:36:26 UTC
|
||||
# Toolchain (from Rich header): MSVC 7.1 / Visual Studio .NET 2003, cl 13.10.3077
|
||||
original: orig/eqgame.exe
|
||||
sha1: e8fdc68b46df69dde0094d57fd7ba2cb33aef1c5
|
||||
image_base: 0x400000
|
||||
# Global compiler flags. Per-file additions: a `// CFLAGS: ...` line near the top of the source.
|
||||
cflags: /O1 /GX /Gy /Zl /W3
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,4 @@
|
||||
Place (or symlink) your own `eqgame.exe` from the EverQuest Titanium client here.
|
||||
|
||||
Expected: linked 2005-10-31 18:36:26 UTC, SHA1 `e8fdc68b46df69dde0094d57fd7ba2cb33aef1c5`.
|
||||
`python3 tools/eq.py verify` checks it. Nothing in this folder is committed.
|
||||
@@ -0,0 +1,61 @@
|
||||
// Intrusive doubly-linked list helpers near the start of .text.
|
||||
// Real names unknown; placeholders until identified.
|
||||
|
||||
struct CListNode {
|
||||
CListNode* next;
|
||||
CListNode* prev;
|
||||
};
|
||||
|
||||
struct CList {
|
||||
CListNode* head;
|
||||
CListNode* tail;
|
||||
|
||||
void Append(CListNode* node);
|
||||
void Remove(CListNode* node);
|
||||
};
|
||||
|
||||
struct CListPair {
|
||||
CListPair() : a(0), b(0), head(0), tail(0) {}
|
||||
|
||||
CListNode* a;
|
||||
CListNode* b;
|
||||
CListNode* head;
|
||||
CListNode* tail;
|
||||
};
|
||||
|
||||
CListPair* g_pListPair;
|
||||
|
||||
// FUNCTION: EQGAME 0x00401022
|
||||
CListPair* GetListPair()
|
||||
{
|
||||
if (!g_pListPair)
|
||||
g_pListPair = new CListPair;
|
||||
return g_pListPair;
|
||||
}
|
||||
|
||||
// FUNCTION: EQGAME 0x00401050
|
||||
void CList::Append(CListNode* node)
|
||||
{
|
||||
if (!head)
|
||||
head = node;
|
||||
else {
|
||||
tail->next = node;
|
||||
node->prev = tail;
|
||||
}
|
||||
tail = node;
|
||||
}
|
||||
|
||||
// FUNCTION: EQGAME 0x0040106e
|
||||
void CList::Remove(CListNode* node)
|
||||
{
|
||||
if (head == node)
|
||||
head = node->next;
|
||||
if (tail == node)
|
||||
tail = node->prev;
|
||||
if (node->prev)
|
||||
node->prev->next = node->next;
|
||||
if (node->next)
|
||||
node->next->prev = node->prev;
|
||||
node->next = 0;
|
||||
node->prev = 0;
|
||||
}
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
"""Minimal PE image and COFF object readers (i386 only, no dependencies)."""
|
||||
import struct
|
||||
|
||||
# i386 COFF relocation types
|
||||
REL_I386_DIR32 = 0x06
|
||||
REL_I386_DIR32NB = 0x07
|
||||
REL_I386_SECTION = 0x0A
|
||||
REL_I386_SECREL = 0x0B
|
||||
REL_I386_REL32 = 0x14
|
||||
|
||||
IMAGE_SCN_CNT_CODE = 0x20
|
||||
IMAGE_SCN_LNK_COMDAT = 0x1000
|
||||
|
||||
|
||||
class PEImage:
|
||||
def __init__(self, path):
|
||||
with open(path, "rb") as f:
|
||||
self.data = f.read()
|
||||
d = self.data
|
||||
pe = struct.unpack_from("<I", d, 0x3C)[0]
|
||||
if d[pe:pe + 4] != b"PE\0\0":
|
||||
raise ValueError(f"{path}: not a PE file")
|
||||
nsec, = struct.unpack_from("<H", d, pe + 6)
|
||||
optsize, = struct.unpack_from("<H", d, pe + 20)
|
||||
opt = pe + 24
|
||||
self.image_base, = struct.unpack_from("<I", d, opt + 28)
|
||||
self.sections = []
|
||||
for i in range(nsec):
|
||||
o = opt + optsize + i * 40
|
||||
name = d[o:o + 8].rstrip(b"\0").decode("latin-1")
|
||||
vsize, va, rawsize, rawptr = struct.unpack_from("<IIII", d, o + 8)
|
||||
chars, = struct.unpack_from("<I", d, o + 36)
|
||||
self.sections.append((name, self.image_base + va, vsize, rawptr, rawsize, chars))
|
||||
|
||||
def section(self, name):
|
||||
for s in self.sections:
|
||||
if s[0] == name:
|
||||
return s
|
||||
raise KeyError(name)
|
||||
|
||||
def read(self, va, size):
|
||||
for name, sva, vsize, rawptr, rawsize, _ in self.sections:
|
||||
if sva <= va < sva + max(vsize, rawsize):
|
||||
off = va - sva
|
||||
out = self.data[rawptr + off:rawptr + min(off + size, rawsize)]
|
||||
return out + b"\0" * (size - len(out)) # bss tail
|
||||
raise ValueError(f"VA 0x{va:08x} not in any section")
|
||||
|
||||
def contains(self, va):
|
||||
return any(s[1] <= va < s[1] + s[2] for s in self.sections)
|
||||
|
||||
|
||||
class CoffSymbol:
|
||||
__slots__ = ("index", "name", "value", "section", "type", "storage")
|
||||
|
||||
def __init__(self, index, name, value, section, type_, storage):
|
||||
self.index, self.name, self.value = index, name, value
|
||||
self.section, self.type, self.storage = section, type_, storage
|
||||
|
||||
@property
|
||||
def is_function(self):
|
||||
return (self.type >> 4) == 2
|
||||
|
||||
@property
|
||||
def is_external(self):
|
||||
return self.storage == 2
|
||||
|
||||
|
||||
class CoffSection:
|
||||
__slots__ = ("index", "name", "data", "chars", "relocs")
|
||||
|
||||
def __init__(self, index, name, data, chars, relocs):
|
||||
self.index, self.name, self.data, self.chars, self.relocs = index, name, data, chars, relocs
|
||||
|
||||
|
||||
IMAGE_COMDAT_SELECT_ANY = 2
|
||||
|
||||
|
||||
class CoffObject:
|
||||
def __init__(self, path):
|
||||
with open(path, "rb") as f:
|
||||
d = f.read()
|
||||
self.path = path
|
||||
machine, nsec, _, symptr, nsym, optsize, _ = struct.unpack_from("<HHIIIHH", d, 0)
|
||||
if machine != 0x14C:
|
||||
raise ValueError(f"{path}: not an i386 COFF object")
|
||||
strtab = symptr + nsym * 18
|
||||
|
||||
def cstr(raw):
|
||||
if raw[:4] == b"\0\0\0\0":
|
||||
off, = struct.unpack_from("<I", raw, 4)
|
||||
end = d.index(b"\0", strtab + off)
|
||||
return d[strtab + off:end].decode("latin-1")
|
||||
return raw.rstrip(b"\0").decode("latin-1")
|
||||
|
||||
self.symbols = {}
|
||||
self.comdat_select = {} # section number -> IMAGE_COMDAT_SELECT_*
|
||||
i = 0
|
||||
while i < nsym:
|
||||
o = symptr + i * 18
|
||||
raw = d[o:o + 8]
|
||||
value, secnum, type_, storage, naux = struct.unpack_from("<IhHBB", d, o + 8)
|
||||
self.symbols[i] = CoffSymbol(i, cstr(raw), value, secnum, type_, storage)
|
||||
if storage == 3 and value == 0 and naux and secnum > 0: # section definition
|
||||
self.comdat_select[secnum] = d[o + 18 + 14]
|
||||
i += 1 + naux
|
||||
|
||||
self.sections = {}
|
||||
for s in range(nsec):
|
||||
o = 20 + optsize + s * 40
|
||||
raw = d[o:o + 8]
|
||||
name = raw.rstrip(b"\0").decode("latin-1")
|
||||
if name.startswith("/"):
|
||||
off = int(name[1:])
|
||||
name = d[strtab + off:d.index(b"\0", strtab + off)].decode("latin-1")
|
||||
size, rawptr, relptr, _, nrel, _, chars = struct.unpack_from("<IIIIHHI", d, o + 16)
|
||||
data = d[rawptr:rawptr + size] if rawptr else b"\0" * size
|
||||
relocs = []
|
||||
for r in range(nrel):
|
||||
va, symidx, typ = struct.unpack_from("<IIH", d, relptr + r * 10)
|
||||
relocs.append((va, symidx, typ))
|
||||
self.sections[s + 1] = CoffSection(s + 1, name, data, chars, relocs)
|
||||
|
||||
def is_inline(self, secnum):
|
||||
"""Inline/template functions are emitted as pick-any COMDATs."""
|
||||
return self.comdat_select.get(secnum) == IMAGE_COMDAT_SELECT_ANY
|
||||
|
||||
def functions(self):
|
||||
"""Yield (symbol, section, start, end) for each function defined in a code section."""
|
||||
by_sec = {}
|
||||
for sym in self.symbols.values():
|
||||
if sym.section > 0 and sym.is_function:
|
||||
by_sec.setdefault(sym.section, []).append(sym)
|
||||
for secnum, syms in by_sec.items():
|
||||
sec = self.sections[secnum]
|
||||
if not sec.chars & IMAGE_SCN_CNT_CODE:
|
||||
continue
|
||||
syms.sort(key=lambda s: s.value)
|
||||
for i, sym in enumerate(syms):
|
||||
end = syms[i + 1].value if i + 1 < len(syms) else len(sec.data)
|
||||
yield sym, sec, sym.value, end
|
||||
Executable
+10
@@ -0,0 +1,10 @@
|
||||
#!/bin/sh
|
||||
# Run MSVC 7.1 (cl 13.10.3077) under Wine.
|
||||
# Usage: tools/cl.sh <cl args...> (paths may be POSIX; they're passed through winepath-free via Z:)
|
||||
ROOT=$(cd "$(dirname "$0")/.." && pwd)
|
||||
MSVC="$ROOT/tools/msvc7.1"
|
||||
[ -x "$MSVC/Bin/cl.exe" ] || { echo "MSVC 7.1 not found in $MSVC (run tools/setup.sh)" >&2; exit 1; }
|
||||
export WINEDEBUG=-all
|
||||
export INCLUDE="Z:$(echo "$MSVC/Include" | tr / '\\')"
|
||||
export PATH_MSVC="$MSVC/Bin"
|
||||
exec wine "$MSVC/Bin/cl.exe" "$@"
|
||||
+445
@@ -0,0 +1,445 @@
|
||||
#!/usr/bin/env python3
|
||||
"""EverQuest (Titanium, eqgame.exe 2005-10-31) matching-decompilation toolkit.
|
||||
|
||||
Subcommands:
|
||||
verify check orig/eqgame.exe is the expected build
|
||||
import-ghidra FILE seed config/eqgame/symbols.txt from tools/ghidra/ExportFunctions.java output
|
||||
build compile src/ with MSVC 7.1 (Wine) via ninja
|
||||
check [NAME|ADDR...] compare compiled functions against the original, byte for byte
|
||||
diff NAME|ADDR side-by-side disassembly of original vs compiled
|
||||
progress matched bytes / functions
|
||||
map ADDR NAME [KIND] name (or rename) a symbol in the map
|
||||
todo [N] list unmatched functions, smallest first
|
||||
"""
|
||||
import argparse
|
||||
import difflib
|
||||
import fnmatch
|
||||
import hashlib
|
||||
import os
|
||||
import re
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import yaml
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from binfmt import (CoffObject, PEImage, REL_I386_DIR32, REL_I386_REL32, # noqa: E402
|
||||
IMAGE_SCN_CNT_CODE)
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
CFG_DIR = os.path.join(ROOT, "config", "eqgame")
|
||||
BUILD = os.path.join(ROOT, "build")
|
||||
OBJ_DIR = os.path.join(BUILD, "obj")
|
||||
|
||||
|
||||
def load_config():
|
||||
with open(os.path.join(CFG_DIR, "config.yaml")) as f:
|
||||
return yaml.safe_load(f)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- symbol map
|
||||
|
||||
class SymbolMap:
|
||||
"""config/eqgame/symbols.txt: `ADDR SIZE KIND NAME` per line, '#' comments.
|
||||
|
||||
KIND: fn (game code), lib (CRT/third-party, not decompiled), thunk, data.
|
||||
"""
|
||||
|
||||
def __init__(self, path):
|
||||
self.path = path
|
||||
self.entries = {} # addr -> [size, kind, name]
|
||||
if os.path.exists(path):
|
||||
with open(path) as f:
|
||||
for line in f:
|
||||
line = line.split("#", 1)[0].split()
|
||||
if len(line) >= 4:
|
||||
self.entries[int(line[0], 16)] = [int(line[1], 16), line[2], line[3]]
|
||||
self._reindex()
|
||||
|
||||
def _reindex(self):
|
||||
self.by_name = {e[2]: a for a, e in self.entries.items()}
|
||||
|
||||
def lookup(self, key):
|
||||
"""Resolve a name or hex address to an address (or None)."""
|
||||
if key in self.by_name:
|
||||
return self.by_name[key]
|
||||
try:
|
||||
a = int(key, 16)
|
||||
except ValueError:
|
||||
return None
|
||||
return a if a in self.entries else None
|
||||
|
||||
def set(self, addr, name, kind=None, size=None):
|
||||
old = self.by_name.get(name)
|
||||
if old is not None and old != addr:
|
||||
raise ValueError(f"{name} already mapped at 0x{old:08x}")
|
||||
e = self.entries.setdefault(addr, [0, kind or "fn", name])
|
||||
e[2] = name
|
||||
if kind:
|
||||
e[1] = kind
|
||||
if size is not None:
|
||||
e[0] = size
|
||||
self._reindex()
|
||||
|
||||
def save(self):
|
||||
with open(self.path, "w") as f:
|
||||
f.write("# eqgame.exe symbol map: ADDR SIZE KIND NAME\n")
|
||||
f.write("# KIND = fn | lib | thunk | data. Names are MSVC-mangled once decompiled.\n")
|
||||
for a in sorted(self.entries):
|
||||
size, kind, name = self.entries[a]
|
||||
f.write(f"0x{a:08x} 0x{size:x} {kind} {name}\n")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- commands
|
||||
|
||||
def cmd_verify(args):
|
||||
cfg = load_config()
|
||||
path = os.path.join(ROOT, cfg["original"])
|
||||
if not os.path.exists(path):
|
||||
sys.exit(f"missing {cfg['original']}: copy or symlink your eqgame.exe there")
|
||||
with open(path, "rb") as f:
|
||||
h = hashlib.sha1(f.read()).hexdigest()
|
||||
if h != cfg["sha1"]:
|
||||
sys.exit(f"{cfg['original']}: sha1 {h} != expected {cfg['sha1']} (wrong client build)")
|
||||
print(f"{cfg['original']}: OK ({h})")
|
||||
|
||||
|
||||
def cmd_import_ghidra(args):
|
||||
m = SymbolMap(os.path.join(CFG_DIR, "symbols.txt"))
|
||||
added = 0
|
||||
with open(args.file) as f:
|
||||
for line in f:
|
||||
addr, size, name, kind = line.split()
|
||||
addr, size = int(addr, 16), int(size, 16)
|
||||
if addr in m.entries:
|
||||
m.entries[addr][0] = m.entries[addr][0] or size
|
||||
continue
|
||||
if name.startswith("FUN_"):
|
||||
name = f"FUN_{addr:08x}"
|
||||
elif kind != "lib":
|
||||
kind = "fn"
|
||||
name = re.sub(r"[^\w@?$<>:.~-]", "_", name)
|
||||
if name in m.by_name:
|
||||
name = f"{name}_{addr:08x}"
|
||||
m.entries[addr] = [size, kind, name]
|
||||
m.by_name[name] = addr
|
||||
added += 1
|
||||
m.save()
|
||||
print(f"imported {added} functions ({len(m.entries)} total)")
|
||||
|
||||
|
||||
def source_files():
|
||||
out = []
|
||||
for base, _, files in os.walk(os.path.join(ROOT, "src")):
|
||||
for fn in files:
|
||||
if fn.endswith((".c", ".cpp")):
|
||||
out.append(os.path.relpath(os.path.join(base, fn), ROOT))
|
||||
return sorted(out)
|
||||
|
||||
|
||||
def obj_path(src):
|
||||
return os.path.join("build", "obj", os.path.splitext(src)[0] + ".obj")
|
||||
|
||||
|
||||
def cmd_build(args):
|
||||
cfg = load_config()
|
||||
lines = [
|
||||
"ninja_required_version = 1.5",
|
||||
"builddir = build",
|
||||
f"cflags = {cfg['cflags']}",
|
||||
"rule cl",
|
||||
" command = tools/cl.sh /nologo /c $cflags $extra /Iinclude /Fo$out $in > $out.log 2>&1"
|
||||
" || { cat $out.log; rm -f $out; exit 1; }",
|
||||
" description = CL $in",
|
||||
"",
|
||||
]
|
||||
for src in source_files():
|
||||
with open(os.path.join(ROOT, src), encoding="latin-1") as f:
|
||||
head = f.read(4096)
|
||||
extra = " ".join(re.findall(r"^//\s*CFLAGS:\s*(.*)$", head, re.M))
|
||||
lines.append(f"build {obj_path(src)}: cl {src} | config/eqgame/config.yaml")
|
||||
if extra:
|
||||
lines.append(f" extra = {extra}")
|
||||
os.makedirs(BUILD, exist_ok=True)
|
||||
with open(os.path.join(BUILD, "build.ninja"), "w") as f:
|
||||
f.write("\n".join(lines) + "\n")
|
||||
r = subprocess.run(["ninja", "-f", "build/build.ninja"] + (["-v"] if args.verbose else []), cwd=ROOT)
|
||||
if r.returncode:
|
||||
sys.exit(r.returncode)
|
||||
|
||||
|
||||
def compiled_functions():
|
||||
"""name -> (obj, sym, sec, start, end) for every function in build/obj."""
|
||||
out = {}
|
||||
for src in source_files():
|
||||
p = os.path.join(ROOT, obj_path(src))
|
||||
if not os.path.exists(p):
|
||||
continue
|
||||
obj = CoffObject(p)
|
||||
for sym, sec, start, end in obj.functions():
|
||||
if sym.is_external:
|
||||
out[sym.name] = (obj, sym, sec, start, end)
|
||||
return out
|
||||
|
||||
|
||||
class Result:
|
||||
def __init__(self, name, addr):
|
||||
self.name, self.addr = name, addr
|
||||
self.status = "?"
|
||||
self.size = 0
|
||||
self.notes = []
|
||||
self.suggest = {} # symbol name -> original address
|
||||
|
||||
|
||||
def compare(pe, smap, obj, sym, sec, start, end):
|
||||
name = sym.name
|
||||
addr = smap.by_name[name]
|
||||
res = Result(name, addr)
|
||||
cb = sec.data[start:end]
|
||||
res.size = len(cb)
|
||||
ob = pe.read(addr, len(cb))
|
||||
mask = bytearray(len(cb))
|
||||
relocs = [(va - start, si, t) for va, si, t in sec.relocs if start <= va < end]
|
||||
for off, _, _ in relocs:
|
||||
mask[off:off + 4] = b"\1\1\1\1"
|
||||
|
||||
diffs = [i for i in range(len(cb)) if not mask[i] and cb[i] != ob[i]]
|
||||
mapsize = smap.entries[addr][0]
|
||||
if mapsize > len(cb):
|
||||
tail = pe.read(addr + len(cb), mapsize - len(cb))
|
||||
if tail.strip(b"\xcc\x90"):
|
||||
res.notes.append(f"original is longer (map size 0x{mapsize:x} vs 0x{len(cb):x})")
|
||||
diffs.append(len(cb))
|
||||
if diffs:
|
||||
res.status = "DIFF"
|
||||
res.notes.insert(0, f"{len(diffs)} byte(s) differ, first at +0x{diffs[0]:x}")
|
||||
return res
|
||||
|
||||
unknown = bad = 0
|
||||
for off, si, typ in relocs:
|
||||
tsym = obj.symbols[si]
|
||||
if typ == REL_I386_DIR32:
|
||||
addend, = struct.unpack_from("<i", cb, off)
|
||||
base = struct.unpack_from("<I", ob, off)[0] - addend
|
||||
elif typ == REL_I386_REL32:
|
||||
addend, = struct.unpack_from("<i", cb, off)
|
||||
base = addr + off + 4 + struct.unpack_from("<i", ob, off)[0] - addend
|
||||
else:
|
||||
res.notes.append(f"+0x{off:x}: unhandled reloc type {typ:#x} to {tsym.name}")
|
||||
unknown += 1
|
||||
continue
|
||||
base &= 0xFFFFFFFF
|
||||
|
||||
if tsym.section == sec.index:
|
||||
expected = addr - start + tsym.value
|
||||
elif tsym.name in smap.by_name:
|
||||
expected = smap.by_name[tsym.name]
|
||||
elif tsym.section > 0 and not tsym.is_external \
|
||||
and not (obj.sections[tsym.section].chars & IMAGE_SCN_CNT_CODE):
|
||||
# local data (string literal, float constant, static table): verify by content
|
||||
tsec = obj.sections[tsym.section]
|
||||
blob = tsec.data[tsym.value:]
|
||||
rmask = {o - tsym.value + k for o, _, _ in tsec.relocs for k in range(4)}
|
||||
got = pe.read(base, len(blob)) if pe.contains(base) else b""
|
||||
if got and all(i in rmask or got[i] == blob[i] for i in range(len(blob))):
|
||||
continue
|
||||
res.notes.append(f"+0x{off:x}: {tsym.name} content differs at 0x{base:08x}")
|
||||
bad += 1
|
||||
continue
|
||||
else:
|
||||
res.suggest[tsym.name] = base
|
||||
unknown += 1
|
||||
continue
|
||||
if expected != base:
|
||||
res.notes.append(f"+0x{off:x}: {tsym.name} -> 0x{base:08x}, map says 0x{expected:08x}")
|
||||
bad += 1
|
||||
if bad:
|
||||
res.status = "BADREF"
|
||||
elif unknown:
|
||||
res.status = "MATCH?"
|
||||
for n, a in res.suggest.items():
|
||||
cur = smap.entries.get(a)
|
||||
res.notes.append(f"unmapped {n} = 0x{a:08x}" + (f" (currently {cur[2]})" if cur else ""))
|
||||
else:
|
||||
res.status = "MATCH"
|
||||
return res
|
||||
|
||||
|
||||
def run_checks(filters=()):
|
||||
cfg = load_config()
|
||||
pe = PEImage(os.path.join(ROOT, cfg["original"]))
|
||||
smap = SymbolMap(os.path.join(CFG_DIR, "symbols.txt"))
|
||||
results, unmapped = [], []
|
||||
for name, (obj, sym, sec, start, end) in sorted(compiled_functions().items()):
|
||||
if name not in smap.by_name:
|
||||
if not obj.is_inline(sec.index): # inline copies are usually folded away
|
||||
unmapped.append((name, obj.path))
|
||||
continue
|
||||
if filters and not any(fnmatch.fnmatchcase(name, f) or smap.lookup(f) == smap.by_name[name]
|
||||
for f in filters):
|
||||
continue
|
||||
results.append(compare(pe, smap, obj, sym, sec, start, end))
|
||||
return smap, results, unmapped
|
||||
|
||||
|
||||
def cmd_check(args):
|
||||
smap, results, unmapped = run_checks(args.names)
|
||||
for r in sorted(results, key=lambda r: r.addr):
|
||||
print(f"{r.status:7} 0x{r.addr:08x} {r.size:5d} {r.name}")
|
||||
if args.verbose or r.status != "MATCH":
|
||||
for n in r.notes:
|
||||
print(f" {n}")
|
||||
if not args.names:
|
||||
for name, path in unmapped:
|
||||
print(f"UNMAPPED {name} ({os.path.relpath(path, ROOT)})")
|
||||
if args.learn:
|
||||
n = 0
|
||||
for r in results:
|
||||
if r.status != "MATCH?":
|
||||
continue
|
||||
for name, a in r.suggest.items():
|
||||
if name in smap.by_name:
|
||||
continue
|
||||
kind = None if a in smap.entries else "data"
|
||||
smap.set(a, name, kind)
|
||||
n += 1
|
||||
smap.save()
|
||||
print(f"learned {n} symbol(s) from matching functions")
|
||||
counts = {}
|
||||
for r in results:
|
||||
counts[r.status] = counts.get(r.status, 0) + 1
|
||||
print(" ".join(f"{k}: {v}" for k, v in sorted(counts.items())) or "nothing to check")
|
||||
if any(r.status in ("DIFF", "BADREF") for r in results):
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def disasm(path, extra):
|
||||
"""[(address, text, reloc_target_or_None)] from objdump."""
|
||||
out = subprocess.run(["i686-w64-mingw32-objdump", "-d", "-r", "-M", "intel", "--no-show-raw-insn",
|
||||
*extra, path], capture_output=True, text=True).stdout
|
||||
lines = []
|
||||
for line in out.splitlines():
|
||||
m = re.match(r"\s+([0-9a-f]+):\s+(.*)", line)
|
||||
if not m:
|
||||
continue
|
||||
text = m.group(2).strip()
|
||||
if re.match(r"(dir32|DISP32|IMAGE_REL|secrel)", text, re.I):
|
||||
a, t, _ = lines[-1]
|
||||
lines[-1] = (a, t, text.split()[-1])
|
||||
else:
|
||||
lines.append((int(m.group(1), 16), re.sub(r"\s+", " ", text), None))
|
||||
return lines
|
||||
|
||||
|
||||
BRANCH = re.compile(r"^(j\w+|call|loop\w*) (?:0x)?([0-9a-f]+)\b(?: <[^>]*>)?$")
|
||||
|
||||
|
||||
def render(lines, base, size, names):
|
||||
"""Normalise to [(offset, display, compare_key)]: intra-function branches become offsets,
|
||||
references to other symbols become their names, everything else is compared literally."""
|
||||
out = []
|
||||
for a, text, reloc in lines:
|
||||
m = BRANCH.match(text)
|
||||
if reloc:
|
||||
shown = re.sub(r"(?:0x)?[0-9a-f]+(?: <[^>]*>)?$", reloc, text) if m else \
|
||||
re.sub(r"0x0\b", reloc, text) if "0x0" in text else f"{text} ; {reloc}"
|
||||
key = f"{m.group(1)} {reloc}" if m else re.sub(r"0x[0-9a-f]+", "REF", text)
|
||||
elif m and base <= int(m.group(2), 16) < base + size:
|
||||
shown = key = f"{m.group(1)} +0x{int(m.group(2), 16) - base:x}"
|
||||
elif m:
|
||||
tgt = names.get(int(m.group(2), 16), f"0x{int(m.group(2), 16):x}")
|
||||
shown = f"{m.group(1)} {tgt}"
|
||||
key = f"{m.group(1)} {tgt}"
|
||||
else:
|
||||
shown = text
|
||||
key = text
|
||||
# absolute addresses in the original that the map can name
|
||||
for h in re.findall(r"0x[0-9a-f]{6,8}", text):
|
||||
n = names.get(int(h, 16))
|
||||
if n:
|
||||
shown = shown.replace(h, n)
|
||||
key = re.sub(r"0x[0-9a-f]{6,8}", "REF", text)
|
||||
out.append((a - base, shown, key))
|
||||
return out
|
||||
|
||||
|
||||
def cmd_diff(args):
|
||||
cfg = load_config()
|
||||
smap = SymbolMap(os.path.join(CFG_DIR, "symbols.txt"))
|
||||
addr = smap.lookup(args.name)
|
||||
if addr is None:
|
||||
sys.exit(f"{args.name}: not in symbol map")
|
||||
name = smap.entries[addr][2]
|
||||
names = {a: e[2] for a, e in smap.entries.items()}
|
||||
funcs = compiled_functions()
|
||||
size = smap.entries[addr][0]
|
||||
mine = []
|
||||
if name in funcs:
|
||||
obj, sym, sec, start, end = funcs[name]
|
||||
size = max(size, end - start)
|
||||
mine = render(disasm(obj.path, [f"--disassemble={name}"]), start, end - start, {})
|
||||
else:
|
||||
print(f"{name} has not been compiled yet; showing original only")
|
||||
orig = render(disasm(os.path.join(ROOT, cfg["original"]),
|
||||
[f"--start-address=0x{addr:x}", f"--stop-address=0x{addr + size:x}"]),
|
||||
addr, size, names)
|
||||
# In the original, a reference to a symbol the compiled side names should compare equal.
|
||||
sm = difflib.SequenceMatcher(None, [k for _, _, k in orig], [k for _, _, k in mine], autojunk=False)
|
||||
w = 60
|
||||
print(f"{'ORIGINAL 0x%08x' % addr:<{w}} | COMPILED {name}")
|
||||
for op, i1, i2, j1, j2 in sm.get_opcodes():
|
||||
for k in range(max(i2 - i1, j2 - j1)):
|
||||
left = orig[i1 + k] if i1 + k < i2 else None
|
||||
right = mine[j1 + k] if j1 + k < j2 else None
|
||||
mark = " " if op == "equal" else ("|" if left and right else (">" if right else "<"))
|
||||
ls = f"{left[0]:5x}: {left[1]}"[:w] if left else ""
|
||||
rs = f"{right[0]:5x}: {right[1]}" if right else ""
|
||||
print(f"{ls:<{w}} {mark} {rs}")
|
||||
|
||||
|
||||
def cmd_progress(args):
|
||||
smap, results, _ = run_checks()
|
||||
matched = {r.addr for r in results if r.status in ("MATCH", "MATCH?")}
|
||||
game = [(a, e) for a, e in smap.entries.items() if e[1] == "fn"]
|
||||
total = sum(e[0] for _, e in game) or 1
|
||||
done = sum(e[0] for a, e in game if a in matched)
|
||||
print(f"game code: {done}/{total} bytes ({100 * done / total:.3f}%), "
|
||||
f"{len(matched & {a for a, _ in game})}/{len(game)} functions")
|
||||
lib = [e for e in smap.entries.values() if e[1] == "lib"]
|
||||
print(f"library code (excluded): {len(lib)} functions, {sum(e[0] for e in lib)} bytes")
|
||||
|
||||
|
||||
def cmd_map(args):
|
||||
smap = SymbolMap(os.path.join(CFG_DIR, "symbols.txt"))
|
||||
smap.set(int(args.addr, 16), args.name, args.kind)
|
||||
smap.save()
|
||||
|
||||
|
||||
def cmd_todo(args):
|
||||
smap, results, _ = run_checks()
|
||||
done = {r.addr for r in results if r.status in ("MATCH", "MATCH?")}
|
||||
todo = sorted((e[0], a, e[2]) for a, e in smap.entries.items() if e[1] == "fn" and a not in done)
|
||||
for size, a, name in todo[:args.n]:
|
||||
print(f"0x{a:08x} {size:6d} {name}")
|
||||
|
||||
|
||||
def main():
|
||||
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
sub = p.add_subparsers(dest="cmd", required=True)
|
||||
sub.add_parser("verify")
|
||||
s = sub.add_parser("import-ghidra"); s.add_argument("file")
|
||||
s = sub.add_parser("build"); s.add_argument("-v", "--verbose", action="store_true")
|
||||
s = sub.add_parser("check"); s.add_argument("names", nargs="*")
|
||||
s.add_argument("-v", "--verbose", action="store_true")
|
||||
s.add_argument("--learn", action="store_true", help="add symbols resolved by matching functions to the map")
|
||||
s = sub.add_parser("diff"); s.add_argument("name")
|
||||
sub.add_parser("progress")
|
||||
s = sub.add_parser("map"); s.add_argument("addr"); s.add_argument("name"); s.add_argument("kind", nargs="?")
|
||||
s = sub.add_parser("todo"); s.add_argument("n", nargs="?", type=int, default=40)
|
||||
args = p.parse_args()
|
||||
globals()["cmd_" + args.cmd.replace("-", "_")](args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,28 @@
|
||||
// Exports every function Ghidra found as: start_va size name kind
|
||||
// kind: fn | thunk | lib (FID-identified library code)
|
||||
// Usage: analyzeHeadless <proj> <name> -import eqgame.exe -postScript ExportFunctions.java <out.txt>
|
||||
import ghidra.app.script.GhidraScript;
|
||||
import ghidra.program.model.address.*;
|
||||
import ghidra.program.model.listing.*;
|
||||
import ghidra.program.model.symbol.*;
|
||||
import java.io.*;
|
||||
|
||||
public class ExportFunctions extends GhidraScript {
|
||||
@Override
|
||||
public void run() throws Exception {
|
||||
String out = getScriptArgs().length > 0 ? getScriptArgs()[0] : "functions.txt";
|
||||
try (PrintWriter w = new PrintWriter(new FileWriter(out))) {
|
||||
for (Function f : currentProgram.getFunctionManager().getFunctions(true)) {
|
||||
if (f.isExternal()) continue;
|
||||
Address entry = f.getEntryPoint();
|
||||
AddressRange r = f.getBody().getRangeContaining(entry);
|
||||
long size = r == null ? 0 : r.getMaxAddress().subtract(entry) + 1;
|
||||
String kind = f.isThunk() ? "thunk" : "fn";
|
||||
Symbol s = f.getSymbol();
|
||||
if (s.getSource() == SourceType.ANALYSIS && !s.getName().startsWith("FUN_")) kind = "lib";
|
||||
w.printf("0x%08x 0x%x %s %s%n", entry.getOffset(), size, f.getName(true).replace(' ', '_'), kind);
|
||||
}
|
||||
}
|
||||
println("exported to " + out);
|
||||
}
|
||||
}
|
||||
Executable
+27
@@ -0,0 +1,27 @@
|
||||
#!/bin/sh
|
||||
# One-time setup: fetch MSVC 7.1 (cl 13.10.3077) and check the original binary.
|
||||
# Usage: tools/setup.sh [path/to/eqgame.exe]
|
||||
set -e
|
||||
ROOT=$(cd "$(dirname "$0")/.." && pwd)
|
||||
cd "$ROOT"
|
||||
|
||||
if [ -n "$1" ]; then
|
||||
ln -sf "$(realpath "$1")" orig/eqgame.exe
|
||||
fi
|
||||
|
||||
if [ ! -x tools/msvc7.1/Bin/cl.exe ]; then
|
||||
# Same package decomp.me uses for its "msvc7.1" compiler (the asset name says 7.0; contents are 7.1).
|
||||
URL=https://github.com/OmniBlade/decomp.me/releases/download/msvcwin9x/msvc7.0.tar.gz
|
||||
TMP=$(mktemp -d)
|
||||
curl -fL -o "$TMP/msvc.tar.gz" "$URL"
|
||||
mkdir -p tools/msvc7.1
|
||||
tar -xzf "$TMP/msvc.tar.gz" -C tools/msvc7.1
|
||||
rm -rf "$TMP"
|
||||
fi
|
||||
|
||||
if ! WINEDEBUG=-all wine tools/msvc7.1/Bin/cl.exe 2>&1 | grep -q "Version 13.10.3077 "; then
|
||||
echo "error: tools/msvc7.1/Bin/cl.exe is not cl 13.10.3077 (or wine is missing)" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "MSVC 7.1 (13.10.3077): OK"
|
||||
python3 tools/eq.py verify
|
||||
Reference in new issue
Block a user