diff --git a/libs/CheatEngine.SDK.Engine/Processes/ProcessOperationStatus.cs b/libs/CheatEngine.SDK.Engine/Processes/ProcessOperationStatus.cs
index 7a692272..3623bc98 100644
--- a/libs/CheatEngine.SDK.Engine/Processes/ProcessOperationStatus.cs
+++ b/libs/CheatEngine.SDK.Engine/Processes/ProcessOperationStatus.cs
@@ -53,7 +53,7 @@ public LuaStatus LuaStatus
public static ProcessOperationStatus GlobalUnavailable =>
new(ProcessOperationStatusKind.GlobalUnavailable, LuaStatus.Ok);
- /// Gets a status for a result outside the documented process-observation shape.
+ /// Gets a status for a result outside the documented process selection or observation shape.
public static ProcessOperationStatus InvalidResult => new(ProcessOperationStatusKind.InvalidResult, LuaStatus.Ok);
///
diff --git a/libs/CheatEngine.SDK.Engine/Processes/ProcessOperationStatusKind.cs b/libs/CheatEngine.SDK.Engine/Processes/ProcessOperationStatusKind.cs
index f52f2e99..a631f25b 100644
--- a/libs/CheatEngine.SDK.Engine/Processes/ProcessOperationStatusKind.cs
+++ b/libs/CheatEngine.SDK.Engine/Processes/ProcessOperationStatusKind.cs
@@ -17,8 +17,8 @@ public enum ProcessOperationStatusKind
TargetNotAttached = 2,
///
- /// An explicit selection call returned normally, but the immediately observed process was absent or did not match
- /// the requested identifier.
+ /// An explicit selection returned no valid-handle confirmation (nil or false), or its immediately observed
+ /// process was absent or did not match the requested identifier.
///
SelectionNotConfirmed = 3,
@@ -28,7 +28,7 @@ public enum ProcessOperationStatusKind
/// A protected Lua lookup or invocation failed.
ProtectedLuaFailure = 5,
- /// The host returned a value outside the documented process-observation shape.
+ /// The host returned a value outside the documented process selection or observation shape.
InvalidResult = 6,
///
diff --git a/libs/CheatEngine.SDK.Engine/Processes/RuntimeProcessOperations.cs b/libs/CheatEngine.SDK.Engine/Processes/RuntimeProcessOperations.cs
index f0e5cf2d..0217facf 100644
--- a/libs/CheatEngine.SDK.Engine/Processes/RuntimeProcessOperations.cs
+++ b/libs/CheatEngine.SDK.Engine/Processes/RuntimeProcessOperations.cs
@@ -55,8 +55,9 @@ public static ProcessOperationStatus ObserveCurrent(out CurrentProcessObservatio
/// The positive process identifier to select.
/// The copied matching target observation only when the returned status is successful.
///
- /// The factual protected selection status. A normal openProcess return is not success by itself: success
- /// requires the next getOpenedProcessID observation to equal .
+ /// The factual protected selection status. Success requires openProcess to return boolean
+ /// , confirming CE's nonzero process handle, and the next getOpenedProcessID
+ /// observation to equal . A missing or false confirmation refuses selection.
///
/// is default or otherwise non-positive.
/// The plugin is not enabled or the calling thread has no Lua state.
@@ -304,10 +305,26 @@ private static ProcessOperationStatus TryOpenProcess(LuaState state, TargetProce
}
state.PushInteger(processId.Value);
- LuaStatus luaStatus = state.TryCall(1, 0);
- return luaStatus.IsOk
+ LuaStatus luaStatus = state.TryCall(1, 1);
+ if (!luaStatus.IsOk)
+ {
+ return ProcessOperationStatus.ProtectedLuaFailure(luaStatus);
+ }
+
+ // CE assigns the requested PID before opening its handle. Only its true return confirms both.
+ LuaType resultType = state.TypeOf(-1);
+ if (resultType == LuaType.Nil)
+ {
+ return ProcessOperationStatus.SelectionNotConfirmed;
+ }
+ if (resultType != LuaType.Boolean)
+ {
+ return ProcessOperationStatus.InvalidResult;
+ }
+
+ return state.ToBoolean(-1)
? ProcessOperationStatus.Success
- : ProcessOperationStatus.ProtectedLuaFailure(luaStatus);
+ : ProcessOperationStatus.SelectionNotConfirmed;
}
private static ProcessOperationStatus TryCallBoolean(LuaState state, LuaRef cache, ReadOnlySpan globalName,
diff --git a/libs/CheatEngine.SDK.Engine/README.md b/libs/CheatEngine.SDK.Engine/README.md
index 973c1a5a..ab0f2c77 100644
--- a/libs/CheatEngine.SDK.Engine/README.md
+++ b/libs/CheatEngine.SDK.Engine/README.md
@@ -372,7 +372,8 @@ observations `RuntimeProcessOperations.ObserveTargetArchitecture` and `TryGetCon
`RuntimeObservations.TryObserveRuntimeInfo` and `InstructionProfiles.TryObserveCurrent` also read it last and report a
difference as `TargetChanged`; instruction operations re-check it before and after their Cheat Engine call.
`TargetSelection.ObserveCurrent` and `RuntimeProcessOperations.ObserveCurrent` read it once, before their other probes,
-and `RuntimeProcessOperations.SelectAndObserve` reads it once, after `openProcess`, to confirm the selection.
+and `RuntimeProcessOperations.SelectAndObserve` first requires `openProcess` to return boolean `true`, confirming
+CE's nonzero process handle, then reads the PID once to confirm the selection. A reported PID alone is insufficient.
| Fact | CE global | SDK member | When unknown |
|-----------------------------------|------------------------------|------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------|
diff --git a/tests/CheatEngine.SDK.Engine.Tests/Processes/RuntimeProcessOperationsTests.cs b/tests/CheatEngine.SDK.Engine.Tests/Processes/RuntimeProcessOperationsTests.cs
index 0233766b..70780d4a 100644
--- a/tests/CheatEngine.SDK.Engine.Tests/Processes/RuntimeProcessOperationsTests.cs
+++ b/tests/CheatEngine.SDK.Engine.Tests/Processes/RuntimeProcessOperationsTests.cs
@@ -250,7 +250,7 @@ public void SelectAndObserve_explicit_pid_requires_the_next_ce_observation_to_ma
LuaState lua = scope.State;
EngineTest.Run(lua, """
opened = 0
- function openProcess(id) opened = id end
+ function openProcess(id) opened = id return true end
function getOpenedProcessID() return opened end
function targetIs64Bit() return true end
"""u8);
@@ -274,7 +274,7 @@ public void SelectAndObserve_unconfirmed_or_failing_selection_keeps_outcomes_dis
{
LuaState lua = mismatchScope.State;
EngineTest.Run(lua, """
- function openProcess(_) end
+ function openProcess(_) return true end
function getOpenedProcessID() return 77 end
function targetIs64Bit() error('must not be called after a mismatched PID') end
"""u8);
@@ -298,6 +298,37 @@ function targetIs64Bit() error('must not be called after a mismatched PID') end
Assert.Equal(0, failureScope.State.Top);
}
+ [Theory]
+ [InlineData("", ProcessOperationStatusKind.SelectionNotConfirmed)]
+ [InlineData("return false", ProcessOperationStatusKind.SelectionNotConfirmed)]
+ [InlineData("return 'true'", ProcessOperationStatusKind.InvalidResult)]
+ [InlineData("return 1", ProcessOperationStatusKind.InvalidResult)]
+ [InlineData("return {}", ProcessOperationStatusKind.InvalidResult)]
+ public void SelectAndObserve_rejects_an_unconfirmed_or_malformed_open_process_result(
+ string returned, ProcessOperationStatusKind expected)
+ {
+ EngineTest.RequireNativeLua();
+ using NativeLuaState state = new();
+ using HostScope scope = new(state);
+ LuaState lua = scope.State;
+ EngineTest.Run(lua, Encoding.UTF8.GetBytes($$"""
+ pidReads = 0
+ bitnessReads = 0
+ function openProcess(id) opened = id {{returned}} end
+ function getOpenedProcessID() pidReads = pidReads + 1 return opened end
+ function targetIs64Bit() bitnessReads = bitnessReads + 1 return true end
+ """));
+
+ ProcessOperationStatus status = RuntimeProcessOperations.SelectAndObserve(new TargetProcessId(42),
+ out CurrentProcessObservation observation);
+
+ Assert.Equal(expected, status.Kind);
+ Assert.Equal(default, observation);
+ Assert.Equal(0, lua.Top);
+ EngineTest.Run(lua, "assert(pidReads == 0 and bitnessReads == 0, 'unconfirmed selection was observed')"u8);
+ Assert.Equal(0, lua.Top);
+ }
+
[Fact]
public void SelectAndObserve_rejects_a_default_process_identifier_before_entering_the_lua_runtime()
{