From b2dc1338d23dadd1579d285cfd4d62364f523a36 Mon Sep 17 00:00:00 2001 From: Torhan Bartel Date: Wed, 14 Oct 2026 00:19:52 +0200 Subject: [PATCH] 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) --- .gitignore | 1 + docs/widescreen.md | 81 +++++++++++++++++++++ tools/README.md | 25 +++++++ tools/decompile.ps1 | 26 +++++++ tools/find-imm.mjs | 73 +++++++++++++++++++ tools/ghidra/ApplyRtti.java | 138 ++++++++++++++++++++++++++++++++++++ tools/ghidra/Decompile.java | 65 +++++++++++++++++ tools/rtti.mjs | 84 ++++++++++++++++++++++ 8 files changed, 493 insertions(+) create mode 100644 docs/widescreen.md create mode 100644 tools/README.md create mode 100644 tools/decompile.ps1 create mode 100644 tools/find-imm.mjs create mode 100644 tools/ghidra/ApplyRtti.java create mode 100644 tools/ghidra/Decompile.java create mode 100644 tools/rtti.mjs diff --git a/.gitignore b/.gitignore index e90e442..05d5d4e 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ /cmake-build-*/ *.rea-snapshot sacred_re.ini +/ghidra/ diff --git a/docs/widescreen.md b/docs/widescreen.md new file mode 100644 index 0000000..180e623 --- /dev/null +++ b/docs/widescreen.md @@ -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). diff --git a/tools/README.md b/tools/README.md new file mode 100644 index 0000000..c5c523e --- /dev/null +++ b/tools/README.md @@ -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 [n]` | disassemble at an address, ignoring Ghidra's function boundaries | +| `find-imm.mjs ` | every instruction using an immediate value (e.g. `1024 768`) | +| `find-imm.mjs /regex/` | every instruction whose text matches (e.g. `/96AB0h\]/`) | +| `rtti.mjs ` | vtables of an MSVC class via RTTI | +| `decompile.ps1 ` | 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.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`. diff --git a/tools/decompile.ps1 b/tools/decompile.ps1 new file mode 100644 index 0000000..bf2fad2 --- /dev/null +++ b/tools/decompile.ps1 @@ -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 diff --git a/tools/find-imm.mjs b/tools/find-imm.mjs new file mode 100644 index 0000000..8d23a1b --- /dev/null +++ b/tools/find-imm.mjs @@ -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 1024 768 [...] +// node tools/find-imm.mjs /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 [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(); diff --git a/tools/ghidra/ApplyRtti.java b/tools/ghidra/ApplyRtti.java new file mode 100644 index 0000000..f05fb8e --- /dev/null +++ b/tools/ghidra/ApplyRtti.java @@ -0,0 +1,138 @@ +// Recover MSVC 6 RTTI in sacred.exe: for every class with a TypeDescriptor +// (".?AV@@"), find its CompleteObjectLocators and vtables, create a +// function for every vtable entry and name default-named ones +// "::vfNN" (NN = byte offset in the vtable). Labels the vtables +// "::vftable" (plus "_at_" 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 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 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 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(); + } +} diff --git a/tools/ghidra/Decompile.java b/tools/ghidra/Decompile.java new file mode 100644 index 0000000..cd6e31c --- /dev/null +++ b/tools/ghidra/Decompile.java @@ -0,0 +1,65 @@ +// Decompile functions of the persistent sacred.exe Ghidra project. +// +// Headless usage (see tools/decompile.ps1): +// analyzeHeadless sacred -process sacred.exe -noanalysis +// -scriptPath tools/ghidra -postScript Decompile.java [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
[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(); + } + } +} diff --git a/tools/rtti.mjs b/tools/rtti.mjs new file mode 100644 index 0000000..7bf2eff --- /dev/null +++ b/tools/rtti.mjs @@ -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 [entries=40] +// +// MSVC 32-bit RTTI: TypeDescriptor { pVFTable, spare, char name[] } with the +// name ".?AV@@"; 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 [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)}`); + } + } + } +}