Map the world view sizing; add RE tooling
- docs/widescreen.md: render-thread frame, cWorldView0 layout and methods, view size = zoom * 1024.0/768.0 float constants and all their users, ortho projection extents, open questions (activity radius, buffers). - tools: find-imm (immediate/regex instruction search), rtti (vtables via MSVC RTTI), decompile.ps1 + Ghidra scripts for a persistent project; ApplyRtti recovers 410 vtables / ~1200 functions Ghidra misses. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
1549464af6
commit
b2dc1338d2
8 files changed
+493
No files matched your search
@@ -4,3 +4,4 @@
|
|||||||
/cmake-build-*/
|
/cmake-build-*/
|
||||||
*.rea-snapshot
|
*.rea-snapshot
|
||||||
sacred_re.ini
|
sacred_re.ini
|
||||||
|
/ghidra/
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
# Widescreen: how the game sizes its view
|
||||||
|
|
||||||
|
Goal: render the world 1:1 at the monitor's native resolution, showing more of
|
||||||
|
the world instead of scaling the 1024x768 image. Render and activity
|
||||||
|
(animation/AI) distance must grow with it. UI keeps its 1024x768 layout.
|
||||||
|
|
||||||
|
## Frame (render thread)
|
||||||
|
|
||||||
|
`cEngine::renderThread` loop, `0x0060e3f0`, per frame:
|
||||||
|
|
||||||
|
1. clear (D3D device `+0x28`)
|
||||||
|
2. updates: `0x401660`, `0x640b50`, `0x634210`, weather `0x417e50`/`0x41a4d0`
|
||||||
|
3. world: `view->vf14(device)` where `view = engine[+8 + idx*4]` (cWorldView0)
|
||||||
|
4. UI: `0x756820()->0x7587b0(device)`, debug overlays `0x60f3e0`/`0x60f500`
|
||||||
|
5. FPS overlay `0x60aa90`, `DxDriver7::Flip`
|
||||||
|
|
||||||
|
Skipped entirely while `g_appFlags & 0x20000` (app inactive).
|
||||||
|
|
||||||
|
## World view (`cWorldView0`, vtable `0x008900f8`)
|
||||||
|
|
||||||
|
| Offset | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| `+0x14/+0x18` | camera position (world pixels) |
|
||||||
|
| `+0x1c/+0x20` | requested camera position (scroll target) |
|
||||||
|
| `+0xb60` | zoom (float, clamped to 0.5 .. 2.0) |
|
||||||
|
| `+0x96ab0` | view width = zoom * 1024.0 |
|
||||||
|
| `+0x96ab4` | view height = zoom * 768.0 |
|
||||||
|
| `+0x96ac0` | extra rows (4) |
|
||||||
|
| `+0x96ac8` | columns = width/96 + 12 |
|
||||||
|
| `+0x96acc` | rows = height/48 + 24 |
|
||||||
|
|
||||||
|
Functions:
|
||||||
|
|
||||||
|
| Address | Name | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `0x00624ae0` | `cWorldView0::SetupView` | computes the size fields above from zoom and the float constants `1024.0` (`0x0088e6f4`) and `768.0` (`0x00890040`); tile corner offsets `±48.2/zoom`, `±24.2/zoom` |
|
||||||
|
| `0x006322b0` | `cWorldView0::Render` (vf14) | iso tile grid: tiles 96x48, two staggered half-rows per row (`0x62b000`), first tile = camera - (width/192, height/96) |
|
||||||
|
| `0x0062a730` | `cWorldView0::UpdateCamera` (vf0c) | scroll blocking against sector edges uses width/2, height/2 |
|
||||||
|
| `0x00628210` | `cWorldView0::SetZoom` | clamps zoom, sets the **ortho projection** for 3D models: x ±267*zoom, y ±200*zoom, z -600..2500, then calls SetupView |
|
||||||
|
| `0x00623c40` | world -> screen projection | `x * 1024.0 / (2/P00)`, `y * 768.0 / (2/P11)` |
|
||||||
|
| `0x006426a0` | build ortho matrix | (l, r, b, t, n, f) |
|
||||||
|
|
||||||
|
All tile/screen positions are pre-transformed (FVF `0x1c4`: XYZRHW, diffuse,
|
||||||
|
specular, tex1); 3D models use the ortho projection.
|
||||||
|
|
||||||
|
### Users of the 1024.0 / 768.0 float constants
|
||||||
|
|
||||||
|
World view (should follow the new size): `0x61e5c2`, `0x61e5ec`, `0x623c82`,
|
||||||
|
`0x623c9c`, `0x624af2`, `0x624b03`, `0x628f65`, `0x628f8f`, `0x629294`,
|
||||||
|
`0x6292bc`, `0x62b43a`, `0x62dc32`, `0x62ea2b`, `0x62ea5e`, `0x62f30d`,
|
||||||
|
`0x62f345`, `0x630656`, `0x63068a`, `0x630f3f`, `0x630f77` (mostly on-screen
|
||||||
|
clip tests).
|
||||||
|
|
||||||
|
Elsewhere (unknown / UI, leave alone for now): `0x40694a`, `0x4ec036`,
|
||||||
|
`0x593c25`, `0x593c3a`, `0x6ccb87`, `0x6ce156`, `0x758a34`, `0x75f79a`,
|
||||||
|
`0x75f7b8`.
|
||||||
|
|
||||||
|
### Ortho projection extents (±267 / ±200 pushed as floats)
|
||||||
|
|
||||||
|
`0x60c1f7`, `0x60da54`, `0x625039`, `0x6283b3`, `0x62858d`, `0x628d3b`.
|
||||||
|
|
||||||
|
## Renderer
|
||||||
|
|
||||||
|
`DxDriver7::SetDisplayMode` (`0x00645390`) creates back buffer, z-buffer and
|
||||||
|
D3D viewport from `DxDisplayMode::width/height` (hardcoded 1024x768 by
|
||||||
|
`InitGame`, `0x00815620`, which also calls `ChangeDisplaySettings(1024x768)`
|
||||||
|
in fullscreen).
|
||||||
|
|
||||||
|
## Fixed 1024x768 elsewhere (later)
|
||||||
|
|
||||||
|
- `0x00613710` engine capture (save-game thumbnail), `0x006483f0` TGA writer
|
||||||
|
- `0x0060e3f0` GDI BitBlt of a 1024x768 bitmap (startup/movie path)
|
||||||
|
- `0x00627260` name/HP labels while Alt is held (no constants, uses view)
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
|
||||||
|
- Activity radius: which creatures/objects are updated and animated
|
||||||
|
(`cWorld` / `cSector`, `0x00603e90` = player?, `0x005fe060` = object by id).
|
||||||
|
- Fixed-size per-view buffers (e.g. `+0x80e40`, 5 vectors at `+0x96ad0` with
|
||||||
|
capacity 0x200) - do they overflow at 3440x1440 (47 columns x 54 rows)?
|
||||||
|
- Mouse picking (screen -> world).
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# tools
|
||||||
|
|
||||||
|
Node tools need `npm install` here once (iced-x86). Paths to sacred.exe are
|
||||||
|
passed explicitly.
|
||||||
|
|
||||||
|
| Tool | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `disasm.mjs <exe> <va> [n]` | disassemble at an address, ignoring Ghidra's function boundaries |
|
||||||
|
| `find-imm.mjs <exe> <value...>` | every instruction using an immediate value (e.g. `1024 768`) |
|
||||||
|
| `find-imm.mjs <exe> /regex/` | every instruction whose text matches (e.g. `/96AB0h\]/`) |
|
||||||
|
| `rtti.mjs <exe> <Class>` | vtables of an MSVC class via RTTI |
|
||||||
|
| `decompile.ps1 <addr...>` | decompile from the persistent Ghidra project `ghidra/` |
|
||||||
|
|
||||||
|
## Persistent Ghidra project (`ghidra/`, not in git)
|
||||||
|
|
||||||
|
Created once (about 7 minutes):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:GHIDRA_HEADLESS_MAXMEM = "8G"
|
||||||
|
& "$env:GHIDRA_INSTALL_DIR\support\analyzeHeadless.bat" ghidra sacred -import "<Sacred Gold>\sacred.exe"
|
||||||
|
& "$env:GHIDRA_INSTALL_DIR\support\analyzeHeadless.bat" ghidra sacred -process sacred.exe -scriptPath tools\ghidra -postScript ApplyRtti.java
|
||||||
|
```
|
||||||
|
|
||||||
|
`ApplyRtti.java` recovers the MSVC 6 RTTI that Ghidra's own script misses:
|
||||||
|
410 vtables, ~1200 extra functions, methods named `Class::vfNN`.
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Decompile addresses from the persistent Ghidra project (ghidra/sacred.gpr).
|
||||||
|
# .\tools\decompile.ps1 0x6322b0 0x62ae70 [-Out file.c] [-ReadOnly]
|
||||||
|
# Prints the C to stdout (and keeps it in -Out, default ghidra\last.c).
|
||||||
|
# Without -ReadOnly, functions created for vtable-only methods are saved.
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory, ValueFromRemainingArguments)][string[]]$Addresses,
|
||||||
|
[string]$Out = "",
|
||||||
|
[switch]$ReadOnly
|
||||||
|
)
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
$root = Split-Path $PSScriptRoot
|
||||||
|
if (-not $Out) { $Out = Join-Path $root "ghidra\last.c" }
|
||||||
|
$ghidra = if ($env:GHIDRA_INSTALL_DIR) { $env:GHIDRA_INSTALL_DIR } else { "E:\Coding\Ghidra\ghidra_12.1.4_PUBLIC" }
|
||||||
|
if (-not $env:JAVA_HOME) { $env:JAVA_HOME = "C:\Program Files\Java\jdk-21" }
|
||||||
|
|
||||||
|
$arguments = @(
|
||||||
|
(Join-Path $root "ghidra"), "sacred",
|
||||||
|
"-process", "sacred.exe", "-noanalysis",
|
||||||
|
"-scriptPath", (Join-Path $PSScriptRoot "ghidra"),
|
||||||
|
"-postScript", "Decompile.java", $Out
|
||||||
|
) + $Addresses
|
||||||
|
if ($ReadOnly) { $arguments += "-readOnly" }
|
||||||
|
|
||||||
|
& (Join-Path $ghidra "support\analyzeHeadless.bat") @arguments *> (Join-Path $root "ghidra\decompile.out")
|
||||||
|
if ($LASTEXITCODE -ne 0) { Get-Content (Join-Path $root "ghidra\decompile.out") -Tail 30; exit $LASTEXITCODE }
|
||||||
|
Get-Content $Out
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
// List every instruction in sacred.exe's .text that uses one of the given
|
||||||
|
// immediate values (decimal or 0x-hex), e.g. to find hardcoded resolutions.
|
||||||
|
//
|
||||||
|
// node tools/find-imm.mjs <exe> 1024 768 [...]
|
||||||
|
// node tools/find-imm.mjs <exe> /96AB0h\]/ (regex on instruction text)
|
||||||
|
//
|
||||||
|
// Output: one line per hit, "address instruction".
|
||||||
|
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { Decoder, DecoderOptions, Formatter, FormatterSyntax, OpKind } from "iced-x86";
|
||||||
|
|
||||||
|
const [exePath, ...valueTexts] = process.argv.slice(2);
|
||||||
|
if (!exePath || valueTexts.length === 0) {
|
||||||
|
console.error("usage: node find-imm.mjs <exe> <value> [value...]");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const textPattern = valueTexts.length === 1 && /^\/.*\/$/.test(valueTexts[0])
|
||||||
|
? new RegExp(valueTexts[0].slice(1, -1), "i")
|
||||||
|
: null;
|
||||||
|
const wanted = textPattern ? new Set() : new Set(valueTexts.map((t) => BigInt(Number(t))));
|
||||||
|
|
||||||
|
const file = readFileSync(exePath);
|
||||||
|
const peOffset = file.readUInt32LE(0x3c);
|
||||||
|
const sectionCount = file.readUInt16LE(peOffset + 6);
|
||||||
|
const optionalSize = file.readUInt16LE(peOffset + 20);
|
||||||
|
const imageBase = file.readUInt32LE(peOffset + 24 + 28);
|
||||||
|
const sectionTable = peOffset + 24 + optionalSize;
|
||||||
|
|
||||||
|
let text = null;
|
||||||
|
for (let i = 0; i < sectionCount; i++) {
|
||||||
|
const s = sectionTable + 40 * i;
|
||||||
|
const name = file.toString("latin1", s, s + 8).replace(/\0+$/, "");
|
||||||
|
if (name === ".text") {
|
||||||
|
text = {
|
||||||
|
virtualAddress: file.readUInt32LE(s + 12),
|
||||||
|
rawSize: file.readUInt32LE(s + 16),
|
||||||
|
rawOffset: file.readUInt32LE(s + 20),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!text) throw new Error("no .text section");
|
||||||
|
|
||||||
|
const bytes = file.subarray(text.rawOffset, text.rawOffset + text.rawSize);
|
||||||
|
const decoder = new Decoder(32, bytes, DecoderOptions.None);
|
||||||
|
decoder.ip = BigInt(imageBase + text.virtualAddress);
|
||||||
|
const formatter = new Formatter(FormatterSyntax.Intel);
|
||||||
|
|
||||||
|
const immediateKinds = new Set([
|
||||||
|
OpKind.Immediate8, OpKind.Immediate16, OpKind.Immediate32,
|
||||||
|
OpKind.Immediate8to16, OpKind.Immediate8to32,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Linear sweep: data embedded in .text can produce a few bogus hits.
|
||||||
|
while (decoder.canDecode) {
|
||||||
|
const instruction = decoder.decode();
|
||||||
|
if (textPattern) {
|
||||||
|
const text = formatter.format(instruction);
|
||||||
|
if (textPattern.test(text)) {
|
||||||
|
console.log(`${Number(instruction.ip).toString(16).padStart(8, "0")} ${text}`);
|
||||||
|
}
|
||||||
|
instruction.free();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (let op = 0; op < instruction.opCount; op++) {
|
||||||
|
if (immediateKinds.has(instruction.opKind(op)) && wanted.has(instruction.immediate(op) & 0xffffffffn)) {
|
||||||
|
console.log(`${Number(instruction.ip).toString(16).padStart(8, "0")} ${formatter.format(instruction)}`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
instruction.free();
|
||||||
|
}
|
||||||
|
decoder.free();
|
||||||
|
formatter.free();
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
// Recover MSVC 6 RTTI in sacred.exe: for every class with a TypeDescriptor
|
||||||
|
// (".?AV<Name>@@"), find its CompleteObjectLocators and vtables, create a
|
||||||
|
// function for every vtable entry and name default-named ones
|
||||||
|
// "<Class>::vfNN" (NN = byte offset in the vtable). Labels the vtables
|
||||||
|
// "<Class>::vftable" (plus "_at_<offset>" for secondary base subobjects).
|
||||||
|
//
|
||||||
|
// Ghidra's own RecoverClassesFromRTTIScript finds nothing in this binary.
|
||||||
|
// Run once, headless, without -readOnly (see tools/README.md).
|
||||||
|
// @category sacred_re
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import ghidra.app.cmd.disassemble.DisassembleCommand;
|
||||||
|
import ghidra.app.script.GhidraScript;
|
||||||
|
import ghidra.program.model.address.Address;
|
||||||
|
import ghidra.program.model.listing.Function;
|
||||||
|
import ghidra.program.model.mem.Memory;
|
||||||
|
import ghidra.program.model.mem.MemoryBlock;
|
||||||
|
import ghidra.program.model.symbol.Namespace;
|
||||||
|
import ghidra.program.model.symbol.SourceType;
|
||||||
|
import ghidra.program.model.symbol.SymbolTable;
|
||||||
|
|
||||||
|
public class ApplyRtti extends GhidraScript {
|
||||||
|
|
||||||
|
private Memory memory;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void run() throws Exception {
|
||||||
|
memory = currentProgram.getMemory();
|
||||||
|
MemoryBlock text = memory.getBlock(".text");
|
||||||
|
MemoryBlock rdata = memory.getBlock(".rdata");
|
||||||
|
SymbolTable symbols = currentProgram.getSymbolTable();
|
||||||
|
|
||||||
|
// 1. TypeDescriptors: the mangled name sits 8 bytes in. Search all blocks.
|
||||||
|
Map<Long, String> typeDescriptors = new HashMap<>();
|
||||||
|
byte[] prefix = ".?AV".getBytes("ISO-8859-1");
|
||||||
|
for (MemoryBlock block : memory.getBlocks()) {
|
||||||
|
if (!block.isInitialized()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Address cursor = block.getStart();
|
||||||
|
while (cursor != null && cursor.compareTo(block.getEnd()) < 0) {
|
||||||
|
Address hit = memory.findBytes(cursor, block.getEnd(), prefix, null, true, monitor);
|
||||||
|
if (hit == null) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
String mangled = readCString(hit, 512);
|
||||||
|
if (mangled.endsWith("@@")) {
|
||||||
|
typeDescriptors.put(hit.getOffset() - 8, mangled.substring(4, mangled.length() - 2));
|
||||||
|
}
|
||||||
|
cursor = hit.add(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
println("type descriptors: " + typeDescriptors.size());
|
||||||
|
|
||||||
|
// 2. CompleteObjectLocators in .rdata: {0, offset, cdOffset, pTD, pCHD}.
|
||||||
|
Map<Long, long[]> locators = new HashMap<>(); // col -> {td, subobjectOffset}
|
||||||
|
long rdataStart = rdata.getStart().getOffset();
|
||||||
|
long rdataEnd = rdata.getEnd().getOffset();
|
||||||
|
for (long a = rdataStart; a + 20 <= rdataEnd; a += 4) {
|
||||||
|
if (readU32(a) != 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
long td = readU32(a + 12);
|
||||||
|
if (typeDescriptors.containsKey(td)) {
|
||||||
|
locators.put(a, new long[] {td, readU32(a + 4)});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
println("object locators: " + locators.size());
|
||||||
|
|
||||||
|
// 3. vtables: a dword in .rdata pointing at a COL; entries follow it.
|
||||||
|
int vtables = 0;
|
||||||
|
int created = 0;
|
||||||
|
int named = 0;
|
||||||
|
for (long a = rdataStart; a + 8 <= rdataEnd; a += 4) {
|
||||||
|
long[] locator = locators.get(readU32(a));
|
||||||
|
if (locator == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String className = typeDescriptors.get(locator[0]);
|
||||||
|
long vtable = a + 4;
|
||||||
|
Namespace namespace = symbols.getOrCreateNameSpace(currentProgram.getGlobalNamespace(),
|
||||||
|
className, SourceType.ANALYSIS);
|
||||||
|
String suffix = locator[1] == 0 ? "" : String.format("_at_%x", locator[1]);
|
||||||
|
symbols.createLabel(toAddr(vtable), "vftable" + suffix, namespace, SourceType.ANALYSIS);
|
||||||
|
vtables++;
|
||||||
|
|
||||||
|
List<Long> entries = new ArrayList<>();
|
||||||
|
for (long e = vtable; e + 4 <= rdataEnd; e += 4) {
|
||||||
|
long target = readU32(e);
|
||||||
|
if (!text.contains(toAddr(target))) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (e != vtable && locators.containsKey(target)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
entries.add(target);
|
||||||
|
}
|
||||||
|
for (int i = 0; i < entries.size(); i++) {
|
||||||
|
Address entry = toAddr(entries.get(i));
|
||||||
|
Function function = getFunctionAt(entry);
|
||||||
|
if (function == null) {
|
||||||
|
new DisassembleCommand(entry, null, true).applyTo(currentProgram, monitor);
|
||||||
|
function = createFunction(entry, null);
|
||||||
|
if (function == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
created++;
|
||||||
|
}
|
||||||
|
if (function.getSymbol().getSource() == SourceType.DEFAULT) {
|
||||||
|
function.setName(String.format("vf%02x%s", i * 4, suffix), SourceType.ANALYSIS);
|
||||||
|
function.setParentNamespace(namespace);
|
||||||
|
named++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
println("vtables: " + vtables + ", functions created: " + created + ", named: " + named);
|
||||||
|
}
|
||||||
|
|
||||||
|
private long readU32(long address) throws Exception {
|
||||||
|
return memory.getInt(toAddr(address)) & 0xffffffffL;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String readCString(Address address, int max) throws Exception {
|
||||||
|
StringBuilder text = new StringBuilder();
|
||||||
|
for (int i = 0; i < max; i++) {
|
||||||
|
byte b = memory.getByte(address.add(i));
|
||||||
|
if (b == 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
text.append((char) (b & 0xff));
|
||||||
|
}
|
||||||
|
return text.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
// Decompile functions of the persistent sacred.exe Ghidra project.
|
||||||
|
//
|
||||||
|
// Headless usage (see tools/decompile.ps1):
|
||||||
|
// analyzeHeadless <projectDir> sacred -process sacred.exe -noanalysis
|
||||||
|
// -scriptPath tools/ghidra -postScript Decompile.java <outFile> <addr> [addr...]
|
||||||
|
//
|
||||||
|
// Addresses without a function (e.g. methods only reachable via a vtable) get
|
||||||
|
// one created first; run without -readOnly to keep those in the project.
|
||||||
|
// @category sacred_re
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.PrintWriter;
|
||||||
|
|
||||||
|
import ghidra.app.cmd.disassemble.DisassembleCommand;
|
||||||
|
import ghidra.app.decompiler.DecompInterface;
|
||||||
|
import ghidra.app.decompiler.DecompileOptions;
|
||||||
|
import ghidra.app.decompiler.DecompileResults;
|
||||||
|
import ghidra.app.script.GhidraScript;
|
||||||
|
import ghidra.program.model.address.Address;
|
||||||
|
import ghidra.program.model.listing.Function;
|
||||||
|
|
||||||
|
public class Decompile extends GhidraScript {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void run() throws Exception {
|
||||||
|
String[] args = getScriptArgs();
|
||||||
|
if (args.length < 2) {
|
||||||
|
printerr("usage: Decompile.java <outFile> <address> [address...]");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
DecompInterface decompiler = new DecompInterface();
|
||||||
|
decompiler.setOptions(new DecompileOptions());
|
||||||
|
decompiler.openProgram(currentProgram);
|
||||||
|
|
||||||
|
try (PrintWriter out = new PrintWriter(new File(args[0]), "UTF-8")) {
|
||||||
|
for (int i = 1; i < args.length; i++) {
|
||||||
|
Address address = toAddr(args[i]);
|
||||||
|
Function function = getFunctionAt(address);
|
||||||
|
if (function == null) {
|
||||||
|
function = getFunctionContaining(address);
|
||||||
|
}
|
||||||
|
if (function == null) {
|
||||||
|
new DisassembleCommand(address, null, true).applyTo(currentProgram, monitor);
|
||||||
|
function = createFunction(address, null);
|
||||||
|
}
|
||||||
|
out.println("// ==== " + args[i] + " ====");
|
||||||
|
if (function == null) {
|
||||||
|
out.println("// could not create a function here");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out.println("// " + function.getName(true) + " @ " + function.getEntryPoint()
|
||||||
|
+ " (" + function.getCallingConventionName() + ")");
|
||||||
|
DecompileResults result = decompiler.decompileFunction(function, 120, monitor);
|
||||||
|
if (result.decompileCompleted()) {
|
||||||
|
out.println(result.getDecompiledFunction().getC());
|
||||||
|
} else {
|
||||||
|
out.println("// decompile failed: " + result.getErrorMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
decompiler.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
// Find the vtables of an MSVC C++ class in sacred.exe via RTTI and list their
|
||||||
|
// entries.
|
||||||
|
//
|
||||||
|
// node tools/rtti.mjs <exe> <ClassName> [entries=40]
|
||||||
|
//
|
||||||
|
// MSVC 32-bit RTTI: TypeDescriptor { pVFTable, spare, char name[] } with the
|
||||||
|
// name ".?AV<Class>@@"; CompleteObjectLocator { signature=0, offset, cdOffset,
|
||||||
|
// pTypeDescriptor, pClassHierarchyDescriptor }; vtable[-1] = &COL.
|
||||||
|
// A class has one vtable per (non-virtual) base subobject; `offset` tells which.
|
||||||
|
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
|
||||||
|
const [exePath, className, entriesText = "40"] = process.argv.slice(2);
|
||||||
|
if (!exePath || !className) {
|
||||||
|
console.error("usage: node rtti.mjs <exe> <ClassName> [entries]");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const maxEntries = Number.parseInt(entriesText, 10);
|
||||||
|
|
||||||
|
const file = readFileSync(exePath);
|
||||||
|
const peOffset = file.readUInt32LE(0x3c);
|
||||||
|
const sectionCount = file.readUInt16LE(peOffset + 6);
|
||||||
|
const optionalSize = file.readUInt16LE(peOffset + 20);
|
||||||
|
const imageBase = file.readUInt32LE(peOffset + 24 + 28);
|
||||||
|
const sectionTable = peOffset + 24 + optionalSize;
|
||||||
|
|
||||||
|
const sections = [];
|
||||||
|
for (let i = 0; i < sectionCount; i++) {
|
||||||
|
const s = sectionTable + 40 * i;
|
||||||
|
sections.push({
|
||||||
|
name: file.toString("latin1", s, s + 8).replace(/\0+$/, ""),
|
||||||
|
va: imageBase + file.readUInt32LE(s + 12),
|
||||||
|
virtualSize: file.readUInt32LE(s + 8),
|
||||||
|
rawOffset: file.readUInt32LE(s + 20),
|
||||||
|
rawSize: file.readUInt32LE(s + 16),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const section = (name) => sections.find((s) => s.name === name);
|
||||||
|
const toOffset = (va) => {
|
||||||
|
for (const s of sections) {
|
||||||
|
if (va >= s.va && va < s.va + Math.min(s.virtualSize, s.rawSize)) return s.rawOffset + va - s.va;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
};
|
||||||
|
const inSection = (va, s) => va >= s.va && va < s.va + s.virtualSize;
|
||||||
|
const hex = (v) => "0x" + v.toString(16).padStart(8, "0");
|
||||||
|
|
||||||
|
// 1. TypeDescriptor: name string lives 8 bytes into it (.data).
|
||||||
|
const mangled = Buffer.from(`.?AV${className}@@\0`, "latin1");
|
||||||
|
const typeDescriptors = [];
|
||||||
|
for (const s of sections) {
|
||||||
|
const raw = file.subarray(s.rawOffset, s.rawOffset + s.rawSize);
|
||||||
|
for (let i = raw.indexOf(mangled); i !== -1; i = raw.indexOf(mangled, i + 1)) {
|
||||||
|
typeDescriptors.push(s.va + i - 8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (typeDescriptors.length === 0) {
|
||||||
|
console.error(`no RTTI type descriptor for ${className}`);
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. CompleteObjectLocators referencing it, 3. vtables whose [-1] is a COL.
|
||||||
|
const rdata = section(".rdata");
|
||||||
|
const text = section(".text");
|
||||||
|
const rdataRaw = file.subarray(rdata.rawOffset, rdata.rawOffset + rdata.rawSize);
|
||||||
|
for (const td of typeDescriptors) {
|
||||||
|
console.log(`${className}: TypeDescriptor ${hex(td)}`);
|
||||||
|
for (let i = 0; i + 20 <= rdataRaw.length; i += 4) {
|
||||||
|
if (rdataRaw.readUInt32LE(i) !== 0 || rdataRaw.readUInt32LE(i + 12) !== td) continue;
|
||||||
|
const col = rdata.va + i;
|
||||||
|
const subobjectOffset = rdataRaw.readUInt32LE(i + 4);
|
||||||
|
for (let j = 0; j + 4 <= rdataRaw.length; j += 4) {
|
||||||
|
if (rdataRaw.readUInt32LE(j) !== col) continue;
|
||||||
|
const vtable = rdata.va + j + 4;
|
||||||
|
console.log(` vtable ${hex(vtable)} (subobject offset 0x${subobjectOffset.toString(16)}, COL ${hex(col)})`);
|
||||||
|
for (let k = 0; k < maxEntries; k++) {
|
||||||
|
const o = toOffset(vtable + 4 * k);
|
||||||
|
const target = file.readUInt32LE(o);
|
||||||
|
if (!inSection(target, text)) break;
|
||||||
|
console.log(` [+0x${(4 * k).toString(16).padStart(2, "0")}] ${hex(target)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user