diff --git a/tools/native-frame-capture/NativeFrameCapture.cs b/tools/native-frame-capture/NativeFrameCapture.cs index 2d2593a..47774f5 100644 --- a/tools/native-frame-capture/NativeFrameCapture.cs +++ b/tools/native-frame-capture/NativeFrameCapture.cs @@ -276,6 +276,9 @@ internal static partial class NativeFrameCapture [DllImport("kernel32.dll", SetLastError = true)] private static extern bool DebugActiveProcessStop(uint processId); + [DllImport("kernel32.dll")] + private static extern uint GetCurrentThreadId(); + [DllImport("kernel32.dll", SetLastError = true)] private static extern bool DebugSetProcessKillOnExit(bool killOnExit); @@ -1717,6 +1720,7 @@ internal static partial class NativeFrameCapture SelfCheckCameraInput(); SelfCheckNativeFogPairing(); SelfCheckRenderInvocationGates(); + SelfCheckDebugEventHandleOwnership(); uint relocated = 0x10000000u + RelocatedGlobalRva; if (relocated != 0x1079518Cu) throw new InvalidOperationException("relocation self-check failed"); bool offsetsMatch = Marshal.SizeOf(typeof(X86ContextLayout)) == X86ContextSize @@ -2179,6 +2183,194 @@ internal static partial class NativeFrameCapture throw new InvalidOperationException("Self-check failed: nullable fog JSON, unknown caller gate, or bounded sample list."); } + private static void SelfCheckDebugEventHandleOwnership() + { + BreakpointInfo owned = new BreakpointInfo { Address = 0x12345678, Armed = true }; + uint ownedStatus = FrameDebugEventContinueStatus(ExceptionDebugEvent, 0x80000003, + owned.Address, new BreakpointInfo[] { owned }); + uint unrelatedStatus = FrameDebugEventContinueStatus(ExceptionDebugEvent, 0x80000003, + 0x12345679, new BreakpointInfo[] { owned }); + if (!ShouldExplicitlyCloseDebugEventFileHandle(CreateProcessDebugEvent) + || ShouldExplicitlyCloseDebugEventFileHandle(CreateThreadDebugEvent) + || !ShouldExplicitlyCloseDebugEventFileHandle(LoadDllDebugEvent) + || ShouldExplicitlyCloseDebugEventFileHandle(ExitThreadDebugEvent) + || ShouldExplicitlyCloseDebugEventFileHandle(ExitProcessDebugEvent) + || ownedStatus != DbgContinue || unrelatedStatus != DbgExceptionNotHandled + || FrameDebugEventContinueStatus(CreateThreadDebugEvent, 0, 0, null) != DbgContinue) + throw new InvalidOperationException("Debug-event handle ownership self-check failed."); + } + + // Win32 debug-event process/thread handles are system-owned and closed on + // the matching EXIT event's ContinueDebugEvent. Only image-file handles + // are explicitly closed by the debugger. See: + // https://learn.microsoft.com/en-us/windows/win32/debug/writing-the-debugger-s-main-loop + // https://learn.microsoft.com/en-us/windows/win32/api/debugapi/nf-debugapi-continuedebugevent + private static bool ShouldExplicitlyCloseDebugEventFileHandle(uint eventCode) + { + return eventCode == CreateProcessDebugEvent || eventCode == LoadDllDebugEvent; + } + + private static void CloseExplicitDebugEventFileHandle(IntPtr eventBuffer, uint eventCode) + { + if (!ShouldExplicitlyCloseDebugEventFileHandle(eventCode)) return; + IntPtr handle = Marshal.ReadIntPtr(eventBuffer, 12); + if (handle != IntPtr.Zero && !CloseHandle(handle)) + Log("DEBUG_EVENT_FILE_HANDLE_CLOSE_FAILED code=" + eventCode + " offset=12" + + " error=" + Marshal.GetLastWin32Error()); + } + + private static uint FrameDebugEventContinueStatus(uint eventCode, uint exceptionCode, + uint exceptionAddress, BreakpointInfo[] breakpoints) + { + if (eventCode != ExceptionDebugEvent) return DbgContinue; + if (exceptionCode == 0x80000003 && breakpoints != null) + { + for (int i = 0; i < breakpoints.Length; i++) + if (breakpoints[i] != null && breakpoints[i].Armed + && breakpoints[i].Address == exceptionAddress) + return DbgContinue; + } + return DbgExceptionNotHandled; + } + + private static bool VerifyFrameTerminationIdentity(Process identity, uint processId, + string expectedPath, long expectedStartTicks, out string evidence) + { + evidence = "identity_unavailable"; + try + { + if (identity == null) return false; + identity.Refresh(); + if (identity.HasExited) { evidence = "already_exited"; return false; } + string actualPath = Path.GetFullPath(identity.MainModule.FileName); + long actualTicks = identity.StartTime.ToUniversalTime().Ticks; + if ((uint)identity.Id != processId || identity.ProcessName != "iron_3d" + || actualTicks != expectedStartTicks + || !String.Equals(actualPath, expectedPath, StringComparison.OrdinalIgnoreCase)) + { + evidence = "pid=" + identity.Id + ";path=" + actualPath + ";startTicks=" + actualTicks; + return false; + } + string hash = HashFile(actualPath); + if (!String.Equals(hash, ExpectedSha256[GameExe], StringComparison.OrdinalIgnoreCase)) + { + evidence = "pid=" + processId + ";path=" + actualPath + ";startTicks=" + actualTicks + ";sha256=" + hash; + return false; + } + evidence = "pid=" + processId + ";path=" + actualPath + ";startTicks=" + actualTicks + ";sha256=" + hash; + return true; + } + catch (Exception error) + { + evidence = error.GetType().Name + ":" + error.Message; + return false; + } + } + + private static bool DrainFrameTerminationEvents(IntPtr process, IntPtr eventBuffer, uint processId, + ref bool eventOutstanding, ref uint outstandingPid, ref uint outstandingTid, + ref uint outstandingCode, ref uint outstandingContinueStatus, ref bool outstandingFileHandleClosed, + BreakpointInfo[] breakpoints, out string evidence) + { + DateTime deadline = DateTime.UtcNow.AddSeconds(5); + bool targetExitEventContinued = false; + uint drainThreadId = GetCurrentThreadId(); + if (eventOutstanding) + { + targetExitEventContinued = outstandingCode == ExitProcessDebugEvent && outstandingPid == processId; + if (!outstandingFileHandleClosed) + { + CloseExplicitDebugEventFileHandle(eventBuffer, outstandingCode); + outstandingFileHandleClosed = true; + } + if (!ContinueDebugEvent(outstandingPid, outstandingTid, outstandingContinueStatus)) + { + int error = Marshal.GetLastWin32Error(); + evidence = "continue_outstanding_failed;code=" + outstandingCode + ";pid=" + outstandingPid + + ";tid=" + outstandingTid + ";status=0x" + outstandingContinueStatus.ToString("X8") + + ";osThreadId=" + GetCurrentThreadId() + ";error=" + error; + return false; + } + Log("FRAME_TERMINATION_OUTSTANDING_EVENT_CONTINUED code=" + outstandingCode + + " pid=" + outstandingPid + " tid=" + outstandingTid + + " status=0x" + outstandingContinueStatus.ToString("X8") + + " osThreadId=" + GetCurrentThreadId() + " targetExitEvent=" + targetExitEventContinued); + eventOutstanding = false; + outstandingPid = outstandingTid = outstandingCode = 0; + outstandingContinueStatus = DbgContinue; + outstandingFileHandleClosed = false; + } + + while (!targetExitEventContinued && DateTime.UtcNow < deadline) + { + uint waitThreadId = GetCurrentThreadId(); + int remaining = (int)Math.Max(1, Math.Min(250, (deadline - DateTime.UtcNow).TotalMilliseconds)); + if (!WaitForDebugEvent(eventBuffer, (uint)remaining)) + { + int error = Marshal.GetLastWin32Error(); + if (error == 121 || error == 258) continue; + evidence = "wait_for_exit_event_failed;osThreadId=" + GetCurrentThreadId() + ";error=" + error; + return false; + } + + uint code = unchecked((uint)Marshal.ReadInt32(eventBuffer, 0)); + uint pid = unchecked((uint)Marshal.ReadInt32(eventBuffer, 4)); + uint tid = unchecked((uint)Marshal.ReadInt32(eventBuffer, 8)); + uint exceptionCode = code == ExceptionDebugEvent + ? unchecked((uint)Marshal.ReadInt32(eventBuffer, 12)) : 0; + uint exceptionAddress = code == ExceptionDebugEvent + ? unchecked((uint)Marshal.ReadInt32(eventBuffer, 24)) : 0; + uint status = FrameDebugEventContinueStatus(code, exceptionCode, exceptionAddress, breakpoints); + eventOutstanding = true; + outstandingPid = pid; + outstandingTid = tid; + outstandingCode = code; + outstandingContinueStatus = status; + outstandingFileHandleClosed = false; + Log("FRAME_TERMINATION_DEBUG_EVENT code=" + code + " pid=" + pid + " tid=" + tid + + " waitOsThreadId=" + waitThreadId + " continueStatus=0x" + status.ToString("X8") + + (code == ExceptionDebugEvent ? " exception=0x" + exceptionCode.ToString("X8") + + " address=0x" + exceptionAddress.ToString("X8") : "")); + CloseExplicitDebugEventFileHandle(eventBuffer, code); + outstandingFileHandleClosed = true; + uint continueThreadId = GetCurrentThreadId(); + if (!ContinueDebugEvent(pid, tid, status)) + { + int error = Marshal.GetLastWin32Error(); + evidence = "continue_exit_drain_event_failed;code=" + code + ";pid=" + pid + ";tid=" + tid + + ";status=0x" + status.ToString("X8") + ";osThreadId=" + continueThreadId + ";error=" + error; + return false; + } + Log("FRAME_TERMINATION_DEBUG_EVENT_CONTINUED code=" + code + " pid=" + pid + " tid=" + tid + + " status=0x" + status.ToString("X8") + " osThreadId=" + continueThreadId); + eventOutstanding = false; + outstandingPid = outstandingTid = outstandingCode = 0; + outstandingContinueStatus = DbgContinue; + outstandingFileHandleClosed = false; + if (code == ExitProcessDebugEvent && pid == processId) targetExitEventContinued = true; + } + + if (!targetExitEventContinued) + { + evidence = "target_exit_process_event_not_drained_within_5s;osThreadId=" + drainThreadId; + return false; + } + uint waitResult = WaitForSingleObject(process, 2000); + uint exitCode; + bool exitRead = GetExitCodeProcess(process, out exitCode); + if (waitResult != 0) + { + evidence = "exit_event_continued_but_process_not_signaled;waitResult=0x" + waitResult.ToString("X8") + + ";exitCode=" + (exitRead ? "0x" + exitCode.ToString("X8") : "unreadable") + + ";osThreadId=" + GetCurrentThreadId(); + return false; + } + evidence = "targetExitEventContinued=true;waitResult=0x" + waitResult.ToString("X8") + + ";exitCode=" + (exitRead ? "0x" + exitCode.ToString("X8") : "unreadable") + + ";osThreadId=" + GetCurrentThreadId(); + return exitRead; + } + private static int Main(string[] args) { try @@ -2284,6 +2476,7 @@ internal static partial class NativeFrameCapture bool fatal = false; bool processExited = false; bool processExitUnconfirmed = false; + int detachError = 0; bool captured = false; SurfaceReadback completedSurface = null; FrameSnapshot completedFrame = null; @@ -2310,6 +2503,13 @@ internal static partial class NativeFrameCapture PendingStep pendingStep = null; RemoteReadbackSession remote = null; RemoteCameraSetterSession cameraSetter = null; + bool eventOutstanding = false, outstandingFileHandleClosed = false; + uint outstandingPid = 0, outstandingTid = 0, outstandingCode = 0, outstandingContinueStatus = DbgContinue; + uint attachOsThreadId = 0, lastWaitOsThreadId = 0, lastContinueOsThreadId = 0; + uint debugEventsSeen = 0, debugEventsContinued = 0, lastDebugEventCode = 0; + uint debugCreateThreadEvents = 0, debugExitThreadEvents = 0, debugLoadDllEvents = 0; + uint debugExceptionEvents = 0, debugSingleStepEvents = 0, lastDebugEventPid = 0, lastDebugEventTid = 0; + const uint ProcessAccessForFrameCapture = 0x0001 | 0x0008 | 0x0010 | 0x0020 | 0x0400 | 0x00100000; FrameSnapshot frame = new FrameSnapshot(); frame.SelectedCameraRequested = selectedCameraInput != null; SelectedCameraState selectedCamera = selectedCameraInput == null ? null @@ -2321,19 +2521,27 @@ internal static partial class NativeFrameCapture || !String.Equals(Path.GetFullPath(identityProcess.MainModule.FileName), expectedPath, StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("The retained process identity changed before debugger attach."); + process = OpenProcess(ProcessAccessForFrameCapture, false, processId); + if (process == IntPtr.Zero) throw new InvalidOperationException("OpenProcess failed: " + Marshal.GetLastWin32Error()); + eventBuffer = Marshal.AllocHGlobal(128); + attachOsThreadId = GetCurrentThreadId(); + Log("DEBUG_ATTACH_REQUEST osThreadId=" + attachOsThreadId + " pid=" + processId + + " processAccess=0x" + ProcessAccessForFrameCapture.ToString("X8") + + " openProcessBeforeAttach=true threadContextAccess=0x" + (ThreadGetContext | ThreadSetContext).ToString("X8")); if (!DebugActiveProcess(processId)) throw new InvalidOperationException("DebugActiveProcess failed: " + Marshal.GetLastWin32Error()); attached = true; + Log("DEBUG_ATTACH_RESULT success=true osThreadId=" + GetCurrentThreadId() + " pid=" + processId); if (identityProcess.HasExited || identityProcess.StartTime.ToUniversalTime().Ticks != expectedStartTicks || !String.Equals(Path.GetFullPath(identityProcess.MainModule.FileName), expectedPath, StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("The retained process identity changed during debugger attach."); if (!DebugSetProcessKillOnExit(false)) throw new InvalidOperationException("DebugSetProcessKillOnExit(false) failed: " + Marshal.GetLastWin32Error()); - process = OpenProcess(ProcessTerminate | ProcessVmOperation | ProcessVmRead | ProcessVmWrite - | ProcessQueryInformation | Synchronize, false, processId); - if (process == IntPtr.Zero) throw new InvalidOperationException("OpenProcess failed: " + Marshal.GetLastWin32Error()); - eventBuffer = Marshal.AllocHGlobal(128); + Log("DEBUG_KILL_ON_EXIT_DISABLED osThreadId=" + GetCurrentThreadId()); + Log("DEBUG_ACCESS processMask=0x" + ProcessAccessForFrameCapture.ToString("X8") + + " threadContextMask=0x" + (ThreadGetContext | ThreadSetContext).ToString("X8") + + " remoteThreadCreation=false"); Log("frame capture attached; waiting for verified camera/projection/present boundaries for " + timeoutSeconds + " seconds"); @@ -2379,6 +2587,7 @@ internal static partial class NativeFrameCapture fatal = true; continue; } + lastWaitOsThreadId = GetCurrentThreadId(); if (!WaitForDebugEvent(eventBuffer, 1000)) { int waitError = Marshal.GetLastWin32Error(); @@ -2389,6 +2598,34 @@ internal static partial class NativeFrameCapture uint eventCode = unchecked((uint)Marshal.ReadInt32(eventBuffer, 0)); uint eventPid = unchecked((uint)Marshal.ReadInt32(eventBuffer, 4)); uint eventTid = unchecked((uint)Marshal.ReadInt32(eventBuffer, 8)); + uint eventExceptionCode = eventCode == ExceptionDebugEvent + ? unchecked((uint)Marshal.ReadInt32(eventBuffer, 12)) : 0; + uint eventExceptionAddress = eventCode == ExceptionDebugEvent + ? unchecked((uint)Marshal.ReadInt32(eventBuffer, 24)) : 0; + eventOutstanding = true; + outstandingPid = eventPid; + outstandingTid = eventTid; + outstandingCode = eventCode; + outstandingContinueStatus = DbgContinue; + outstandingFileHandleClosed = false; + debugEventsSeen++; + lastDebugEventCode = eventCode; + lastDebugEventPid = eventPid; + lastDebugEventTid = eventTid; + if (eventCode == CreateThreadDebugEvent) debugCreateThreadEvents++; + else if (eventCode == ExitThreadDebugEvent) debugExitThreadEvents++; + else if (eventCode == LoadDllDebugEvent) debugLoadDllEvents++; + else if (eventCode == ExceptionDebugEvent) + { + debugExceptionEvents++; + if (eventExceptionCode == 0x80000004) debugSingleStepEvents++; + } + if (eventCode != ExceptionDebugEvent || eventExceptionCode != 0x80000004) + Log("DEBUG_EVENT sequence=" + debugEventsSeen + " code=" + eventCode + + " pid=" + eventPid + " tid=" + eventTid + + " exception=0x" + eventExceptionCode.ToString("X8") + + " address=0x" + eventExceptionAddress.ToString("X8") + + " waitOsThreadId=" + lastWaitOsThreadId); uint continueStatus = DbgContinue; bool shouldStop = false; BreakpointInfo hitBreakpoint = null; @@ -2398,21 +2635,17 @@ internal static partial class NativeFrameCapture if (eventCode == CreateProcessDebugEvent) { IntPtr imageFile = Marshal.ReadIntPtr(eventBuffer, 12); - IntPtr processHandle = Marshal.ReadIntPtr(eventBuffer, 16); - IntPtr threadHandle = Marshal.ReadIntPtr(eventBuffer, 20); string imagePath = PathForHandle(imageFile); if (imagePath.Length != 0 && !String.Equals(imagePath, NormalizePath(expectedPath), StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("CREATE_PROCESS image path mismatch: " + imagePath); - if (processHandle != IntPtr.Zero) CloseHandle(processHandle); - if (threadHandle != IntPtr.Zero) CloseHandle(threadHandle); - if (imageFile != IntPtr.Zero) CloseHandle(imageFile); - Log("CREATE_PROCESS path=" + imagePath); + CloseExplicitDebugEventFileHandle(eventBuffer, eventCode); + outstandingFileHandleClosed = true; + Log("CREATE_PROCESS path=" + imagePath + + " debuggerOwnedProcessInitialThreadHandles=retainedForSystemExitCleanup"); } else if (eventCode == CreateThreadDebugEvent) { - IntPtr threadHandle = Marshal.ReadIntPtr(eventBuffer, 12); - if (threadHandle != IntPtr.Zero) CloseHandle(threadHandle); - Log("CREATE_THREAD tid=" + eventTid + " handleClosed=" + (threadHandle != IntPtr.Zero)); + Log("CREATE_THREAD tid=" + eventTid + " debuggerOwnedThreadHandle=retainedForSystemExitCleanup"); } else if (eventCode == ExitThreadDebugEvent) { @@ -2510,7 +2743,11 @@ internal static partial class NativeFrameCapture presentOriginalByte, ref presentBreakpoint); } } - finally { if (imageFile != IntPtr.Zero) CloseHandle(imageFile); } + finally + { + CloseExplicitDebugEventFileHandle(eventBuffer, eventCode); + outstandingFileHandleClosed = true; + } } else if (eventCode == ExceptionDebugEvent) { @@ -3295,14 +3532,28 @@ internal static partial class NativeFrameCapture } } + outstandingContinueStatus = continueStatus; + lastContinueOsThreadId = GetCurrentThreadId(); if (!ContinueDebugEvent(eventPid, eventTid, continueStatus)) { fatal = true; - Log("FATAL ContinueDebugEvent failed=" + Marshal.GetLastWin32Error()); + int continueError = Marshal.GetLastWin32Error(); + Log("FATAL ContinueDebugEvent failed=" + continueError + " code=" + eventCode + + " pid=" + eventPid + " tid=" + eventTid + " status=0x" + continueStatus.ToString("X8") + + " osThreadId=" + lastContinueOsThreadId); if (process != IntPtr.Zero && !TerminateProcess(process, 0xE009)) Log("FATAL ContinueDebugEvent recovery terminate failed=" + Marshal.GetLastWin32Error()); break; } + eventOutstanding = false; + outstandingPid = outstandingTid = outstandingCode = 0; + outstandingContinueStatus = DbgContinue; + outstandingFileHandleClosed = false; + debugEventsContinued++; + if (eventCode != ExceptionDebugEvent || eventExceptionCode != 0x80000004) + Log("DEBUG_CONTINUE_OK sequence=" + debugEventsContinued + " code=" + eventCode + + " pid=" + eventPid + " tid=" + eventTid + " status=0x" + continueStatus.ToString("X8") + + " osThreadId=" + lastContinueOsThreadId); if (shouldStop || CanStopCapture(captured, stopAfterAttempt, remote != null || cameraSetter != null, pendingStep != null, selectedCamera != null && selectedCamera.Applied && !selectedCamera.RestoreVerified)) break; } @@ -3316,7 +3567,7 @@ internal static partial class NativeFrameCapture { bool cameraMutationUnrestored = selectedCamera != null && selectedCamera.Applied && !selectedCamera.RestoreVerified; if (!processExited && process != IntPtr.Zero && (remote != null || cameraSetter != null || pendingStep != null - || cameraMutationUnrestored + || cameraMutationUnrestored || eventOutstanding || cameraBreakpoint != null && cameraBreakpoint.Armed || projectionBreakpoint != null && projectionBreakpoint.Armed || returnBreakpoint != null && returnBreakpoint.Armed @@ -3328,26 +3579,63 @@ internal static partial class NativeFrameCapture string state = remote != null ? "REMOTE_STUB_IN_FLIGHT" : (cameraSetter != null ? "CAMERA_SETTER_IN_FLIGHT" : (cameraMutationUnrestored ? "SELECTED_CAMERA_MUTATION_NOT_RESTORED" : (pendingStep != null ? "SINGLE_STEP_IN_FLIGHT" : "ARMED_BREAKPOINTS_WITHOUT_STOPPED_EVENT"))); - Log("FINAL_" + state + "; terminating only the path/tick/hash-verified scratch process before detach"); - if (!TerminateProcess(process, 0xE006)) - Log("FINAL_TERMINATE_FAILED error=" + Marshal.GetLastWin32Error()); - if (WaitForSingleObject(process, 2000) == 0) processExited = true; + string identityEvidence; + if (!VerifyFrameTerminationIdentity(identityProcess, processId, expectedPath, + expectedStartTicks, out identityEvidence)) + { + processExitUnconfirmed = true; + fatal = true; + Log("FINAL_TERMINATION_REFUSED identity=" + identityEvidence + " state=" + state); + } else { - fatal = true; - processExitUnconfirmed = true; - Log("FATAL scratch termination was not confirmed before detach"); + Log("FINAL_" + state + "; terminating verified scratch before detach identity=" + identityEvidence); + if (!TerminateProcess(process, 0xE006)) + Log("FINAL_TERMINATE_FAILED error=" + Marshal.GetLastWin32Error()); + string drainEvidence; + if (DrainFrameTerminationEvents(process, eventBuffer, processId, + ref eventOutstanding, ref outstandingPid, ref outstandingTid, ref outstandingCode, + ref outstandingContinueStatus, ref outstandingFileHandleClosed, + new BreakpointInfo[] { cameraBreakpoint, projectionBreakpoint, returnBreakpoint, + atmosphereBreakpoint, fogConsumerBreakpoint, presentBreakpoint, missionBreakpoint }, + out drainEvidence)) + { + processExited = true; + Log("FINAL_TERMINATION_DRAIN_CONFIRMED " + drainEvidence); + } + else + { + fatal = true; + processExitUnconfirmed = true; + Log("FATAL_FINAL_TERMINATION_DRAIN_FAILED " + drainEvidence); + } } } if (attached && !processExitUnconfirmed) { + Log("DEBUG_DETACH_REQUEST osThreadId=" + GetCurrentThreadId() + + " attachOsThreadId=" + attachOsThreadId + " processId=" + processId + + " eventOutstanding=" + eventOutstanding + " outstandingCode=" + outstandingCode + + " outstandingPid=" + outstandingPid + " outstandingTid=" + outstandingTid + + " outstandingContinueStatus=0x" + outstandingContinueStatus.ToString("X8") + + " waitOsThreadId=" + lastWaitOsThreadId + " lastContinueOsThreadId=" + lastContinueOsThreadId + + " lastEventCode=" + lastDebugEventCode + " lastEventPid=" + lastDebugEventPid + + " lastEventTid=" + lastDebugEventTid + " eventsSeen=" + debugEventsSeen + + " eventsContinued=" + debugEventsContinued + " createThreads=" + debugCreateThreadEvents + + " exitThreads=" + debugExitThreadEvents + " dllLoads=" + debugLoadDllEvents + + " exceptions=" + debugExceptionEvents + " singleSteps=" + debugSingleStepEvents); detached = DebugActiveProcessStop(processId); - if (detached) Log("DEBUG_DETACHED"); + if (detached) Log("DEBUG_DETACHED osThreadId=" + GetCurrentThreadId()); else { fatal = true; - int detachError = Marshal.GetLastWin32Error(); - Log("FATAL DebugActiveProcessStop failed=" + detachError); + detachError = Marshal.GetLastWin32Error(); + Log("FATAL DebugActiveProcessStop failed=" + detachError + + " osThreadId=" + GetCurrentThreadId() + " eventOutstanding=" + eventOutstanding + + " eventsSeen=" + debugEventsSeen + " eventsContinued=" + debugEventsContinued + + " lastEventCode=" + lastDebugEventCode + " lastEventPid=" + lastDebugEventPid + + " lastEventTid=" + lastDebugEventTid + " lastWaitOsThreadId=" + lastWaitOsThreadId + + " lastContinueOsThreadId=" + lastContinueOsThreadId); if (process != IntPtr.Zero) { uint waitBeforeRecovery = WaitForSingleObject(process, 0); @@ -3360,22 +3648,41 @@ internal static partial class NativeFrameCapture + " waitError=" + waitBeforeError + " exitError=" + exitBeforeError); if (waitBeforeRecovery == 0x00000102 && exitRead && exitBeforeRecovery == 0x00000103) { - Log("DETACH_FAILURE_TARGET_STILL_ACTIVE; terminating only exact verified scratch after state snapshot"); - if (!TerminateProcess(process, 0xE00A)) + string terminationEvidence; + if (!VerifyFrameTerminationIdentity(identityProcess, processId, expectedPath, + expectedStartTicks, out terminationEvidence)) { - int terminateError = Marshal.GetLastWin32Error(); - Log("DETACH_FAILURE_TERMINATE_FAILED error=" + terminateError); + processExitUnconfirmed = true; + Log("DETACH_FAILURE_TERMINATION_REFUSED identity=" + terminationEvidence); + } + else + { + Log("DETACH_FAILURE_TARGET_STILL_ACTIVE; terminating verified scratch identity=" + terminationEvidence); + if (!TerminateProcess(process, 0xE00A)) + Log("DETACH_FAILURE_TERMINATE_FAILED error=" + Marshal.GetLastWin32Error()); + string drainEvidence; + if (DrainFrameTerminationEvents(process, eventBuffer, processId, + ref eventOutstanding, ref outstandingPid, ref outstandingTid, ref outstandingCode, + ref outstandingContinueStatus, ref outstandingFileHandleClosed, + new BreakpointInfo[] { cameraBreakpoint, projectionBreakpoint, returnBreakpoint, + atmosphereBreakpoint, fogConsumerBreakpoint, presentBreakpoint, missionBreakpoint }, + out drainEvidence)) + { + processExited = true; + Log("DETACH_FAILURE_TERMINATION_DRAIN_CONFIRMED " + drainEvidence); + uint detachThreadId = GetCurrentThreadId(); + bool detachedAfterExit = DebugActiveProcessStop(processId); + int detachAfterExitError = detachedAfterExit ? 0 : Marshal.GetLastWin32Error(); + Log("DEBUG_DETACH_AFTER_TERMINATION success=" + detachedAfterExit + + " osThreadId=" + detachThreadId + " error=" + detachAfterExitError); + detached = detachedAfterExit; + } + else + { + processExitUnconfirmed = true; + Log("DETACH_FAILURE_TERMINATION_DRAIN_FAILED " + drainEvidence); + } } - uint waitAfterRecovery = WaitForSingleObject(process, 2000); - int waitAfterError = waitAfterRecovery == 0xFFFFFFFF ? Marshal.GetLastWin32Error() : 0; - uint exitAfterRecovery; - bool exitAfterRead = GetExitCodeProcess(process, out exitAfterRecovery); - int exitAfterError = exitAfterRead ? 0 : Marshal.GetLastWin32Error(); - Log("PROCESS_STATE_AFTER_DETACH_FAILURE_RECOVERY waitResult=0x" + waitAfterRecovery.ToString("X8") - + " exitCode=" + (exitAfterRead ? "0x" + exitAfterRecovery.ToString("X8") : "unreadable") - + " waitError=" + waitAfterError + " exitError=" + exitAfterError); - if (waitAfterRecovery == 0) processExited = true; - else processExitUnconfirmed = true; } } } diff --git a/tools/native-frame-capture/README.md b/tools/native-frame-capture/README.md index 5264d6a..4df05d0 100644 --- a/tools/native-frame-capture/README.md +++ b/tools/native-frame-capture/README.md @@ -49,7 +49,9 @@ if ($LASTEXITCODE -ne 0) { throw 'Selected-camera capture failed; inspect its lo This verifies repeatable camera handling, not deterministic whole-scene pixels. The helper has no setter for FOV, near/far planes, or fog, and does not freeze simulation state; `atmosphere_seconds` remains an observed phase sample rather than a restored simulation clock. If the callsite probe cannot verify a mission path, `mission_path` remains `null` and `mission_identity` remains `unknown`. -For a second capture, reuse the still-running PID only after the first run detached successfully, and choose a new output basename. Reuse the same validated camera input if comparing the same pose. The helper rejects any existing JSON, PNG, or log with the chosen basename and never overwrites those files. After a successful one-shot capture it detaches and leaves the scratch game running. If recovery requires termination, it stops only the exact path/tick/hash-verified scratch process. +For a second capture, reuse the still-running PID only after the first run detached successfully, and choose a new output basename. Reuse the same validated camera input if comparing the same pose. The helper rejects any existing JSON, PNG, or log with the chosen basename and never overwrites those files. After a successful one-shot capture it detaches and leaves the scratch game running. It explicitly closes only image-file handles from `CREATE_PROCESS` and `LOAD_DLL`; the system owns the process/thread handles supplied by `CREATE_PROCESS` and `CREATE_THREAD` until the corresponding exit event is continued. If guarded recovery must terminate the scratch process, the helper drains its outstanding and queued debug events through `EXIT_PROCESS` before detaching. It withholds output when process exit cannot be confirmed. + +Live verification on 2026-10-12 produced two sequential AutoDemo captures on PID 15792. Both detached successfully and left the same process running. The first paired 157 of 157 native fog calls; the second paired 250 of 250. Both had zero rejected, cross-thread, or camera/projection-mismatch samples, and both verified the Terrain module hash and same-generation camera/projection pairing. To compare a viewer frame with a native capture, use the same phase JSON for the camera and the PNG conversion. When `atmosphere_seconds` is present, the viewer uses it unless `--atmosphere-seconds` is given, and holds the atmosphere schedule at that sample for the fixed-camera readback. Choose fresh `viewer.bin` and `viewer.png` paths because the viewer writes the raw file and conversion refuses to overwrite an existing PNG.