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
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion Directory.Build.props
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<Project>
<PropertyGroup>
<Version>0.2.1</Version>
<Version>0.2.2</Version>
<Authors>BARS</Authors>
<Company>BARS</Company>
<Product>SimConnect.NET</Product>
Expand Down
15 changes: 15 additions & 0 deletions SimConnect.NET.sln
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -45,13 +47,26 @@ 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
EndGlobalSection
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}
Expand Down
250 changes: 195 additions & 55 deletions src/SimConnect.NET/AI/SimObjectManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,14 @@

namespace SimConnect.NET.AI
{
internal delegate Task<int> ObjectCreationInvoker(
string containerTitle,
string livery,
SimConnectDataInitPosition position,
uint requestId,
Action<uint, uint> registerPacketId,
CancellationToken cancellationToken);

/// <summary>
/// Manages creation, tracking, and removal of AI simulation objects.
/// Provides a high-level interface for spawning and managing objects in the simulation.
Expand All @@ -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<uint, SimObject> managedObjects = new();
private readonly ConcurrentDictionary<uint, PendingObjectCreation> pendingCreations = new();
private readonly ConcurrentDictionary<uint, uint> requestIdsBySendId = new();
private readonly ConcurrentDictionary<uint, uint> sendIdsByRequestId = new();
private readonly ConcurrentDictionary<string, ConcurrentDictionary<uint, SimObject>> objectsByType = new(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentDictionary<Type, MethodInfo> setAsyncMethodCache = new();
private int activeObjectCount;
Expand All @@ -36,9 +48,19 @@ public class SimObjectManager : IDisposable
/// </summary>
/// <param name="client">The SimConnect client instance.</param>
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;
}

/// <summary>
Expand Down Expand Up @@ -68,58 +90,27 @@ public async Task<SimObject> 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);
}
/// <summary>
/// Creates a new AI simulation object with an MSFS 2024 modular-object livery.
/// </summary>
/// <param name="containerTitle">The container title (case-sensitive).</param>
/// <param name="livery">The modular SimObject livery name or folder name.</param>
/// <param name="position">The initial position and orientation.</param>
/// <param name="userData">Optional user data to associate with the object.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>The created simulation object.</returns>
public Task<SimObject> CreateObjectWithLiveryAsync(
string containerTitle,
string livery,
SimConnectDataInitPosition position,
object? userData = null,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrEmpty(livery);
return this.CreateObjectCoreAsync(containerTitle, livery, position, userData, cancellationToken);
}

/// <summary>
Expand Down Expand Up @@ -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,
Expand All @@ -320,13 +312,22 @@ public void ProcessObjectCreated(uint requestId, uint objectId, string container
/// </summary>
/// <param name="requestId">The request ID that failed.</param>
/// <param name="error">The error that occurred.</param>
public void ProcessObjectCreationFailed(uint requestId, SimConnectError error)
/// <param name="sendId">The native packet ID that failed.</param>
/// <param name="index">The one-based index of the parameter that failed.</param>
/// <returns>The exception used to complete the pending operation, or null if the request was not pending.</returns>
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;
}

/// <summary>
Expand All @@ -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)
Expand All @@ -371,13 +374,150 @@ 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(
valueType,
static type => SetAsyncByNameMethod.MakeGenericMethod(type));
}

private async Task<SimObject> 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<int> InvokeObjectCreationAsync(
string containerTitle,
string livery,
SimConnectDataInitPosition position,
uint requestId,
Action<uint, uint> 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)
Comment thread
AussieScorcher marked this conversation as resolved.
Dismissed
: SimConnectNative.SimConnect_AICreateSimulatedObject(handle, containerTitle, position, requestId);
Comment thread
AussieScorcher marked this conversation as resolved.
Dismissed
if (result != (int)SimConnectError.None)
{
return result;
}

var packetIdResult = SimConnectNative.SimConnect_GetLastSentPacketID(handle, out var sendId);
Comment thread
AussieScorcher marked this conversation as resolved.
Dismissed
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<uint, uint>(sendId, requestId));
}
}

private void TrackObject(SimObject simObject)
{
while (true)
Expand Down Expand Up @@ -468,7 +608,7 @@ public PendingObjectCreation(string containerTitle, SimConnectDataInitPosition p

public SimConnectDataInitPosition Position { get; }

public TaskCompletionSource<SimObject> Completion { get; } = new();
public TaskCompletionSource<SimObject> Completion { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
}
}
}
Loading
Loading