Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,18 @@ public bool IsMainThread
/// <remarks>
/// <paramref name="cancellationToken" /> is observed before dispatch admission only. It never attempts to
/// interrupt a callback or Lua primitive that has already begun on Cheat Engine's main thread.
/// A <see langword="false" /> result represents cancellation or a dispatcher admission/infrastructure failure.
/// Exceptions thrown by <paramref name="callback" /> are rethrown unchanged.
/// </remarks>
public bool TryInvoke(Action callback, out CheatEngineFailure failure,
CancellationToken cancellationToken = default);

/// <summary>Runs a callback on the captured main thread and returns its managed result.</summary>
/// <remarks>Cancellation is observed before dispatch admission and never while the callback is running.</remarks>
/// <remarks>
/// Cancellation is observed before dispatch admission and never while the callback is running. A
/// <see langword="false" /> result represents cancellation or a dispatcher admission/infrastructure failure;
/// exceptions thrown by <paramref name="callback" /> are rethrown unchanged.
/// </remarks>
public bool TryInvoke<T>(Func<T> callback, [MaybeNullWhen(false)] out T result,
out CheatEngineFailure failure,
CancellationToken cancellationToken = default);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@ public ProcessEnumerationResult GetProcesses(ProcessEnumerationRequest request,
CancellationToken cancellationToken = default);

/// <summary>Tries to get a copied snapshot of the currently selected target process.</summary>
/// <remarks>
/// Returns <see cref="CheatEngineFailureKind.TargetNotAttached" /> when Cheat Engine has no selected target or
/// its selected target is no longer available in local process metadata. An inconsistent local metadata result
/// returns <see cref="CheatEngineFailureKind.InvalidHostResult" />. Invalid arguments, lifecycle failures, and
/// unexpected implementation exceptions are not converted into a <c>Try</c> result.
/// </remarks>
public bool TryGetCurrent(out ProcessSnapshot snapshot, out CheatEngineFailure failure,
CancellationToken cancellationToken = default);

Expand All @@ -25,6 +31,11 @@ public bool TryGetCurrent(out ProcessSnapshot snapshot, out CheatEngineFailure f
/// Re-reads Cheat Engine's selected target and advances the selection epoch when its PID or observed
/// architecture changed.
/// </summary>
/// <remarks>
/// Returns <see cref="CheatEngineFailureKind.TargetNotAttached" /> and invalidates an observed selection when
/// the selected target is absent or no longer has local process metadata. This is an observation, not an
/// atomic process-lifetime guarantee.
/// </remarks>
public bool TryRefresh(out ProcessSnapshot snapshot, out CheatEngineFailure failure,
CancellationToken cancellationToken = default);

Expand Down
126 changes: 92 additions & 34 deletions libs/CheatEngine.Client.Core/Domains/ProcessClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -157,25 +157,35 @@ public bool TryAttach(
throw new ArgumentOutOfRangeException(nameof(processId));
}

ProcessSnapshot captured = default;
CurrentProcessCapture captured = default;
if (!_dispatcher.TryInvoke(
() =>
{
_host.OpenProcess(processId.Value);
captured = CaptureCurrent("Processes.Attach");
if (captured.Id != processId)
{
throw new InvalidOperationException("Cheat Engine did not select the requested process.");
}
},
out failure,
cancellationToken))
() =>
{
_host.OpenProcess(processId.Value);
captured = CaptureCurrent("Processes.Attach");
},
out failure,
cancellationToken))
{
snapshot = default;
return false;
}

snapshot = captured;
if (!TryGetCapturedSnapshot(captured, "Processes.Attach", out snapshot, out failure))
{
return false;
}

if (snapshot.Id != processId)
{
snapshot = default;
failure = new CheatEngineFailure(
CheatEngineFailureKind.OperationRejected,
"Processes.Attach",
"Cheat Engine did not select the requested process.");
return false;
}

return true;
}

Expand Down Expand Up @@ -336,53 +346,77 @@ private bool TryReadCurrent(
out CheatEngineFailure failure,
CancellationToken cancellationToken)
{
ProcessSnapshot captured = default;
CurrentProcessCapture captured = default;
if (!_dispatcher.TryInvoke(() => captured = CaptureCurrent(operation), out failure, cancellationToken))
{
snapshot = default;
if (failure.Kind == CheatEngineFailureKind.OperationRejected)
{
failure = new CheatEngineFailure(
CheatEngineFailureKind.TargetNotAttached,
operation,
failure.Message,
failure.Exception);
}

return false;
}

snapshot = captured;
return true;
return TryGetCapturedSnapshot(captured, operation, out snapshot, out failure);
}

