From 100f656b089898e4ffef48871f5ed5ea068d54b6 Mon Sep 17 00:00:00 2001 From: Valentin Popov Date: Sun, 11 Oct 2026 17:37:48 +0400 Subject: Fix terrain height and native frame capture Use raw world-space terrain heights throughout mesh, camera-floor, shadow, and sun-ray paths while preserving the projection far-plane conversion. Complete the verified selected-camera readback flow with frozen phase sampling, capture tooling, and documentation. --- .../NativeFrameCapture.Readback.cs | 1863 ++++++++++++++++++++ 1 file changed, 1863 insertions(+) create mode 100644 tools/native-frame-capture/NativeFrameCapture.Readback.cs (limited to 'tools/native-frame-capture/NativeFrameCapture.Readback.cs') diff --git a/tools/native-frame-capture/NativeFrameCapture.Readback.cs b/tools/native-frame-capture/NativeFrameCapture.Readback.cs new file mode 100644 index 0000000..872487b --- /dev/null +++ b/tools/native-frame-capture/NativeFrameCapture.Readback.cs @@ -0,0 +1,1863 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; +using System.IO; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Text; +using System.Web.Script.Serialization; +using System.Drawing; +using System.Drawing.Imaging; + +internal static partial class NativeFrameCapture +{ + private const uint NgiRendererGlobalRva = 0x3A460; + private const uint NgiDeviceGlobalRva = 0x3A488; + private const uint NgiRendererVtableRva = 0x315E0; + private const uint NgiWindowedPresentRva = 0x6E1B; + private const uint NgiFullscreenPresentRva = 0x6E38; + private const uint DdLockReadOnly = 0x00000010; + private const uint DdLockDoNotWait = 0x00004000; + private const int D3dDeviceGetRenderTargetOffset = 0x24; + private const int DdSurfaceLockOffset = 0x64; + private const int DdSurfaceUnlockOffset = 0x80; + private const int DdSurfaceReleaseOffset = 0x08; + private const uint DdErrSurfaceBusy = 0x887601AE; + private const uint DdErrWasStillDrawing = 0x8876021C; + private const uint DdsdPitch = 0x00000008; + private const uint DdsdHeight = 0x00000002; + private const uint DdsdWidth = 0x00000004; + private const uint DdsdPixelFormat = 0x00001000; + private const uint DdsdLpSurface = 0x00000800; + private const uint DdpfFourCc = 0x00000004; + private const uint DdpfRgb = 0x00000040; + private const int DdSurfaceDesc2Size = 124; + private const int CameraInputJsonLimit = 1024 * 1024; + private const uint ContextAllX86 = 0x0001003F; + // THREAD_QUERY_INFORMATION from the Windows SDK; required by NtQueryInformationThread. + private const uint ThreadQueryInformation = 0x00000040; + private const int ContextEflagsOffset = 192; + private const int PixelReadbackLimit = 64 * 1024 * 1024; + private const uint RemoteCodePage = 0x1000; + private const uint RemoteDataOffset = 0x1000; + private const uint RemoteAllocationBytes = 0x2000; + private const uint RemoteStubMaxCallStackBytes = 24; + private const uint PageGuard = 0x00000100; + private const uint RemoteDataStatusOffset = 0; + private const uint RemoteDataGetHrOffset = 4; + private const uint RemoteDataLockHrOffset = 8; + private const uint RemoteDataUnlockHrOffset = 12; + private const uint RemoteDataReleaseCountOffset = 16; + private const uint RemoteDataSurfaceOffset = 20; + private const uint RemoteDataDescOffset = 24; + private const uint CameraSetterMatrixOffset = 64; + private const uint CameraTransformInterfaceVtableRva = 0x66558; + private const uint CameraTransformSetterRva = 0x54C70; + private const uint CameraTransformInvalidatorRva = 0x55280; + private const uint CameraTransformSetterSlotOffset = 0x1C; + private const uint CameraTransformInvalidatorSlotOffset = 0x30; + private const uint RenderFunctionStackFrameBytes = 0x70; + + [StructLayout(LayoutKind.Sequential)] + private struct ClientIdX86 + { + public IntPtr UniqueProcess; + public IntPtr UniqueThread; + } + + [StructLayout(LayoutKind.Sequential)] + private struct ThreadBasicInformationX86 + { + public int ExitStatus; + public IntPtr TebBaseAddress; + public ClientIdX86 ClientId; + public UIntPtr AffinityMask; + public int Priority; + public int BasePriority; + } + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern IntPtr VirtualAllocEx(IntPtr process, IntPtr address, + UIntPtr size, uint allocationType, uint protection); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool VirtualFreeEx(IntPtr process, IntPtr address, + UIntPtr size, uint freeType); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool TerminateProcess(IntPtr process, uint exitCode); + + [DllImport("ntdll.dll")] + private static extern int NtQueryInformationThread(IntPtr thread, int informationClass, + out ThreadBasicInformationX86 information, int informationLength, IntPtr returnLength); + + private sealed class SurfaceReadback + { + public uint Width; + public uint Height; + public int Pitch; + public uint BitCount; + public uint RedMask; + public uint GreenMask; + public uint BlueMask; + public uint AlphaMask; + public uint SurfacePointer; + public byte[] Rows; + public string Sha256; + public bool RgbMasksVerified; + } + + private sealed class ProjectionReadback + { + public int[] Viewport; + public float Near; + public float Far; + public float Fov; + public byte Mode; + public uint Renderer; + public uint RendererVtable; + public bool Verified; + public string Failure; + } + + private sealed class CameraReadback + { + public uint Camera; + public uint Vtable; + public uint TransformInterface; + public uint TransformVtable; + public byte CameraMode; + public uint EntryEsp; + public uint ReturnAddress; + public uint SelectorField; + public uint[] Words; + public byte[] EntryMatrixBytes; + public byte[] ProjectionMatrixBytes; + public bool CurrentAtProjectionVerified; + public bool WordsChangedAtProjection; + public string EntryMatrixSha256; + public string ProjectionMatrixSha256; + public bool MatrixFinite; + public bool LayoutVerified; + public string Failure; + } + + private sealed class SelectedCameraInput + { + public string Path; + public byte[] MatrixBytes; + public uint[] MatrixWords; + public string MatrixSha256; + public int[] Viewport; + public float Near; + public float Far; + public float FieldOfView; + public byte ProjectionMode; + } + + private enum CameraSetterPhase { Apply, Restore } + + private sealed class RemoteCameraSetterSession + { + public RemoteCode Code; + public uint Region; + public uint Camera; + public uint ThreadId; + public uint OriginalEsp; + public byte[] OriginalContext; + public byte[] CallerStack; + public uint FunctionEntryEsp; + public uint FunctionReturnAddress; + public uint EsiAtBoundary; + public byte[] FunctionCallerStack; + public byte[] MatrixBytes; + public CameraSetterPhase Phase; + public BreakpointInfo ResumeBreakpoint; + public bool RearmOnStep; + public DateTime DeadlineUtc; + } + + private sealed class RemoteCode + { + public uint Base; + public uint TrapAddress; + public uint DataBase; + public byte[] Bytes; + public int[] CallInstructionOffsets; + public int MaxCallStackBytes; + } + + private static IntPtr AllocateAlignedX86Context(out IntPtr allocation) + { + allocation = Marshal.AllocHGlobal(X86ContextSize + 15); + long raw = allocation.ToInt64(); + return new IntPtr((raw + 15L) & ~15L); + } + + private static byte[] GetFullX86Context(IntPtr thread) + { + IntPtr allocation; + IntPtr context = AllocateAlignedX86Context(out allocation); + try + { + Marshal.Copy(new byte[X86ContextSize], 0, context, X86ContextSize); + Marshal.WriteInt32(context, 0, unchecked((int)ContextAllX86)); + if (!GetThreadContext(thread, context)) + throw new InvalidOperationException("GetThreadContext(CONTEXT_ALL_X86) failed: " + Marshal.GetLastWin32Error()); + byte[] bytes = new byte[X86ContextSize]; + Marshal.Copy(context, bytes, 0, bytes.Length); + return bytes; + } + finally { Marshal.FreeHGlobal(allocation); } + } + + private static void SetFullX86Context(IntPtr thread, byte[] bytes) + { + if (bytes == null || bytes.Length != X86ContextSize) + throw new ArgumentException("Expected a complete x86 CONTEXT buffer.", "bytes"); + IntPtr allocation; + IntPtr context = AllocateAlignedX86Context(out allocation); + try + { + Marshal.Copy(bytes, 0, context, bytes.Length); + Marshal.WriteInt32(context, 0, unchecked((int)ContextAllX86)); + if (!SetThreadContext(thread, context)) + throw new InvalidOperationException("SetThreadContext(CONTEXT_ALL_X86) failed: " + Marshal.GetLastWin32Error()); + } + finally { Marshal.FreeHGlobal(allocation); } + } + + private static byte[] ContextForRemoteCode(byte[] original, uint codeAddress, uint stackPointer) + { + if (original == null || original.Length != X86ContextSize) + throw new ArgumentException("Expected a complete x86 CONTEXT buffer.", "original"); + byte[] result = (byte[])original.Clone(); + Buffer.BlockCopy(BitConverter.GetBytes(unchecked((int)ContextAllX86)), 0, result, 0, 4); + Buffer.BlockCopy(BitConverter.GetBytes(unchecked((int)codeAddress)), 0, result, ContextEipOffset, 4); + Buffer.BlockCopy(BitConverter.GetBytes(unchecked((int)stackPointer)), 0, result, ContextEspOffset, 4); + int flags = BitConverter.ToInt32(result, ContextEflagsOffset) & ~0x100; + Buffer.BlockCopy(BitConverter.GetBytes(flags), 0, result, ContextEflagsOffset, 4); + return result; + } + + private static uint ComputeSafeNativeStackPointer(IntPtr process, IntPtr thread, byte[] originalContext) + { + if (originalContext == null || originalContext.Length != X86ContextSize) + throw new ArgumentException("Expected a complete x86 CONTEXT buffer.", "originalContext"); + ThreadBasicInformationX86 information; + int status = NtQueryInformationThread(thread, 0, out information, + Marshal.SizeOf(typeof(ThreadBasicInformationX86)), IntPtr.Zero); + if (status < 0 || information.TebBaseAddress == IntPtr.Zero) + throw new InvalidOperationException("NtQueryInformationThread(ThreadBasicInformation) failed: 0x" + status.ToString("X8")); + uint teb = unchecked((uint)information.TebBaseAddress.ToInt32()); + uint stackBase = ReadU32Exact(process, unchecked(teb + 4)); + uint stackLimit = ReadU32Exact(process, unchecked(teb + 8)); + uint originalEsp = BitConverter.ToUInt32(originalContext, ContextEspOffset); + if (!HasNativeStackBounds(stackLimit, stackBase, originalEsp, RemoteStubMaxCallStackBytes)) + { + Log("REMOTE_STACK_BOUNDS_INVALID teb=0x" + teb.ToString("X8") + + " stackLimit=0x" + stackLimit.ToString("X8") + " originalEsp=0x" + originalEsp.ToString("X8") + + " stackBase=0x" + stackBase.ToString("X8") + " callerBytes=" + RemoteStubMaxCallStackBytes); + throw new InvalidOperationException("Native render-thread ESP does not leave the verified caller-push bytes within its committed stack bounds."); + } + uint lowestPushByte = originalEsp - RemoteStubMaxCallStackBytes; + MemoryBasicInformation memory = new MemoryBasicInformation(); + UIntPtr queried = VirtualQueryEx(process, Ptr(lowestPushByte), out memory, + new UIntPtr((uint)Marshal.SizeOf(typeof(MemoryBasicInformation)))); + uint regionBase = unchecked((uint)memory.BaseAddress.ToInt32()); + uint regionSize = memory.RegionSize.ToUInt32(); + string stackEvidence = "state=0x" + memory.State.ToString("X8") + + " protect=0x" + memory.Protect.ToString("X8") + " region=0x" + regionBase.ToString("X8") + + "+0x" + regionSize.ToString("X8"); + bool stackRangeWritable = queried.ToUInt32() != 0 && IsSafeNativeStackRange(stackLimit, stackBase, + originalEsp, RemoteStubMaxCallStackBytes, regionBase, regionSize, memory.State, memory.Protect); + Log("REMOTE_STACK_BOUNDS teb=0x" + teb.ToString("X8") + + " stackLimit=0x" + stackLimit.ToString("X8") + " originalEsp=0x" + originalEsp.ToString("X8") + + " stackBase=0x" + stackBase.ToString("X8") + " lowestPushByte=0x" + lowestPushByte.ToString("X8") + + " callerBytes=" + RemoteStubMaxCallStackBytes + " " + stackEvidence + " writable=" + stackRangeWritable); + if (!stackRangeWritable) + throw new InvalidOperationException("The full present-thread stub push range is not in one committed writable stack region or touches a guard page."); + return originalEsp; + } + + private static bool HasNativeStackBounds(uint stackLimit, uint stackBase, uint originalEsp, uint callerBytes) + { + return callerBytes != 0 && originalEsp > stackLimit && originalEsp < stackBase + && originalEsp >= callerBytes && originalEsp - callerBytes >= stackLimit; + } + + private static bool IsSafeNativeStackRange(uint stackLimit, uint stackBase, uint originalEsp, + uint callerBytes, uint regionBase, uint regionSize, uint state, uint protect) + { + if (!HasNativeStackBounds(stackLimit, stackBase, originalEsp, callerBytes) || regionSize == 0) + return false; + uint lowestPushByte = originalEsp - callerBytes; + ulong regionEnd = (ulong)regionBase + regionSize; + uint protection = protect & 0xFF; + bool writableProtection = protection == 0x04 || protection == 0x08 + || protection == 0x40 || protection == 0x80; + return state == MemCommit && (protect & PageGuard) == 0 && writableProtection + && lowestPushByte >= regionBase && (ulong)originalEsp <= regionEnd; + } + + private sealed class X86CodeBuilder + { + private readonly List _bytes = new List(); + private readonly Dictionary _labels = new Dictionary(StringComparer.Ordinal); + private readonly List> _relative32 = new List>(); + private readonly List _callInstructionOffsets = new List(); + private int _int3Count; + private int _terminalOffset = -1; + private int _currentPushBytes; + private int _maxCallStackBytes; + + public int Position { get { return _bytes.Count; } } + + public void Emit(params byte[] bytes) + { + _bytes.AddRange(bytes); + } + + public void EmitU32(uint value) + { + _bytes.AddRange(BitConverter.GetBytes(value)); + } + + public void PushReg(byte opcode) + { + if (opcode < 0x50 || opcode > 0x57) + throw new InvalidOperationException("Expected one x86 push-register opcode."); + _bytes.Add(opcode); + _currentPushBytes = checked(_currentPushBytes + 4); + } + + public void PushImm8(byte value) + { + _bytes.Add(0x6A); + _bytes.Add(value); + _currentPushBytes = checked(_currentPushBytes + 4); + } + + public void PushImm32(uint value) + { + _bytes.Add(0x68); + EmitU32(value); + _currentPushBytes = checked(_currentPushBytes + 4); + } + + public void CallStdCall(int argumentBytes, params byte[] opcode) + { + if (argumentBytes < 0 || (argumentBytes & 3) != 0 || _currentPushBytes != argumentBytes + || opcode == null || opcode.Length == 0) + throw new InvalidOperationException("x86 stdcall must match the emitted dword arguments."); + _maxCallStackBytes = Math.Max(_maxCallStackBytes, checked(argumentBytes + 4)); + _callInstructionOffsets.Add(_bytes.Count); + _bytes.AddRange(opcode); + // IDirect3DDevice7/IDirectDrawSurface7 vtable methods use STDMETHODCALLTYPE + // (stdcall), so the callee removes its arguments before returning. + _currentPushBytes -= argumentBytes; + } + + public void Mark(string label) + { + if (_labels.ContainsKey(label)) throw new InvalidOperationException("Duplicate x86 label: " + label); + _labels.Add(label, _bytes.Count); + } + + public void JumpIf(byte condition, string label) + { + _bytes.Add(0x0F); + _bytes.Add((byte)(0x80 | condition)); + int operand = _bytes.Count; + EmitU32(0); + _relative32.Add(new KeyValuePair(operand, label)); + } + + public void Jump(string label) + { + _bytes.Add(0xE9); + int operand = _bytes.Count; + EmitU32(0); + _relative32.Add(new KeyValuePair(operand, label)); + } + + public void EmitTerminalInt3() + { + _terminalOffset = _bytes.Count; + _bytes.Add(0xCC); + _int3Count++; + } + + public int Int3Count { get { return _int3Count; } } + public int TerminalOffset { get { return _terminalOffset; } } + public int BranchCount { get { return _relative32.Count; } } + public int MaxCallStackBytes { get { return _maxCallStackBytes; } } + public int[] CallInstructionOffsets { get { return _callInstructionOffsets.ToArray(); } } + + public void AssertTerminalControlFlow(byte[] finishedBytes, int expectedBranchCount) + { + if (finishedBytes == null || _int3Count != 1 || _terminalOffset != finishedBytes.Length - 1 + || _terminalOffset < 0 || finishedBytes[_terminalOffset] != 0xCC + || _relative32.Count != expectedBranchCount || _currentPushBytes != 0) + throw new InvalidOperationException("Readback stub must have one terminal INT3 and the expected branch count."); + + // Validate only branches registered by Jump/JumpIf. A rel32 operand + // can itself contain 0xCC bytes, which are not instructions. + for (int i = 0; i < _relative32.Count; i++) + { + KeyValuePair branch = _relative32[i]; + int labelOffset; + if (!_labels.TryGetValue(branch.Value, out labelOffset) + || branch.Key < 0 || branch.Key + 4 > finishedBytes.Length) + throw new InvalidOperationException("Readback stub has an invalid branch record."); + int resolvedOffset = checked(branch.Key + 4 + BitConverter.ToInt32(finishedBytes, branch.Key)); + if (resolvedOffset != labelOffset || resolvedOffset < 0 || resolvedOffset > _terminalOffset) + throw new InvalidOperationException("Readback stub branch does not resolve to a valid instruction label."); + } + } + + public byte[] Finish() + { + for (int i = 0; i < _relative32.Count; i++) + { + KeyValuePair fixup = _relative32[i]; + int destination; + if (!_labels.TryGetValue(fixup.Value, out destination)) + throw new InvalidOperationException("Unresolved x86 label: " + fixup.Value); + int relative = destination - (fixup.Key + 4); + byte[] bytes = BitConverter.GetBytes(relative); + for (int j = 0; j < 4; j++) _bytes[fixup.Key + j] = bytes[j]; + } + return _bytes.ToArray(); + } + } + + private static RemoteCode BuildReadbackStub(uint allocationBase, uint device, bool cleanup) + { + uint data = unchecked(allocationBase + RemoteDataOffset); + uint statusAddress = unchecked(data + RemoteDataStatusOffset); + uint getHrAddress = unchecked(data + RemoteDataGetHrOffset); + uint lockHrAddress = unchecked(data + RemoteDataLockHrOffset); + uint unlockHrAddress = unchecked(data + RemoteDataUnlockHrOffset); + uint releaseAddress = unchecked(data + RemoteDataReleaseCountOffset); + uint surfaceAddress = unchecked(data + RemoteDataSurfaceOffset); + uint descAddress = unchecked(data + RemoteDataDescOffset); + + X86CodeBuilder code = new X86CodeBuilder(); + if (!cleanup) + { + code.Emit(0xBE); code.EmitU32(device); // mov esi, device + code.Emit(0x8B, 0x06); // mov eax, [esi] + code.Emit(0x8D, 0x3D); code.EmitU32(surfaceAddress); // lea edi, [surface] + code.PushReg(0x57); code.PushReg(0x56); // push edi; push esi + code.CallStdCall(8, 0xFF, 0x50, (byte)D3dDeviceGetRenderTargetOffset); // call [eax+GetRenderTarget] + code.Emit(0xA3); code.EmitU32(getHrAddress); // mov [getHr], eax + code.Emit(0x85, 0xC0); // test eax,eax + code.JumpIf(0x89, "get_success"); // jns get_success + code.Emit(0x8B, 0x35); code.EmitU32(surfaceAddress); // mov esi, [surface] + code.Emit(0x85, 0xF6); // test esi,esi + code.JumpIf(0x84, "get_failed_no_surface"); + code.Emit(0xC7, 0x05); code.EmitU32(statusAddress); code.EmitU32(2); + code.Jump("release_and_stop"); + + code.Mark("get_success"); + code.Emit(0x8B, 0x35); code.EmitU32(surfaceAddress); // mov esi, [surface] + code.Emit(0x85, 0xF6); // test esi,esi + code.JumpIf(0x84, "get_success_no_surface"); + code.Emit(0x8B, 0x06); // mov eax, [esi] + code.PushImm8(0x00); // push NULL event + code.PushImm32(DdLockReadOnly | DdLockDoNotWait); + code.Emit(0x8D, 0x3D); code.EmitU32(descAddress); // lea edi,[desc] + code.PushReg(0x57); code.PushImm8(0x00); code.PushReg(0x56); // desc; NULL rect; this + code.CallStdCall(20, 0xFF, 0x50, (byte)DdSurfaceLockOffset); // call [eax+Lock] + code.Emit(0xA3); code.EmitU32(lockHrAddress); // mov [lockHr], eax + code.Emit(0x85, 0xC0); // test eax,eax + code.JumpIf(0x88, "lock_failed"); // js lock_failed + code.Emit(0xC7, 0x05); code.EmitU32(statusAddress); code.EmitU32(1); + code.Jump("stop"); // lock held; host copies pixels, then cleans up + + code.Mark("lock_failed"); + code.Emit(0xC7, 0x05); code.EmitU32(statusAddress); code.EmitU32(4); + code.Jump("release_and_stop"); + + code.Mark("get_success_no_surface"); + code.Emit(0xC7, 0x05); code.EmitU32(statusAddress); code.EmitU32(3); + code.Jump("stop"); + + code.Mark("get_failed_no_surface"); + code.Emit(0xC7, 0x05); code.EmitU32(statusAddress); code.EmitU32(5); + code.Jump("stop"); + + code.Mark("release_and_stop"); + code.Emit(0x8B, 0x35); code.EmitU32(surfaceAddress); + code.Emit(0x8B, 0x06); // mov eax, [esi] + code.PushReg(0x56); // push this + code.CallStdCall(4, 0xFF, 0x50, (byte)DdSurfaceReleaseOffset); // call [eax+Release] + code.Emit(0xA3); code.EmitU32(releaseAddress); + code.Jump("stop"); + } + else + { + code.Emit(0x8B, 0x35); code.EmitU32(surfaceAddress); // mov esi, [surface] + code.Emit(0x85, 0xF6); + code.JumpIf(0x84, "cleanup_no_surface"); + code.Emit(0x8B, 0x06); // mov eax, [esi] + code.PushImm8(0x00); // Unlock(NULL) + code.PushReg(0x56); // push this + code.CallStdCall(8, 0xFF, 0x90, 0x80, 0x00, 0x00, 0x00); // call dword ptr [eax+0x80] (disp32) + code.Emit(0xA3); code.EmitU32(unlockHrAddress); + code.Emit(0x8B, 0x06); // call Release even if Unlock failed + code.PushReg(0x56); + code.CallStdCall(4, 0xFF, 0x50, (byte)DdSurfaceReleaseOffset); + code.Emit(0xA3); code.EmitU32(releaseAddress); + code.Emit(0xC7, 0x05); code.EmitU32(statusAddress); code.EmitU32(6); + code.Jump("stop"); + code.Mark("cleanup_no_surface"); + code.Emit(0xC7, 0x05); code.EmitU32(statusAddress); code.EmitU32(7); + code.Jump("stop"); + } + + code.Mark("stop"); + code.EmitTerminalInt3(); + + byte[] bytes = code.Finish(); + code.AssertTerminalControlFlow(bytes, cleanup ? 3 : 10); + int expectedStack = cleanup ? 12 : 24; + if (code.MaxCallStackBytes != expectedStack) + throw new InvalidOperationException("Unexpected x86 caller stack footprint in the readback stub."); + if (bytes.Length > RemoteCodePage) + throw new InvalidOperationException("Remote DirectDraw stub exceeds one code page."); + RemoteCode result = new RemoteCode(); + result.Base = allocationBase; + result.DataBase = data; + result.Bytes = bytes; + result.CallInstructionOffsets = code.CallInstructionOffsets; + result.MaxCallStackBytes = code.MaxCallStackBytes; + result.TrapAddress = unchecked(allocationBase + (uint)code.TerminalOffset); + return result; + } + + private static RemoteCode BuildCameraSetterStub(uint allocationBase, uint transformInterface) + { + uint dataBase = unchecked(allocationBase + RemoteDataOffset); + uint matrixAddress = unchecked(dataBase + CameraSetterMatrixOffset); + X86CodeBuilder code = new X86CodeBuilder(); + code.Emit(0xBE); code.EmitU32(transformInterface); // mov esi, transform interface + code.Emit(0x8B, 0x0E); // mov ecx, [esi] (verified vtable) + code.PushImm32(matrixAddress); // argument 3: 64-byte transform + code.PushImm8(1); // argument 2: mode 1 + code.PushReg(0x56); // argument 1: this + code.CallStdCall(12, 0xFF, 0x51, (byte)CameraTransformSetterSlotOffset); + code.EmitTerminalInt3(); + byte[] bytes = code.Finish(); + code.AssertTerminalControlFlow(bytes, 0); + if (code.MaxCallStackBytes != 16 || bytes.Length > RemoteCodePage) + throw new InvalidOperationException("Camera setter stub has an unexpected stack footprint or size."); + RemoteCode result = new RemoteCode(); + result.Base = allocationBase; + result.DataBase = dataBase; + result.Bytes = bytes; + result.CallInstructionOffsets = code.CallInstructionOffsets; + result.MaxCallStackBytes = code.MaxCallStackBytes; + result.TrapAddress = unchecked(allocationBase + (uint)code.TerminalOffset); + return result; + } + + private static bool VerifyCameraSetterAbi(IntPtr process, uint terrainBase, uint camera, + out uint transformInterface, out string evidence) + { + transformInterface = unchecked(camera + 4); + uint primaryVtable = ReadU32(process, camera); + uint selector = ReadU32(process, unchecked(camera + 0x10)); + byte[] modeBytes = new byte[1]; + bool modeRead = ReadExact(process, unchecked(camera + 0x1A0), modeBytes); + uint transformVtable = ReadU32(process, transformInterface); + uint setter = ReadU32(process, unchecked(transformVtable + CameraTransformSetterSlotOffset)); + uint invalidate = ReadU32(process, unchecked(transformVtable + CameraTransformInvalidatorSlotOffset)); + bool valid = terrainBase != 0 + && primaryVtable == unchecked(terrainBase + ExternalCameraVtableRva) + && transformVtable == unchecked(terrainBase + CameraTransformInterfaceVtableRva) + && setter == unchecked(terrainBase + CameraTransformSetterRva) + && invalidate == unchecked(terrainBase + CameraTransformInvalidatorRva) + && selector == 0xFFFFFFFF && modeRead && modeBytes[0] == 0; + evidence = "camera=0x" + camera.ToString("X8") + " primaryVtable=0x" + primaryVtable.ToString("X8") + + " transform=0x" + transformInterface.ToString("X8") + " transformVtable=0x" + transformVtable.ToString("X8") + + " setter=0x" + setter.ToString("X8") + " invalidator=0x" + invalidate.ToString("X8") + + " selector=0x" + selector.ToString("X8") + + " mode=" + (modeRead ? modeBytes[0].ToString(CultureInfo.InvariantCulture) : "unreadable") + + " valid=" + valid; + return valid; + } + + private static RemoteCameraSetterSession StartCameraSetter(IntPtr process, uint threadId, + uint terrainBase, uint camera, byte[] matrixBytes, byte[] originalContext, + BreakpointInfo resumeBreakpoint, bool rearmOnStep, CameraSetterPhase phase, + uint functionEntryEsp, uint functionReturnAddress) + { + if (matrixBytes == null || matrixBytes.Length != 64 || originalContext == null + || originalContext.Length != X86ContextSize || resumeBreakpoint == null || !resumeBreakpoint.Armed) + throw new InvalidOperationException("Camera setter request is incomplete."); + string abiEvidence; + uint transformInterface; + if (!VerifyCameraSetterAbi(process, terrainBase, camera, out transformInterface, out abiEvidence)) + throw new InvalidOperationException("Camera setter ABI refused: " + abiEvidence); + Log("verified native camera setter ABI " + abiEvidence + " slot=+0x1C this=0x" + + transformInterface.ToString("X8") + " mode=1 matrixBytes=64 ret=0x0C"); + + uint expectedEip = unchecked(resumeBreakpoint.Address + 1); + uint originalEip = BitConverter.ToUInt32(originalContext, ContextEipOffset); + uint originalEsp = BitConverter.ToUInt32(originalContext, ContextEspOffset); + if (originalEip != expectedEip || originalEsp > UInt32.MaxValue - 8 + || functionEntryEsp > UInt32.MaxValue - 8) + throw new InvalidOperationException("Camera setter breakpoint EIP/ESP did not match its verified boundary."); + byte[] callerStack = new byte[8]; + if (!ReadExact(process, originalEsp, callerStack)) + throw new InvalidOperationException("Could not snapshot the actual native stack words at the setter boundary."); + byte[] functionCallerStack = new byte[8]; + if (!ReadExact(process, functionEntryEsp, functionCallerStack) + || !CameraFunctionStackMatches(phase, functionCallerStack, functionReturnAddress, camera)) + throw new InvalidOperationException("The native stdRenderGame return/argument stack did not match the selected setter phase."); + uint esiAtBoundary = BitConverter.ToUInt32(originalContext, ContextEsiOffset); + if (phase == CameraSetterPhase.Apply) + { + if (originalEsp != functionEntryEsp || BitConverter.ToUInt32(callerStack, 4) != camera) + throw new InvalidOperationException("The camera-entry setter stack did not contain the original camera argument."); + } + else + { + if (functionEntryEsp < RenderFunctionStackFrameBytes + || originalEsp != functionEntryEsp - RenderFunctionStackFrameBytes) + throw new InvalidOperationException("The restore boundary did not preserve the selected function ESP."); + } + + IntPtr thread = OpenThread(ThreadGetContext | ThreadSetContext | ThreadQueryInformation, false, threadId); + if (thread == IntPtr.Zero) throw new InvalidOperationException("OpenThread for camera setter failed: " + Marshal.GetLastWin32Error()); + uint region = 0; + try + { + uint safeEsp = ComputeSafeNativeStackPointer(process, thread, originalContext); + region = AllocateReadbackRegion(process); + RemoteCode code = BuildCameraSetterStub(region, transformInterface); + WriteRemoteStub(process, code, false); + UIntPtr written; + if (!WriteProcessMemory(process, Ptr(unchecked(code.DataBase + CameraSetterMatrixOffset)), matrixBytes, + new UIntPtr(64), out written) || written.ToUInt32() != 64) + throw new InvalidOperationException("Could not copy the validated 64-byte camera matrix to the bounded setter stub."); + RemoteCameraSetterSession session = new RemoteCameraSetterSession(); + session.Code = code; + session.Region = region; + session.Camera = camera; + session.ThreadId = threadId; + session.OriginalEsp = originalEsp; + session.OriginalContext = (byte[])originalContext.Clone(); + session.CallerStack = callerStack; + session.FunctionEntryEsp = functionEntryEsp; + session.FunctionReturnAddress = functionReturnAddress; + session.EsiAtBoundary = esiAtBoundary; + session.FunctionCallerStack = functionCallerStack; + session.MatrixBytes = (byte[])matrixBytes.Clone(); + session.Phase = phase; + session.ResumeBreakpoint = resumeBreakpoint; + session.RearmOnStep = rearmOnStep; + session.DeadlineUtc = DateTime.UtcNow.AddSeconds(5); + SetFullX86Context(thread, ContextForRemoteCode(originalContext, code.Base, safeEsp)); + Log("CAMERA_SETTER_STARTED phase=" + phase + " tid=" + threadId + + " camera=0x" + camera.ToString("X8") + " stub=0x" + code.Base.ToString("X8") + + " terminal=0x" + code.TrapAddress.ToString("X8") + " originalEsp=0x" + originalEsp.ToString("X8") + + " currentStack=" + BitConverter.ToString(callerStack) + + " functionCallerStack=" + BitConverter.ToString(functionCallerStack) + + " observedFunctionArgument=0x" + BitConverter.ToUInt32(functionCallerStack, 4).ToString("X8") + + " esiAtBoundary=0x" + esiAtBoundary.ToString("X8") + + " matrixSha256=" + HashBytes(matrixBytes)); + return session; + } + catch + { + if (region != 0) VirtualFreeEx(process, Ptr(region), UIntPtr.Zero, 0x8000); + throw; + } + finally { CloseHandle(thread); } + } + + private static bool VerifyCameraSetterReturnContext(IntPtr process, + RemoteCameraSetterSession session, out string evidence) + { + evidence = "unverified"; + IntPtr thread = OpenThread(ThreadGetContext, false, session.ThreadId); + if (thread == IntPtr.Zero) + { + evidence = "OpenThread failed=" + Marshal.GetLastWin32Error(); + return false; + } + try + { + byte[] context = GetFullX86Context(thread); + uint eip = BitConverter.ToUInt32(context, ContextEipOffset); + uint esp = BitConverter.ToUInt32(context, ContextEspOffset); + byte[] callerStack = new byte[session.CallerStack.Length]; + byte[] functionCallerStack = new byte[session.FunctionCallerStack.Length]; + byte[] matrix = new byte[session.MatrixBytes.Length]; + bool stackReadable = ReadExact(process, session.OriginalEsp, callerStack); + bool functionStackReadable = ReadExact(process, session.FunctionEntryEsp, functionCallerStack); + bool matrixReadable = ReadExact(process, + unchecked(session.Code.DataBase + CameraSetterMatrixOffset), matrix); + bool stackMatches = stackReadable && ByteArraysEqual(callerStack, session.CallerStack); + bool functionStackMatches = functionStackReadable + && ByteArraysEqual(functionCallerStack, session.FunctionCallerStack) + && CameraFunctionStackMatches(session.Phase, functionCallerStack, + session.FunctionReturnAddress, session.Camera); + bool matrixMatches = matrixReadable && ByteArraysEqual(matrix, session.MatrixBytes); + bool valid = eip == unchecked(session.Code.TrapAddress + 1) + && esp == session.OriginalEsp && stackMatches && functionStackMatches + && matrixMatches; + evidence = "expectedEip=0x" + unchecked(session.Code.TrapAddress + 1).ToString("X8") + + " actualEip=0x" + eip.ToString("X8") + " expectedEsp=0x" + session.OriginalEsp.ToString("X8") + + " actualEsp=0x" + esp.ToString("X8") + " callerStackIntact=" + stackMatches + + " functionCallerStackIntact=" + functionStackMatches + + " observedFunctionArgument=0x" + BitConverter.ToUInt32(session.FunctionCallerStack, 4).ToString("X8") + + " esiAtBoundary=0x" + session.EsiAtBoundary.ToString("X8") + + " matrixDataIntact=" + matrixMatches; + return valid; + } + finally { CloseHandle(thread); } + } + + private static byte[] ReadCameraMatrix(IntPtr process, uint camera) + { + byte[] matrix = new byte[64]; + if (!ReadExact(process, unchecked(camera + 0x20), matrix)) + throw new InvalidOperationException("Could not read back the native camera selector-0 matrix."); + return matrix; + } + + private static bool CameraFunctionStackMatches(CameraSetterPhase phase, byte[] stackWords, + uint expectedReturnAddress, uint camera) + { + if (stackWords == null || stackWords.Length != 8 + || BitConverter.ToUInt32(stackWords, 0) != expectedReturnAddress) + return false; + return phase == CameraSetterPhase.Restore + || BitConverter.ToUInt32(stackWords, 4) == camera; + } + + private static void SelfCheckRenderInvocationGates() + { + const uint threadId = 11; + const uint eip = 0x10013CE5; + const uint esp = 0x001AFC28; + const uint camera = 0x1644A8D8; + const uint returnAddress = 0x1005F243; + uint observedArgument; + bool overwrittenArgumentAccepted = RenderInvocationMatches(threadId, threadId, + eip, eip, esp, esp, camera, camera, returnAddress, returnAddress, true, + 0x0BADF00D, out observedArgument) && observedArgument == 0x0BADF00D; + bool wrongThreadRejected = !RenderInvocationMatches(threadId + 1, threadId, + eip, eip, esp, esp, camera, camera, returnAddress, returnAddress, true, + 0x0BADF00D, out observedArgument); + bool wrongEsiRejected = !RenderInvocationMatches(threadId, threadId, + eip, eip, esp, esp, camera + 4, camera, returnAddress, returnAddress, true, + 0x0BADF00D, out observedArgument); + bool wrongReturnRejected = !RenderInvocationMatches(threadId, threadId, + eip, eip, esp, esp, camera, camera, returnAddress + 4, returnAddress, false, + 0x0BADF00D, out observedArgument); + bool epilogueAllowsReassignedEsi = RenderInvocationMatches(threadId, threadId, + eip, eip, esp, esp, camera + 0x100, camera, returnAddress, returnAddress, false, + 0x0BADF00D, out observedArgument); + byte[] entryStack = new byte[8]; + Buffer.BlockCopy(BitConverter.GetBytes(returnAddress), 0, entryStack, 0, 4); + Buffer.BlockCopy(BitConverter.GetBytes(camera), 0, entryStack, 4, 4); + byte[] overwrittenStack = (byte[])entryStack.Clone(); + Buffer.BlockCopy(BitConverter.GetBytes(0x0BADF00Du), 0, overwrittenStack, 4, 4); + bool applyRequiresOriginalCameraArgument = CameraFunctionStackMatches( + CameraSetterPhase.Apply, entryStack, returnAddress, camera) + && !CameraFunctionStackMatches(CameraSetterPhase.Apply, overwrittenStack, returnAddress, camera); + bool restorePreservesOverwrittenArgument = CameraFunctionStackMatches( + CameraSetterPhase.Restore, overwrittenStack, returnAddress, camera) + && !CameraFunctionStackMatches(CameraSetterPhase.Restore, overwrittenStack, returnAddress + 4, camera); + if (!overwrittenArgumentAccepted || !wrongThreadRejected || !wrongEsiRejected + || !epilogueAllowsReassignedEsi + || !wrongReturnRejected || !applyRequiresOriginalCameraArgument + || !restorePreservesOverwrittenArgument) + throw new InvalidOperationException("Self-check failed: selected invocation identity or phase-specific camera stack gate."); + } + + private static bool IsSelectedRenderInvocation(IntPtr process, byte[] context, uint threadId, + SelectedCameraState selected, uint expectedBreakpointEip, bool requireEsiCamera, + out string evidence) + { + if (selected == null) + { + evidence = "selected invocation state is missing"; + return false; + } + if (threadId != selected.ThreadId) + { + evidence = "actualTid=" + threadId + " expectedTid=" + selected.ThreadId + + " camera=0x" + selected.CameraPointer.ToString("X8"); + return false; + } + return IsRenderInvocation(process, context, threadId, selected.ThreadId, + selected.CameraPointer, selected.EntryEsp, selected.FunctionStackEsp, + selected.ReturnAddress, expectedBreakpointEip, requireEsiCamera, out evidence); + } + + private static bool IsRenderInvocation(IntPtr process, byte[] context, uint threadId, + uint expectedThreadId, uint camera, uint entryEsp, uint functionStackEsp, + uint returnAddress, uint expectedBreakpointEip, bool requireEsiCamera, out string evidence) + { + uint actualEip = 0; + uint actualEsp = 0; + uint actualEsi = 0; + if (context != null && context.Length == X86ContextSize) + { + actualEip = BitConverter.ToUInt32(context, ContextEipOffset); + actualEsp = BitConverter.ToUInt32(context, ContextEspOffset); + actualEsi = BitConverter.ToUInt32(context, ContextEsiOffset); + } + byte[] callerStack = new byte[8]; + bool stackReadable = entryEsp <= UInt32.MaxValue - 8 + && ReadExact(process, entryEsp, callerStack); + uint savedReturn = stackReadable ? BitConverter.ToUInt32(callerStack, 0) : 0; + uint observedArgument = stackReadable ? BitConverter.ToUInt32(callerStack, 4) : 0; + uint diagnosticArgument; + bool valid = RenderInvocationMatches(threadId, expectedThreadId, actualEip, + expectedBreakpointEip, actualEsp, functionStackEsp, actualEsi, camera, + savedReturn, returnAddress, requireEsiCamera, observedArgument, out diagnosticArgument) && stackReadable; + evidence = "actualTid=" + threadId + " expectedTid=" + expectedThreadId + + " actualEip=0x" + actualEip.ToString("X8") + " expectedEip=0x" + expectedBreakpointEip.ToString("X8") + + " actualEsp=0x" + actualEsp.ToString("X8") + " expectedFunctionEsp=0x" + functionStackEsp.ToString("X8") + + " entryEsp=0x" + entryEsp.ToString("X8") + " savedReturn=0x" + savedReturn.ToString("X8") + + " expectedReturn=0x" + returnAddress.ToString("X8") + " observedEntryArgument=0x" + diagnosticArgument.ToString("X8") + + " entryArgumentMayBeOverwritten=true esi=0x" + actualEsi.ToString("X8") + + " esiRequired=" + requireEsiCamera + + " expectedCamera=0x" + camera.ToString("X8") + " stackReadable=" + stackReadable + + " match=" + valid; + return valid; + } + + private static bool RenderInvocationMatches(uint threadId, uint expectedThreadId, + uint eip, uint expectedEip, uint esp, uint expectedEsp, uint esi, uint camera, + uint savedReturn, uint expectedReturn, bool requireEsiCamera, + uint observedArgument, out uint diagnosticArgument) + { + // The camera argument's original stack slot becomes a COM output pointer + // during World3D's selector-6 query. Keep it in diagnostics, not identity. + diagnosticArgument = observedArgument; + return threadId == expectedThreadId && eip == expectedEip && esp == expectedEsp + && (!requireEsiCamera || esi == camera) && savedReturn == expectedReturn; + } + + private static RemoteReadbackSession StartRemoteReadback(IntPtr process, uint threadId, uint ngiBase, + BreakpointInfo present, FrameSnapshot frame, string outputJson) + { + uint device; + uint getRenderTarget; + string evidence; + if (!VerifyD3d7GetRenderTarget(process, ngiBase, out device, out getRenderTarget, out evidence)) + throw new InvalidOperationException("D3D7 GetRenderTarget call was refused: " + evidence); + Log("verified D3D7 GetRenderTarget ABI " + evidence); + if (frame == null || frame.Projection == null + || ReadU32(process, unchecked(ngiBase + NgiRendererGlobalRva)) != frame.Projection.Renderer) + throw new InvalidOperationException("Ngi32 renderer changed since the matching projection snapshot."); + if (ReadU32(process, unchecked(frame.Projection.Renderer + 0x114)) == 0) + throw new InvalidOperationException("Ngi32 is not in the verified windowed present path for RVA 0x6E1B."); + + IntPtr thread = OpenThread(ThreadGetContext | ThreadSetContext | ThreadQueryInformation, false, threadId); + if (thread == IntPtr.Zero) throw new InvalidOperationException("OpenThread for D3D7 readback failed: " + Marshal.GetLastWin32Error()); + uint region = 0; + try + { + byte[] originalContext = GetFullX86Context(thread); + uint stoppedEip = BitConverter.ToUInt32(originalContext, ContextEipOffset); + if (stoppedEip != unchecked(present.Address + 1)) + throw new InvalidOperationException("Ngi32 present EIP did not match the armed callsite."); + uint safeEsp = ComputeSafeNativeStackPointer(process, thread, originalContext); + uint originalEsp = BitConverter.ToUInt32(originalContext, ContextEspOffset); + if (originalEsp > UInt32.MaxValue - PresentCallArgumentsBytes) + throw new InvalidOperationException("Ngi32 present argument block address overflowed."); + byte[] presentStackArguments = new byte[PresentCallArgumentsBytes]; + if (!ReadExact(process, originalEsp, presentStackArguments)) + throw new InvalidOperationException("Could not read the original Ngi32 present call's 24-byte argument block."); + region = AllocateReadbackRegion(process); + RemoteCode acquire = BuildReadbackStub(region, device, false); + WriteRemoteStub(process, acquire, true); + RemoteReadbackSession session = new RemoteReadbackSession(); + session.Acquire = acquire; + session.Region = region; + session.ThreadId = threadId; + session.SafeEsp = safeEsp; + session.OriginalContext = originalContext; + session.PresentStackArguments = presentStackArguments; + session.PresentStackArgumentsIntact = true; + session.RemoteContextIntact = true; + session.PresentBreakpoint = present; + session.Frame = frame; + session.Generation = frame.CameraGeneration; + session.OutputJson = outputJson; + session.OutputPng = Path.ChangeExtension(outputJson, ".png"); + session.DeadlineUtc = DateTime.UtcNow.AddSeconds(5); + session.Phase = RemoteReadbackPhase.Acquire; + Log("PRESENT_STACK_ARGS stage=before-com esp=0x" + originalEsp.ToString("X8") + + " bytes=" + BitConverter.ToString(presentStackArguments)); + Log("REMOTE_ACQUIRE_STARTED tid=" + threadId + " stub=0x" + acquire.Base.ToString("X8") + + " terminal=0x" + acquire.TrapAddress.ToString("X8") + " safeEsp=0x" + safeEsp.ToString("X8") + + " generation=" + session.Generation); + SetFullX86Context(thread, ContextForRemoteCode(originalContext, acquire.Base, safeEsp)); + return session; + } + catch + { + if (region != 0) VirtualFreeEx(process, Ptr(region), UIntPtr.Zero, 0x8000); + throw; + } + finally { CloseHandle(thread); } + } + + private static void BeginRemoteCleanup(IntPtr process, RemoteReadbackSession session) + { + session.Cleanup = BuildReadbackStub(session.Region, 0, true); + WriteRemoteStub(process, session.Cleanup, false); + IntPtr thread = OpenThread(ThreadGetContext | ThreadSetContext, false, session.ThreadId); + if (thread == IntPtr.Zero) throw new InvalidOperationException("OpenThread for D3D7 Unlock/Release failed: " + Marshal.GetLastWin32Error()); + try + { + SetFullX86Context(thread, ContextForRemoteCode(session.OriginalContext, session.Cleanup.Base, session.SafeEsp)); + } + finally { CloseHandle(thread); } + session.Phase = RemoteReadbackPhase.Cleanup; + session.DeadlineUtc = DateTime.UtcNow.AddSeconds(5); + Log("REMOTE_CLEANUP_STARTED tid=" + session.ThreadId + " stub=0x" + session.Cleanup.Base.ToString("X8") + + " terminal=0x" + session.Cleanup.TrapAddress.ToString("X8")); + } + + private static uint AllocateReadbackRegion(IntPtr process) + { + const uint MemReserve = 0x2000; + IntPtr memory = VirtualAllocEx(process, IntPtr.Zero, new UIntPtr(RemoteAllocationBytes), + MemReserve | MemCommit, 0x04); + if (memory == IntPtr.Zero) throw new InvalidOperationException("VirtualAllocEx for bounded readback stub failed: " + Marshal.GetLastWin32Error()); + uint address = unchecked((uint)memory.ToInt32()); + if (address == 0 || address + RemoteAllocationBytes < address) + { + VirtualFreeEx(process, memory, UIntPtr.Zero, 0x8000); + throw new InvalidOperationException("VirtualAllocEx returned an invalid x86 address."); + } + // Keep the executable stub on its own read/execute page. The result + // structure stays writable; COM uses the stopped render thread's + // native stack after a TEB stack-bound check. + uint oldProtection; + if (!VirtualProtectEx(process, memory, new UIntPtr(RemoteCodePage), 0x20, out oldProtection)) + { + VirtualFreeEx(process, memory, UIntPtr.Zero, 0x8000); + throw new InvalidOperationException("VirtualProtectEx for readback code failed: " + Marshal.GetLastWin32Error()); + } + return address; + } + + private static void WriteRemoteStub(IntPtr process, RemoteCode code, bool initializeData) + { + uint oldProtection; + if (!VirtualProtectEx(process, Ptr(code.Base), new UIntPtr(RemoteCodePage), PageExecuteReadWrite, out oldProtection)) + throw new InvalidOperationException("Could not make temporary readback stub writable: " + Marshal.GetLastWin32Error()); + try + { + UIntPtr written; + if (!WriteProcessMemory(process, Ptr(code.Base), code.Bytes, + new UIntPtr((uint)code.Bytes.Length), out written) || written.ToUInt32() != (uint)code.Bytes.Length) + throw new InvalidOperationException("Writing temporary readback stub failed: " + Marshal.GetLastWin32Error()); + if (initializeData) + { + byte[] descriptor = new byte[DdSurfaceDesc2Size]; + Buffer.BlockCopy(BitConverter.GetBytes((uint)DdSurfaceDesc2Size), 0, descriptor, 0, 4); + UIntPtr descWritten; + if (!WriteProcessMemory(process, Ptr(unchecked(code.DataBase + RemoteDataDescOffset)), descriptor, + new UIntPtr((uint)descriptor.Length), out descWritten) || descWritten.ToUInt32() != (uint)descriptor.Length) + throw new InvalidOperationException("Initializing DDSURFACEDESC2 failed: " + Marshal.GetLastWin32Error()); + byte[] zeros = new byte[RemoteDataDescOffset]; + UIntPtr zerosWritten; + if (!WriteProcessMemory(process, Ptr(code.DataBase), zeros, + new UIntPtr((uint)zeros.Length), out zerosWritten) || zerosWritten.ToUInt32() != (uint)zeros.Length) + throw new InvalidOperationException("Initializing readback result block failed: " + Marshal.GetLastWin32Error()); + } + } + finally + { + uint ignored; + if (!VirtualProtectEx(process, Ptr(code.Base), new UIntPtr(RemoteCodePage), oldProtection, out ignored)) + throw new InvalidOperationException("Restoring temporary readback code protection failed: " + Marshal.GetLastWin32Error()); + if (!FlushInstructionCache(process, Ptr(code.Base), new UIntPtr((uint)code.Bytes.Length))) + throw new InvalidOperationException("FlushInstructionCache for readback stub failed: " + Marshal.GetLastWin32Error()); + } + } + + private static bool ReadExact(IntPtr process, uint address, byte[] bytes) + { + UIntPtr read; + return address != 0 && ReadProcessMemory(process, Ptr(address), bytes, + new UIntPtr((uint)bytes.Length), out read) && read.ToUInt32() == (uint)bytes.Length; + } + + private static bool VerifyPresentStackArguments(IntPtr process, RemoteReadbackSession session, string stage) + { + uint esp = BitConverter.ToUInt32(session.OriginalContext, ContextEspOffset); + byte[] current = new byte[PresentCallArgumentsBytes]; + bool readable = esp <= UInt32.MaxValue - PresentCallArgumentsBytes + && ReadExact(process, esp, current); + bool unchanged = readable && ByteArraysEqual(session.PresentStackArguments, current); + Log("PRESENT_STACK_ARGS stage=" + stage + " esp=0x" + esp.ToString("X8") + + " readable=" + readable + " unchanged=" + unchanged + + " before=" + (session.PresentStackArguments == null ? "null" : BitConverter.ToString(session.PresentStackArguments)) + + " after=" + (readable ? BitConverter.ToString(current) : "unreadable")); + return unchanged; + } + + private static bool VerifyRemoteStubContext(IntPtr process, RemoteReadbackSession session, + uint expectedEip, string stage) + { + IntPtr thread = OpenThread(ThreadGetContext, false, session.ThreadId); + if (thread == IntPtr.Zero) + { + Log("REMOTE_STUB_CONTEXT stage=" + stage + " OpenThreadFailed=" + Marshal.GetLastWin32Error()); + return false; + } + try + { + byte[] context = GetFullX86Context(thread); + uint eip = BitConverter.ToUInt32(context, ContextEipOffset); + uint esp = BitConverter.ToUInt32(context, ContextEspOffset); + uint expectedEsp = BitConverter.ToUInt32(session.OriginalContext, ContextEspOffset); + bool matches = eip == expectedEip && esp == expectedEsp; + Log("REMOTE_STUB_CONTEXT stage=" + stage + " expectedEip=0x" + expectedEip.ToString("X8") + + " actualEip=0x" + eip.ToString("X8") + " expectedEsp=0x" + expectedEsp.ToString("X8") + + " actualEsp=0x" + esp.ToString("X8") + " matches=" + matches); + return matches; + } + catch (Exception error) + { + Log("REMOTE_STUB_CONTEXT stage=" + stage + " readFailed=" + error.Message); + return false; + } + finally { CloseHandle(thread); } + } + + private static bool ByteArraysEqual(byte[] left, byte[] right) + { + if (left == null || right == null || left.Length != right.Length) return false; + for (int i = 0; i < left.Length; i++) + if (left[i] != right[i]) return false; + return true; + } + + private static uint ReadU32Exact(IntPtr process, uint address) + { + byte[] bytes = new byte[4]; + if (!ReadExact(process, address, bytes)) throw new InvalidOperationException("Unreadable remote readback result at 0x" + address.ToString("X8")); + return BitConverter.ToUInt32(bytes, 0); + } + + private static ProjectionReadback ReadProjection(IntPtr process, uint ngiBase) + { + ProjectionReadback result = new ProjectionReadback(); + uint rendererPointer = ReadU32(process, unchecked(ngiBase + NgiRendererGlobalRva)); + result.Renderer = rendererPointer; + if (rendererPointer == 0) + { + result.Failure = "Ngi32 renderer global is null"; + return result; + } + uint vtable = ReadU32(process, rendererPointer); + result.RendererVtable = vtable; + if (vtable != unchecked(ngiBase + NgiRendererVtableRva)) + { + result.Failure = "Ngi32 renderer vtable mismatch: 0x" + vtable.ToString("X8"); + return result; + } + byte[] viewportBytes = new byte[16]; + byte[] nearBytes = new byte[4]; + byte[] farBytes = new byte[4]; + byte[] fovBytes = new byte[4]; + byte[] modeBytes = new byte[1]; + if (!ReadExact(process, unchecked(rendererPointer + 0x18), viewportBytes) + || !ReadExact(process, unchecked(rendererPointer + 0x38), nearBytes) + || !ReadExact(process, unchecked(rendererPointer + 0x3C), farBytes) + || !ReadExact(process, unchecked(rendererPointer + 0x54), fovBytes) + || !ReadExact(process, unchecked(rendererPointer + 0x118), modeBytes)) + { + result.Failure = "Ngi32 projection fields are unreadable"; + return result; + } + int[] viewport = new int[4]; + for (int i = 0; i < viewport.Length; i++) viewport[i] = BitConverter.ToInt32(viewportBytes, i * 4); + float nearPlane = BitConverter.ToSingle(nearBytes, 0); + float farPlane = BitConverter.ToSingle(farBytes, 0); + float fov = BitConverter.ToSingle(fovBytes, 0); + result.Viewport = viewport; + result.Near = nearPlane; + result.Far = farPlane; + result.Fov = fov; + result.Mode = modeBytes[0]; + int width = viewport[2] - viewport[0]; + int height = viewport[3] - viewport[1]; + result.Verified = width > 0 && height > 0 && width <= 8192 && height <= 8192 + && IsFinite(nearPlane) && nearPlane > 0.0f + && IsFinite(farPlane) && farPlane > nearPlane + && IsFinite(fov) && fov > 0.0f && fov < 3.141593f; + if (!result.Verified) result.Failure = "Ngi32 viewport or projection values failed bounded finite/range checks"; + return result; + } + + private static SurfaceReadback ReadSurfaceRows(IntPtr process, uint descAddress) + { + byte[] desc = new byte[DdSurfaceDesc2Size]; + if (!ReadExact(process, descAddress, desc)) throw new InvalidOperationException("Could not read D3D7 surface descriptor."); + Log("D3D7_LOCK_DDSURFACEDESC2 bytes=" + desc.Length + " raw=" + BitConverter.ToString(desc)); + uint size = BitConverter.ToUInt32(desc, 0); + uint flags = BitConverter.ToUInt32(desc, 4); + uint height = BitConverter.ToUInt32(desc, 8); + uint width = BitConverter.ToUInt32(desc, 12); + int pitch = BitConverter.ToInt32(desc, 16); + uint surface = BitConverter.ToUInt32(desc, 36); + uint pfSize = BitConverter.ToUInt32(desc, 72); + uint pfFlags = BitConverter.ToUInt32(desc, 76); + uint fourCc = BitConverter.ToUInt32(desc, 80); + uint bitCount = BitConverter.ToUInt32(desc, 84); + uint red = BitConverter.ToUInt32(desc, 88); + uint green = BitConverter.ToUInt32(desc, 92); + uint blue = BitConverter.ToUInt32(desc, 96); + uint alpha = BitConverter.ToUInt32(desc, 100); + if (size != DdSurfaceDesc2Size || pfSize != 32) + throw new InvalidOperationException("DDSURFACEDESC2 or DDPIXELFORMAT size was unexpected."); + if (!HasLockedSurfaceDescriptor(flags, surface)) + throw new InvalidOperationException("D3D7 Lock descriptor flags were incomplete: size=" + size + + " flags=0x" + flags.ToString("X8") + " width=" + width + " height=" + height + + " pitch=" + pitch + " surface=0x" + surface.ToString("X8") + + " pfSize=" + pfSize + " pfFlags=0x" + pfFlags.ToString("X8") + + " bitCount=" + bitCount + " masks=0x" + red.ToString("X8") + "/0x" + + green.ToString("X8") + "/0x" + blue.ToString("X8") + "/0x" + alpha.ToString("X8")); + if ((flags & DdsdLpSurface) == 0) + Log("D3D7_LOCK_LPSURFACE_FLAG_ABSENT accepted_after_S_OK_and_nonzero_pointer; exact bounded ReadProcessMemory remains required"); + if ((pfFlags & DdpfRgb) == 0 || (pfFlags & DdpfFourCc) != 0) + throw new InvalidOperationException("D3D7 render target is not an uncompressed RGB surface."); + if (bitCount != 16 && bitCount != 24 && bitCount != 32) + throw new InvalidOperationException("Unsupported D3D7 render-target bit depth: " + bitCount); + if (width == 0 || height == 0 || width > 8192 || height > 8192 || surface == 0) + throw new InvalidOperationException("D3D7 surface dimensions or pointer are outside the accepted bounds."); + long rowBytes = ((long)width * bitCount + 7) / 8; + long pitchAbs = Math.Abs((long)pitch); + long totalBytes = pitchAbs * height; + if (pitch == 0 || pitchAbs < rowBytes || totalBytes <= 0 || totalBytes > PixelReadbackLimit) + throw new InvalidOperationException("D3D7 pitch/byte count is outside the bounded readback limits."); + long lowest = pitch >= 0 ? surface : (long)surface + (long)(height - 1) * pitch; + if (lowest <= 0 || lowest + totalBytes > UInt32.MaxValue) + throw new InvalidOperationException("D3D7 surface address range overflowed x86 address space."); + byte[] rows = new byte[(int)totalBytes]; + if (!ReadExact(process, unchecked((uint)lowest), rows)) + throw new InvalidOperationException("Could not copy the bounded locked D3D7 surface rows."); + SurfaceReadback result = new SurfaceReadback(); + result.Width = width; + result.Height = height; + result.Pitch = pitch; + result.BitCount = bitCount; + result.RedMask = red; + result.GreenMask = green; + result.BlueMask = blue; + result.AlphaMask = alpha; + result.SurfacePointer = surface; + result.Rows = rows; + result.RgbMasksVerified = IsValidChannelMask(red, bitCount, true) + && IsValidChannelMask(green, bitCount, true) + && IsValidChannelMask(blue, bitCount, true) + && IsValidChannelMask(alpha, bitCount, false) + && (red & green) == 0 && (red & blue) == 0 && (green & blue) == 0 + && (alpha == 0 || ((alpha & red) == 0 && (alpha & green) == 0 && (alpha & blue) == 0)); + if (!result.RgbMasksVerified) + throw new InvalidOperationException("D3D7 RGB channel masks are missing or overlap."); + using (SHA256 sha = SHA256.Create()) + { + byte[] hash = sha.ComputeHash(rows); + StringBuilder hex = new StringBuilder(hash.Length * 2); + for (int i = 0; i < hash.Length; i++) hex.Append(hash[i].ToString("X2")); + result.Sha256 = hex.ToString(); + } + return result; + } + + private static bool HasLockedSurfaceDescriptor(uint flags, uint surface) + { + const uint required = DdsdHeight | DdsdWidth | DdsdPitch | DdsdPixelFormat; + // Successful IDirectDrawSurface7::Lock returns the lpSurface address; + // tolerate drivers that omit only DDSD_LPSURFACE, then require exact bounded + // ReadProcessMemory for every pixel row before accepting the capture. + return surface != 0 && (flags & required) == required; + } + + private static int MaskChannel(uint pixel, uint mask, int fallback) + { + if (mask == 0) return fallback; + int shift = 0; + while (((mask >> shift) & 1) == 0 && shift < 31) shift++; + ulong maximum = mask >> shift; + ulong value = (pixel & mask) >> shift; + return (int)((value * 255UL + maximum / 2UL) / maximum); + } + + private static bool IsValidChannelMask(uint mask, uint bitCount, bool required) + { + if (mask == 0) return !required; + if (bitCount < 32 && (mask >> (int)bitCount) != 0) return false; + int shift = 0; + while (shift < 32 && ((mask >> shift) & 1) == 0) shift++; + if (shift >= 32) return false; + uint normalized = mask >> shift; + return (normalized & unchecked(normalized + 1u)) == 0; + } + + private static byte[] DecodeSurfaceToBgra(SurfaceReadback surface) + { + if (surface == null || surface.Rows == null || !surface.RgbMasksVerified) + throw new InvalidOperationException("Surface was not verified before pixel conversion."); + int width = checked((int)surface.Width); + int height = checked((int)surface.Height); + int bytesPerPixel = checked((int)(surface.BitCount / 8)); + int sourcePitch = checked((int)Math.Abs((long)surface.Pitch)); + int rowBytes = checked(width * bytesPerPixel); + if (width <= 0 || height <= 0 || (surface.BitCount != 16 && surface.BitCount != 24 && surface.BitCount != 32) + || sourcePitch < rowBytes || surface.Rows.Length < checked(sourcePitch * height)) + throw new InvalidOperationException("Surface pixel buffer shape is inconsistent."); + byte[] bgra = new byte[checked(width * height * 4)]; + for (int y = 0; y < height; y++) + { + int sourceRow = surface.Pitch >= 0 ? y : (height - 1 - y); + int sourceBase = checked(sourceRow * sourcePitch); + int destinationBase = checked(y * width * 4); + for (int x = 0; x < width; x++) + { + int offset = sourceBase + x * bytesPerPixel; + uint packed; + if (surface.BitCount == 16) packed = BitConverter.ToUInt16(surface.Rows, offset); + else if (surface.BitCount == 24) + packed = (uint)(surface.Rows[offset] | (surface.Rows[offset + 1] << 8) | (surface.Rows[offset + 2] << 16)); + else packed = BitConverter.ToUInt32(surface.Rows, offset); + int outOffset = destinationBase + x * 4; + bgra[outOffset] = (byte)MaskChannel(packed, surface.BlueMask, 0); + bgra[outOffset + 1] = (byte)MaskChannel(packed, surface.GreenMask, 0); + bgra[outOffset + 2] = (byte)MaskChannel(packed, surface.RedMask, 0); + // PNG is a screenshot of the composited framebuffer. The + // render target's alpha bits are not window opacity metadata. + bgra[outOffset + 3] = 255; + } + } + return bgra; + } + + private static void AssertPixel(byte[] actual, params byte[] expected) + { + if (actual == null || actual.Length != expected.Length) + throw new InvalidOperationException("Pixel converter self-check returned the wrong byte count."); + for (int i = 0; i < expected.Length; i++) + if (actual[i] != expected[i]) + throw new InvalidOperationException("Pixel converter self-check failed at byte " + i + "."); + } + + private static void SaveSurfacePng(SurfaceReadback surface, string outputPath) + { + int width = checked((int)surface.Width); + int height = checked((int)surface.Height); + bool created = false; + try + { + using (FileStream png = new FileStream(outputPath, FileMode.CreateNew, FileAccess.Write, FileShare.None)) + { + created = true; + using (Bitmap bitmap = new Bitmap(width, height, PixelFormat.Format32bppArgb)) + { + Rectangle rectangle = new Rectangle(0, 0, width, height); + BitmapData bits = bitmap.LockBits(rectangle, ImageLockMode.WriteOnly, PixelFormat.Format32bppArgb); + try + { + if (bits.Stride < width * 4) throw new InvalidOperationException("PNG staging bitmap stride is too short."); + byte[] sourceBgra = DecodeSurfaceToBgra(surface); + byte[] rgba = new byte[checked(bits.Stride * height)]; + int sourceStride = width * 4; + for (int y = 0; y < height; y++) + Buffer.BlockCopy(sourceBgra, y * sourceStride, rgba, y * bits.Stride, sourceStride); + Marshal.Copy(rgba, 0, bits.Scan0, rgba.Length); + } + finally { bitmap.UnlockBits(bits); } + bitmap.Save(png, ImageFormat.Png); + } + png.Flush(); + } + } + catch + { + if (created) try { File.Delete(outputPath); } catch { } + throw; + } + } + + private static void WriteTextCreateNew(string outputPath, string text) + { + bool created = false; + try + { + using (FileStream output = new FileStream(outputPath, FileMode.CreateNew, FileAccess.Write, FileShare.None)) + { + created = true; + byte[] bytes = new UTF8Encoding(false).GetBytes(text); + output.Write(bytes, 0, bytes.Length); + output.Flush(); + } + } + catch + { + if (created) try { File.Delete(outputPath); } catch { } + throw; + } + } + + private static CameraReadback ReadCameraSnapshot(IntPtr process, uint threadId, uint terrainBase) + { + CameraReadback result = new CameraReadback(); + IntPtr thread = OpenThread(ThreadGetContext | ThreadSetContext, false, threadId); + IntPtr contextAllocation; + IntPtr context = AllocateAlignedX86Context(out contextAllocation); + try + { + Marshal.Copy(new byte[X86ContextSize], 0, context, X86ContextSize); + Marshal.WriteInt32(context, 0, unchecked((int)ContextAllX86)); + if (thread == IntPtr.Zero || !GetThreadContext(thread, context)) + { + result.Failure = "World3D breakpoint context was unreadable: " + Marshal.GetLastWin32Error(); + return result; + } + uint esp = unchecked((uint)Marshal.ReadInt32(context, ContextEspOffset)); + uint returnAddress = ReadU32(process, esp); + uint camera = ReadU32(process, unchecked(esp + 4)); + uint vtable = ReadU32(process, camera); + uint transformInterface = unchecked(camera + 4); + uint transformVtable = ReadU32(process, transformInterface); + uint selectorField = ReadU32(process, unchecked(camera + 0x10)); + byte[] modeBytes = new byte[1]; + byte cameraMode = ReadExact(process, unchecked(camera + 0x1A0), modeBytes) ? modeBytes[0] : (byte)0xFF; + byte[] matrixBytes = new byte[64]; + if (camera == 0 || !ReadExact(process, unchecked(camera + 0x20), matrixBytes)) + { + result.Failure = "World3D camera argument or 64-byte selector matrix was unreadable"; + return result; + } + uint[] words = new uint[16]; + bool matrixFinite = true; + for (int i = 0; i < words.Length; i++) + { + words[i] = BitConverter.ToUInt32(matrixBytes, i * 4); + if (!IsFinite(BitConverter.ToSingle(matrixBytes, i * 4))) matrixFinite = false; + } + uint expectedVtable = unchecked(terrainBase + ExternalCameraVtableRva); + uint expectedTransformVtable = unchecked(terrainBase + 0x66558); + uint setterSlot = ReadU32(process, unchecked(transformVtable + 0x1C)); + uint invalidateSlot = ReadU32(process, unchecked(transformVtable + 0x30)); + bool setterAbiVerified = transformVtable == expectedTransformVtable + && setterSlot == unchecked(terrainBase + 0x54C70) + && invalidateSlot == unchecked(terrainBase + 0x55280); + result.Camera = camera; + result.Vtable = vtable; + result.TransformInterface = transformInterface; + result.TransformVtable = transformVtable; + result.CameraMode = cameraMode; + result.EntryEsp = esp; + result.ReturnAddress = returnAddress; + result.SelectorField = selectorField; + result.Words = words; + result.EntryMatrixBytes = (byte[])matrixBytes.Clone(); + result.EntryMatrixSha256 = HashBytes(matrixBytes); + result.MatrixFinite = matrixFinite; + result.LayoutVerified = terrainBase != 0 && vtable == expectedVtable + && setterAbiVerified && cameraMode == 0 + && selectorField == 0xFFFFFFFF && matrixFinite; + if (!result.LayoutVerified) + result.Failure = "external camera primary/transform vtables, setter slots, normal mode, selector field, or matrix finite check failed"; + return result; + } + finally + { + Marshal.FreeHGlobal(contextAllocation); + if (thread != IntPtr.Zero) CloseHandle(thread); + } + } + + private static Dictionary ParseJsonObject(string json) + { + if (String.IsNullOrEmpty(json) || json.Length > CameraInputJsonLimit) + throw new InvalidDataException("Camera JSON is empty or exceeds the 1 MiB input limit."); + JavaScriptSerializer serializer = new JavaScriptSerializer(); + serializer.MaxJsonLength = CameraInputJsonLimit; + serializer.RecursionLimit = 32; + object value; + try { value = serializer.DeserializeObject(json); } + catch (Exception error) { throw new InvalidDataException("Camera JSON is malformed.", error); } + Dictionary result = value as Dictionary; + if (result == null) throw new InvalidDataException("Camera JSON must be a top-level object."); + return result; + } + + private static object RequiredJsonField(Dictionary json, string key) + { + object value; + if (json == null || !json.TryGetValue(key, out value) || value == null) + throw new InvalidDataException("Camera JSON is missing '" + key + "'."); + return value; + } + + private static string JsonStringField(Dictionary json, string key) + { + string value = RequiredJsonField(json, key) as string; + if (value == null) throw new InvalidDataException("Camera JSON field '" + key + "' must be a string."); + return value; + } + + private static object[] JsonArrayField(Dictionary json, string key) + { + object value = RequiredJsonField(json, key); + IList list = value as IList; + if (list == null) throw new InvalidDataException("Camera JSON field '" + key + "' must be an array."); + object[] result = new object[list.Count]; + list.CopyTo(result, 0); + return result; + } + + private static bool TryJsonDouble(object value, out double result) + { + if (value is double) result = (double)value; + else if (value is decimal) result = (double)(decimal)value; + else if (value is int) result = (int)value; + else if (value is long) result = (long)value; + else if (value is uint) result = (uint)value; + else if (value is ulong) result = (ulong)value; + else if (value is float) result = (float)value; + else { result = 0; return false; } + return !Double.IsNaN(result) && !Double.IsInfinity(result); + } + + private static bool TryJsonUInt32(object value, out uint result) + { + double number; + if (!TryJsonDouble(value, out number) || number < 0 || number > UInt32.MaxValue + || Math.Truncate(number) != number) + { result = 0; return false; } + result = (uint)number; + return true; + } + + private static bool TryJsonInt32(object value, out int result) + { + double number; + if (!TryJsonDouble(value, out number) || number < Int32.MinValue || number > Int32.MaxValue + || Math.Truncate(number) != number) + { result = 0; return false; } + result = (int)number; + return true; + } + + private static bool TryJsonFloat(object value, out float result) + { + double number; + if (!TryJsonDouble(value, out number) || number < -Single.MaxValue || number > Single.MaxValue) + { result = 0; return false; } + result = (float)number; + return IsFinite(result); + } + + private static bool IsOrthonormalAffineCamera(byte[] matrixBytes, out string failure) + { + failure = null; + if (matrixBytes == null || matrixBytes.Length != 64) + { + failure = "camera matrix must contain exactly 64 bytes"; + return false; + } + float[] m = new float[16]; + for (int i = 0; i < m.Length; i++) + { + m[i] = BitConverter.ToSingle(matrixBytes, i * 4); + if (!IsFinite(m[i])) { failure = "camera matrix contains a nonfinite value"; return false; } + } + const double affineTolerance = 0.0001; + if (Math.Abs(m[12]) > affineTolerance || Math.Abs(m[13]) > affineTolerance + || Math.Abs(m[14]) > affineTolerance || Math.Abs(m[15] - 1.0) > affineTolerance) + { + failure = "camera matrix last row is not affine [0,0,0,1]"; + return false; + } + if (Math.Abs(m[3]) > 1000000 || Math.Abs(m[7]) > 1000000 || Math.Abs(m[11]) > 1000000) + { + failure = "camera translation is outside the supported finite range"; + return false; + } + + // Native selector-0 matrices are row-major affine transforms with a + // rigid, orthonormal 3x3 basis. Reject shear/scale before the DLL call. + double[] rowLengthSquared = new double[3]; + for (int row = 0; row < 3; row++) + { + int offset = row * 4; + for (int column = 0; column < 3; column++) + rowLengthSquared[row] += (double)m[offset + column] * m[offset + column]; + if (Math.Abs(rowLengthSquared[row] - 1.0) > 0.02) + { + failure = "camera basis row is not unit length"; + return false; + } + } + for (int firstRow = 0; firstRow < 3; firstRow++) + for (int secondRow = firstRow + 1; secondRow < 3; secondRow++) + { + double dot = 0; + for (int column = 0; column < 3; column++) + dot += (double)m[firstRow * 4 + column] * m[secondRow * 4 + column]; + if (Math.Abs(dot) > 0.01) + { + failure = "camera basis contains shear or nonorthogonal axes"; + return false; + } + } + double determinant = (double)m[0] * (m[5] * m[10] - m[6] * m[9]) + - (double)m[1] * (m[4] * m[10] - m[6] * m[8]) + + (double)m[2] * (m[4] * m[9] - m[5] * m[8]); + if (Math.Abs(Math.Abs(determinant) - 1.0) > 0.04) + { + failure = "camera basis is singular or not approximately rigid"; + return false; + } + return true; + } + + private static SelectedCameraInput ParseSelectedCameraJson(string json, string sourcePath) + { + Dictionary root = ParseJsonObject(json); + bool renderInputUsable = RequiredJsonField(root, "render_input_usable") is bool + && (bool)RequiredJsonField(root, "render_input_usable"); + if (!String.Equals(JsonStringField(root, "schema"), "fparkan-legacy-camera-v1", StringComparison.Ordinal) + || !String.Equals(JsonStringField(root, "capture_status"), "native-frame-captured", StringComparison.Ordinal) + || !renderInputUsable) + throw new InvalidDataException("Camera input must be a usable fparkan-legacy-camera-v1 native capture."); + + SelectedCameraInput input = new SelectedCameraInput(); + input.Path = Path.GetFullPath(sourcePath); + object[] words = JsonArrayField(root, "selector0_words"); + if (words.Length != 16) throw new InvalidDataException("selector0_words must contain exactly 16 uint32 values."); + input.MatrixWords = new uint[16]; + input.MatrixBytes = new byte[64]; + for (int i = 0; i < words.Length; i++) + { + uint word; + if (!TryJsonUInt32(words[i], out word)) + throw new InvalidDataException("selector0_words contains a value outside uint32 range."); + input.MatrixWords[i] = word; + Buffer.BlockCopy(BitConverter.GetBytes(word), 0, input.MatrixBytes, i * 4, 4); + } + string matrixFailure; + if (!IsOrthonormalAffineCamera(input.MatrixBytes, out matrixFailure)) + throw new InvalidDataException("Camera input rejected: " + matrixFailure + "."); + input.MatrixSha256 = HashBytes(input.MatrixBytes); + + object[] viewport = JsonArrayField(root, "viewport"); + if (viewport.Length != 4) throw new InvalidDataException("viewport must contain exactly four integers."); + input.Viewport = new int[4]; + for (int i = 0; i < viewport.Length; i++) + if (!TryJsonInt32(viewport[i], out input.Viewport[i])) + throw new InvalidDataException("viewport contains an invalid integer."); + if (input.Viewport[0] < 0 || input.Viewport[1] < 0 + || input.Viewport[2] <= input.Viewport[0] || input.Viewport[3] <= input.Viewport[1]) + throw new InvalidDataException("viewport bounds must have positive width and height."); + + if (!TryJsonFloat(RequiredJsonField(root, "near_plane"), out input.Near) + || !TryJsonFloat(RequiredJsonField(root, "far_plane"), out input.Far) + || !TryJsonFloat(RequiredJsonField(root, "field_of_view_radians"), out input.FieldOfView) + || !IsFinite(input.Near) || !IsFinite(input.Far) || !IsFinite(input.FieldOfView) + || input.Near <= 0 || input.Far <= input.Near || input.FieldOfView <= 0.05f || input.FieldOfView >= 3.1f) + throw new InvalidDataException("Camera projection must have finite, ordered near/far planes and a finite perspective FOV."); + uint projectionMode; + if (!TryJsonUInt32(RequiredJsonField(root, "projection_mode_byte"), out projectionMode) + || projectionMode == 0 || projectionMode > Byte.MaxValue) + throw new InvalidDataException("Camera input requires a verified perspective projection mode."); + input.ProjectionMode = (byte)projectionMode; + return input; + } + + private static SelectedCameraInput LoadSelectedCameraInput(string path) + { + string fullPath = Path.GetFullPath(path); + if (!File.Exists(fullPath)) throw new FileNotFoundException("Camera input JSON does not exist.", fullPath); + FileInfo file = new FileInfo(fullPath); + if (file.Length > CameraInputJsonLimit) + throw new InvalidDataException("Camera input exceeds the 1 MiB limit."); + byte[] bytes = File.ReadAllBytes(fullPath); + if (bytes.Length > CameraInputJsonLimit) + throw new InvalidDataException("Camera input exceeded the 1 MiB limit while being read."); + int offset = bytes.Length >= 3 && bytes[0] == 0xEF && bytes[1] == 0xBB && bytes[2] == 0xBF ? 3 : 0; + string text; + try { text = new UTF8Encoding(false, true).GetString(bytes, offset, bytes.Length - offset); } + catch (DecoderFallbackException error) { throw new InvalidDataException("Camera input is not valid UTF-8.", error); } + return ParseSelectedCameraJson(text, fullPath); + } + + private static bool ProjectionMatchesCameraInput(ProjectionReadback projection, SelectedCameraInput input) + { + if (projection == null || input == null || !projection.Verified || projection.Mode != input.ProjectionMode + || projection.Viewport == null || projection.Viewport.Length != 4) return false; + for (int i = 0; i < 4; i++) if (projection.Viewport[i] != input.Viewport[i]) return false; + return NearlyEqual(projection.Near, input.Near, 0.0001f) + && NearlyEqual(projection.Far, input.Far, 0.001f) + && NearlyEqual(projection.Fov, input.FieldOfView, 0.0001f); + } + + private static bool NearlyEqual(float left, float right, float tolerance) + { + return IsFinite(left) && IsFinite(right) + && Math.Abs((double)left - right) <= tolerance * Math.Max(1.0, Math.Max(Math.Abs((double)left), Math.Abs((double)right))); + } + + private static void SelfCheckCameraInput() + { + string valid = "{\"schema\":\"fparkan-legacy-camera-v1\",\"capture_status\":\"native-frame-captured\"," + + "\"render_input_usable\":true,\"selector0_words\":[1065353216,0,0,1065353216," + + "0,1065353216,0,1073741824,0,0,1065353216,1077936128,0,0,0,1065353216]," + + "\"viewport\":[0,0,1280,1024],\"near_plane\":0.5,\"far_plane\":700," + + "\"field_of_view_radians\":1.04,\"projection_mode_byte\":1}"; + SelectedCameraInput parsed = ParseSelectedCameraJson(valid, "input.json"); + if (parsed.MatrixBytes.Length != 64 || parsed.Viewport[2] != 1280 || parsed.MatrixSha256.Length != 64) + throw new InvalidOperationException("Camera JSON parser self-check failed on a valid rigid transform."); + string zeroMatrix = valid.Replace("1065353216,0,0,1065353216,0,1065353216,0,1073741824,0,0,1065353216,1077936128,0,0,0,1065353216", + "0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0"); + bool rejected = false; + try { ParseSelectedCameraJson(zeroMatrix, "bad.json"); } + catch (InvalidDataException) { rejected = true; } + if (!rejected) throw new InvalidOperationException("Camera JSON parser accepted a singular all-zero matrix."); + string nonPerspective = valid.Replace("\"projection_mode_byte\":1", "\"projection_mode_byte\":0"); + rejected = false; + try { ParseSelectedCameraJson(nonPerspective, "bad.json"); } + catch (InvalidDataException) { rejected = true; } + if (!rejected) throw new InvalidOperationException("Camera JSON parser accepted a non-perspective projection."); + string nonNumericWord = valid.Replace("1065353216,0,0,1065353216", "\"1065353216\",0,0,1065353216"); + rejected = false; + try { ParseSelectedCameraJson(nonNumericWord, "bad.json"); } + catch (InvalidDataException) { rejected = true; } + if (!rejected) throw new InvalidOperationException("Camera JSON parser accepted a quoted matrix word."); + } + + private static bool RefreshCameraWordsAtProjection(IntPtr process, CameraReadback camera, + uint terrainBase, uint generation) + { + if (camera == null || !camera.LayoutVerified || camera.Camera == 0 + || camera.EntryMatrixBytes == null || camera.EntryMatrixBytes.Length != 64) + { + if (camera != null) + { + camera.CurrentAtProjectionVerified = false; + camera.Failure = "camera entry layout was not verified before the projection boundary"; + } + return false; + } + + uint vtable = ReadU32(process, camera.Camera); + uint selectorField = ReadU32(process, unchecked(camera.Camera + 0x10)); + uint expectedVtable = unchecked(terrainBase + ExternalCameraVtableRva); + byte[] current = new byte[64]; + if (vtable != expectedVtable || selectorField != 0xFFFFFFFF + || !ReadExact(process, unchecked(camera.Camera + 0x20), current)) + { + camera.CurrentAtProjectionVerified = false; + camera.Failure = "camera pointer, vtable, selector, or matrix changed before projection"; + Log("CAMERA_MATRIX_AT_PROJECTION_REJECTED generation=" + generation + + " vtable=0x" + vtable.ToString("X8") + " selector=0x" + selectorField.ToString("X8") + + " expectedVtable=0x" + expectedVtable.ToString("X8")); + return false; + } + + uint[] words = new uint[16]; + bool finite = true; + for (int i = 0; i < words.Length; i++) + { + words[i] = BitConverter.ToUInt32(current, i * 4); + if (!IsFinite(BitConverter.ToSingle(current, i * 4))) finite = false; + } + if (!finite) + { + camera.CurrentAtProjectionVerified = false; + camera.Failure = "camera matrix contains a non-finite value at projection boundary"; + Log("CAMERA_MATRIX_AT_PROJECTION_REJECTED generation=" + generation + " nonFinite=true"); + return false; + } + + bool changed = !ByteArraysEqual(camera.EntryMatrixBytes, current); + camera.Vtable = vtable; + camera.SelectorField = selectorField; + camera.Words = words; + camera.ProjectionMatrixBytes = (byte[])current.Clone(); + camera.MatrixFinite = true; + camera.CurrentAtProjectionVerified = true; + camera.WordsChangedAtProjection = changed; + camera.ProjectionMatrixSha256 = HashBytes(current); + Log("CAMERA_MATRIX_AT_PROJECTION generation=" + generation + " verified=true changedSinceEntry=" + changed + + " entrySha256=" + camera.EntryMatrixSha256 + " projectionSha256=" + camera.ProjectionMatrixSha256); + return true; + } + + private static string HashBytes(byte[] bytes) + { + using (SHA256 sha = SHA256.Create()) + { + byte[] hash = sha.ComputeHash(bytes); + StringBuilder result = new StringBuilder(hash.Length * 2); + for (int i = 0; i < hash.Length; i++) result.Append(hash[i].ToString("X2")); + return result.ToString(); + } + } + + private static int SelfCheckReadback() + { + if (IntPtr.Size != 4) throw new InvalidOperationException("This helper must run as x86."); + uint flags = DdLockReadOnly | DdLockDoNotWait; + if (DdLockReadOnly != 0x10 || DdLockDoNotWait != 0x4000 || flags != 0x4010 || flags == 0x30) + throw new InvalidOperationException("DDLOCK flag self-check failed."); + RemoteCode acquire = BuildReadbackStub(0x10000000, 0x20000000, false); + RemoteCode cleanup = BuildReadbackStub(0x10000000, 0, true); + RemoteCode cameraSetter = BuildCameraSetterStub(0x30000000, 0x40000000); + string[] d3d7DeviceVtable = new string[] { + "QueryInterface", "AddRef", "Release", "GetCaps", "EnumTextureFormats", "BeginScene", "EndScene", + "GetDirect3D", "SetRenderTarget", "GetRenderTarget", "Clear", "SetTransform", "GetTransform", + "SetViewport", "MultiplyTransform", "GetViewport", "SetMaterial", "GetMaterial", "SetLight", "GetLight" + }; + string[] surface7Vtable = new string[] { + "QueryInterface", "AddRef", "Release", "AddAttachedSurface", "AddOverlayDirtyRect", "Blt", "BltBatch", + "BltFast", "DeleteAttachedSurface", "EnumAttachedSurfaces", "EnumOverlayZOrders", "Flip", "GetAttachedSurface", + "GetBltStatus", "GetCaps", "GetClipper", "GetColorKey", "GetDC", "GetFlipStatus", "GetOverlayPosition", + "GetPalette", "GetPixelFormat", "GetSurfaceDesc", "Initialize", "IsLost", "Lock", "ReleaseDC", "Restore", + "SetClipper", "SetColorKey", "SetOverlayPosition", "SetPalette", "Unlock", "UpdateOverlay", + "UpdateOverlayDisplay", "UpdateOverlayZOrder", "GetDDInterface", "PageLock", "PageUnlock", "SetSurfaceDesc", + "SetPrivateData", "GetPrivateData", "FreePrivateData", "GetUniquenessValue", "ChangeUniquenessValue", + "SetPriority", "GetPriority", "SetLOD", "GetLOD" + }; + byte[] originalContext = new byte[X86ContextSize]; + const uint originalEsp = 0x00100100; + Buffer.BlockCopy(BitConverter.GetBytes(unchecked((int)0x12345678)), 0, originalContext, ContextEipOffset, 4); + Buffer.BlockCopy(BitConverter.GetBytes(unchecked((int)originalEsp)), 0, originalContext, ContextEspOffset, 4); + Buffer.BlockCopy(BitConverter.GetBytes(0x00000202), 0, originalContext, ContextEflagsOffset, 4); + byte[] remoteContext = ContextForRemoteCode(originalContext, 0x10001000, originalEsp); + byte[] presentArgs = new byte[PresentCallArgumentsBytes]; + for (int i = 0; i < presentArgs.Length; i++) presentArgs[i] = (byte)(i + 1); + byte[] samePresentArgs = (byte[])presentArgs.Clone(); + byte[] changedPresentArgs = (byte[])presentArgs.Clone(); + changedPresentArgs[PresentCallArgumentsBytes - 1] ^= 1; + bool validStackRange = IsSafeNativeStackRange(0x00100000, 0x00200000, originalEsp, 24, + 0x00100000, 0x1000, MemCommit, 0x04); + if (Marshal.SizeOf(typeof(X86ContextLayout)) != X86ContextSize + || Marshal.OffsetOf(typeof(X86ContextLayout), "Eip").ToInt32() != ContextEipOffset + || Marshal.OffsetOf(typeof(X86ContextLayout), "Esp").ToInt32() != ContextEspOffset + || ContextAllX86 != 0x0001003F || acquire.Bytes.Length == 0 || cleanup.Bytes.Length == 0 + || acquire.MaxCallStackBytes != RemoteStubMaxCallStackBytes || cleanup.MaxCallStackBytes != 12 + || cameraSetter.Bytes.Length == 0 || cameraSetter.MaxCallStackBytes != 16 + || cameraSetter.CallInstructionOffsets == null || cameraSetter.CallInstructionOffsets.Length != 1 + || DecodeIndirectCallDisplacement(cameraSetter.Bytes, cameraSetter.CallInstructionOffsets[0]) + != CameraTransformSetterSlotOffset + || cameraSetter.TrapAddress != 0x30000000 + cameraSetter.Bytes.Length - 1 + || VtableOffset(d3d7DeviceVtable, "GetRenderTarget") != D3dDeviceGetRenderTargetOffset + || VtableOffset(surface7Vtable, "Lock") != DdSurfaceLockOffset + || VtableOffset(surface7Vtable, "SetOverlayPosition") != 0x78 + || VtableOffset(surface7Vtable, "Unlock") != DdSurfaceUnlockOffset + || VtableOffset(surface7Vtable, "Release") != DdSurfaceReleaseOffset + || cleanup.CallInstructionOffsets == null || cleanup.CallInstructionOffsets.Length != 2 + || DecodeIndirectCallDisplacement(cleanup.Bytes, cleanup.CallInstructionOffsets[0]) != DdSurfaceUnlockOffset + || DecodeIndirectCallDisplacement(cleanup.Bytes, cleanup.CallInstructionOffsets[1]) != DdSurfaceReleaseOffset + || acquire.CallInstructionOffsets == null || acquire.CallInstructionOffsets.Length != 3 + || DecodeIndirectCallDisplacement(acquire.Bytes, acquire.CallInstructionOffsets[0]) != D3dDeviceGetRenderTargetOffset + || DecodeIndirectCallDisplacement(acquire.Bytes, acquire.CallInstructionOffsets[1]) != DdSurfaceLockOffset + || DecodeIndirectCallDisplacement(acquire.Bytes, acquire.CallInstructionOffsets[2]) != DdSurfaceReleaseOffset + || DecodeIndirectCallDisplacement(new byte[] { 0xFF, 0x50, 0x80 }, 0) != -128 + || acquire.TrapAddress != 0x10000000 + acquire.Bytes.Length - 1 + || cleanup.TrapAddress != 0x10000000 + cleanup.Bytes.Length - 1 + || !validStackRange || !HasNativeStackBounds(0x00100000, 0x00200000, 0x00100018, 24) + || HasNativeStackBounds(0x00100000, 0x00200000, 0x00100017, 24) + || HasNativeStackBounds(0x00100000, 0x00200000, 0x00000010, 24) + || HasNativeStackBounds(0x00100000, 0x00200000, 0x00100100, 0) + || !IsSafeNativeStackRange(0x00100000, 0x00200000, originalEsp, 24, + 0x001000E8, 0x1000, MemCommit, 0x04) + || IsSafeNativeStackRange(0x00100000, 0x00200000, originalEsp, 24, + 0x00100000, 0x1000, MemCommit, 0x104) + || IsSafeNativeStackRange(0x00100000, 0x00200000, originalEsp, 24, + 0x00100000, 0xF0, MemCommit, 0x04) + || BitConverter.ToUInt32(remoteContext, ContextEspOffset) != originalEsp + || BitConverter.ToUInt32(remoteContext, ContextEipOffset) != 0x10001000 + || BitConverter.ToUInt32(remoteContext, ContextEflagsOffset) != 0x00000202 + || !ByteArraysEqual(presentArgs, samePresentArgs) + || ByteArraysEqual(presentArgs, changedPresentArgs) + || !IsViewportInsideSurface(new int[] { 0, 0, 1280, 1024 }, 1280, 1024) + || IsViewportInsideSurface(new int[] { -1, 0, 1280, 1024 }, 1280, 1024) + || IsViewportInsideSurface(new int[] { 0, 0, 1281, 1024 }, 1280, 1024) + || IsViewportInsideSurface(new int[] { 0, 0, 0, 1024 }, 1280, 1024)) + throw new InvalidOperationException("Remote stub branch/INT3 self-check failed."); + if (!HasLockedSurfaceDescriptor(0x100F, 0x19510000) + || HasLockedSurfaceDescriptor(0x1007, 0x19510000) + || HasLockedSurfaceDescriptor(0x100F, 0)) + throw new InvalidOperationException("D3D7 Lock descriptor compatibility self-check failed."); + AssertPixel(DecodeSurfaceToBgra(new SurfaceReadback { + Width = 1, Height = 1, Pitch = 2, BitCount = 16, + RedMask = 0xF800, GreenMask = 0x07E0, BlueMask = 0x001F, + Rows = new byte[] { 0x1F, 0xF8 }, RgbMasksVerified = true + }), 255, 0, 255, 255); + AssertPixel(DecodeSurfaceToBgra(new SurfaceReadback { + Width = 1, Height = 1, Pitch = 3, BitCount = 24, + RedMask = 0x00FF0000, GreenMask = 0x0000FF00, BlueMask = 0x000000FF, + Rows = new byte[] { 0x11, 0x22, 0x33 }, RgbMasksVerified = true + }), 0x11, 0x22, 0x33, 255); + AssertPixel(DecodeSurfaceToBgra(new SurfaceReadback { + Width = 1, Height = 1, Pitch = 4, BitCount = 32, + RedMask = 0x00FF0000, GreenMask = 0x0000FF00, BlueMask = 0x000000FF, AlphaMask = 0xFF000000, + Rows = new byte[] { 0x44, 0x55, 0x66, 0x77 }, RgbMasksVerified = true + }), 0x44, 0x55, 0x66, 255); + AssertPixel(DecodeSurfaceToBgra(new SurfaceReadback { + Width = 1, Height = 2, Pitch = -3, BitCount = 24, + RedMask = 0x00FF0000, GreenMask = 0x0000FF00, BlueMask = 0x000000FF, + Rows = new byte[] { 1, 2, 3, 4, 5, 6 }, RgbMasksVerified = true + }), 4, 5, 6, 255, 1, 2, 3, 255); + Console.WriteLine("self-check: D3D7 GetRenderTarget and Surface7 Lock/Unlock/Release SDK vtable ordinals OK"); + Console.WriteLine("self-check: caller stack footprint (24/12 bytes), original ESP, committed writable range and guard rejection OK"); + Console.WriteLine("self-check: selected-camera setter stub uses verified interface slot +0x1C and 16-byte maximum caller stack OK"); + Console.WriteLine("self-check: 24-byte present call arguments compare unchanged and detect a changed byte"); + Console.WriteLine("self-check: return context logging verifies remote EIP/ESP before restoring the original present call"); + Console.WriteLine("self-check: viewport bounds fit the captured pixel surface OK"); + Console.WriteLine("self-check: DDLOCK_READONLY|DONOTWAIT=0x4010; success/failure paths include Release; cleanup calls Release after Unlock regardless of result"); + Console.WriteLine("self-check: RGB565, 24-bit, 32-bit opaque PNG conversion and negative-pitch row order OK"); + return 0; + } + + private static int VtableOffset(string[] methodOrder, string method) + { + for (int i = 0; i < methodOrder.Length; i++) + if (String.Equals(methodOrder[i], method, StringComparison.Ordinal)) return i * 4; + return -1; + } + + private static int DecodeIndirectCallDisplacement(byte[] code, int offset) + { + if (code == null || offset < 0 || offset + 2 > code.Length || code[offset] != 0xFF + || ((code[offset + 1] >> 3) & 7) != 2) + throw new InvalidOperationException("Expected an x86 indirect CALL instruction."); + int mod = code[offset + 1] >> 6; + int rm = code[offset + 1] & 7; + if (mod == 1) + { + if (offset + 3 > code.Length) throw new InvalidOperationException("Truncated disp8 indirect CALL."); + return unchecked((sbyte)code[offset + 2]); + } + if (mod == 2 || (mod == 0 && rm == 5)) + { + if (offset + 6 > code.Length) throw new InvalidOperationException("Truncated disp32 indirect CALL."); + return BitConverter.ToInt32(code, offset + 2); + } + return 0; + } +} -- cgit v1.2.3