From 9e2cc1a283b689079b930068d2041876d11b8a10 Mon Sep 17 00:00:00 2001 From: Valentin Popov Date: Sat, 18 Jul 2026 22:30:49 +0400 Subject: [PATCH] docs(script): trace host callback --- docs/appendices/script-vm.md | 12 ++++++++++++ tools/capture-ai-init.ps1 | 24 ++++++++++++++++++++++-- tools/ghidra/ExportIron3dAiCallback.java | 23 +++++++++++++++++++++++ 3 files changed, 57 insertions(+), 2 deletions(-) create mode 100644 tools/ghidra/ExportIron3dAiCallback.java diff --git a/docs/appendices/script-vm.md b/docs/appendices/script-vm.md index 7141582..107aa5a 100644 --- a/docs/appendices/script-vm.md +++ b/docs/appendices/script-vm.md @@ -240,6 +240,18 @@ defaults. Runtime exposes `resolve_loaded_handler30` for the exact opaque command without invoking a guessed game-side consumer: the tenth `CreateSuperAI` callback argument still needs its own recovery. +The live GOG AutoDemo closes that consumer boundary: the read-only callback +pointer at `ai.dll + 0x555e4` is `0x100611d0`, or `iron3d.dll + 0x611d0` at +the observed load base. Its recovered `__cdecl` ABI is `(mode, command, +payload)`, matching `Handler(30)` as `(0, first, second)`. In `mode == 0`, +`command == 0` and `payload == 0` selects `VOICE_MISSION_FAIL`, records the +failed status, and clears an IGame byte; `payload == 1` selects +`VOICE_MISSION_COMPLETE`, records completion, and sets that byte. Commands +1, 3, 4, and 5 have additional game-side paths; mode 2 is a separate IGame +call. Those branches are not yet assigned Rust gameplay meanings. Reproduce +the current evidence with `capture-ai-init.ps1` and +`ExportIron3dAiCallback.java`. + ### Handler(8): problem-record state write `Handler(8)` is the ninth VM-table entry at GOG `ai.dll` VA `0x10009b0d`. diff --git a/tools/capture-ai-init.ps1 b/tools/capture-ai-init.ps1 index 0c39b5e..3ee91c7 100644 --- a/tools/capture-ai-init.ps1 +++ b/tools/capture-ai-init.ps1 @@ -52,12 +52,18 @@ $snapshot = [FparkanAiInitCapture]::CreateToolhelp32Snapshot( [FparkanAiInitCapture]::TH32CS_SNAPMODULE -bor [FparkanAiInitCapture]::TH32CS_SNAPMODULE32, [uint32]$ProcessId) $aiBase = $null +$modules = @() try { $entry = [FparkanAiInitCapture+MODULEENTRY32]::new() $entry.dwSize = [Runtime.InteropServices.Marshal]::SizeOf([type][FparkanAiInitCapture+MODULEENTRY32]) if ([FparkanAiInitCapture]::Module32First($snapshot, [ref]$entry)) { do { - if ($entry.szModule -ieq 'ai.dll') { $aiBase = $entry.modBaseAddr.ToInt64(); break } + $modules += [ordered]@{ + name = $entry.szModule + base = $entry.modBaseAddr.ToInt64() + size = [int64]$entry.modBaseSize + } + if ($entry.szModule -ieq 'ai.dll') { $aiBase = $entry.modBaseAddr.ToInt64() } $entry = [FparkanAiInitCapture+MODULEENTRY32]::new() $entry.dwSize = [Runtime.InteropServices.Marshal]::SizeOf([type][FparkanAiInitCapture+MODULEENTRY32]) } while ([FparkanAiInitCapture]::Module32Next($snapshot, [ref]$entry)) @@ -70,6 +76,12 @@ $process = [FparkanAiInitCapture]::OpenProcess( $false, [uint32]$ProcessId) if ($process -eq [IntPtr]::Zero) { throw "OpenProcess read-only failed" } try { + # CreateSuperAI stores its tenth host-callback argument at DAT_100555e4. + $callbackBytes = Read-Bytes $process ($aiBase + 0x555e4) 4 + $callback = [BitConverter]::ToUInt32($callbackBytes, 0) + $callbackModule = $modules | Where-Object { + $callback -ge $_.base -and [int64]$callback -lt ($_.base + $_.size) + } | Select-Object -First 1 # GetSuperAI(i) returns (&DAT_10055398)[i], with ai.dll preferred base 0x10000000. $entries = Read-Bytes $process ($aiBase + 0x55398) (64 * 4) $samples = for ($index = 0; $index -lt 64; $index++) { @@ -86,6 +98,14 @@ try { } } catch { } } - [ordered]@{ schema = 'fparkan-ai-init-v1'; process_id = $ProcessId; ai_module_base = ('0x{0:X8}' -f $aiBase); entries = @($samples) } | + [ordered]@{ + schema = 'fparkan-ai-init-v1' + process_id = $ProcessId + ai_module_base = ('0x{0:X8}' -f $aiBase) + handler30_callback = ('0x{0:X8}' -f $callback) + handler30_callback_module = if ($null -eq $callbackModule) { $null } else { $callbackModule.name } + handler30_callback_rva = if ($null -eq $callbackModule) { $null } else { ('0x{0:X}' -f ([int64]$callback - $callbackModule.base)) } + entries = @($samples) + } | ConvertTo-Json -Depth 4 -Compress } finally { [void][FparkanAiInitCapture]::CloseHandle($process) } diff --git a/tools/ghidra/ExportIron3dAiCallback.java b/tools/ghidra/ExportIron3dAiCallback.java new file mode 100644 index 0000000..23e9888 --- /dev/null +++ b/tools/ghidra/ExportIron3dAiCallback.java @@ -0,0 +1,23 @@ +// Emits the live CreateSuperAI host callback selected by the GOG AutoDemo. +// Run headless; the original PE remains read only. +import ghidra.app.decompiler.DecompInterface; +import ghidra.app.script.GhidraScript; +import ghidra.program.model.address.Address; +import ghidra.program.model.listing.Function; + +public class ExportIron3dAiCallback extends GhidraScript { + private static final long ADDRESS = 0x100611d0L; + + @Override + public void run() throws Exception { + Address address = currentProgram.getAddressFactory().getDefaultAddressSpace() + .getAddress(ADDRESS); + Function function = currentProgram.getFunctionManager().getFunctionAt(address); + println("===== Iron3D CreateSuperAI callback ====="); + if (function == null) { println("missing"); return; } + DecompInterface decompiler = new DecompInterface(); + decompiler.openProgram(currentProgram); + println(decompiler.decompileFunction(function, 120, monitor).getDecompiledFunction().getC()); + decompiler.dispose(); + } +}