Framework for Sacred 2 Gold, game log mirror, borderless fullscreen
Ported from sacredK with module-relative addresses, hooks by exported name and a SHA-256 check of the six game modules (Community Patch 2.65.1). Borderless windowed at monitor size replaces exclusive fullscreen, whose focus loss during loading made the game quit and crash on exit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
8a7bdfd99f
44 files changed
+2505
No files matched your search
@@ -0,0 +1,45 @@
|
||||
# tools
|
||||
|
||||
Node tools need `npm install` here once (iced-x86). Module paths are passed
|
||||
explicitly (`<Sacred 2 Gold>\system\s2logic.dll`, ...); VAs are at the
|
||||
module's preferred base, as in Ghidra. In Git Bash, arguments starting with
|
||||
`/` are rewritten as paths: set `MSYS_NO_PATHCONV=1` for `/regex/` and pass
|
||||
module paths as `D:/...`.
|
||||
|
||||
| Tool | Purpose |
|
||||
|---|---|
|
||||
| `exports.mjs <module> [regex]` | exported functions/data with VAs, by decorated name (`exports.mjs s2logic.dll "@cQuestMgr@@"`) |
|
||||
| `exports.mjs <module> 0xVA` | the nearest export at or before a VA |
|
||||
| `disasm.mjs <module> <va> [n]` | disassemble at an address, ignoring Ghidra's function boundaries |
|
||||
| `find-imm.mjs <module> <value...>` | every instruction using an immediate value (e.g. `1024 768`) |
|
||||
| `find-imm.mjs <module> /regex/` | every instruction whose text matches |
|
||||
| `rtti.mjs <module> <Class>` | vtables of an MSVC class via RTTI |
|
||||
| `decompile.ps1 <module> <addr\|symbol...>` | decompile from the persistent Ghidra project `ghidra/` |
|
||||
| `rename.ps1` | apply `names.txt` (`module 0xADDR Name`) to the Ghidra project |
|
||||
| `game-input.ps1` | dot-source: `Start-Sacred2`, `Focus-Sacred2`, `Get-GameWindows`, clicks, keys, `Save-Screen`, `Stop-Sacred2` |
|
||||
|
||||
## Driving the game
|
||||
|
||||
Started from a background process, the game window doesn't get the
|
||||
foreground; `Focus-Sacred2` (synthetic Alt + `SetForegroundWindow`) brings it
|
||||
up. The intro videos run ~50 s before the main menu. Synthetic clicks reach
|
||||
the menu only sometimes (2026-10-31: "Beenden" opened its dialog once, the
|
||||
dialog's "Ja" never reacted); `$game.CloseMainWindow()` quits the game
|
||||
cleanly. Don't touch mouse/keyboard while a script drives the game.
|
||||
|
||||
## Persistent Ghidra project (`ghidra/`, not in git)
|
||||
|
||||
One project `sacred2` with a program per module, created once (Ghidra's own
|
||||
analysis; exports give most functions their names and MSVC 8 RTTI is
|
||||
recognised):
|
||||
|
||||
```powershell
|
||||
$env:GHIDRA_HEADLESS_MAXMEM = "12G"
|
||||
$sys = "<Sacred 2 Gold>\system"
|
||||
& "$env:GHIDRA_INSTALL_DIR\support\analyzeHeadless.bat" ghidra sacred2 -import "$sys\sacred2.exe" "$sys\orcSystem.dll" "$sys\s2core.dll" "$sys\s2logic.dll" "$sys\s2logicai.dll" "$sys\s2render.dll" -analysisTimeoutPerFile 7200
|
||||
```
|
||||
|
||||
```powershell
|
||||
.\tools\decompile.ps1 orcSystem.dll 0x10018c60 -ReadOnly
|
||||
.\tools\decompile.ps1 s2logic.dll "?advanceTime@cQuestMgr@@QAEXM@Z" -ReadOnly
|
||||
```
|
||||
@@ -0,0 +1,31 @@
|
||||
# Decompile from the persistent Ghidra project (ghidra/sacred2.gpr).
|
||||
# .\tools\decompile.ps1 s2logic.dll 0x10123450 0x10123a80 [-Out file.c] [-ReadOnly]
|
||||
# .\tools\decompile.ps1 orcSystem.dll "?logMessage@Log@Orc@@UAEXABVString@2@W4LogMessageLevel@2@@Z"
|
||||
# Addresses are VAs at the module's preferred base (as Ghidra shows them);
|
||||
# an argument that is not a number is looked up as a (decorated) symbol name.
|
||||
# Prints the C to stdout (and keeps it in -Out, default ghidra\last.c).
|
||||
# Without -ReadOnly, functions created for vtable-only methods are saved.
|
||||
[CmdletBinding(PositionalBinding = $false)]
|
||||
param(
|
||||
[Parameter(Mandatory, Position = 0)][string]$Module,
|
||||
[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"), "sacred2",
|
||||
"-process", $Module, "-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,60 @@
|
||||
// Disassemble a game module (sacred2.exe, s2logic.dll, ...) at a virtual
|
||||
// address (as in Ghidra: the module's preferred image base), independent of
|
||||
// Ghidra's function boundaries (useful for code only reached via pointers).
|
||||
//
|
||||
// node tools/disasm.mjs <module> <va> [count=60]
|
||||
//
|
||||
// Stops after `count` instructions or at the first RET/INT3 run, whichever
|
||||
// comes later than 8 instructions.
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { Decoder, DecoderOptions, Formatter, FormatterSyntax, Mnemonic } from "iced-x86";
|
||||
|
||||
const [exePath, vaText, countText = "60"] = process.argv.slice(2);
|
||||
if (!exePath || !vaText) {
|
||||
console.error("usage: node disasm.mjs <exe> <va> [count]");
|
||||
process.exit(1);
|
||||
}
|
||||
const va = Number.parseInt(vaText, 16);
|
||||
const count = Number.parseInt(countText, 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;
|
||||
|
||||
function fileOffset(address) {
|
||||
const rva = address - imageBase;
|
||||
for (let i = 0; i < sectionCount; i++) {
|
||||
const s = sectionTable + 40 * i;
|
||||
const virtualSize = file.readUInt32LE(s + 8);
|
||||
const virtualAddress = file.readUInt32LE(s + 12);
|
||||
const rawOffset = file.readUInt32LE(s + 20);
|
||||
if (rva >= virtualAddress && rva < virtualAddress + virtualSize) {
|
||||
return rawOffset + rva - virtualAddress;
|
||||
}
|
||||
}
|
||||
throw new Error(`address 0x${address.toString(16)} is not in a section`);
|
||||
}
|
||||
|
||||
const start = fileOffset(va);
|
||||
const bytes = file.subarray(start, start + count * 15);
|
||||
const decoder = new Decoder(32, bytes, DecoderOptions.None);
|
||||
decoder.ip = BigInt(va);
|
||||
const formatter = new Formatter(FormatterSyntax.Intel);
|
||||
formatter.firstOperandCharIndex = 8;
|
||||
|
||||
for (let n = 0; n < count && decoder.canDecode; n++) {
|
||||
const instruction = decoder.decode();
|
||||
const address = Number(instruction.ip).toString(16).padStart(8, "0");
|
||||
const offset = Number(instruction.ip) - va;
|
||||
const raw = [...bytes.subarray(offset, offset + instruction.length)]
|
||||
.map((b) => b.toString(16).padStart(2, "0"))
|
||||
.join(" ");
|
||||
console.log(`${address} ${raw.padEnd(30)} ${formatter.format(instruction)}`);
|
||||
instruction.free();
|
||||
}
|
||||
decoder.free();
|
||||
formatter.free();
|
||||
@@ -0,0 +1,73 @@
|
||||
// List the exports of a game module with their VAs (at the module's
|
||||
// preferred base, as in Ghidra). The game DLLs export most of their C++
|
||||
// classes with decorated names, so this is a quick index of what is where.
|
||||
//
|
||||
// node tools/exports.mjs <module> [regex] exports whose name matches
|
||||
// node tools/exports.mjs <module> 0x10018c60 the export at (or before) a VA
|
||||
//
|
||||
// Output: "va name", sorted by name (by address for a VA lookup).
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
const [modulePath, query] = process.argv.slice(2);
|
||||
if (!modulePath) {
|
||||
console.error("usage: node exports.mjs <module> [regex | 0xVA]");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const file = readFileSync(modulePath);
|
||||
const peOffset = file.readUInt32LE(0x3c);
|
||||
const sectionCount = file.readUInt16LE(peOffset + 6);
|
||||
const optionalSize = file.readUInt16LE(peOffset + 20);
|
||||
const optional = peOffset + 24;
|
||||
const imageBase = file.readUInt32LE(optional + 28);
|
||||
const sectionTable = optional + optionalSize;
|
||||
|
||||
function fileOffset(rva) {
|
||||
for (let i = 0; i < sectionCount; i++) {
|
||||
const s = sectionTable + 40 * i;
|
||||
const virtualSize = file.readUInt32LE(s + 8);
|
||||
const virtualAddress = file.readUInt32LE(s + 12);
|
||||
const rawOffset = file.readUInt32LE(s + 20);
|
||||
if (rva >= virtualAddress && rva < virtualAddress + Math.max(virtualSize, file.readUInt32LE(s + 16))) {
|
||||
return rawOffset + rva - virtualAddress;
|
||||
}
|
||||
}
|
||||
throw new Error(`rva 0x${rva.toString(16)} is not in a section`);
|
||||
}
|
||||
|
||||
const exportRva = file.readUInt32LE(optional + 96);
|
||||
if (!exportRva) {
|
||||
console.error("no export table");
|
||||
process.exit(1);
|
||||
}
|
||||
const dir = fileOffset(exportRva);
|
||||
const functionCount = file.readUInt32LE(dir + 20);
|
||||
const nameCount = file.readUInt32LE(dir + 24);
|
||||
const functions = fileOffset(file.readUInt32LE(dir + 28));
|
||||
const names = fileOffset(file.readUInt32LE(dir + 32));
|
||||
const ordinals = fileOffset(file.readUInt32LE(dir + 36));
|
||||
|
||||
const exports = [];
|
||||
for (let i = 0; i < nameCount && functionCount; i++) {
|
||||
const nameOffset = fileOffset(file.readUInt32LE(names + 4 * i));
|
||||
const end = file.indexOf(0, nameOffset);
|
||||
const name = file.toString("latin1", nameOffset, end);
|
||||
const ordinal = file.readUInt16LE(ordinals + 2 * i);
|
||||
const va = imageBase + file.readUInt32LE(functions + 4 * ordinal);
|
||||
exports.push({ va, name });
|
||||
}
|
||||
|
||||
const hex = (va) => va.toString(16).padStart(8, "0");
|
||||
let shown;
|
||||
if (query && /^0x[0-9a-f]+$/i.test(query)) {
|
||||
const va = Number.parseInt(query, 16);
|
||||
const before = exports.filter((e) => e.va <= va).sort((a, b) => b.va - a.va);
|
||||
shown = before.length ? exports.filter((e) => e.va === before[0].va) : [];
|
||||
} else {
|
||||
const pattern = query ? new RegExp(query.replace(/^\/|\/$/g, ""), "i") : null;
|
||||
shown = exports.filter((e) => !pattern || pattern.test(e.name)).sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
for (const e of shown) {
|
||||
console.log(`${hex(e.va)} ${e.name}`);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
// List every instruction in a game module'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,137 @@
|
||||
# Drive the game for automated tests: start it, click, press keys, take
|
||||
# screenshots. Dot-source it: . .\tools\game-input.ps1
|
||||
#
|
||||
# $game = Start-Sacred2 # launcher, returns the sacred2.exe process
|
||||
# Wait-Log "Orc initialized" -Game $game
|
||||
# Click-At 1720 779 # screen pixels (left click)
|
||||
# Press-Key 0x1B # virtual key (ESC)
|
||||
# Save-Screen screenshots\x.png # primary monitor
|
||||
# Get-GameWindows $game # visible top-level windows of the process
|
||||
# Stop-Sacred2 $game # the game and its local server s2gs.exe
|
||||
#
|
||||
# Input goes to the foreground window: don't touch mouse/keyboard meanwhile.
|
||||
|
||||
Add-Type -AssemblyName System.Drawing
|
||||
Add-Type -Namespace Sacred2Re -Name Input -MemberDefinition @'
|
||||
[DllImport("user32.dll")] public static extern bool SetCursorPos(int x, int y);
|
||||
[DllImport("user32.dll")] public static extern void mouse_event(uint flags, uint dx, uint dy, uint data, System.UIntPtr extra);
|
||||
[DllImport("user32.dll")] public static extern void keybd_event(byte vk, byte scan, uint flags, System.UIntPtr extra);
|
||||
[DllImport("user32.dll")] public static extern bool SetForegroundWindow(System.IntPtr window);
|
||||
public delegate bool EnumProc(System.IntPtr window, System.IntPtr param);
|
||||
[DllImport("user32.dll")] public static extern bool EnumWindows(EnumProc proc, System.IntPtr param);
|
||||
[DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(System.IntPtr window, out uint process);
|
||||
[DllImport("user32.dll")] public static extern bool IsWindowVisible(System.IntPtr window);
|
||||
[DllImport("user32.dll", CharSet = CharSet.Unicode)] public static extern int GetWindowText(System.IntPtr window, System.Text.StringBuilder text, int size);
|
||||
[DllImport("user32.dll", CharSet = CharSet.Unicode)] public static extern int GetClassName(System.IntPtr window, System.Text.StringBuilder text, int size);
|
||||
[StructLayout(LayoutKind.Sequential)] public struct Rect { public int Left, Top, Right, Bottom; }
|
||||
[DllImport("user32.dll")] public static extern bool GetWindowRect(System.IntPtr window, out Rect rect);
|
||||
[DllImport("user32.dll")] public static extern int GetWindowLong(System.IntPtr window, int index);
|
||||
'@
|
||||
|
||||
$script:Root = Split-Path $PSScriptRoot
|
||||
$script:LogPath = Join-Path $script:Root "build\x86-debug\sacred2K.log"
|
||||
|
||||
# Visible top-level windows of a process: handle, class, title, rect, style.
|
||||
function Get-GameWindows($Game) {
|
||||
$found = New-Object System.Collections.ArrayList
|
||||
$callback = [Sacred2Re.Input+EnumProc] {
|
||||
param($window, $param)
|
||||
[uint32]$process = 0
|
||||
[Sacred2Re.Input]::GetWindowThreadProcessId($window, [ref]$process) | Out-Null
|
||||
if ($process -eq $Game.Id -and [Sacred2Re.Input]::IsWindowVisible($window)) {
|
||||
$title = New-Object System.Text.StringBuilder 256
|
||||
$class = New-Object System.Text.StringBuilder 256
|
||||
[Sacred2Re.Input]::GetWindowText($window, $title, 256) | Out-Null
|
||||
[Sacred2Re.Input]::GetClassName($window, $class, 256) | Out-Null
|
||||
$rect = New-Object Sacred2Re.Input+Rect
|
||||
[Sacred2Re.Input]::GetWindowRect($window, [ref]$rect) | Out-Null
|
||||
$style = [Sacred2Re.Input]::GetWindowLong($window, -16)
|
||||
[void]$found.Add([pscustomobject]@{
|
||||
Handle = $window; Class = $class.ToString(); Title = $title.ToString()
|
||||
Rect = "$($rect.Left),$($rect.Top)-$($rect.Right),$($rect.Bottom)"; Style = ('0x{0:x8}' -f $style)
|
||||
})
|
||||
}
|
||||
$true
|
||||
}
|
||||
[Sacred2Re.Input]::EnumWindows($callback, [IntPtr]::Zero) | Out-Null
|
||||
$found
|
||||
}
|
||||
|
||||
# Windows does not hand the foreground to a window started from a background
|
||||
# process; a synthetic Alt press lifts that restriction for SetForegroundWindow.
|
||||
function Focus-Sacred2($Game) {
|
||||
$Game.Refresh()
|
||||
if (-not $Game.MainWindowHandle -or $Game.MainWindowHandle -eq [IntPtr]::Zero) { return $false }
|
||||
[Sacred2Re.Input]::keybd_event(0x12, 0, 0, [UIntPtr]::Zero)
|
||||
[Sacred2Re.Input]::keybd_event(0x12, 0, 2, [UIntPtr]::Zero)
|
||||
[Sacred2Re.Input]::SetForegroundWindow($Game.MainWindowHandle)
|
||||
}
|
||||
|
||||
# Waits until sacred2K.log (written anew at every start) has at least $Count
|
||||
# lines containing $Pattern; then waits $AfterMs.
|
||||
function Wait-Log([string]$Pattern, [int]$Count = 1, [int]$TimeoutSeconds = 60, [int]$AfterMs = 500, $Game = $null) {
|
||||
$deadline = (Get-Date).AddSeconds($TimeoutSeconds)
|
||||
while ((Get-Date) -lt $deadline) {
|
||||
if ($Game) {
|
||||
if ($Game.HasExited) { throw "sacred2.exe exited (code $($Game.ExitCode)) waiting for '$Pattern'" }
|
||||
Focus-Sacred2 $Game | Out-Null
|
||||
}
|
||||
$found = 0
|
||||
if (Test-Path $script:LogPath) {
|
||||
$found = @(Select-String -Path $script:LogPath -Pattern $Pattern -SimpleMatch).Count
|
||||
}
|
||||
if ($found -ge $Count) { Start-Sleep -Milliseconds $AfterMs; return }
|
||||
Start-Sleep -Milliseconds 200
|
||||
}
|
||||
throw "timeout waiting for '$Pattern' in sacred2K.log"
|
||||
}
|
||||
|
||||
# Starts the game through the launcher; returns the sacred2.exe process.
|
||||
function Start-Sacred2([string]$Arguments = "") {
|
||||
if (Get-Process sacred2 -ErrorAction SilentlyContinue) { throw "sacred2.exe is already running" }
|
||||
if (Test-Path $script:LogPath) { Remove-Item $script:LogPath -ErrorAction SilentlyContinue }
|
||||
$launcher = Join-Path $script:Root "build\x86-debug\sacred2K.exe"
|
||||
if ($Arguments) { $null = Start-Process $launcher -ArgumentList $Arguments } else { $null = Start-Process $launcher }
|
||||
$game = $null
|
||||
for ($i = 0; $i -lt 80 -and -not $game; $i++) {
|
||||
Start-Sleep -Milliseconds 250
|
||||
$game = Get-Process sacred2 -ErrorAction SilentlyContinue | Select-Object -First 1
|
||||
}
|
||||
if (-not $game) { throw "sacred2.exe did not start" }
|
||||
$game
|
||||
}
|
||||
|
||||
function Stop-Sacred2($Game) {
|
||||
if ($Game -and -not $Game.HasExited) {
|
||||
Stop-Process -Id $Game.Id -Force
|
||||
$Game.WaitForExit(10000) | Out-Null
|
||||
}
|
||||
Get-Process s2gs -ErrorAction SilentlyContinue | Stop-Process -Force
|
||||
}
|
||||
|
||||
function Click-At([int]$X, [int]$Y, [int]$AfterMs = 800) {
|
||||
[Sacred2Re.Input]::SetCursorPos($X, $Y) | Out-Null
|
||||
Start-Sleep -Milliseconds 150
|
||||
[Sacred2Re.Input]::mouse_event(0x0002, 0, 0, 0, [UIntPtr]::Zero) # left down
|
||||
Start-Sleep -Milliseconds 80
|
||||
[Sacred2Re.Input]::mouse_event(0x0004, 0, 0, 0, [UIntPtr]::Zero) # left up
|
||||
Start-Sleep -Milliseconds $AfterMs
|
||||
}
|
||||
|
||||
function Press-Key([byte]$VirtualKey, [int]$AfterMs = 800) {
|
||||
[Sacred2Re.Input]::keybd_event($VirtualKey, 0, 0, [UIntPtr]::Zero)
|
||||
Start-Sleep -Milliseconds 80
|
||||
[Sacred2Re.Input]::keybd_event($VirtualKey, 0, 2, [UIntPtr]::Zero) # key up
|
||||
Start-Sleep -Milliseconds $AfterMs
|
||||
}
|
||||
|
||||
function Save-Screen([string]$Path, [int]$Width = 3440, [int]$Height = 1440) {
|
||||
$bitmap = New-Object System.Drawing.Bitmap $Width, $Height
|
||||
$graphics = [System.Drawing.Graphics]::FromImage($bitmap)
|
||||
$graphics.CopyFromScreen(0, 0, 0, 0, $bitmap.Size)
|
||||
$full = if ([IO.Path]::IsPathRooted($Path)) { $Path } else { Join-Path $script:Root $Path }
|
||||
New-Item -ItemType Directory -Force (Split-Path $full) | Out-Null
|
||||
$bitmap.Save($full)
|
||||
$graphics.Dispose(); $bitmap.Dispose()
|
||||
$full
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// Decompile functions of one program of the persistent Ghidra project.
|
||||
//
|
||||
// Headless usage (see tools/decompile.ps1):
|
||||
// analyzeHeadless <projectDir> sacred2 -process <module> -noanalysis
|
||||
// -scriptPath tools/ghidra -postScript Decompile.java <outFile> <addr|symbol> [...]
|
||||
//
|
||||
// An argument that is not a number is looked up as a symbol name (e.g. a
|
||||
// decorated export). 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 sacred2K
|
||||
|
||||
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;
|
||||
import ghidra.program.model.symbol.SymbolIterator;
|
||||
|
||||
public class Decompile extends GhidraScript {
|
||||
|
||||
// A hex address (0x-prefixed or 8 digits), or the address of a symbol with
|
||||
// that name.
|
||||
private Address resolve(String text) {
|
||||
if (text.matches("0x[0-9a-fA-F]+|[0-9a-fA-F]{8}")) {
|
||||
return toAddr(text);
|
||||
}
|
||||
SymbolIterator symbols = currentProgram.getSymbolTable().getSymbols(text);
|
||||
return symbols.hasNext() ? symbols.next().getAddress() : null;
|
||||
}
|
||||
|
||||
@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 = resolve(args[i]);
|
||||
if (address == null) {
|
||||
out.println("// ==== " + args[i] + " ====");
|
||||
out.println("// no such address or symbol");
|
||||
continue;
|
||||
}
|
||||
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,86 @@
|
||||
// Apply function names from tools/names.txt to the persistent Ghidra project
|
||||
// (run on every program; each line names its module).
|
||||
//
|
||||
// Headless usage (see tools/rename.ps1):
|
||||
// analyzeHeadless <projectDir> sacred2 -process -noanalysis
|
||||
// -scriptPath tools/ghidra -postScript Rename.java <namesFile>
|
||||
//
|
||||
// names.txt: one "module 0xADDRESS Name" per line (module as the program
|
||||
// name, e.g. s2logic.dll; the address at its preferred base), "Class::Method" puts the function
|
||||
// into a class namespace; "#" starts a comment. Functions are created where
|
||||
// none starts at the address yet. Names starting with "g_" label data
|
||||
// (globals, tables) instead of functions.
|
||||
// @category sacred2K
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Paths;
|
||||
|
||||
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.symbol.Namespace;
|
||||
import ghidra.program.model.symbol.SourceType;
|
||||
import ghidra.program.model.symbol.SymbolTable;
|
||||
|
||||
public class Rename extends GhidraScript {
|
||||
|
||||
@Override
|
||||
protected void run() throws Exception {
|
||||
String[] args = getScriptArgs();
|
||||
if (args.length != 1) {
|
||||
printerr("usage: Rename.java <namesFile>");
|
||||
return;
|
||||
}
|
||||
SymbolTable symbols = currentProgram.getSymbolTable();
|
||||
int renamed = 0;
|
||||
int labelled = 0;
|
||||
for (String raw : Files.readAllLines(Paths.get(args[0]), StandardCharsets.UTF_8)) {
|
||||
String line = raw.replaceAll("#.*", "").trim();
|
||||
if (line.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
String[] parts = line.split("\\s+");
|
||||
if (parts.length != 3) {
|
||||
printerr("bad line: " + raw);
|
||||
continue;
|
||||
}
|
||||
if (!parts[0].equalsIgnoreCase(currentProgram.getName())) {
|
||||
continue;
|
||||
}
|
||||
parts = new String[] {parts[1], parts[2]};
|
||||
Address address = toAddr(parts[0]);
|
||||
if (parts[1].startsWith("g_")) {
|
||||
createLabel(address, parts[1], true, SourceType.USER_DEFINED);
|
||||
labelled++;
|
||||
continue;
|
||||
}
|
||||
Function function = getFunctionAt(address);
|
||||
if (function == null) {
|
||||
new DisassembleCommand(address, null, true).applyTo(currentProgram, monitor);
|
||||
function = createFunction(address, null);
|
||||
}
|
||||
if (function == null) {
|
||||
printerr("no function at " + parts[0]);
|
||||
continue;
|
||||
}
|
||||
String name = parts[1];
|
||||
Namespace namespace = currentProgram.getGlobalNamespace();
|
||||
int separator = name.lastIndexOf("::");
|
||||
if (separator >= 0) {
|
||||
String className = name.substring(0, separator);
|
||||
name = name.substring(separator + 2);
|
||||
namespace = symbols.getNamespace(className, currentProgram.getGlobalNamespace());
|
||||
if (namespace == null) {
|
||||
namespace = symbols.createClass(currentProgram.getGlobalNamespace(), className,
|
||||
SourceType.USER_DEFINED);
|
||||
}
|
||||
}
|
||||
function.setParentNamespace(namespace);
|
||||
function.setName(name, SourceType.USER_DEFINED);
|
||||
renamed++;
|
||||
}
|
||||
println(currentProgram.getName() + ": renamed " + renamed + " functions, labelled " + labelled + " data addresses");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
# Names for the Ghidra project, applied with tools/rename.ps1.
|
||||
# One "module 0xADDRESS Name" per line (address at the module's preferred
|
||||
# base); "Class::Method" puts a function into a class namespace, "g_" names
|
||||
# label data. Exported functions already carry their names from the export
|
||||
# table; list only what we identify ourselves.
|
||||
Generated
+19
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "sacredk-tools",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "sacredk-tools",
|
||||
"dependencies": {
|
||||
"iced-x86": "1.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/iced-x86": {
|
||||
"version": "1.21.0",
|
||||
"resolved": "https://registry.npmjs.org/iced-x86/-/iced-x86-1.21.0.tgz",
|
||||
"integrity": "sha512-zHK5/RREJbD7N2BfbeiHJciHbZKCSwI5c34wASV9/B8vNe+mjOzqK8RIabhMLmqHv3L6UvuFDlpdS/dqleQyeA==",
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "sacredk-tools",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"iced-x86": "1.21.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
# Apply tools/names.txt to every program of the persistent Ghidra project
|
||||
# (ghidra/sacred2.gpr); each line names its module.
|
||||
# .\tools\rename.ps1
|
||||
$ErrorActionPreference = "Stop"
|
||||
$root = Split-Path $PSScriptRoot
|
||||
$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"), "sacred2",
|
||||
"-process", "-noanalysis",
|
||||
"-scriptPath", (Join-Path $PSScriptRoot "ghidra"),
|
||||
"-postScript", "Rename.java", (Join-Path $PSScriptRoot "names.txt")
|
||||
)
|
||||
& (Join-Path $ghidra "support\analyzeHeadless.bat") @arguments *> (Join-Path $root "ghidra\rename.out")
|
||||
if ($LASTEXITCODE -ne 0) { Get-Content (Join-Path $root "ghidra\rename.out") -Tail 30; exit $LASTEXITCODE }
|
||||
Select-String -Path (Join-Path $root "ghidra\rename.out") -Pattern "renamed|bad line|no function"
|
||||
@@ -0,0 +1,84 @@
|
||||
// Find the vtables of an MSVC C++ class in a game module 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