aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--docs/tomes/05-render.md20
-rw-r--r--tools/capture-terrain-shade-cache.ps1219
2 files changed, 239 insertions, 0 deletions
diff --git a/docs/tomes/05-render.md b/docs/tomes/05-render.md
index 5eef3ed..e0970c7 100644
--- a/docs/tomes/05-render.md
+++ b/docs/tomes/05-render.md
@@ -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
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
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
diff --git a/tools/capture-terrain-shade-cache.ps1 b/tools/capture-terrain-shade-cache.ps1
new file mode 100644
index 0000000..7de5f9c
--- /dev/null
+++ b/tools/capture-terrain-shade-cache.ps1
@@ -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)
+}