private ProcessSnapshot CaptureCurrent(string operation)
private CurrentProcessCapture CaptureCurrent(string operation)
{
long processId = _host.GetOpenedProcessId();
if (processId is <= 0 or > int.MaxValue)
{
ClearObservedSelection(operation);
throw new InvalidOperationException("Cheat Engine has no selected local target process.");
return new CurrentProcessCapture(CurrentProcessCaptureFailure.NoTargetSelected);
}

TargetProcessId id = new(checked((int) processId));
if (!_host.TryGetLocalProcess(id.Value, out LocalProcessInfo process))
{
ClearObservedSelection(operation);
throw new InvalidOperationException("The selected process no longer exists locally.");
return new CurrentProcessCapture(CurrentProcessCaptureFailure.LocalProcessUnavailable);
}

if (process.Id != id.Value)
{
throw new EngineMarshallingException(
"Processes.GetCurrent",
EngineMarshallingDirection.Result,
"metadata for the selected process identifier",
$"metadata for process {process.Id}");
return new CurrentProcessCapture(CurrentProcessCaptureFailure.InvalidLocalMetadata, process.Id);
}

CheatEngineArchitecture architecture = TryGetTargetArchitecture();
return ObserveSelection(id, process, architecture, operation);
return new CurrentProcessCapture(ObserveSelection(id, process, architecture, operation));
}

private static bool TryGetCapturedSnapshot(
CurrentProcessCapture captured,
string operation,
out ProcessSnapshot snapshot,
out CheatEngineFailure failure)
{
if (captured.Failure == CurrentProcessCaptureFailure.None)
{
snapshot = captured.Snapshot;
failure = default;
return true;
}

snapshot = default;
failure = captured.Failure switch
{
CurrentProcessCaptureFailure.NoTargetSelected => new CheatEngineFailure(
CheatEngineFailureKind.TargetNotAttached,
operation,
"Cheat Engine has no selected local target process."),
CurrentProcessCaptureFailure.LocalProcessUnavailable => new CheatEngineFailure(
CheatEngineFailureKind.TargetNotAttached,
operation,
"The selected target process is no longer available in local process metadata."),
CurrentProcessCaptureFailure.InvalidLocalMetadata => new CheatEngineFailure(
CheatEngineFailureKind.InvalidHostResult,
operation,
"The selected target's local process metadata did not match its identifier.",
new EngineMarshallingException(
operation,
EngineMarshallingDirection.Result,
"metadata for the selected process identifier",
$"metadata for process {captured.ObservedProcessId}")),
_ => throw new InvalidOperationException("The current process capture produced an unknown failure.")
};
return false;
}

private CheatEngineArchitecture TryGetTargetArchitecture()
Expand Down Expand Up @@ -518,4 +552,28 @@ private static CheatEngineFailure Cancelled(string operation)
}

private readonly record struct ProcessSelection(TargetProcessId Id, CheatEngineArchitecture Architecture);

private readonly record struct CurrentProcessCapture(
ProcessSnapshot Snapshot,
CurrentProcessCaptureFailure Failure,
int ObservedProcessId)
{
internal CurrentProcessCapture(ProcessSnapshot snapshot)
: this(snapshot, CurrentProcessCaptureFailure.None, 0)
{
}

internal CurrentProcessCapture(CurrentProcessCaptureFailure failure, int observedProcessId = 0)
: this(default, failure, observedProcessId)
{
}
}

private enum CurrentProcessCaptureFailure
{
None,
NoTargetSelected,
LocalProcessUnavailable,
InvalidLocalMetadata
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -57,16 +57,18 @@ public void CallbackExceptionsAreRethrownWithoutBeingClassifiedAsHostFailures()
using ControlledCoreLifetimeContext context = new();
using CoreLifetime lifetime = new(context);
SdkMainThreadDispatcher dispatcher = new(lifetime, new RecordingMainThreadInvoker());
InvalidOperationException expectedActionException = new("action callback");
InvalidOperationException expectedFunctionException = new("function callback");

InvalidOperationException actionException = Assert.Throws<InvalidOperationException>(() =>
dispatcher.TryInvoke(static () => throw new InvalidOperationException("action callback"),
dispatcher.TryInvoke(() => throw expectedActionException,
out CheatEngineFailure _, TestContext.Current.CancellationToken));
InvalidOperationException functionException = Assert.Throws<InvalidOperationException>(() =>
dispatcher.TryInvoke<int>(static () => throw new InvalidOperationException("function callback"), out _,
dispatcher.TryInvoke<int>(() => throw expectedFunctionException, out _,
out CheatEngineFailure _, TestContext.Current.CancellationToken));

Assert.Equal("action callback", actionException.Message);
Assert.Equal("function callback", functionException.Message);
Assert.Same(expectedActionException, actionException);
Assert.Same(expectedFunctionException, functionException);
}

[Fact]
Expand Down
Loading