Phase 8A (2/2): RioJoy.Core multi-targets net48 + net40 (Windows XP flavor)

- TargetFrameworks net48;net40. net48 keeps x64 + ViGEm + System.IO.Ports
  package; net40 adds Microsoft.Bcl.Async + System.ValueTuple and uses the
  in-box SerialPort.
- Compat/TaskCompat bridges Task.Run/Delay/WhenAny/WhenAll (TaskEx on
  net40) and SemaphoreSlim.WaitAsync (net40 blocks briefly - trivial at
  9600 baud).
- IReadOnlyList/IReadOnlyDictionary -> IList/IDictionary throughout
  (net40 predates the IReadOnly* interfaces and the Bcl backport cannot
  make arrays implement them).
- HashCode.Combine replaced with a manual combine (Bcl.HashCode has no
  net40 build); Marshal.SizeOf<T> -> typeof form; ViGEmJoystickSink
  gated #if !NET40. HidFeederJoystickSink stays on both flavors - it
  will drive RioGamepadXP.sys on XP via the same contract.

Both TFMs build; 275 tests green on net48.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Cyd
2026-07-11 20:41:58 -05:00
co-authored by Claude Fable 5
parent 3b2af7b79a
commit 1ff0b16015
28 changed files with 121 additions and 53 deletions
+4 -2
View File
@@ -10,10 +10,12 @@ namespace System.Collections.Generic
/// <summary>
/// Polyfill for CollectionExtensions.GetValueOrDefault (netstandard2.1+),
/// absent on net48. Returns the value for <paramref name="key"/> or
/// default(TValue) when the key is missing.
/// default(TValue) when the key is missing. Takes IDictionary rather than
/// IReadOnlyDictionary: net40 (Windows XP flavor) predates the IReadOnly*
/// interfaces, so shared code uses the classic ones throughout.
/// </summary>
public static TValue? GetValueOrDefault<TKey, TValue>(
this IReadOnlyDictionary<TKey, TValue> dictionary, TKey key)
this IDictionary<TKey, TValue> dictionary, TKey key)
{
if (dictionary is null) throw new ArgumentNullException(nameof(dictionary));
return dictionary.TryGetValue(key, out var value) ? value : default;
+40
View File
@@ -0,0 +1,40 @@
namespace RioJoy.Core.Compat;
/// <summary>
/// net48/net40 bridge for the handful of Task-era statics the code uses. On
/// net40 (the Windows XP flavor) these come from Microsoft.Bcl.Async's
/// <c>TaskEx</c>, and <c>SemaphoreSlim.WaitAsync</c> doesn't exist at all —
/// there the wait blocks briefly instead, which at 9600 baud (tiny writes,
/// rare contention) costs nothing measurable.
/// </summary>
internal static class TaskCompat
{
#if NET40
public static Task Run(Action action) => TaskEx.Run(action);
public static Task Delay(TimeSpan delay, CancellationToken cancellationToken) =>
TaskEx.Delay(delay, cancellationToken);
public static Task<Task> WhenAny(IEnumerable<Task> tasks) => TaskEx.WhenAny(tasks);
public static Task WhenAll(IEnumerable<Task> tasks) => TaskEx.WhenAll(tasks);
public static Task WaitAsync(SemaphoreSlim semaphore, CancellationToken cancellationToken)
{
semaphore.Wait(cancellationToken); // net40: no WaitAsync; block (see class doc)
return TaskEx.FromResult(true);
}
#else
public static Task Run(Action action) => Task.Run(action);
public static Task Delay(TimeSpan delay, CancellationToken cancellationToken) =>
Task.Delay(delay, cancellationToken);
public static Task<Task> WhenAny(IEnumerable<Task> tasks) => Task.WhenAny(tasks);
public static Task WhenAll(IEnumerable<Task> tasks) => Task.WhenAll(tasks);
public static Task WaitAsync(SemaphoreSlim semaphore, CancellationToken cancellationToken) =>
semaphore.WaitAsync(cancellationToken);
#endif
}
+3 -3
View File
@@ -27,7 +27,7 @@ public sealed record PanelGroup(
bool LampCapable,
int OriginCol,
int OriginRow,
IReadOnlyList<int?> Addresses);
IList<int?> Addresses);
/// <summary>One address button placed on the panel (absolute cell coordinates).</summary>
public sealed record PanelButton(int Address, PanelGroup Group, int Col, int Row, bool LampCapable);
@@ -66,7 +66,7 @@ public static class CockpitPanel
}
/// <summary>All panel groups, positioned for rendering.</summary>
public static IReadOnlyList<PanelGroup> Groups { get; } = new[]
public static IList<PanelGroup> Groups { get; } = new[]
{
// Upper MFD row.
new PanelGroup("Upper Left MFD", PanelGroupKind.Mfd, 4, 2, true, 0, 1, Mfd(0x2F)),
@@ -90,7 +90,7 @@ public static class CockpitPanel
};
/// <summary>Flatten the groups to positioned <see cref="PanelButton"/>s (absolute cells).</summary>
public static IReadOnlyList<PanelButton> Buttons()
public static IList<PanelButton> Buttons()
{
var buttons = new List<PanelButton>();
foreach (PanelGroup g in Groups)
+2 -2
View File
@@ -34,7 +34,7 @@ public sealed record SheetCell(int Row, int Col, string Text, int? Address, Shee
/// </summary>
public static class SheetLayout
{
public static IReadOnlyList<SheetCell> Load(string path) => Parse(File.ReadAllText(path));
public static IList<SheetCell> Load(string path) => Parse(File.ReadAllText(path));
/// <summary>
/// The RIO address a cell's text encodes (two hex digits in 0x000x6F), or null.
@@ -66,7 +66,7 @@ public static class SheetLayout
/// longer than <paramref name="maxTextLength"/> is dropped as a merged-cell
/// artifact; the clean export has none.
/// </summary>
public static IReadOnlyList<SheetCell> Parse(string csv, int maxTextLength = 40)
public static IList<SheetCell> Parse(string csv, int maxTextLength = 40)
{
if (csv is null) throw new ArgumentNullException(nameof(csv));
+1 -1
View File
@@ -13,7 +13,7 @@ public readonly record struct KeyName(string Name, byte Value);
public static class KeyCatalog
{
/// <summary>All catalogued keys, in menu order (letters, digits, F-keys, …).</summary>
public static IReadOnlyList<KeyName> Entries { get; } = Build();
public static IList<KeyName> Entries { get; } = Build();
private static readonly Dictionary<byte, string> ByValue =
Entries.GroupBy(e => e.Value).ToDictionary(g => g.Key, g => g.First().Name);
+1 -1
View File
@@ -14,7 +14,7 @@ public sealed class RioInputMap
public RioInputMap() { }
/// <summary>Create a map from raw 16-bit words indexed by address.</summary>
public RioInputMap(IReadOnlyDictionary<int, ushort> entries)
public RioInputMap(IDictionary<int, ushort> entries)
{
if (entries is null) throw new ArgumentNullException(nameof(entries));
foreach ((int address, ushort raw) in entries)
@@ -114,7 +114,7 @@ public sealed class HidFeederJoystickSink : IJoystickSink, IDisposable
try
{
var ifData = new SP_DEVICE_INTERFACE_DATA();
ifData.cbSize = Marshal.SizeOf<SP_DEVICE_INTERFACE_DATA>();
ifData.cbSize = Marshal.SizeOf(typeof(SP_DEVICE_INTERFACE_DATA));
if (!SetupDiEnumDeviceInterfaces(devInfo, IntPtr.Zero, ref guid, 0, ref ifData))
return null;
+1 -1
View File
@@ -69,7 +69,7 @@ public sealed class SendInputSink : IInputSink
}
private static void Send(INPUT input) =>
SendInput(1, new[] { input }, Marshal.SizeOf<INPUT>());
SendInput(1, new[] { input }, Marshal.SizeOf(typeof(INPUT)));
// --- Win32 interop --------------------------------------------------------
+3 -1
View File
@@ -1,3 +1,4 @@
#if !NET40 // ViGEmBus is Win10+ only; the XP flavor uses RioGamepadXP (PLAN.md §8B)
using Nefarius.ViGEm.Client;
using Nefarius.ViGEm.Client.Targets;
using Nefarius.ViGEm.Client.Targets.Xbox360;
@@ -26,7 +27,7 @@ public sealed class ViGEmJoystickSink : IJoystickSink, IDisposable
};
/// <summary>Display names for the mappable buttons, aligned with <see cref="ButtonMap"/>.</summary>
public static IReadOnlyList<string> ButtonNames { get; } = new[]
public static IList<string> ButtonNames { get; } = new[]
{
"A", "B", "X", "Y", "LB", "RB", "Back", "Start", "L3", "R3", "Guide",
};
@@ -125,3 +126,4 @@ public sealed class ViGEmJoystickSink : IJoystickSink, IDisposable
_client.Dispose();
}
}
#endif
@@ -18,8 +18,8 @@ public static class GoobieDataImporter
{
/// <summary>A parsed data file: the field names and one label map per row.</summary>
public sealed record Sheet(
IReadOnlyList<string> Fields,
IReadOnlyList<IReadOnlyDictionary<string, string>> Rows);
IList<string> Fields,
IList<IDictionary<string, string>> Rows);
public static Sheet Load(string path) => Parse(File.ReadAllText(path));
@@ -33,7 +33,7 @@ public static class GoobieDataImporter
throw new FormatException("No lists found; expected a header list of field names.");
List<string> fields = lists[0];
var rows = new List<IReadOnlyDictionary<string, string>>();
var rows = new List<IDictionary<string, string>>();
for (int i = 1; i < lists.Count; i++)
{
@@ -51,7 +51,7 @@ public static class GoobieDataImporter
/// Convenience: the label map for a single row (default the first), with empty
/// values dropped — ready to hand to <see cref="OverlayLayoutEngine.Layout"/>.
/// </summary>
public static IReadOnlyDictionary<string, string> LabelsForRow(Sheet sheet, int rowIndex = 0)
public static IDictionary<string, string> LabelsForRow(Sheet sheet, int rowIndex = 0)
{
if (sheet is null) throw new ArgumentNullException(nameof(sheet));
if (rowIndex < 0 || rowIndex >= sheet.Rows.Count)
+2 -2
View File
@@ -15,7 +15,7 @@ public static class OverlayHitTester
/// A region's rectangle is its stored cell geometry; label rotation does not
/// affect hit-testing.
/// </summary>
public static IReadOnlyList<OverlayRegion> RegionsAt(OverlayTemplate template, double x, double y)
public static IList<OverlayRegion> RegionsAt(OverlayTemplate template, double x, double y)
{
if (template is null) throw new ArgumentNullException(nameof(template));
@@ -33,7 +33,7 @@ public static class OverlayHitTester
/// </summary>
public static OverlayRegion? NextAt(OverlayTemplate template, double x, double y, string? currentName)
{
IReadOnlyList<OverlayRegion> hits = RegionsAt(template, x, y);
IList<OverlayRegion> hits = RegionsAt(template, x, y);
if (hits.Count == 0)
return null;
@@ -20,9 +20,9 @@ public sealed class OverlayLayoutEngine
/// in <paramref name="labels"/> (or with empty text) are skipped — matching the
/// legacy behavior where a blank field produced no visible text.
/// </summary>
public IReadOnlyList<PlacedLabel> Layout(
public IList<PlacedLabel> Layout(
OverlayTemplate template,
IReadOnlyDictionary<string, string> labels,
IDictionary<string, string> labels,
OverlayLayoutOptions? options = null)
{
if (template is null) throw new ArgumentNullException(nameof(template));
+4 -1
View File
@@ -33,7 +33,10 @@ public readonly struct SwitchDecision : IEquatable<SwitchDecision>
public bool Equals(SwitchDecision other) => Mode == other.Mode && ReferenceEquals(Profile, other.Profile);
public override bool Equals(object? obj) => obj is SwitchDecision d && Equals(d);
public override int GetHashCode() => HashCode.Combine(Mode, Profile);
// Manual combine: HashCode.Combine needs Microsoft.Bcl.HashCode, which has
// no net40 build (Windows XP flavor).
public override int GetHashCode() =>
((int)Mode * 397) ^ (Profile?.GetHashCode() ?? 0);
public override string ToString() => Mode == SwitchMode.Activate ? $"Activate({Profile?.Name})" : Mode.ToString();
}
@@ -62,7 +62,7 @@ public sealed class AutoSwitchWatcher
{
try
{
await Task.Delay(interval, cancellationToken).ConfigureAwait(false);
await Compat.TaskCompat.Delay(interval, cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
+1 -1
View File
@@ -44,7 +44,7 @@ public sealed class IniFile
}
/// <summary>All key/value pairs in a section (empty if the section is absent).</summary>
public IReadOnlyDictionary<string, string> Section(string name) =>
public IDictionary<string, string> Section(string name) =>
_sections.TryGetValue(name, out Dictionary<string, string>? s)
? s
: new Dictionary<string, string>();
+29 -8
View File
@@ -1,26 +1,47 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<!-- net48 test branch. Uses Win32 P/Invoke (SendInput, SystemParametersInfo)
and System.IO.Ports. Span/records/init/Index are polyfilled (System.Memory
+ PolySharp); System.Text.Json comes from NuGet (not in the net48 BCL). -->
<TargetFramework>net48</TargetFramework>
<!-- Two flavors (PLAN.md §Phase 8): net48 = Windows 10/11 (x64, ViGEm +
RioGamepad); net40 = Windows XP SP3 (x86 cabinets — .NET 4.0 is XP's
ceiling). Modern language features come from PolySharp on both; net40
async machinery comes from Microsoft.Bcl.Async (TaskEx via
Compat/TaskCompat). Win32 P/Invoke (SendInput, DeviceIoControl) and
in-box System.IO.Ports work on both. -->
<TargetFrameworks>net48;net40</TargetFrameworks>
<Platforms>x64</Platforms>
<PlatformTarget>x64</PlatformTarget>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
</PropertyGroup>
<!-- net48 pins x64 (matches the driver + tray exe); net40 stays AnyCPU and
the XP tray exe pins x86. -->
<PropertyGroup Condition="'$(TargetFramework)' == 'net48'">
<PlatformTarget>x64</PlatformTarget>
</PropertyGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'net40'">
<!-- net40 has no System.Net.Http assembly for the implicit global using. -->
<Using Remove="System.Net.Http" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Nefarius.ViGEm.Client" Version="1.21.256" />
<PackageReference Include="System.IO.Ports" Version="8.0.0" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="Microsoft.Bcl.HashCode" Version="1.1.1" />
<PackageReference Include="Microsoft.NETFramework.ReferenceAssemblies" Version="1.0.3" PrivateAssets="all" />
<PackageReference Include="PolySharp" Version="1.14.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'net48'">
<PackageReference Include="Nefarius.ViGEm.Client" Version="1.21.256" />
<PackageReference Include="System.IO.Ports" Version="8.0.0" />
</ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'net40'">
<PackageReference Include="Microsoft.Bcl.Async" Version="1.0.168" />
<PackageReference Include="System.ValueTuple" Version="4.5.0" />
</ItemGroup>
</Project>
+4 -4
View File
@@ -69,19 +69,19 @@ public sealed class RioSerialLink
{
// If any running loop ends (transport closed / error / cancellation),
// tear the others down too.
await Task.WhenAny(loops).ConfigureAwait(false);
await Compat.TaskCompat.WhenAny(loops).ConfigureAwait(false);
}
finally
{
linked.Cancel();
await Task.WhenAll(loops.Select(Swallow)).ConfigureAwait(false);
await Compat.TaskCompat.WhenAll(loops.Select(Swallow)).ConfigureAwait(false);
}
}
/// <summary>Send a pre-built packet (see <see cref="PacketBuilder"/>) to the RIO.</summary>
public async Task SendAsync(byte[] packet, CancellationToken cancellationToken = default)
{
await _writeLock.WaitAsync(cancellationToken).ConfigureAwait(false);
await Compat.TaskCompat.WaitAsync(_writeLock, cancellationToken).ConfigureAwait(false);
try
{
await _transport.WriteAsync(packet, cancellationToken).ConfigureAwait(false);
@@ -191,7 +191,7 @@ public sealed class RioSerialLink
{
while (!ct.IsCancellationRequested)
{
await Task.Delay(_options.AnalogPollInterval, ct).ConfigureAwait(false);
await Compat.TaskCompat.Delay(_options.AnalogPollInterval, ct).ConfigureAwait(false);
await RequestAnalogAsync(ct).ConfigureAwait(false);
@@ -74,7 +74,7 @@ public sealed class SerialPortTransport : IRioTransport
// still releases the handle.
}
Task close = Task.Run(() =>
Task close = Compat.TaskCompat.Run(() =>
{
try { _port.Dispose(); }
catch { /* best-effort release */ }
@@ -28,7 +28,7 @@ public sealed class ProfileWallpaperGenerator
public string Generate(
OverlayTemplate template,
string templateDirectory,
IReadOnlyDictionary<string, string> labels,
IDictionary<string, string> labels,
string outputPath,
OverlayLayoutOptions? options = null)
{
+3 -3
View File
@@ -31,7 +31,7 @@ public sealed class SkiaOverlayRenderer
/// </summary>
public SKBitmap Render(
OverlayTemplate template,
IReadOnlyDictionary<string, string> labels,
IDictionary<string, string> labels,
SKBitmap baseImage,
OverlayLayoutOptions? options = null)
{
@@ -95,7 +95,7 @@ public sealed class SkiaOverlayRenderer
/// <summary>Render and encode to PNG bytes.</summary>
public byte[] RenderToPng(
OverlayTemplate template,
IReadOnlyDictionary<string, string> labels,
IDictionary<string, string> labels,
SKBitmap baseImage,
OverlayLayoutOptions? options = null)
{
@@ -110,7 +110,7 @@ public sealed class SkiaOverlayRenderer
/// </summary>
public void RenderToFile(
OverlayTemplate template,
IReadOnlyDictionary<string, string> labels,
IDictionary<string, string> labels,
string outputPath,
OverlayLayoutOptions? options = null)
{
+1 -1
View File
@@ -10,7 +10,7 @@ namespace RioJoy.Tray.Editor;
/// </summary>
internal sealed class PanelCanvas : Control
{
private static readonly IReadOnlyList<PanelButton> AllButtons = CockpitPanel.Buttons();
private static readonly IList<PanelButton> AllButtons = CockpitPanel.Buttons();
public PanelCanvas()
{
+5 -5
View File
@@ -40,7 +40,7 @@ public static class PanelView
public const int CalOriginCol = 2;
public const int CalOriginRow = 9;
public static IReadOnlyList<CalibrationCell> CalibrationCells { get; } = new[]
public static IList<CalibrationCell> CalibrationCells { get; } = new[]
{
new CalibrationCell(JoyStickSetting.InvertX, "Inv X", 2, 10),
new CalibrationCell(JoyStickSetting.InvertY, "Inv Y", 2, 11),
@@ -51,7 +51,7 @@ public static class PanelView
new CalibrationCell(JoyStickSetting.EnableZR, "ZR mix", 2, 16),
};
public static Size GridSize(IReadOnlyList<PanelButton> buttons)
public static Size GridSize(IList<PanelButton> buttons)
{
int maxCol = 0, maxRow = 0;
foreach (PanelButton b in buttons)
@@ -69,8 +69,8 @@ public static class PanelView
public static void Paint(
Graphics g,
IReadOnlyList<PanelButton> buttons,
IReadOnlyList<PanelGroup> groups,
IList<PanelButton> buttons,
IList<PanelGroup> groups,
Func<int, string?> label,
Func<int, string?> function,
Func<int, bool> lit,
@@ -225,7 +225,7 @@ public static class PanelView
}
}
public static PanelButton? HitTest(IReadOnlyList<PanelButton> buttons, Point p)
public static PanelButton? HitTest(IList<PanelButton> buttons, Point p)
{
foreach (PanelButton b in buttons)
if (Cell(b.Col, b.Row).Contains(p))
@@ -50,7 +50,7 @@ public class CockpitPanelTests
public void Keypad_PhysicalLayout_MapsDigitsToAddresses()
{
// Internal keypad: top-left "1" -> 0x51, bottom-middle "0" -> 0x50, "C" -> 0x5C.
IReadOnlyList<PanelButton> all = CockpitPanel.Buttons();
IList<PanelButton> all = CockpitPanel.Buttons();
PanelGroup pad = CockpitPanel.Groups.Single(g => g.Title == "Internal Keypad");
Assert.Equal(0x51, pad.Addresses[0]); // row0 col0 = "1"
@@ -47,7 +47,7 @@ public class SheetLayoutTests
"\"\",\"IsLit\",\"\",\"\",\"0F\"\n" +
"\"\",\"\",\"\",\"\",\"HOME\"";
IReadOnlyList<SheetCell> cells = SheetLayout.Parse(csv);
IList<SheetCell> cells = SheetLayout.Parse(csv);
SheetCell addr = Assert.Single(cells, c => c.Text == "0F");
Assert.Equal(0x0F, addr.Address);
@@ -64,7 +64,7 @@ public class SheetLayoutTests
[Fact]
public void Parse_RealSheet_HasFullAddressMap()
{
IReadOnlyList<SheetCell> cells = SheetLayout.Load(
IList<SheetCell> cells = SheetLayout.Load(
TestRepo.CustomBackground("config-sheet.csv"));
// Every RIO address 0x00..0x6F that exists should appear exactly once as a cell.
@@ -26,7 +26,7 @@ public class GoobieDataImporterTests
[Fact]
public void LabelsForRow_DropsEmptyValues()
{
IReadOnlyDictionary<string, string> labels =
IDictionary<string, string> labels =
GoobieDataImporter.LabelsForRow(GoobieDataImporter.Parse(Sample));
Assert.True(labels.ContainsKey("b-00"));
@@ -21,7 +21,7 @@ public class OverlayHitTesterTests
[Fact]
public void RegionsAt_ReturnsSmallestFirst()
{
IReadOnlyList<OverlayRegion> hits = OverlayHitTester.RegionsAt(Template(), 100, 50);
IList<OverlayRegion> hits = OverlayHitTester.RegionsAt(Template(), 100, 50);
Assert.Equal(new[] { "small", "big" }, hits.Select(r => r.Name));
}
@@ -133,7 +133,7 @@ public class OverlayLayoutEngineTests
["b-99"] = "ORPHAN", // no such region -> ignored
};
IReadOnlyList<PlacedLabel> placed = Engine.Layout(template, labels);
IList<PlacedLabel> placed = Engine.Layout(template, labels);
Assert.Equal(2, placed.Count);
Assert.Equal("b-00", placed[0].RegionName);
@@ -35,7 +35,7 @@ public class OverlayRenderIntegrationTests
Assert.Equal(template.Width, baseImg.Width);
Assert.Equal(template.Height, baseImg.Height);
IReadOnlyDictionary<string, string> labels =
IDictionary<string, string> labels =
GoobieDataImporter.LabelsForRow(GoobieDataImporter.Load(TestRepo.CustomBackground("TEST.data")));
using SKBitmap rendered = new SkiaOverlayRenderer().Render(template, labels, baseImg);