feat(tools): capture live terrain shade cache

This commit is contained in:
2026-07-18 19:15:18 +04:00
parent 5c1ccae393
commit 273bd9e477
2 changed files with 239 additions and 0 deletions
+20
View File
@@ -1735,6 +1735,26 @@ recovered lookup routine. Therefore a future runtime implementation must treat
cache availability as contextual and cannot dereference or prebuild the cache cache availability as contextual and cannot dereference or prebuild the cache
solely from constructor state. solely from constructor state.
`tools/capture-terrain-shade-cache.ps1` now makes the same evidence repeatable
without debugger attachment. It discovers `Terrain.dll` with Toolhelp, opens
only `PROCESS_QUERY_INFORMATION | PROCESS_VM_READ`, searches readable
32-bit-process regions for the already proven cache vtable, and excludes the
Terrain image itself because relocations there also contain that pointer value.
It reports only addresses and count metadata; it does not alter the process or
save game resources. On the unattended GOG AutoDemo process 5632 on 2026-07-18
it found the live cache at `0x035C64B4`, with entry table `0x17284590` and
exclusive count `3462`. Two adjacent passive captures retained the cache
address/table/count but returned result views `0x0274BD89` and `0x008A60DC`.
That changing result view is direct evidence that it is transient lookup
state, not a persistent cache identity or a WEAR row. The observation narrows
the next terrain task to profiling the cache's generated entries and their
consumer; it still does not establish a Land1 blend equation.
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File `
tools\capture-terrain-shade-cache.ps1 -ProcessId <iron_3d-pid>
```
The static Vulkan bridge now carries each contiguous face run as a separate The static Vulkan bridge now carries each contiguous face run as a separate
draw range keyed by the original packed `material_tag`; it neither reorders draw range keyed by the original packed `material_tag`; it neither reorders
triangles nor collapses the high byte. For the first usable visual layer, the triangles nor collapses the high byte. For the first usable visual layer, the
+219
View File
@@ -0,0 +1,219 @@
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[int]$ProcessId,
[ValidateRange(1, 60)]
[int]$CaptureAttempts = 6,
[ValidateRange(0, 1000)]
[int]$RetryIntervalMilliseconds = 100,
[ValidateRange(0x00010000, 0x7fff0000)]
[UInt32]$SearchStart = 0x01000000,
[ValidateRange(0x00010000, 0x7fff0000)]
[UInt32]$SearchEnd = 0x20000000
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
# This observer deliberately opens only PROCESS_QUERY_INFORMATION | PROCESS_VM_READ.
# It neither sends input nor writes, suspends, injects into, or calls the original game.
Add-Type -TypeDefinition @'
using System;
using System.Runtime.InteropServices;
public static class FparkanTerrainShadeProbe {
public const uint TH32CS_SNAPMODULE = 0x00000008;
public const uint TH32CS_SNAPMODULE32 = 0x00000010;
public const uint PROCESS_QUERY_INFORMATION = 0x00000400;
public const uint PROCESS_VM_READ = 0x00000010;
public const uint MEM_COMMIT = 0x1000;
public const uint PAGE_NOACCESS = 0x01;
public const uint PAGE_GUARD = 0x100;
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
public struct MODULEENTRY32 {
public uint dwSize;
public uint th32ModuleID;
public uint th32ProcessID;
public uint GlblcntUsage;
public uint ProccntUsage;
public IntPtr modBaseAddr;
public uint modBaseSize;
public IntPtr hModule;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 256)] public string szModule;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 260)] public string szExePath;
}
[StructLayout(LayoutKind.Sequential)]
public struct MEMORY_BASIC_INFORMATION {
public IntPtr BaseAddress;
public IntPtr AllocationBase;
public uint AllocationProtect;
public IntPtr RegionSize;
public uint State;
public uint Protect;
public uint Type;
}
[DllImport("kernel32.dll", SetLastError = true)]
public static extern IntPtr CreateToolhelp32Snapshot(uint flags, uint processId);
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
public static extern bool Module32First(IntPtr snapshot, ref MODULEENTRY32 entry);
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
public static extern bool Module32Next(IntPtr snapshot, ref MODULEENTRY32 entry);
[DllImport("kernel32.dll", SetLastError = true)]
public static extern IntPtr OpenProcess(uint access, bool inheritHandle, uint processId);
[DllImport("kernel32.dll", SetLastError = true)]
public static extern bool ReadProcessMemory(
IntPtr process, IntPtr address, [Out] byte[] buffer, IntPtr size, out IntPtr bytesRead);
[DllImport("kernel32.dll", SetLastError = true)]
public static extern IntPtr VirtualQueryEx(
IntPtr process, IntPtr address, out MEMORY_BASIC_INFORMATION buffer, IntPtr length);
[DllImport("kernel32.dll", SetLastError = true)]
public static extern bool CloseHandle(IntPtr handle);
public static int FindU32(byte[] bytes, uint expected, int start) {
for (int index = start; index <= bytes.Length - 4; index += 4) {
if (BitConverter.ToUInt32(bytes, index) == expected) return index;
}
return -1;
}
}
'@
function Get-LastWin32ErrorText {
$code = [Runtime.InteropServices.Marshal]::GetLastWin32Error()
"$code ($([ComponentModel.Win32Exception]::new($code).Message))"
}
function Get-TerrainModuleBase {
param([int]$ProbeProcessId)
$snapshot = [FparkanTerrainShadeProbe]::CreateToolhelp32Snapshot(
[FparkanTerrainShadeProbe]::TH32CS_SNAPMODULE -bor [FparkanTerrainShadeProbe]::TH32CS_SNAPMODULE32,
[uint32]$ProbeProcessId
)
if ($snapshot -eq [IntPtr]::Zero -or $snapshot.ToInt64() -eq -1) {
throw "CreateToolhelp32Snapshot failed: $(Get-LastWin32ErrorText)"
}
try {
$module = [FparkanTerrainShadeProbe+MODULEENTRY32]::new()
$module.dwSize = [Runtime.InteropServices.Marshal]::SizeOf([type][FparkanTerrainShadeProbe+MODULEENTRY32])
if (-not [FparkanTerrainShadeProbe]::Module32First($snapshot, [ref]$module)) {
throw "Module32First failed: $(Get-LastWin32ErrorText)"
}
do {
if ($module.szModule -ieq 'Terrain.dll') {
return $module.modBaseAddr.ToInt64()
}
$module = [FparkanTerrainShadeProbe+MODULEENTRY32]::new()
$module.dwSize = [Runtime.InteropServices.Marshal]::SizeOf([type][FparkanTerrainShadeProbe+MODULEENTRY32])
} while ([FparkanTerrainShadeProbe]::Module32Next($snapshot, [ref]$module))
} finally {
[void][FparkanTerrainShadeProbe]::CloseHandle($snapshot)
}
throw "Terrain.dll is not loaded by process $ProbeProcessId"
}
function Read-Bytes {
param([IntPtr]$Process, [Int64]$Address, [int]$Length)
$buffer = [byte[]]::new($Length)
$read = [IntPtr]::Zero
if (-not [FparkanTerrainShadeProbe]::ReadProcessMemory(
$Process, [IntPtr]$Address, $buffer, [IntPtr]$Length, [ref]$read)) {
return $null
}
if ($read.ToInt64() -ne $Length) {
return $null
}
return $buffer
}
function Find-ShadeCache {
param(
[IntPtr]$Process,
[UInt32]$ExpectedVtable,
[UInt32]$Start,
[UInt32]$End,
[Int64]$ExcludedImageStart,
[Int64]$ExcludedImageEnd
)
$mbiSize = [Runtime.InteropServices.Marshal]::SizeOf([type][FparkanTerrainShadeProbe+MEMORY_BASIC_INFORMATION])
[Int64]$cursor = $Start
while ($cursor -lt $End) {
$mbi = [FparkanTerrainShadeProbe+MEMORY_BASIC_INFORMATION]::new()
$queried = [FparkanTerrainShadeProbe]::VirtualQueryEx(
$Process, [IntPtr]$cursor, [ref]$mbi, [IntPtr]$mbiSize
)
if ($queried -eq [IntPtr]::Zero) { break }
$base = $mbi.BaseAddress.ToInt64()
$size = $mbi.RegionSize.ToInt64()
if ($size -le 0) { break }
$next = $base + $size
if ($mbi.State -eq [FparkanTerrainShadeProbe]::MEM_COMMIT -and
($mbi.Protect -band [FparkanTerrainShadeProbe]::PAGE_NOACCESS) -eq 0 -and
($mbi.Protect -band [FparkanTerrainShadeProbe]::PAGE_GUARD) -eq 0) {
$regionStart = [Math]::Max($base, [Int64]$Start)
$regionEnd = [Math]::Min($next, [Int64]$End)
for ([Int64]$offset = $regionStart; $offset -lt $regionEnd; $offset += 65536) {
$length = [int][Math]::Min(65536, $regionEnd - $offset)
$bytes = Read-Bytes $Process $offset $length
if ($null -eq $bytes) { continue }
$searchIndex = 0
while ($searchIndex -le $bytes.Length - 4) {
$index = [FparkanTerrainShadeProbe]::FindU32($bytes, $ExpectedVtable, $searchIndex)
if ($index -lt 0) { break }
$candidate = $offset + $index
if ($candidate -lt $ExcludedImageStart -or $candidate -ge $ExcludedImageEnd) {
return $candidate
}
$searchIndex = $index + 4
}
}
}
$cursor = [Math]::Max($next, $cursor + 4096)
}
return $null
}
if ($SearchEnd -le $SearchStart) { throw 'SearchEnd must exceed SearchStart' }
$terrainBase = Get-TerrainModuleBase $ProcessId
$process = [FparkanTerrainShadeProbe]::OpenProcess(
[FparkanTerrainShadeProbe]::PROCESS_QUERY_INFORMATION -bor [FparkanTerrainShadeProbe]::PROCESS_VM_READ,
$false,
[uint32]$ProcessId
)
if ($process -eq [IntPtr]::Zero) { throw "OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ) failed: $(Get-LastWin32ErrorText)" }
try {
$expectedVtable = [uint32]($terrainBase + 0x643d0)
$last = 'cache vtable was not present in readable scan range'
for ($attempt = 1; $attempt -le $CaptureAttempts; $attempt++) {
# The vtable value itself naturally appears in the Terrain image as
# relocation data; only a heap-resident object is a cache candidate.
$cache = Find-ShadeCache $process $expectedVtable $SearchStart $SearchEnd $terrainBase ($terrainBase + 0x100000)
if ($null -ne $cache) {
$header = Read-Bytes $process $cache 324
if ($null -ne $header) {
[ordered]@{
schema = 'fparkan-terrain-shade-cache-v1'
process_id = $ProcessId
terrain_module_base = ('0x{0:X8}' -f $terrainBase)
cache_vtable_rva = '0x643d0'
cache_object = ('0x{0:X8}' -f $cache)
result_view = ('0x{0:X8}' -f [BitConverter]::ToUInt32($header, 24))
entry_table = ('0x{0:X8}' -f [BitConverter]::ToUInt32($header, 316))
entry_count = [BitConverter]::ToUInt32($header, 320)
scan_attempt = $attempt
} | ConvertTo-Json -Compress
exit 0
}
$last = "cache candidate 0x$('{0:X8}' -f $cache) became unreadable"
}
if ($attempt -lt $CaptureAttempts -and $RetryIntervalMilliseconds -gt 0) {
Start-Sleep -Milliseconds $RetryIntervalMilliseconds
}
}
throw "No live GetShade cache after $CaptureAttempts scans ($last)"
} finally {
[void][FparkanTerrainShadeProbe]::CloseHandle($process)
}