273 lines
11 KiB
PowerShell
273 lines
11 KiB
PowerShell
[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;
|
|
}
|
|
|
|
public static ulong Fnv1a64(byte[] bytes, int offset, int length) {
|
|
ulong hash = 14695981039346656037UL;
|
|
unchecked {
|
|
for (int index = offset; index < offset + length; index++) {
|
|
hash ^= bytes[index];
|
|
hash *= 1099511628211UL;
|
|
}
|
|
}
|
|
return hash;
|
|
}
|
|
}
|
|
'@
|
|
|
|
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
|
|
}
|
|
|
|
function Get-ShadeCacheSummary {
|
|
param([IntPtr]$Process, [Int64]$Cache, [byte[]]$Header)
|
|
$entryTable = [BitConverter]::ToUInt32($Header, 316)
|
|
$entryCount = [BitConverter]::ToUInt32($Header, 320)
|
|
$summary = [ordered]@{
|
|
materialized_entries = $null
|
|
profile_banks = @()
|
|
}
|
|
# The count field is runtime data. Keep a hard observer bound so a corrupt
|
|
# or stale candidate cannot turn a passive sample into an excessive read.
|
|
if ($entryTable -lt 0x1000 -or $entryCount -gt 100000) { return $summary }
|
|
$entryBytes = Read-Bytes $Process $entryTable ([int]($entryCount * 8))
|
|
if ($null -eq $entryBytes) { return $summary }
|
|
$materialized = 0
|
|
$banks = [System.Collections.Generic.SortedSet[int]]::new()
|
|
for ($index = 0; $index -lt $entryCount; $index++) {
|
|
$offset = $index * 8
|
|
if ([BitConverter]::ToUInt32($entryBytes, $offset) -ge 0x1000) {
|
|
$materialized++
|
|
[void]$banks.Add([int]$entryBytes[$offset + 4])
|
|
}
|
|
}
|
|
$summary.materialized_entries = $materialized
|
|
if ($banks.Count -eq 0) { return $summary }
|
|
$maxBank = $banks.Max
|
|
if ($maxBank -ge 100) { return $summary }
|
|
$bankBytes = Read-Bytes $Process ($Cache + 332) (($maxBank + 1) * 212)
|
|
if ($null -eq $bankBytes) { return $summary }
|
|
$profiles = [System.Collections.Generic.List[object]]::new()
|
|
foreach ($bank in $banks) {
|
|
$profiles.Add([ordered]@{
|
|
bank = $bank
|
|
fnv1a64 = [FparkanTerrainShadeProbe]::Fnv1a64($bankBytes, $bank * 212, 212).ToString()
|
|
})
|
|
}
|
|
$summary.profile_banks = @($profiles)
|
|
return $summary
|
|
}
|
|
|
|
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) {
|
|
$summary = Get-ShadeCacheSummary $process $cache $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)
|
|
materialized_entries = $summary.materialized_entries
|
|
profile_banks = $summary.profile_banks
|
|
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)
|
|
}
|