diff --git a/CHANGELOG.md b/CHANGELOG.md index 2407964..2a9cde5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.2.2] - 2026-07-23 + +### Added + +- Added MSFS 2024 modular SimObject livery support through `CreateObjectWithLiveryAsync` and the extended SimConnect object-creation API. +- Added native send ID and parameter index details to `SimConnectErrorEventArgs` for richer error diagnostics. +- Added a controlled SimObject creation diagnostic and a dedicated unit-test project covering creation success, failure correlation, timeout, and cleanup. + +### Changed + +- AI object creation now correlates client request IDs with SimConnect packet IDs so server-side failures complete the correct pending operation immediately. +- Simulator identification is now awaited before selecting the MSFS 2020 or MSFS 2024 object-creation API. +- The .NET 8 diagnostic runner now accepts `--no-wait` for non-interactive execution. + +### Fixed + +- Object-creation timeouts now surface as `TimeoutException` and remove pending request and packet mappings. +- Pending creation continuations now run asynchronously, avoiding inline continuation work on the SimConnect message-processing path. + ## [0.2.1] - 2026-05-12 ### Performance diff --git a/Directory.Build.props b/Directory.Build.props index 09f2a76..afd0284 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,6 @@ - 0.2.1 + 0.2.2 BARS BARS SimConnect.NET diff --git a/SimConnect.NET.sln b/SimConnect.NET.sln index 758c169..431b8ba 100644 --- a/SimConnect.NET.sln +++ b/SimConnect.NET.sln @@ -11,6 +11,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{02EA681E EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SimConnect.NET.Tests.Net8", "tests\SimConnect.NET.Tests.Net8\SimConnect.NET.Tests.Net8.csproj", "{38DFE777-B0F1-DC77-6E04-6DAEFC6F00DB}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SimConnect.NET.UnitTests", "tests\SimConnect.NET.UnitTests\SimConnect.NET.UnitTests.csproj", "{98EE3239-CEF0-4339-B66B-A6FCD4509337}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -45,6 +47,18 @@ Global {38DFE777-B0F1-DC77-6E04-6DAEFC6F00DB}.Release|x64.Build.0 = Release|Any CPU {38DFE777-B0F1-DC77-6E04-6DAEFC6F00DB}.Release|x86.ActiveCfg = Release|Any CPU {38DFE777-B0F1-DC77-6E04-6DAEFC6F00DB}.Release|x86.Build.0 = Release|Any CPU + {98EE3239-CEF0-4339-B66B-A6FCD4509337}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {98EE3239-CEF0-4339-B66B-A6FCD4509337}.Debug|Any CPU.Build.0 = Debug|Any CPU + {98EE3239-CEF0-4339-B66B-A6FCD4509337}.Debug|x64.ActiveCfg = Debug|Any CPU + {98EE3239-CEF0-4339-B66B-A6FCD4509337}.Debug|x64.Build.0 = Debug|Any CPU + {98EE3239-CEF0-4339-B66B-A6FCD4509337}.Debug|x86.ActiveCfg = Debug|Any CPU + {98EE3239-CEF0-4339-B66B-A6FCD4509337}.Debug|x86.Build.0 = Debug|Any CPU + {98EE3239-CEF0-4339-B66B-A6FCD4509337}.Release|Any CPU.ActiveCfg = Release|Any CPU + {98EE3239-CEF0-4339-B66B-A6FCD4509337}.Release|Any CPU.Build.0 = Release|Any CPU + {98EE3239-CEF0-4339-B66B-A6FCD4509337}.Release|x64.ActiveCfg = Release|Any CPU + {98EE3239-CEF0-4339-B66B-A6FCD4509337}.Release|x64.Build.0 = Release|Any CPU + {98EE3239-CEF0-4339-B66B-A6FCD4509337}.Release|x86.ActiveCfg = Release|Any CPU + {98EE3239-CEF0-4339-B66B-A6FCD4509337}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -52,6 +66,7 @@ Global GlobalSection(NestedProjects) = preSolution {4252A45B-8C7E-487B-9670-53935D9CD06A} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {38DFE777-B0F1-DC77-6E04-6DAEFC6F00DB} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} + {98EE3239-CEF0-4339-B66B-A6FCD4509337} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {D38737E6-D81A-4DDE-9278-DE960E039FEB} diff --git a/src/SimConnect.NET/AI/SimObjectManager.cs b/src/SimConnect.NET/AI/SimObjectManager.cs index 155f9e2..6ef74ee 100644 --- a/src/SimConnect.NET/AI/SimObjectManager.cs +++ b/src/SimConnect.NET/AI/SimObjectManager.cs @@ -8,6 +8,14 @@ namespace SimConnect.NET.AI { + internal delegate Task ObjectCreationInvoker( + string containerTitle, + string livery, + SimConnectDataInitPosition position, + uint requestId, + Action registerPacketId, + CancellationToken cancellationToken); + /// /// Manages creation, tracking, and removal of AI simulation objects. /// Provides a high-level interface for spawning and managing objects in the simulation. @@ -23,8 +31,12 @@ public class SimObjectManager : IDisposable parameters[0].ParameterType == typeof(string)); private readonly SimConnectClient client; + private readonly ObjectCreationInvoker objectCreationInvoker; + private readonly TimeSpan objectCreationTimeout; private readonly ConcurrentDictionary managedObjects = new(); private readonly ConcurrentDictionary pendingCreations = new(); + private readonly ConcurrentDictionary requestIdsBySendId = new(); + private readonly ConcurrentDictionary sendIdsByRequestId = new(); private readonly ConcurrentDictionary> objectsByType = new(StringComparer.OrdinalIgnoreCase); private readonly ConcurrentDictionary setAsyncMethodCache = new(); private int activeObjectCount; @@ -36,9 +48,19 @@ public class SimObjectManager : IDisposable /// /// The SimConnect client instance. public SimObjectManager(SimConnectClient client) + : this(client, null, TimeSpan.FromSeconds(10)) + { + } + + internal SimObjectManager( + SimConnectClient client, + ObjectCreationInvoker? objectCreationInvoker, + TimeSpan objectCreationTimeout) { ArgumentNullException.ThrowIfNull(client); this.client = client; + this.objectCreationInvoker = objectCreationInvoker ?? this.InvokeObjectCreationAsync; + this.objectCreationTimeout = objectCreationTimeout; } /// @@ -68,58 +90,27 @@ public async Task CreateObjectAsync( object? userData = null, CancellationToken cancellationToken = default) { - ObjectDisposedException.ThrowIf(this.disposed, nameof(SimObjectManager)); - ArgumentException.ThrowIfNullOrEmpty(containerTitle); - - cancellationToken.ThrowIfCancellationRequested(); - - var requestId = Interlocked.Increment(ref this.nextRequestId); - var pendingCreation = new PendingObjectCreation(containerTitle, position); - - // Store the pending creation request - this.pendingCreations[requestId] = pendingCreation; - - try - { - var result = await this.client.InvokeNativeAsync( - handle => SimConnectNative.SimConnect_AICreateSimulatedObject( - handle, - containerTitle, - position, - requestId), - cancellationToken).ConfigureAwait(false); - - if (result != (int)SimConnectError.None) - { - this.pendingCreations.TryRemove(requestId, out _); - throw SimConnectErrorMapper.Wrap($"Create AI object '{containerTitle}'", result); - } - - if (SimConnectLogger.IsLevelEnabled(SimConnectLogger.LogLevel.Debug)) - { - SimConnectLogger.Debug($"SimObjectManager: Requested creation of '{containerTitle}' with requestId {requestId}"); - } - - // Wait for the object creation to complete with shorter timeout - using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - timeoutCts.CancelAfter(TimeSpan.FromSeconds(10)); // Reduced from 30 to 10 seconds - - var createdObject = await pendingCreation.Completion.Task.WaitAsync(timeoutCts.Token).ConfigureAwait(false); - createdObject.UserData = userData; + return await this.CreateObjectCoreAsync(containerTitle, string.Empty, position, userData, cancellationToken).ConfigureAwait(false); + } - SimConnectLogger.Info($"SimObjectManager: Successfully created object {createdObject}"); - return createdObject; - } - catch (OperationCanceledException) - { - this.pendingCreations.TryRemove(requestId, out _); - throw; - } - catch (Exception ex) when (ex is not SimConnectException) - { - this.pendingCreations.TryRemove(requestId, out _); - throw SimConnectErrorMapper.Wrap($"Create AI object '{containerTitle}'", SimConnectError.Error, ex); - } + /// + /// Creates a new AI simulation object with an MSFS 2024 modular-object livery. + /// + /// The container title (case-sensitive). + /// The modular SimObject livery name or folder name. + /// The initial position and orientation. + /// Optional user data to associate with the object. + /// Cancellation token for the operation. + /// The created simulation object. + public Task CreateObjectWithLiveryAsync( + string containerTitle, + string livery, + SimConnectDataInitPosition position, + object? userData = null, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(livery); + return this.CreateObjectCoreAsync(containerTitle, livery, position, userData, cancellationToken); } /// @@ -299,6 +290,7 @@ public void ProcessObjectCreated(uint requestId, uint objectId, string container { if (this.pendingCreations.TryRemove(requestId, out var pendingCreation)) { + this.RemovePacketMapping(requestId); var simObject = new SimObject( objectId, pendingCreation.ContainerTitle, @@ -320,13 +312,22 @@ public void ProcessObjectCreated(uint requestId, uint objectId, string container /// /// The request ID that failed. /// The error that occurred. - public void ProcessObjectCreationFailed(uint requestId, SimConnectError error) + /// The native packet ID that failed. + /// The one-based index of the parameter that failed. + /// The exception used to complete the pending operation, or null if the request was not pending. + public SimConnectException? ProcessObjectCreationFailed(uint requestId, SimConnectError error, uint sendId = 0, uint index = 0) { if (this.pendingCreations.TryRemove(requestId, out var pendingCreation)) { - pendingCreation.Completion.SetException(SimConnectErrorMapper.Wrap("Object creation", error)); - SimConnectLogger.Error($"SimObjectManager: Object creation failed for requestId {requestId}: {SimConnectErrorMapper.Format(error)}"); + this.RemovePacketMapping(requestId); + var operation = $"Create AI object '{pendingCreation.ContainerTitle}' (requestId={requestId}, sendId={sendId}, index={index})"; + var exception = SimConnectErrorMapper.Wrap(operation, error); + pendingCreation.Completion.TrySetException(exception); + SimConnectLogger.Error($"SimObjectManager: {operation} failed: {SimConnectErrorMapper.Format(error)}"); + return exception; } + + return null; } /// @@ -348,6 +349,8 @@ public void Dispose() } this.pendingCreations.Clear(); + this.requestIdsBySendId.Clear(); + this.sendIdsByRequestId.Clear(); // Mark all objects as inactive (don't remove from sim since we're disposing) foreach (var obj in this.managedObjects.Values) @@ -371,6 +374,11 @@ public void Dispose() } } + internal bool TryResolveRequestId(uint sendId, out uint requestId) + { + return this.requestIdsBySendId.TryGetValue(sendId, out requestId); + } + private MethodInfo GetSetAsyncMethod(Type valueType) { return this.setAsyncMethodCache.GetOrAdd( @@ -378,6 +386,138 @@ private MethodInfo GetSetAsyncMethod(Type valueType) static type => SetAsyncByNameMethod.MakeGenericMethod(type)); } + private async Task CreateObjectCoreAsync( + string containerTitle, + string livery, + SimConnectDataInitPosition position, + object? userData, + CancellationToken cancellationToken) + { + ObjectDisposedException.ThrowIf(this.disposed, nameof(SimObjectManager)); + ArgumentException.ThrowIfNullOrEmpty(containerTitle); + + cancellationToken.ThrowIfCancellationRequested(); + + var requestId = Interlocked.Increment(ref this.nextRequestId); + var pendingCreation = new PendingObjectCreation(containerTitle, position); + this.pendingCreations[requestId] = pendingCreation; + + try + { + var result = await this.objectCreationInvoker( + containerTitle, + livery, + position, + requestId, + this.RegisterPacketId, + cancellationToken).ConfigureAwait(false); + + if (result != (int)SimConnectError.None) + { + this.RemovePendingCreation(requestId); + throw SimConnectErrorMapper.Wrap($"Create AI object '{containerTitle}'", result); + } + + if (SimConnectLogger.IsLevelEnabled(SimConnectLogger.LogLevel.Debug)) + { + SimConnectLogger.Debug($"SimObjectManager: Requested creation of '{containerTitle}' with requestId {requestId}"); + } + + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCts.CancelAfter(this.objectCreationTimeout); + + SimObject createdObject; + try + { + createdObject = await pendingCreation.Completion.Task.WaitAsync(timeoutCts.Token).ConfigureAwait(false); + } + catch (OperationCanceledException ex) when (!cancellationToken.IsCancellationRequested) + { + throw new TimeoutException($"Create AI object '{containerTitle}' timed out after {this.objectCreationTimeout.TotalSeconds:0.###} seconds.", ex); + } + + createdObject.UserData = userData; + SimConnectLogger.Info($"SimObjectManager: Successfully created object {createdObject}"); + return createdObject; + } + catch (OperationCanceledException) + { + this.RemovePendingCreation(requestId); + throw; + } + catch (TimeoutException) + { + this.RemovePendingCreation(requestId); + throw; + } + catch (Exception ex) when (ex is not SimConnectException) + { + this.RemovePendingCreation(requestId); + throw SimConnectErrorMapper.Wrap($"Create AI object '{containerTitle}'", SimConnectError.Error, ex); + } + } + + private async Task InvokeObjectCreationAsync( + string containerTitle, + string livery, + SimConnectDataInitPosition position, + uint requestId, + Action registerPacketId, + CancellationToken cancellationToken) + { + var useExtendedApi = await this.client.GetIsMSFS2024Async(cancellationToken).ConfigureAwait(false); + if (!useExtendedApi && !string.IsNullOrEmpty(livery)) + { + throw new NotSupportedException("SimObject liveries require MSFS 2024 and SimConnect_AICreateSimulatedObject_EX1."); + } + + return await this.client.InvokeNativeAsync( + handle => + { + var result = useExtendedApi + ? SimConnectNative.SimConnect_AICreateSimulatedObject_EX1(handle, containerTitle, livery, position, requestId) + : SimConnectNative.SimConnect_AICreateSimulatedObject(handle, containerTitle, position, requestId); + if (result != (int)SimConnectError.None) + { + return result; + } + + var packetIdResult = SimConnectNative.SimConnect_GetLastSentPacketID(handle, out var sendId); + if (packetIdResult == (int)SimConnectError.None) + { + registerPacketId(sendId, requestId); + } + else + { + SimConnectLogger.Warning( + $"SimObjectManager: Created object request {requestId}, but packet ID lookup failed: {SimConnectErrorMapper.Format(packetIdResult)}. Server errors cannot be correlated for this request."); + } + + return (int)SimConnectError.None; + }, + cancellationToken).ConfigureAwait(false); + } + + private void RegisterPacketId(uint sendId, uint requestId) + { + this.requestIdsBySendId[sendId] = requestId; + this.sendIdsByRequestId[requestId] = sendId; + } + + private void RemovePendingCreation(uint requestId) + { + this.pendingCreations.TryRemove(requestId, out _); + this.RemovePacketMapping(requestId); + } + + private void RemovePacketMapping(uint requestId) + { + if (this.sendIdsByRequestId.TryRemove(requestId, out var sendId)) + { + this.requestIdsBySendId.TryRemove(new KeyValuePair(sendId, requestId)); + } + } + private void TrackObject(SimObject simObject) { while (true) @@ -468,7 +608,7 @@ public PendingObjectCreation(string containerTitle, SimConnectDataInitPosition p public SimConnectDataInitPosition Position { get; } - public TaskCompletionSource Completion { get; } = new(); + public TaskCompletionSource Completion { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); } } } diff --git a/src/SimConnect.NET/Events/SimConnectErrorEventArgs.cs b/src/SimConnect.NET/Events/SimConnectErrorEventArgs.cs index 936d732..a125b84 100644 --- a/src/SimConnect.NET/Events/SimConnectErrorEventArgs.cs +++ b/src/SimConnect.NET/Events/SimConnectErrorEventArgs.cs @@ -18,12 +18,22 @@ public class SimConnectErrorEventArgs : EventArgs /// The exception that was thrown, if any. /// Additional context about when/where the error occurred. /// The timestamp when the error occurred. - public SimConnectErrorEventArgs(SimConnectError error, Exception? exception = null, string? context = null, DateTime? timestamp = null) + /// The native packet ID associated with the error. + /// The one-based index of the parameter associated with the error. + public SimConnectErrorEventArgs( + SimConnectError error, + Exception? exception = null, + string? context = null, + DateTime? timestamp = null, + uint? sendId = null, + uint? index = null) { this.Error = error; this.Exception = exception; this.Context = context ?? string.Empty; this.Timestamp = timestamp ?? DateTime.UtcNow; + this.SendId = sendId; + this.Index = index; } /// @@ -46,6 +56,16 @@ public SimConnectErrorEventArgs(SimConnectError error, Exception? exception = nu /// public DateTime Timestamp { get; } + /// + /// Gets the native packet ID associated with the error, when supplied by SimConnect. + /// + public uint? SendId { get; } + + /// + /// Gets the one-based parameter index associated with the error, when supplied by SimConnect. + /// + public uint? Index { get; } + /// /// Gets a value indicating whether this error is recoverable. /// diff --git a/src/SimConnect.NET/Properties/AssemblyInfo.cs b/src/SimConnect.NET/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..08b378a --- /dev/null +++ b/src/SimConnect.NET/Properties/AssemblyInfo.cs @@ -0,0 +1,7 @@ +// +// Copyright (c) BARS. All rights reserved. +// + +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("SimConnect.NET.UnitTests")] diff --git a/src/SimConnect.NET/SimConnectClient.cs b/src/SimConnect.NET/SimConnectClient.cs index a106a60..8db3495 100644 --- a/src/SimConnect.NET/SimConnectClient.cs +++ b/src/SimConnect.NET/SimConnectClient.cs @@ -26,6 +26,7 @@ public sealed class SimConnectClient : IDisposable, IAsyncDisposable private bool disposed; private CancellationTokenSource? messageLoopCancellation; private Task? messageProcessingTask; + private TaskCompletionSource? simulatorIdentification; private SimVarManager? simVarManager; private AircraftDataManager? aircraftDataManager; private SimObjectManager? simObjectManager; @@ -246,6 +247,7 @@ public async Task ConnectAsync(IntPtr windowHandle = default, uint userEventWin3 throw new InvalidOperationException("Already connected to SimConnect."); } + this.simulatorIdentification = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); await this.nativeDispatcher.InvokeAsync( () => { @@ -340,6 +342,8 @@ public async Task DisconnectAsync() this.inputEventManager?.Dispose(); this.inputGroupManager?.Dispose(); this.simObjectManager = null; + this.simulatorIdentification?.TrySetCanceled(); + this.simulatorIdentification = null; this.isMSFS2024 = false; this.simVarManager = null; this.aircraftDataManager = null; @@ -649,6 +653,17 @@ internal Task InvokeNativeAsync(Func operation, CancellationTok return this.nativeDispatcher.InvokeAsync(() => operation(this.Handle), cancellationToken); } + internal async Task GetIsMSFS2024Async(CancellationToken cancellationToken) + { + var identification = this.simulatorIdentification; + if (identification == null) + { + throw new InvalidOperationException("Simulator identification is unavailable because SimConnect is not connected."); + } + + return await identification.Task.WaitAsync(cancellationToken).ConfigureAwait(false); + } + /// /// Processes an assigned object ID message from SimConnect. /// @@ -686,12 +701,23 @@ private void ProcessError(IntPtr ppData) SimConnectLogger.Warning($"SimConnect error received: {SimConnectErrorMapper.Format(error)} (SendId={recvError.SendId}, Index={recvError.Index})"); - this.OnErrorOccurred(error, null, $"SimConnect error (SendId={recvError.SendId}, Index={recvError.Index})"); - - if (this.simObjectManager != null) + SimConnectException? creationException = null; + if (this.simObjectManager != null && + this.simObjectManager.TryResolveRequestId(recvError.SendId, out var requestId)) { - this.simObjectManager.ProcessObjectCreationFailed(recvError.SendId, error); + creationException = this.simObjectManager.ProcessObjectCreationFailed( + requestId, + error, + recvError.SendId, + recvError.Index); } + + this.OnErrorOccurred( + error, + creationException ?? SimConnectErrorMapper.Wrap("SimConnect server request", error), + $"SimConnect error (SendId={recvError.SendId}, Index={recvError.Index})", + recvError.SendId, + recvError.Index); } catch (Exception ex) when (!ExceptionHelper.IsCritical(ex)) { @@ -712,6 +738,7 @@ private void ProcessOpen(IntPtr ppData) // According to community reports (and current beta docs), ApplicationVersionMajor == 12 indicates MSFS 2024. this.isMSFS2024 = recvOpen.ApplicationVersionMajor == 12; + this.simulatorIdentification?.TrySetResult(this.isMSFS2024); SimConnectLogger.Info($"SimConnect OPEN received: AppVersion={recvOpen.ApplicationVersionMajor}.{recvOpen.ApplicationVersionMinor} Build={recvOpen.ApplicationBuildMajor}.{recvOpen.ApplicationBuildMinor} (IsMSFS2024={this.isMSFS2024})"); } catch (Exception ex) when (!ExceptionHelper.IsCritical(ex)) @@ -933,9 +960,16 @@ private void OnConnectionStatusChanged(bool previousStatus, bool currentStatus) /// The SimConnect error that occurred. /// The exception that was thrown, if any. /// Additional context about when/where the error occurred. - private void OnErrorOccurred(SimConnectError error, Exception? exception = null, string? context = null) + /// The native packet ID associated with the error. + /// The one-based parameter index associated with the error. + private void OnErrorOccurred( + SimConnectError error, + Exception? exception = null, + string? context = null, + uint? sendId = null, + uint? index = null) { - var eventArgs = new SimConnectErrorEventArgs(error, exception, context); + var eventArgs = new SimConnectErrorEventArgs(error, exception, context, sendId: sendId, index: index); this.ErrorOccurred?.Invoke(this, eventArgs); } diff --git a/src/SimConnect.NET/SimConnectNative.cs b/src/SimConnect.NET/SimConnectNative.cs index d32afdb..55683f0 100644 --- a/src/SimConnect.NET/SimConnectNative.cs +++ b/src/SimConnect.NET/SimConnectNative.cs @@ -282,6 +282,14 @@ public static extern int SimConnect_AICreateSimulatedObject( SimConnectDataInitPosition initPos, uint requestId); + [DllImport("SimConnect.dll")] + public static extern int SimConnect_AICreateSimulatedObject_EX1( + IntPtr hSimConnect, + [MarshalAs(UnmanagedType.LPStr)] string szContainerTitle, + [MarshalAs(UnmanagedType.LPStr)] string szLivery, + SimConnectDataInitPosition initPos, + uint requestId); + [DllImport("SimConnect.dll")] public static extern int SimConnect_AIReleaseControl( IntPtr hSimConnect, diff --git a/tests/SimConnect.NET.Tests.Net8/Tests/CoffeeCupDiagnosticTests.cs b/tests/SimConnect.NET.Tests.Net8/Tests/CoffeeCupDiagnosticTests.cs new file mode 100644 index 0000000..8f2f71b --- /dev/null +++ b/tests/SimConnect.NET.Tests.Net8/Tests/CoffeeCupDiagnosticTests.cs @@ -0,0 +1,73 @@ +// +// Copyright (c) BARS. All rights reserved. +// + +using SimConnect.NET.Events; + +namespace SimConnect.NET.Tests.Net8.Tests +{ + /// + /// Runs a controlled built-in SimObject creation diagnostic at the user aircraft position. + /// + internal sealed class SimObjectCreationDiagnosticTests : ISimConnectTest + { + private const string ModelTitle = "CoffeeCup"; + + /// + public string Name => "SimObject creation diagnostic"; + + /// + public string Description => "Creates and removes one selected SimObject at the user aircraft position (CoffeeCup by default; SIMCONNECT_DIAGNOSTIC_MODEL overrides it)"; + + /// + public string Category => "AI Diagnostic"; + + /// + public async Task RunAsync(SimConnectClient client, CancellationToken cancellationToken = default) + { + var modelTitle = Environment.GetEnvironmentVariable("SIMCONNECT_DIAGNOSTIC_MODEL") ?? ModelTitle; + + void OnError(object? sender, SimConnectErrorEventArgs args) + { + Console.WriteLine( + $" SimConnect error: code={(uint)args.Error} name={args.Error} sendId={args.SendId} index={args.Index} context={args.Context} exception={args.Exception}"); + } + + client.ErrorOccurred += OnError; + try + { + var latitude = await client.SimVars.GetAsync("PLANE LATITUDE", "degrees", cancellationToken: cancellationToken); + var longitude = await client.SimVars.GetAsync("PLANE LONGITUDE", "degrees", cancellationToken: cancellationToken); + var altitude = await client.SimVars.GetAsync("PLANE ALTITUDE", "feet", cancellationToken: cancellationToken); + var heading = await client.SimVars.GetAsync("PLANE HEADING DEGREES TRUE", "degrees", cancellationToken: cancellationToken); + var position = new SimConnectDataInitPosition + { + Latitude = latitude, + Longitude = longitude, + Altitude = altitude, + Heading = heading, + Pitch = 0, + Bank = 0, + OnGround = 1, + Airspeed = 0, + }; + + Console.WriteLine( + $" Spawn: model={modelTitle} lat={latitude:F8} lon={longitude:F8} altFeet={altitude:F2} heading={heading:F2} simulator={(client.IsMSFS2024 ? "MSFS 2024" : "MSFS 2020")}"); + + var created = await client.AIObjects.CreateObjectAsync( + modelTitle, + position, + "Controlled CoffeeCup diagnostic", + cancellationToken); + Console.WriteLine($" {modelTitle} succeeded: objectId={created.ObjectId}"); + await client.AIObjects.RemoveObjectAsync(created, cancellationToken); + return true; + } + finally + { + client.ErrorOccurred -= OnError; + } + } + } +} diff --git a/tests/SimConnect.NET.Tests.Net8/Tests/TestRunner.cs b/tests/SimConnect.NET.Tests.Net8/Tests/TestRunner.cs index 103ec06..5d398c7 100644 --- a/tests/SimConnect.NET.Tests.Net8/Tests/TestRunner.cs +++ b/tests/SimConnect.NET.Tests.Net8/Tests/TestRunner.cs @@ -28,6 +28,7 @@ public TestRunner() new SimVarTests(), new AircraftTests(), new AIObjectTests(), + new SimObjectCreationDiagnosticTests(), new InputEventTests(), new InputEventValueTests(), new PerformanceTests(), @@ -72,8 +73,11 @@ public static async Task Main(string[] args) } Console.WriteLine(); - Console.WriteLine("Press any key to exit..."); - Console.ReadKey(); + if (!args.Contains("--no-wait", StringComparer.OrdinalIgnoreCase)) + { + Console.WriteLine("Press any key to exit..."); + Console.ReadKey(); + } } /// diff --git a/tests/SimConnect.NET.UnitTests/SimConnect.NET.UnitTests.csproj b/tests/SimConnect.NET.UnitTests/SimConnect.NET.UnitTests.csproj new file mode 100644 index 0000000..8b18d2b --- /dev/null +++ b/tests/SimConnect.NET.UnitTests/SimConnect.NET.UnitTests.csproj @@ -0,0 +1,25 @@ + + + + net8.0 + enable + enable + false + true + false + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + diff --git a/tests/SimConnect.NET.UnitTests/SimObjectManagerTests.cs b/tests/SimConnect.NET.UnitTests/SimObjectManagerTests.cs new file mode 100644 index 0000000..e828b26 --- /dev/null +++ b/tests/SimConnect.NET.UnitTests/SimObjectManagerTests.cs @@ -0,0 +1,133 @@ +using System.Diagnostics; +using SimConnect.NET.AI; +using Xunit; + +namespace SimConnect.NET.UnitTests; + +/// +/// Tests AI object creation request correlation and cleanup. +/// +public sealed class SimObjectManagerTests +{ + /// + /// Verifies a native packet ID resolves and immediately fails its different client request ID. + /// + /// The server-side object creation error to preserve. + [Theory] + [InlineData(SimConnectError.CreateObjectFailed)] + [InlineData(SimConnectError.ObjectContainer)] + [InlineData(SimConnectError.ObjectAi)] + public async Task DifferentNativeSendIdFailsCorrectPendingCreationImmediately(SimConnectError error) + { + const uint nativeSendId = 17; + uint clientRequestId = 0; + var nativeCallCompleted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var client = new SimConnectClient("Unit test"); + using var manager = new SimObjectManager( + client, + (title, livery, position, requestId, registerPacketId, cancellationToken) => + { + clientRequestId = requestId; + registerPacketId(nativeSendId, requestId); + nativeCallCompleted.SetResult(); + return Task.FromResult((int)SimConnectError.None); + }, + TimeSpan.FromSeconds(10)); + + var creation = manager.CreateObjectAsync("BARS_Light_21", default); + await nativeCallCompleted.Task.WaitAsync(TimeSpan.FromSeconds(1)); + + Assert.NotEqual(nativeSendId, clientRequestId); + Assert.True(manager.TryResolveRequestId(nativeSendId, out var resolvedRequestId)); + Assert.Equal(clientRequestId, resolvedRequestId); + + var stopwatch = Stopwatch.StartNew(); + manager.ProcessObjectCreationFailed(resolvedRequestId, error, nativeSendId, 1); + var exception = await Assert.ThrowsAsync(() => creation); + + Assert.True(stopwatch.Elapsed < TimeSpan.FromSeconds(1)); + Assert.Equal(error, exception.ErrorCode); + Assert.Contains("BARS_Light_21", exception.Message, StringComparison.Ordinal); + Assert.Contains("sendId=17", exception.Message, StringComparison.Ordinal); + Assert.False(manager.TryResolveRequestId(nativeSendId, out _)); + } + + /// + /// Verifies timeout cleanup removes the native packet mapping. + /// + [Fact] + public async Task TimeoutRemovesNativePacketMapping() + { + const uint nativeSendId = 23; + using var client = new SimConnectClient("Unit test"); + using var manager = new SimObjectManager( + client, + (title, livery, position, requestId, registerPacketId, cancellationToken) => + { + registerPacketId(nativeSendId, requestId); + return Task.FromResult((int)SimConnectError.None); + }, + TimeSpan.FromMilliseconds(20)); + + await Assert.ThrowsAsync(() => manager.CreateObjectAsync("CoffeeCup", default)); + + Assert.False(manager.TryResolveRequestId(nativeSendId, out _)); + } + + /// + /// Verifies successful creation removes the native packet mapping. + /// + [Fact] + public async Task SuccessRemovesNativePacketMapping() + { + const uint nativeSendId = 29; + uint clientRequestId = 0; + var nativeCallCompleted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var client = new SimConnectClient("Unit test"); + using var manager = new SimObjectManager( + client, + (title, livery, position, requestId, registerPacketId, cancellationToken) => + { + clientRequestId = requestId; + registerPacketId(nativeSendId, requestId); + nativeCallCompleted.SetResult(); + return Task.FromResult((int)SimConnectError.None); + }, + TimeSpan.FromSeconds(10)); + + var creation = manager.CreateObjectAsync("CoffeeCup", default); + await nativeCallCompleted.Task.WaitAsync(TimeSpan.FromSeconds(1)); + manager.ProcessObjectCreated(clientRequestId, 123, string.Empty, default); + + var created = await creation; + Assert.Equal(123u, created.ObjectId); + Assert.False(manager.TryResolveRequestId(nativeSendId, out _)); + } + + /// + /// Verifies creation can complete when native packet correlation is unavailable. + /// + [Fact] + public async Task SuccessWithoutPacketMappingStillCompletes() + { + uint clientRequestId = 0; + var nativeCallCompleted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var client = new SimConnectClient("Unit test"); + using var manager = new SimObjectManager( + client, + (title, livery, position, requestId, registerPacketId, cancellationToken) => + { + clientRequestId = requestId; + nativeCallCompleted.SetResult(); + return Task.FromResult((int)SimConnectError.None); + }, + TimeSpan.FromSeconds(10)); + + var creation = manager.CreateObjectAsync("CoffeeCup", default); + await nativeCallCompleted.Task.WaitAsync(TimeSpan.FromSeconds(1)); + manager.ProcessObjectCreated(clientRequestId, 123, string.Empty, default); + + var created = await creation; + Assert.Equal(123u, created.ObjectId); + } +}