diff --git a/SimConnect.NET.sln b/SimConnect.NET.sln
index 758c169..899140f 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", "{A5983127-E3A8-4E4A-9F48-307DAB9B2CF0}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -43,15 +45,28 @@ Global
{38DFE777-B0F1-DC77-6E04-6DAEFC6F00DB}.Release|Any CPU.Build.0 = Release|Any CPU
{38DFE777-B0F1-DC77-6E04-6DAEFC6F00DB}.Release|x64.ActiveCfg = Release|Any CPU
{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
+ {38DFE777-B0F1-DC77-6E04-6DAEFC6F00DB}.Release|x86.ActiveCfg = Release|Any CPU
+ {38DFE777-B0F1-DC77-6E04-6DAEFC6F00DB}.Release|x86.Build.0 = Release|Any CPU
+ {A5983127-E3A8-4E4A-9F48-307DAB9B2CF0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {A5983127-E3A8-4E4A-9F48-307DAB9B2CF0}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {A5983127-E3A8-4E4A-9F48-307DAB9B2CF0}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {A5983127-E3A8-4E4A-9F48-307DAB9B2CF0}.Debug|x64.Build.0 = Debug|Any CPU
+ {A5983127-E3A8-4E4A-9F48-307DAB9B2CF0}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {A5983127-E3A8-4E4A-9F48-307DAB9B2CF0}.Debug|x86.Build.0 = Debug|Any CPU
+ {A5983127-E3A8-4E4A-9F48-307DAB9B2CF0}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {A5983127-E3A8-4E4A-9F48-307DAB9B2CF0}.Release|Any CPU.Build.0 = Release|Any CPU
+ {A5983127-E3A8-4E4A-9F48-307DAB9B2CF0}.Release|x64.ActiveCfg = Release|Any CPU
+ {A5983127-E3A8-4E4A-9F48-307DAB9B2CF0}.Release|x64.Build.0 = Release|Any CPU
+ {A5983127-E3A8-4E4A-9F48-307DAB9B2CF0}.Release|x86.ActiveCfg = Release|Any CPU
+ {A5983127-E3A8-4E4A-9F48-307DAB9B2CF0}.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}
+ {38DFE777-B0F1-DC77-6E04-6DAEFC6F00DB} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
+ {A5983127-E3A8-4E4A-9F48-307DAB9B2CF0} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {D38737E6-D81A-4DDE-9278-DE960E039FEB}
diff --git a/src/SimConnect.NET/Facilities/FacilityDataResponse.cs b/src/SimConnect.NET/Facilities/FacilityDataResponse.cs
new file mode 100644
index 0000000..dcf92a2
--- /dev/null
+++ b/src/SimConnect.NET/Facilities/FacilityDataResponse.cs
@@ -0,0 +1,49 @@
+//
+// Copyright (c) BARS. All rights reserved.
+//
+
+using System.Collections.Generic;
+
+namespace SimConnect.NET.Facilities
+{
+ ///
+ /// Represents the result of a facility data request. Contains one or more payloads describing the
+ /// requested facility and any child objects (runways, approaches, etc.).
+ ///
+ public sealed class FacilityDataResponse
+ {
+ internal FacilityDataResponse(uint requestId, IReadOnlyList results)
+ {
+ this.RequestId = requestId;
+ this.Results = results;
+ }
+
+ ///
+ /// Gets the client supplied request identifier.
+ ///
+ public uint RequestId { get; }
+
+ ///
+ /// Gets the collection of data packets returned by SimConnect.
+ ///
+ public IReadOnlyList Results { get; }
+
+ ///
+ /// Finds the first payload matching the specified .
+ ///
+ /// The desired data type.
+ /// The payload if present; otherwise .
+ public FacilityDataResult? Find(SimConnectFacilityDataType dataType)
+ {
+ foreach (var result in this.Results)
+ {
+ if (result.DataType == dataType)
+ {
+ return result;
+ }
+ }
+
+ return null;
+ }
+ }
+}
diff --git a/src/SimConnect.NET/Facilities/FacilityDataResult.cs b/src/SimConnect.NET/Facilities/FacilityDataResult.cs
new file mode 100644
index 0000000..065cf99
--- /dev/null
+++ b/src/SimConnect.NET/Facilities/FacilityDataResult.cs
@@ -0,0 +1,86 @@
+//
+// Copyright (c) BARS. All rights reserved.
+//
+
+using System;
+using System.Runtime.InteropServices;
+
+namespace SimConnect.NET.Facilities
+{
+ ///
+ /// Represents a single facility data packet returned by SimConnect.
+ ///
+ public readonly struct FacilityDataResult
+ {
+ private readonly byte[] payload;
+
+ internal FacilityDataResult(
+ uint uniqueRequestId,
+ uint parentUniqueRequestId,
+ SimConnectFacilityDataType dataType,
+ bool isListItem,
+ uint itemIndex,
+ uint listSize,
+ byte[] payload)
+ {
+ this.UniqueRequestId = uniqueRequestId;
+ this.ParentUniqueRequestId = parentUniqueRequestId;
+ this.DataType = dataType;
+ this.IsListItem = isListItem;
+ this.ItemIndex = itemIndex;
+ this.ListSize = listSize;
+ this.payload = payload ?? Array.Empty();
+ }
+
+ ///
+ /// Gets the unique request identifier assigned by SimConnect.
+ ///
+ public uint UniqueRequestId { get; }
+
+ ///
+ /// Gets the parent unique request identifier, if any.
+ ///
+ public uint ParentUniqueRequestId { get; }
+
+ ///
+ /// Gets the facility data type represented by this payload.
+ ///
+ public SimConnectFacilityDataType DataType { get; }
+
+ ///
+ /// Gets a value indicating whether this payload represents an element of a list.
+ ///
+ public bool IsListItem { get; }
+
+ ///
+ /// Gets the index of the item within a list, if is true.
+ ///
+ public uint ItemIndex { get; }
+
+ ///
+ /// Gets the total number of items in the list, if is true.
+ ///
+ public uint ListSize { get; }
+
+ ///
+ /// Gets the raw payload memory returned by SimConnect.
+ ///
+ public ReadOnlyMemory Payload => this.payload;
+
+ ///
+ /// Marshals the payload into a managed struct of the specified type.
+ ///
+ /// The target struct type. Must be blittable.
+ /// The marshalled struct.
+ public T As()
+ where T : struct
+ {
+ if (this.payload.Length < Marshal.SizeOf())
+ {
+ throw new InvalidOperationException("Payload is smaller than the requested struct type.");
+ }
+
+ return MemoryMarshal.Read(this.payload.AsSpan());
+ }
+ }
+}
diff --git a/src/SimConnect.NET/Facilities/FacilityDefinition.cs b/src/SimConnect.NET/Facilities/FacilityDefinition.cs
new file mode 100644
index 0000000..9e0d1ac
--- /dev/null
+++ b/src/SimConnect.NET/Facilities/FacilityDefinition.cs
@@ -0,0 +1,37 @@
+//
+// Copyright (c) BARS. All rights reserved.
+//
+
+using System;
+using System.Collections.Generic;
+
+namespace SimConnect.NET.Facilities
+{
+ ///
+ /// Represents a facility data definition registered with SimConnect.
+ ///
+ public sealed class FacilityDefinition
+ {
+ internal FacilityDefinition(uint definitionId, IReadOnlyList fields, Type? structType)
+ {
+ this.DefinitionId = definitionId;
+ this.Fields = fields;
+ this.StructType = structType;
+ }
+
+ ///
+ /// Gets the SimConnect definition identifier.
+ ///
+ public uint DefinitionId { get; }
+
+ ///
+ /// Gets the list of facility fields that are part of this definition.
+ ///
+ public IReadOnlyList Fields { get; }
+
+ ///
+ /// Gets the struct type used to generate this definition, if any.
+ ///
+ public Type? StructType { get; }
+ }
+}
diff --git a/src/SimConnect.NET/Facilities/FacilityDefinitionBuilder.cs b/src/SimConnect.NET/Facilities/FacilityDefinitionBuilder.cs
new file mode 100644
index 0000000..7b9fbc2
--- /dev/null
+++ b/src/SimConnect.NET/Facilities/FacilityDefinitionBuilder.cs
@@ -0,0 +1,35 @@
+//
+// Copyright (c) BARS. All rights reserved.
+//
+
+using System;
+using System.Collections.Generic;
+
+namespace SimConnect.NET.Facilities
+{
+ ///
+ /// Fluent builder for instances.
+ ///
+ public sealed class FacilityDefinitionBuilder
+ {
+ private readonly List fields = new();
+
+ ///
+ /// Gets the configured field list.
+ ///
+ internal IReadOnlyList Fields => this.fields;
+
+ ///
+ /// Adds a field path to the definition.
+ ///
+ /// The SimConnect facility field path (for example "Airport.Latitude").
+ /// The same builder instance to allow fluent chaining.
+ /// Thrown when is null or whitespace.
+ public FacilityDefinitionBuilder AddField(string fieldPath)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(fieldPath);
+ this.fields.Add(fieldPath);
+ return this;
+ }
+ }
+}
diff --git a/src/SimConnect.NET/Facilities/FacilityFieldAttribute.cs b/src/SimConnect.NET/Facilities/FacilityFieldAttribute.cs
new file mode 100644
index 0000000..0693125
--- /dev/null
+++ b/src/SimConnect.NET/Facilities/FacilityFieldAttribute.cs
@@ -0,0 +1,37 @@
+//
+// Copyright (c) BARS. All rights reserved.
+//
+
+using System;
+
+namespace SimConnect.NET.Facilities
+{
+ ///
+ /// Annotates a struct field with a SimConnect facility field path for automatic definition creation.
+ ///
+ [AttributeUsage(AttributeTargets.Field, AllowMultiple = false)]
+ public sealed class FacilityFieldAttribute : Attribute
+ {
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The SimConnect facility field path.
+ /// Optional explicit ordering (lower numbers are processed first).
+ public FacilityFieldAttribute(string path, int order = 0)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(path);
+ this.Path = path;
+ this.Order = order;
+ }
+
+ ///
+ /// Gets the SimConnect facility field path represented by this attribute.
+ ///
+ public string Path { get; }
+
+ ///
+ /// Gets the optional ordering index. Lower values are processed first.
+ ///
+ public int Order { get; }
+ }
+}
diff --git a/src/SimConnect.NET/Facilities/FacilityManager.cs b/src/SimConnect.NET/Facilities/FacilityManager.cs
new file mode 100644
index 0000000..6fcee93
--- /dev/null
+++ b/src/SimConnect.NET/Facilities/FacilityManager.cs
@@ -0,0 +1,514 @@
+//
+// Copyright (c) BARS. All rights reserved.
+//
+
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.Linq;
+using System.Reflection;
+using System.Runtime.InteropServices;
+using System.Threading;
+using System.Threading.Tasks;
+using SimConnect.NET.Internal;
+
+namespace SimConnect.NET.Facilities
+{
+ ///
+ /// Provides high-level helpers for working with the SimConnect facility APIs, including
+ /// dynamic data definitions, ad-hoc facility data requests, and in-range subscriptions.
+ ///
+ public sealed class FacilityManager : IDisposable
+ {
+ private const uint BaseDefinitionId = 50000;
+ private const uint BaseRequestId = 60000;
+
+ private readonly IntPtr simConnectHandle;
+ private readonly ISimConnectFacilityApi facilityApi;
+ private readonly ConcurrentDictionary definitionCache = new();
+ private readonly ConcurrentDictionary minimalListRequests = new();
+ private readonly ConcurrentDictionary facilityDataRequests = new();
+ private readonly ConcurrentDictionary activeSubscriptions = new();
+ private readonly ConcurrentDictionary subscriptionLookup = new();
+ private int definitionCounter = (int)BaseDefinitionId - 1;
+ private int requestCounter = (int)BaseRequestId - 1;
+ private bool disposed;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The active SimConnect handle.
+ public FacilityManager(IntPtr simConnectHandle)
+ : this(simConnectHandle, null)
+ {
+ }
+
+ ///
+ /// Initializes a new instance of the class for testing.
+ ///
+ /// The active SimConnect handle.
+ /// Optional facility API abstraction used for testing.
+ internal FacilityManager(IntPtr simConnectHandle, ISimConnectFacilityApi? facilityApi)
+ {
+ if (simConnectHandle == IntPtr.Zero)
+ {
+ throw new ArgumentException("SimConnect handle must be initialized.", nameof(simConnectHandle));
+ }
+
+ this.simConnectHandle = simConnectHandle;
+ this.facilityApi = facilityApi ?? SimConnectFacilityApi.Instance;
+ }
+
+ ///
+ /// Creates a facility definition from the supplied builder configuration.
+ ///
+ /// Action that defines the desired facility fields.
+ /// A instance bound to a SimConnect definition ID.
+ public FacilityDefinition CreateDefinition(Action configure)
+ {
+ ObjectDisposedException.ThrowIf(this.disposed, nameof(FacilityManager));
+ ArgumentNullException.ThrowIfNull(configure);
+
+ var builder = new FacilityDefinitionBuilder();
+ configure(builder);
+ if (builder.Fields.Count == 0)
+ {
+ throw new InvalidOperationException("At least one facility field must be added to the definition.");
+ }
+
+ var definitionId = this.GetNextDefinitionId();
+ foreach (var field in builder.Fields)
+ {
+ ThrowIfError(
+ this.facilityApi.AddToFacilityDefinition(this.simConnectHandle, definitionId, field),
+ $"Add facility field '{field}'");
+ }
+
+ return new FacilityDefinition(definitionId, builder.Fields.ToArray(), null);
+ }
+
+ ///
+ /// Creates or retrieves a cached facility definition using annotations.
+ ///
+ /// Struct containing annotated fields.
+ /// The cached .
+ public FacilityDefinition GetOrCreateDefinition()
+ where T : struct
+ {
+ ObjectDisposedException.ThrowIf(this.disposed, nameof(FacilityManager));
+ return this.definitionCache.GetOrAdd(typeof(T), _ => this.CreateDefinitionFromStruct());
+ }
+
+ ///
+ /// Requests facility data using a previously created definition.
+ ///
+ /// The facility definition to use.
+ /// The ICAO identifier of the facility.
+ /// Optional region string.
+ /// Cancellation token.
+ /// A task returning the full response payload.
+ public Task RequestFacilityDataAsync(
+ FacilityDefinition definition,
+ string icao,
+ string? region = null,
+ CancellationToken cancellationToken = default)
+ {
+ ObjectDisposedException.ThrowIf(this.disposed, nameof(FacilityManager));
+ ArgumentNullException.ThrowIfNull(definition);
+ ArgumentException.ThrowIfNullOrEmpty(icao);
+
+ var requestId = this.GetNextRequestId();
+ var requestState = new FacilityDataRequestState(requestId);
+ if (!this.facilityDataRequests.TryAdd(requestId, requestState))
+ {
+ throw new InvalidOperationException("Failed to track facility data request state.");
+ }
+
+ cancellationToken.Register(() =>
+ {
+ if (this.facilityDataRequests.TryRemove(requestId, out var canceled))
+ {
+ canceled.TrySetCanceled();
+ }
+ });
+
+ ThrowIfError(
+ this.facilityApi.RequestFacilityData(this.simConnectHandle, definition.DefinitionId, requestId, icao, region ?? string.Empty),
+ "RequestFacilityData");
+
+ return requestState.Task;
+ }
+
+ ///
+ /// Requests facility data for the specified annotated struct type.
+ ///
+ /// The annotated struct type describing the desired fields.
+ /// The ICAO identifier.
+ /// Optional region code.
+ /// Cancellation token for the request.
+ /// The complete .
+ public Task RequestFacilityDataAsync(
+ string icao,
+ string? region = null,
+ CancellationToken cancellationToken = default)
+ where T : struct
+ => this.RequestFacilityDataAsync(this.GetOrCreateDefinition(), icao, region, cancellationToken);
+
+ ///
+ /// Requests the minimal facility list (ICAO and Lat/Lon/Alt) for the specified type.
+ ///
+ /// The facility list type (airport, waypoint, etc.).
+ /// Cancellation token for the request.
+ /// A task that completes with the full list of facilities.
+ public Task> RequestMinimalListAsync(
+ SimConnectFacilityListType type,
+ CancellationToken cancellationToken = default)
+ {
+ ObjectDisposedException.ThrowIf(this.disposed, nameof(FacilityManager));
+ var requestId = this.GetNextRequestId();
+ var state = new FacilityMinimalListRequest(requestId);
+ if (!this.minimalListRequests.TryAdd(requestId, state))
+ {
+ throw new InvalidOperationException("Failed to track facility list request state.");
+ }
+
+ cancellationToken.Register(() =>
+ {
+ if (this.minimalListRequests.TryRemove(requestId, out var canceled))
+ {
+ canceled.TrySetCanceled();
+ }
+ });
+
+ ThrowIfError(
+ this.facilityApi.RequestFacilitiesListEx1(this.simConnectHandle, (uint)type, requestId),
+ "RequestFacilitiesList_EX1");
+
+ return state.Task;
+ }
+
+ ///
+ /// Subscribes to facilities entering and leaving the user's reality bubble.
+ ///
+ /// Facility list type to monitor.
+ /// Callback invoked when facilities enter range.
+ /// Optional callback for facilities leaving range.
+ /// A disposable subscription object.
+ public FacilitySubscription SubscribeToMinimalFacilities(
+ SimConnectFacilityListType type,
+ Action> onEntered,
+ Action>? onExited = null)
+ {
+ ObjectDisposedException.ThrowIf(this.disposed, nameof(FacilityManager));
+ ArgumentNullException.ThrowIfNull(onEntered);
+
+ var inRangeRequestId = this.GetNextRequestId();
+ var outRangeRequestId = this.GetNextRequestId();
+ var subscription = new FacilitySubscription(this, type, inRangeRequestId, outRangeRequestId, onEntered, onExited);
+
+ if (!this.activeSubscriptions.TryAdd(type, subscription))
+ {
+ throw new InvalidOperationException($"A subscription for {type} already exists. Dispose the existing subscription before creating a new one.");
+ }
+
+ this.subscriptionLookup[inRangeRequestId] = subscription;
+ this.subscriptionLookup[outRangeRequestId] = subscription;
+
+ try
+ {
+ ThrowIfError(
+ this.facilityApi.SubscribeToFacilitiesEx1(this.simConnectHandle, (uint)type, inRangeRequestId, outRangeRequestId),
+ "SubscribeToFacilities_EX1");
+ }
+ catch
+ {
+ this.subscriptionLookup.TryRemove(inRangeRequestId, out _);
+ this.subscriptionLookup.TryRemove(outRangeRequestId, out _);
+ this.activeSubscriptions.TryRemove(type, out _);
+ throw;
+ }
+
+ return subscription;
+ }
+
+ ///
+ /// Releases unmanaged resources.
+ ///
+ public void Dispose()
+ {
+ if (this.disposed)
+ {
+ return;
+ }
+
+ foreach (var subscription in this.activeSubscriptions.Values)
+ {
+ subscription?.Dispose();
+ }
+
+ this.disposed = true;
+ }
+
+ internal void RemoveSubscription(FacilitySubscription subscription)
+ {
+ if (subscription == null)
+ {
+ return;
+ }
+
+ this.subscriptionLookup.TryRemove(subscription.EnteredRequestId, out _);
+ this.subscriptionLookup.TryRemove(subscription.ExitedRequestId, out _);
+ this.activeSubscriptions.TryRemove(subscription.Type, out _);
+ try
+ {
+ ThrowIfError(
+ this.facilityApi.UnsubscribeFromFacilitiesEx1(this.simConnectHandle, (uint)subscription.Type, true, true),
+ "UnsubscribeToFacilities_EX1");
+ }
+ catch (SimConnectException ex) when (!ExceptionHelper.IsCritical(ex))
+ {
+ SimConnectLogger.Warning($"Failed to unsubscribe from facilities: {ex.Message}");
+ }
+ }
+
+ internal void ProcessFacilityMinimalList(IntPtr data)
+ {
+ if (data == IntPtr.Zero)
+ {
+ return;
+ }
+
+ var header = Marshal.PtrToStructure(data)!;
+ var dataStart = IntPtr.Add(data, FacilityMinimalListHeader.SizeInBytes);
+ var list = FacilityManager.MarshalArray(dataStart, (int)header.ArraySize);
+
+ if (this.subscriptionLookup.TryGetValue(header.RequestId, out var subscription))
+ {
+ subscription.Dispatch(header.RequestId, list);
+ return;
+ }
+
+ if (this.minimalListRequests.TryGetValue(header.RequestId, out var request))
+ {
+ request.AddChunk(list, header.EntryNumber, header.OutOf);
+ if (request.IsComplete)
+ {
+ this.minimalListRequests.TryRemove(header.RequestId, out _);
+ }
+
+ return;
+ }
+ }
+
+ internal void ProcessFacilityData(IntPtr data)
+ {
+ if (data == IntPtr.Zero)
+ {
+ return;
+ }
+
+ var recv = Marshal.PtrToStructure(data)!;
+ if (this.facilityDataRequests.TryGetValue(recv.UserRequestId, out var state))
+ {
+ state.AddPacket(data, recv);
+ }
+ }
+
+ internal void ProcessFacilityDataEnd(IntPtr data)
+ {
+ if (data == IntPtr.Zero)
+ {
+ return;
+ }
+
+ var recvEnd = Marshal.PtrToStructure(data)!;
+ if (this.facilityDataRequests.TryRemove(recvEnd.RequestId, out var state))
+ {
+ state.Complete();
+ }
+ }
+
+ private static void ThrowIfError(int result, string operation)
+ {
+ if (result == (int)SimConnectError.None)
+ {
+ return;
+ }
+
+ var error = (SimConnectError)result;
+ var message = $"{operation} failed: {SimConnectErrorMapper.Describe(error)}";
+ throw new SimConnectException(message, error);
+ }
+
+ private static T[] MarshalArray(IntPtr dataStart, int count)
+ where T : struct
+ {
+ if (count <= 0 || dataStart == IntPtr.Zero)
+ {
+ return Array.Empty();
+ }
+
+ var elementSize = Marshal.SizeOf();
+ var result = new T[count];
+ for (var i = 0; i < count; i++)
+ {
+ var elementPtr = IntPtr.Add(dataStart, i * elementSize);
+ result[i] = Marshal.PtrToStructure(elementPtr)!;
+ }
+
+ return result;
+ }
+
+ private FacilityDefinition CreateDefinitionFromStruct()
+ where T : struct
+ {
+ var fields = typeof(T)
+ .GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)
+ .Select(f => (Field: f, Attribute: f.GetCustomAttribute()))
+ .Where(tuple => tuple.Attribute != null)
+ .OrderBy(tuple => tuple.Attribute!.Order)
+ .ThenBy(tuple => tuple.Field.MetadataToken)
+ .Select(tuple => tuple.Attribute!.Path)
+ .ToList();
+
+ if (fields.Count == 0)
+ {
+ throw new InvalidOperationException($"Type {typeof(T).FullName} does not define any FacilityFieldAttribute annotations.");
+ }
+
+ var definitionId = this.GetNextDefinitionId();
+ foreach (var field in fields)
+ {
+ ThrowIfError(
+ this.facilityApi.AddToFacilityDefinition(this.simConnectHandle, definitionId, field),
+ $"Add facility field '{field}'");
+ }
+
+ return new FacilityDefinition(definitionId, fields, typeof(T));
+ }
+
+ private uint GetNextDefinitionId() => (uint)Interlocked.Increment(ref this.definitionCounter);
+
+ private uint GetNextRequestId() => (uint)Interlocked.Increment(ref this.requestCounter);
+
+ [StructLayout(LayoutKind.Sequential, Pack = 1)]
+ private struct FacilityMinimalListHeader
+ {
+ public static readonly int SizeInBytes = Marshal.SizeOf();
+
+ public uint Size;
+ public uint Version;
+ public uint Id;
+ public uint RequestId;
+ public uint ArraySize;
+ public uint EntryNumber;
+ public uint OutOf;
+ }
+
+ [StructLayout(LayoutKind.Sequential, Pack = 1)]
+ private struct SimConnectRecvFacilityDataInternal
+ {
+ public static readonly int DataOffset = Marshal.OffsetOf(nameof(Data)).ToInt32();
+
+ public uint Size;
+ public uint Version;
+ public uint Id;
+ public uint UserRequestId;
+ public uint UniqueRequestId;
+ public uint ParentUniqueRequestId;
+ public SimConnectFacilityDataType Type;
+ public uint IsListItem;
+ public uint ItemIndex;
+ public uint ListSize;
+ public uint Data;
+ }
+
+ private sealed class FacilityMinimalListRequest
+ {
+ private readonly TaskCompletionSource> tcs = new(TaskCreationOptions.RunContinuationsAsynchronously);
+ private readonly List buffer = new();
+ private uint expectedPackets;
+ private uint receivedPackets;
+
+ internal FacilityMinimalListRequest(uint requestId)
+ {
+ }
+
+ internal Task> Task => this.tcs.Task;
+
+ internal bool IsComplete => this.expectedPackets != 0 && this.receivedPackets >= this.expectedPackets;
+
+ internal void AddChunk(SimConnectFacilityMinimal[] chunk, uint entryNumber, uint outOf)
+ {
+ if (chunk.Length > 0)
+ {
+ this.buffer.AddRange(chunk);
+ }
+
+ this.receivedPackets++;
+ this.expectedPackets = outOf == 0 ? 1u : outOf;
+
+ if (this.IsComplete)
+ {
+ this.tcs.TrySetResult(this.buffer);
+ }
+ }
+
+ internal void TrySetCanceled()
+ {
+ this.tcs.TrySetCanceled();
+ }
+ }
+
+ private sealed class FacilityDataRequestState
+ {
+ private readonly TaskCompletionSource tcs = new(TaskCreationOptions.RunContinuationsAsynchronously);
+ private readonly List packets = new();
+ private readonly uint requestId;
+
+ internal FacilityDataRequestState(uint requestId)
+ {
+ this.requestId = requestId;
+ }
+
+ internal Task Task => this.tcs.Task;
+
+ internal void AddPacket(IntPtr basePtr, SimConnectRecvFacilityDataInternal data)
+ {
+ var payloadSize = (int)data.Size - SimConnectRecvFacilityDataInternal.DataOffset;
+ if (payloadSize < 0)
+ {
+ payloadSize = 0;
+ }
+
+ var payload = new byte[payloadSize];
+ if (payloadSize > 0)
+ {
+ var payloadPtr = IntPtr.Add(basePtr, SimConnectRecvFacilityDataInternal.DataOffset);
+ Marshal.Copy(payloadPtr, payload, 0, payloadSize);
+ }
+
+ var result = new FacilityDataResult(
+ data.UniqueRequestId,
+ data.ParentUniqueRequestId,
+ data.Type,
+ data.IsListItem != 0,
+ data.ItemIndex,
+ data.ListSize,
+ payload);
+
+ this.packets.Add(result);
+ }
+
+ internal void Complete()
+ {
+ this.tcs.TrySetResult(new FacilityDataResponse(this.requestId, this.packets));
+ }
+
+ internal void TrySetCanceled()
+ {
+ this.tcs.TrySetCanceled();
+ }
+ }
+ }
+}
diff --git a/src/SimConnect.NET/Facilities/FacilitySubscription.cs b/src/SimConnect.NET/Facilities/FacilitySubscription.cs
new file mode 100644
index 0000000..c88f69f
--- /dev/null
+++ b/src/SimConnect.NET/Facilities/FacilitySubscription.cs
@@ -0,0 +1,70 @@
+//
+// Copyright (c) BARS. All rights reserved.
+//
+
+using System;
+using System.Collections.Generic;
+
+namespace SimConnect.NET.Facilities
+{
+ ///
+ /// Represents an active subscription created through .
+ ///
+ public sealed class FacilitySubscription : IDisposable
+ {
+ private readonly FacilityManager manager;
+ private readonly Action> onEntered;
+ private readonly Action>? onExited;
+ private bool disposed;
+
+ internal FacilitySubscription(
+ FacilityManager manager,
+ SimConnectFacilityListType type,
+ uint enteredRequestId,
+ uint exitedRequestId,
+ Action> onEntered,
+ Action>? onExited)
+ {
+ this.manager = manager;
+ this.Type = type;
+ this.EnteredRequestId = enteredRequestId;
+ this.ExitedRequestId = exitedRequestId;
+ this.onEntered = onEntered ?? throw new ArgumentNullException(nameof(onEntered));
+ this.onExited = onExited;
+ }
+
+ ///
+ /// Gets the facility type represented by the subscription.
+ ///
+ public SimConnectFacilityListType Type { get; }
+
+ internal uint EnteredRequestId { get; }
+
+ internal uint ExitedRequestId { get; }
+
+ ///
+ public void Dispose()
+ {
+ if (this.disposed)
+ {
+ return;
+ }
+
+ this.disposed = true;
+ this.manager.RemoveSubscription(this);
+ GC.SuppressFinalize(this);
+ }
+
+ internal void Dispatch(uint requestId, IReadOnlyList facilities)
+ {
+ if (requestId == this.EnteredRequestId)
+ {
+ this.onEntered(facilities);
+ }
+ else if (requestId == this.ExitedRequestId)
+ {
+ this.onExited?.Invoke(facilities);
+ }
+ }
+ }
+}
diff --git a/src/SimConnect.NET/Internal/ISimConnectFacilityApi.cs b/src/SimConnect.NET/Internal/ISimConnectFacilityApi.cs
new file mode 100644
index 0000000..4a43d30
--- /dev/null
+++ b/src/SimConnect.NET/Internal/ISimConnectFacilityApi.cs
@@ -0,0 +1,125 @@
+//
+// Copyright (c) BARS. All rights reserved.
+//
+
+using System;
+
+namespace SimConnect.NET.Internal
+{
+ ///
+ /// Abstraction over the SimConnect facility P/Invoke surface.
+ ///
+ internal interface ISimConnectFacilityApi
+ {
+ /// Adds a field to a facility definition.
+ /// Active SimConnect handle.
+ /// Target facility definition identifier.
+ /// The field path to add.
+ /// The raw SimConnect result.
+ int AddToFacilityDefinition(IntPtr handle, uint definitionId, string fieldName);
+
+ /// Requests facility data using a definition.
+ /// Active SimConnect handle.
+ /// Definition identifier.
+ /// Client request identifier.
+ /// Facility ICAO code.
+ /// Optional region code.
+ /// The raw SimConnect result.
+ int RequestFacilityData(IntPtr handle, uint definitionId, uint requestId, string icao, string region);
+
+ /// Requests a facility list using the legacy API.
+ /// Active SimConnect handle.
+ /// Facility list type.
+ /// Client request identifier.
+ /// The raw SimConnect result.
+ int RequestFacilitiesList(IntPtr handle, uint type, uint requestId);
+
+ /// Requests a facility list using the EX1 API.
+ /// Active SimConnect handle.
+ /// Facility list type.
+ /// Client request identifier.
+ /// The raw SimConnect result.
+ int RequestFacilitiesListEx1(IntPtr handle, uint type, uint requestId);
+
+ /// Subscribes to facilities entering/exiting range.
+ /// Active SimConnect handle.
+ /// Facility list type.
+ /// Request ID for entries entering range.
+ /// Request ID for entries leaving range.
+ /// The raw SimConnect result.
+ int SubscribeToFacilitiesEx1(IntPtr handle, uint type, uint newInRangeRequestId, uint oldOutRangeRequestId);
+
+ /// Unsubscribes from facility notifications.
+ /// Active SimConnect handle.
+ /// Facility list type.
+ /// Whether to unsubscribe from new-in-range events.
+ /// Whether to unsubscribe from out-of-range events.
+ /// The raw SimConnect result.
+ int UnsubscribeFromFacilitiesEx1(IntPtr handle, uint type, bool unsubscribeNewInRange, bool unsubscribeOldOutRange);
+ }
+
+ ///
+ /// Default implementation that forwards calls to .
+ ///
+ internal sealed class SimConnectFacilityApi : ISimConnectFacilityApi
+ {
+ private SimConnectFacilityApi()
+ {
+ }
+
+ /// Gets the shared singleton instance.
+ public static ISimConnectFacilityApi Instance { get; } = new SimConnectFacilityApi();
+
+ /// Adds a field to a facility definition.
+ /// Active SimConnect handle.
+ /// Target facility definition identifier.
+ /// The field path to add.
+ /// The raw SimConnect result.
+ public int AddToFacilityDefinition(IntPtr handle, uint definitionId, string fieldName)
+ => SimConnectNative.SimConnect_AddToFacilityDefinition(handle, definitionId, fieldName);
+
+ /// Requests facility data using a definition.
+ /// Active SimConnect handle.
+ /// Definition identifier.
+ /// Client request identifier.
+ /// Facility ICAO code.
+ /// Optional region code.
+ /// The raw SimConnect result.
+ public int RequestFacilityData(IntPtr handle, uint definitionId, uint requestId, string icao, string region)
+ => SimConnectNative.SimConnect_RequestFacilityData(handle, definitionId, requestId, icao, region ?? string.Empty);
+
+ /// Requests a facility list using the legacy API.
+ /// Active SimConnect handle.
+ /// Facility list type.
+ /// Client request identifier.
+ /// The raw SimConnect result.
+ public int RequestFacilitiesList(IntPtr handle, uint type, uint requestId)
+ => SimConnectNative.SimConnect_RequestFacilitiesList(handle, type, requestId);
+
+ /// Requests a facility list using the EX1 API.
+ /// Active SimConnect handle.
+ /// Facility list type.
+ /// Client request identifier.
+ /// The raw SimConnect result.
+ public int RequestFacilitiesListEx1(IntPtr handle, uint type, uint requestId)
+ => SimConnectNative.SimConnect_RequestFacilitiesList_EX1(handle, type, requestId);
+
+ /// Subscribes to facilities entering/exiting range.
+ /// Active SimConnect handle.
+ /// Facility list type.
+ /// Request ID for entries entering range.
+ /// Request ID for entries leaving range.
+ /// The raw SimConnect result.
+ public int SubscribeToFacilitiesEx1(IntPtr handle, uint type, uint newInRangeRequestId, uint oldOutRangeRequestId)
+ => SimConnectNative.SimConnect_SubscribeToFacilities_EX1(handle, type, newInRangeRequestId, oldOutRangeRequestId);
+
+ /// Unsubscribes from facility notifications.
+ /// Active SimConnect handle.
+ /// Facility list type.
+ /// Whether to unsubscribe from new-in-range events.
+ /// Whether to unsubscribe from out-of-range events.
+ /// The raw SimConnect result.
+ public int UnsubscribeFromFacilitiesEx1(IntPtr handle, uint type, bool unsubscribeNewInRange, bool unsubscribeOldOutRange)
+ => SimConnectNative.SimConnect_UnsubscribeToFacilities_EX1(handle, type, unsubscribeNewInRange, unsubscribeOldOutRange);
+ }
+}
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 c5b6d3f..179d10d 100644
--- a/src/SimConnect.NET/SimConnectClient.cs
+++ b/src/SimConnect.NET/SimConnectClient.cs
@@ -9,6 +9,7 @@
using SimConnect.NET.AI;
using SimConnect.NET.Aircraft;
using SimConnect.NET.Events;
+using SimConnect.NET.Facilities;
using SimConnect.NET.InputEvents;
using SimConnect.NET.Internal;
using SimConnect.NET.SimVar;
@@ -31,6 +32,7 @@ public sealed class SimConnectClient : IDisposable
private SimObjectManager? simObjectManager;
private InputEventManager? inputEventManager;
private InputGroupManager? inputGroupManager;
+ private FacilityManager? facilityManager;
private int reconnectAttempts;
private Task? reconnectTask;
private CancellationTokenSource? reconnectCancellation;
@@ -193,6 +195,23 @@ public InputGroupManager InputGroups
}
}
+ ///
+ /// Gets the facility manager for querying and subscribing to facility data.
+ ///
+ /// Thrown when not connected to SimConnect.
+ public FacilityManager Facilities
+ {
+ get
+ {
+ if (!this.isConnected || this.facilityManager == null)
+ {
+ throw new InvalidOperationException("Not connected to SimConnect. Call ConnectAsync first.");
+ }
+
+ return this.facilityManager;
+ }
+ }
+
///
/// Gets the SimConnect handle for advanced operations.
///
@@ -254,6 +273,7 @@ await Task.Run(
this.simObjectManager = new SimObjectManager(this);
this.inputEventManager = new InputEventManager(this.simConnectHandle);
this.inputGroupManager = new InputGroupManager(this.simConnectHandle);
+ this.facilityManager = new FacilityManager(this.simConnectHandle);
this.messageLoopCancellation = new CancellationTokenSource();
this.messageProcessingTask = this.StartMessageProcessingLoopAsync(this.messageLoopCancellation.Token);
@@ -276,12 +296,14 @@ public async Task DisconnectAsync()
this.simVarManager?.Dispose();
this.inputEventManager?.Dispose();
this.inputGroupManager?.Dispose();
+ this.facilityManager?.Dispose();
this.simObjectManager = null;
this.isMSFS2024 = false;
this.simVarManager = null;
this.aircraftDataManager = null;
this.inputEventManager = null;
this.inputGroupManager = null;
+ this.facilityManager = null;
if (this.messageLoopCancellation != null)
{
@@ -415,6 +437,15 @@ public async Task ProcessNextMessageAsync(CancellationToken cancellationTo
case SimConnectRecvId.SubscribeInputEvent:
this.inputEventManager?.ProcessReceivedData(ppData, pcbData);
break;
+ case SimConnectRecvId.FacilityMinimalList:
+ this.facilityManager?.ProcessFacilityMinimalList(ppData);
+ break;
+ case SimConnectRecvId.FacilityData:
+ this.facilityManager?.ProcessFacilityData(ppData);
+ break;
+ case SimConnectRecvId.FacilityDataEnd:
+ this.facilityManager?.ProcessFacilityDataEnd(ppData);
+ break;
case SimConnectRecvId.AirportList:
case SimConnectRecvId.VorList:
case SimConnectRecvId.NdbList:
diff --git a/src/SimConnect.NET/Structs/SimConnectIcao.cs b/src/SimConnect.NET/Structs/SimConnectIcao.cs
index 0ad0a96..06c8a2e 100644
--- a/src/SimConnect.NET/Structs/SimConnectIcao.cs
+++ b/src/SimConnect.NET/Structs/SimConnectIcao.cs
@@ -3,6 +3,7 @@
//
using System.Runtime.InteropServices;
+using System.Text;
namespace SimConnect.NET
{
@@ -12,24 +13,82 @@ namespace SimConnect.NET
[StructLayout(LayoutKind.Sequential, Pack = 1, CharSet = CharSet.Ansi)]
public struct SimConnectIcao
{
+ private const int IcaoSize = 9;
+ private const int IdentSize = 9;
+ private const int RegionSize = 9;
+ private const int AirportSize = 5;
+
+ [MarshalAs(UnmanagedType.ByValArray, SizeConst = IcaoSize)]
+ private byte[]? icao;
+
+ [MarshalAs(UnmanagedType.ByValArray, SizeConst = IdentSize)]
+ private byte[]? ident;
+
+ [MarshalAs(UnmanagedType.ByValArray, SizeConst = RegionSize)]
+ private byte[]? region;
+
+ [MarshalAs(UnmanagedType.ByValArray, SizeConst = AirportSize)]
+ private byte[]? airport;
+
+ ///
+ /// Gets the ICAO identifier (e.g. "KJFK").
+ ///
+ public string Icao => Decode(this.icao);
+
///
- /// The type of the ICAO code.
+ /// Gets the facility ident (e.g. runway or navaid name).
///
- public char Type;
+ public string Ident => Decode(this.ident);
///
- /// The identity string (fixed 6 characters).
+ /// Gets the region (State/area) code.
///
- public char Ident;
+ public string Region => Decode(this.region);
///
- /// The region string (fixed 3 characters).
+ /// Gets the airport identifier if the facility belongs to an airport.
///
- public char Region;
+ public string Airport => Decode(this.airport);
///
- /// The airport string (fixed 5 characters).
+ /// Creates a new instance from strings.
///
- public char Airport;
+ /// ICAO identifier.
+ /// Facility ident.
+ /// Region code.
+ /// Airport identifier.
+ /// A populated .
+ public static SimConnectIcao FromStrings(string? icao, string? ident = null, string? region = null, string? airport = null)
+ {
+ return new SimConnectIcao
+ {
+ icao = Encode(IcaoSize, icao),
+ ident = Encode(IdentSize, ident),
+ region = Encode(RegionSize, region),
+ airport = Encode(AirportSize, airport),
+ };
+ }
+
+ private static string Decode(byte[]? buffer)
+ {
+ if (buffer == null || buffer.Length == 0)
+ {
+ return string.Empty;
+ }
+
+ return Encoding.ASCII.GetString(buffer).TrimEnd('\0', ' ');
+ }
+
+ private static byte[] Encode(int size, string? value)
+ {
+ var buffer = new byte[size];
+ if (!string.IsNullOrEmpty(value))
+ {
+ var bytes = Encoding.ASCII.GetBytes(value);
+ Array.Copy(bytes, buffer, Math.Min(bytes.Length, size - 1));
+ }
+
+ return buffer;
+ }
}
}
diff --git a/tests/SimConnect.NET.UnitTests/FacilityManagerTests.cs b/tests/SimConnect.NET.UnitTests/FacilityManagerTests.cs
new file mode 100644
index 0000000..90301f0
--- /dev/null
+++ b/tests/SimConnect.NET.UnitTests/FacilityManagerTests.cs
@@ -0,0 +1,401 @@
+//
+// Copyright (c) BARS. All rights reserved.
+//
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Runtime.InteropServices;
+using System.Threading.Tasks;
+using SimConnect.NET;
+using SimConnect.NET.Facilities;
+using SimConnect.NET.Internal;
+using Xunit;
+
+namespace SimConnect.NET.UnitTests
+{
+ ///
+ /// Unit tests exercising the surface.
+ ///
+ public class FacilityManagerTests
+ {
+ ///
+ /// Verifies that custom definitions register every requested field.
+ ///
+ [Fact]
+ public void CreateDefinition_RegistersAllFields()
+ {
+ var api = new FakeFacilityApi();
+ using var manager = new FacilityManager(new IntPtr(1), api);
+
+ var definition = manager.CreateDefinition(builder => builder.AddField("Airport.Latitude").AddField("Airport.Longitude"));
+
+ Assert.Equal(2, api.AddedFields.Count);
+ Assert.Equal(definition.DefinitionId, api.AddedFields[0].DefinitionId);
+ }
+
+ ///
+ /// Ensures that minimal facility list requests complete when data arrives.
+ ///
+ /// A task that completes when the test has validated the behavior.
+ [Fact]
+ public async Task MinimalListRequest_CompletesWhenMessageArrives()
+ {
+ var api = new FakeFacilityApi();
+ using var manager = new FacilityManager(new IntPtr(1), api);
+
+ var requestTask = manager.RequestMinimalListAsync(SimConnectFacilityListType.Airport);
+ var requestId = api.MinimalRequests.Single().RequestId;
+ var facilities = new[]
+ {
+ new SimConnectFacilityMinimal
+ {
+ Icao = SimConnectIcao.FromStrings("KJFK"),
+ LatLonAlt = new SimConnectDataLatLonAlt { Latitude = 40.64, Longitude = -73.78, Altitude = 13 },
+ },
+ };
+
+ using (var message = FacilityMessageBuilder.CreateMinimalListMessage(requestId, facilities))
+ {
+ manager.ProcessFacilityMinimalList(message.Pointer);
+ }
+
+ var result = await requestTask;
+ Assert.Single(result);
+ Assert.Equal("KJFK", result[0].Icao.Icao);
+ }
+
+ ///
+ /// Validates that facility data requests return all received payloads.
+ ///
+ /// A task that completes when the test has validated the behavior.
+ [Fact]
+ public async Task FacilityDataRequest_ReturnsPayloads()
+ {
+ var api = new FakeFacilityApi();
+ using var manager = new FacilityManager(new IntPtr(1), api);
+
+ var definition = manager.CreateDefinition(b => b.AddField("Airport.Name"));
+ var task = manager.RequestFacilityDataAsync(definition, "TEST");
+ var request = api.DataRequests.Single();
+ Assert.Equal("TEST", request.Icao);
+
+ var payload = new byte[16];
+ for (int i = 0; i < payload.Length; i++)
+ {
+ payload[i] = (byte)(i + 1);
+ }
+
+ using (var message = FacilityMessageBuilder.CreateFacilityDataMessage(request.RequestId, SimConnectFacilityDataType.Airport, payload))
+ {
+ manager.ProcessFacilityData(message.Pointer);
+ }
+
+ var end = new SimConnectRecvFacilityDataEnd
+ {
+ Size = (uint)Marshal.SizeOf(),
+ Version = 1,
+ Id = (uint)SimConnectRecvId.FacilityDataEnd,
+ RequestId = request.RequestId,
+ };
+
+ IntPtr endPtr = Marshal.AllocHGlobal(Marshal.SizeOf());
+ try
+ {
+ Marshal.StructureToPtr(end, endPtr, false);
+ manager.ProcessFacilityDataEnd(endPtr);
+ }
+ finally
+ {
+ Marshal.FreeHGlobal(endPtr);
+ }
+
+ var response = await task;
+ Assert.Single(response.Results);
+ Assert.Equal(payload, response.Results[0].Payload.ToArray());
+ }
+
+ ///
+ /// Ensures that subscription callbacks fire when facilities enter and exit range.
+ ///
+ [Fact]
+ public void Subscription_DispatchesCallbacks()
+ {
+ var api = new FakeFacilityApi();
+ using var manager = new FacilityManager(new IntPtr(1), api);
+
+ List entered = new();
+ List exited = new();
+
+ using var subscription = manager.SubscribeToMinimalFacilities(
+ SimConnectFacilityListType.Airport,
+ facilities => entered.AddRange(facilities.Select(f => f.Icao.Icao)),
+ facilities => exited.AddRange(facilities.Select(f => f.Icao.Icao)));
+
+ var entry = api.Subscriptions.Single();
+ var facilities = new[]
+ {
+ new SimConnectFacilityMinimal
+ {
+ Icao = SimConnectIcao.FromStrings("SUB1"),
+ LatLonAlt = default,
+ },
+ };
+
+ using (var enterMessage = FacilityMessageBuilder.CreateMinimalListMessage(entry.EnterRequestId, facilities))
+ {
+ manager.ProcessFacilityMinimalList(enterMessage.Pointer);
+ }
+
+ using (var exitMessage = FacilityMessageBuilder.CreateMinimalListMessage(entry.ExitRequestId, facilities))
+ {
+ manager.ProcessFacilityMinimalList(exitMessage.Pointer);
+ }
+
+ Assert.Equal(new[] { "SUB1" }, entered);
+ Assert.Equal(new[] { "SUB1" }, exited);
+ }
+
+ ///
+ /// Provides helper methods to create simulated facility messages.
+ ///
+ private static class FacilityMessageBuilder
+ {
+ ///
+ /// Creates a simulated facility minimal list message.
+ ///
+ /// The request identifier associated with the list.
+ /// The facilities included in the message.
+ /// A disposable message handle.
+ public static FacilityMessageHandle CreateMinimalListMessage(uint requestId, IReadOnlyList facilities)
+ {
+ var header = new MinimalListHeader
+ {
+ Size = (uint)(MinimalListHeader.SizeInBytes + (Marshal.SizeOf() * facilities.Count)),
+ Version = 1,
+ Id = (uint)SimConnectRecvId.FacilityMinimalList,
+ RequestId = requestId,
+ ArraySize = (uint)facilities.Count,
+ EntryNumber = 0,
+ OutOf = 1,
+ };
+
+ var headerBytes = StructureToBytes(header);
+ var elementSize = Marshal.SizeOf();
+ var totalSize = headerBytes.Length + (elementSize * facilities.Count);
+ var buffer = new byte[totalSize];
+ Array.Copy(headerBytes, buffer, headerBytes.Length);
+
+ if (facilities.Count > 0)
+ {
+ var elementPtr = Marshal.AllocHGlobal(elementSize);
+ try
+ {
+ for (int i = 0; i < facilities.Count; i++)
+ {
+ Marshal.StructureToPtr(facilities[i], elementPtr, false);
+ Marshal.Copy(elementPtr, buffer, headerBytes.Length + (i * elementSize), elementSize);
+ }
+ }
+ finally
+ {
+ Marshal.FreeHGlobal(elementPtr);
+ }
+ }
+
+ return FacilityMessageHandle.Factory.FromBuffer(buffer);
+ }
+
+ ///
+ /// Creates a simulated facility data message for the specified payload.
+ ///
+ /// The request identifier.
+ /// The facility data type.
+ /// The payload bytes to embed.
+ /// A disposable message handle.
+ public static FacilityMessageHandle CreateFacilityDataMessage(uint requestId, SimConnectFacilityDataType type, byte[] payload)
+ {
+ var header = new FacilityDataHeader
+ {
+ Size = (uint)(FacilityDataHeader.PayloadOffset + payload.Length),
+ Version = 1,
+ Id = (uint)SimConnectRecvId.FacilityData,
+ UserRequestId = requestId,
+ UniqueRequestId = 42,
+ ParentUniqueRequestId = 0,
+ Type = type,
+ IsListItem = 0,
+ ItemIndex = 0,
+ ListSize = 0,
+ Data = 0,
+ };
+
+ var headerBytes = StructureToBytes(header);
+ var totalSize = Math.Max(headerBytes.Length, FacilityDataHeader.PayloadOffset + payload.Length);
+ var buffer = new byte[totalSize];
+ Array.Copy(headerBytes, buffer, headerBytes.Length);
+ Array.Copy(payload, 0, buffer, FacilityDataHeader.PayloadOffset, payload.Length);
+
+ return FacilityMessageHandle.Factory.FromBuffer(buffer, FacilityDataHeader.PayloadOffset + payload.Length);
+ }
+
+ ///
+ /// Converts a structure into a byte array for native marshalling tests.
+ ///
+ /// The structure type.
+ /// The value to copy.
+ /// A byte array containing the structure.
+ private static byte[] StructureToBytes(T value)
+ where T : struct
+ {
+ var size = Marshal.SizeOf();
+ var ptr = Marshal.AllocHGlobal(size);
+ try
+ {
+ Marshal.StructureToPtr(value, ptr, false);
+ var buffer = new byte[size];
+ Marshal.Copy(ptr, buffer, 0, size);
+ return buffer;
+ }
+ finally
+ {
+ Marshal.FreeHGlobal(ptr);
+ }
+ }
+
+ [StructLayout(LayoutKind.Sequential, Pack = 1)]
+ private struct MinimalListHeader
+ {
+ public static readonly int SizeInBytes = Marshal.SizeOf();
+
+ public uint Size;
+ public uint Version;
+ public uint Id;
+ public uint RequestId;
+ public uint ArraySize;
+ public uint EntryNumber;
+ public uint OutOf;
+ }
+
+ [StructLayout(LayoutKind.Sequential, Pack = 1)]
+ private struct FacilityDataHeader
+ {
+ public static readonly int PayloadOffset = Marshal.SizeOf() - sizeof(uint);
+
+ public uint Size;
+ public uint Version;
+ public uint Id;
+ public uint UserRequestId;
+ public uint UniqueRequestId;
+ public uint ParentUniqueRequestId;
+ public SimConnectFacilityDataType Type;
+ public uint IsListItem;
+ public uint ItemIndex;
+ public uint ListSize;
+ public uint Data;
+ }
+ }
+
+ ///
+ /// Disposable wrapper around unmanaged facility message buffers.
+ ///
+ private sealed class FacilityMessageHandle : IDisposable
+ {
+ private FacilityMessageHandle(IntPtr pointer, int length)
+ {
+ this.Pointer = pointer;
+ this.Length = length;
+ }
+
+ ///
+ /// Gets the unmanaged pointer to the simulated message.
+ ///
+ public IntPtr Pointer { get; }
+
+ ///
+ /// Gets the length of the unmanaged buffer.
+ ///
+ public int Length { get; }
+
+ ///
+ public void Dispose()
+ {
+ if (this.Pointer != IntPtr.Zero)
+ {
+ Marshal.FreeHGlobal(this.Pointer);
+ }
+ }
+
+ ///
+ /// Factory helpers for creating instances.
+ ///
+ public static class Factory
+ {
+ ///
+ /// Creates a disposable facility message handle from the provided buffer.
+ ///
+ /// The buffer to copy into unmanaged memory.
+ /// Optional size override when the buffer is larger than the payload.
+ /// A disposable facility message handle.
+ public static FacilityMessageHandle FromBuffer(byte[] buffer, int? sizeOverride = null)
+ {
+ var size = sizeOverride ?? buffer.Length;
+ var ptr = Marshal.AllocHGlobal(size);
+ Marshal.Copy(buffer, 0, ptr, size);
+ return new FacilityMessageHandle(ptr, size);
+ }
+ }
+ }
+
+ ///
+ /// Fake facility API used to capture facility requests.
+ ///
+ private sealed class FakeFacilityApi : ISimConnectFacilityApi
+ {
+ public List<(uint DefinitionId, string Field)> AddedFields { get; } = new();
+
+ public List<(uint Type, uint RequestId)> MinimalRequests { get; } = new();
+
+ public List<(uint DefinitionId, uint RequestId, string Icao, string Region)> DataRequests { get; } = new();
+
+ public List<(uint Type, uint EnterRequestId, uint ExitRequestId)> Subscriptions { get; } = new();
+
+ public List<(uint Type, bool NewRange, bool OldRange)> Unsubscriptions { get; } = new();
+
+ public int AddToFacilityDefinition(IntPtr handle, uint definitionId, string fieldName)
+ {
+ this.AddedFields.Add((definitionId, fieldName));
+ return 0;
+ }
+
+ public int RequestFacilityData(IntPtr handle, uint definitionId, uint requestId, string icao, string region)
+ {
+ this.DataRequests.Add((definitionId, requestId, icao, region));
+ return 0;
+ }
+
+ public int RequestFacilitiesList(IntPtr handle, uint type, uint requestId)
+ {
+ return 0;
+ }
+
+ public int RequestFacilitiesListEx1(IntPtr handle, uint type, uint requestId)
+ {
+ this.MinimalRequests.Add((type, requestId));
+ return 0;
+ }
+
+ public int SubscribeToFacilitiesEx1(IntPtr handle, uint type, uint newInRangeRequestId, uint oldOutRangeRequestId)
+ {
+ this.Subscriptions.Add((type, newInRangeRequestId, oldOutRangeRequestId));
+ return 0;
+ }
+
+ public int UnsubscribeFromFacilitiesEx1(IntPtr handle, uint type, bool unsubscribeNewInRange, bool unsubscribeOldOutRange)
+ {
+ this.Unsubscriptions.Add((type, unsubscribeNewInRange, unsubscribeOldOutRange));
+ return 0;
+ }
+ }
+ }
+}
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..b7f8ca7
--- /dev/null
+++ b/tests/SimConnect.NET.UnitTests/SimConnect.NET.UnitTests.csproj
@@ -0,0 +1,18 @@
+
+
+ net8.0
+ enable
+ false
+ latest
+ true
+
+
+
+
+
+
+
+
+
+
+