net48 port (test branch): retarget all projects to .NET Framework 4.8

Retargets RioJoy.Core/Overlay/Tray + tests from net8.0-windows to net48 so
the app can be tested as a framework-dependent build (relies on the in-box
.NET Framework 4.8 on Windows 10/11). Builds clean; all 241 tests pass.

Polyfills (no behavior change):
- PolySharp source generator for init/records/Index/Range/required members.
- System.Memory, System.Text.Json, Microsoft.Bcl.HashCode,
  System.Threading.Channels (tests) NuGet packages.
- Compat/Net48Polyfills.cs: GetValueOrDefault, KeyValuePair.Deconstruct,
  Math.Clamp; tests/TestPolyfills.cs: Task.WaitAsync.

Source adjustments for APIs absent on net48:
- ArgumentNullException/ArgumentException.ThrowIf* inlined to manual guards.
- Convert.ToHexString, Encoding.Latin1, Environment.ProcessPath,
  ApplicationConfiguration.Initialize, Enum.GetNames<T>/GetValues<T>,
  string.StartsWith(char), string.Split(char, opts), TextBox.PlaceholderText,
  PeriodicTimer, Memory-based Stream Read/WriteAsync, array range-slicing.
- Dropped [SupportedOSPlatform] hints (net48 is single-platform).

deploy/build-package.ps1: publish framework-dependent (no self-contained).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Cyd
2026-06-30 12:34:47 -05:00
co-authored by Claude Opus 4.8
parent 67b56559f7
commit fe87c79f55
42 changed files with 198 additions and 93 deletions
+4 -4
View File
@@ -47,12 +47,12 @@ try {
$pkgDir = Join-Path $staging 'RIOJoy' $pkgDir = Join-Path $staging 'RIOJoy'
New-Item -ItemType Directory -Force -Path $pkgDir | Out-Null New-Item -ItemType Directory -Force -Path $pkgDir | Out-Null
# 1. Publish the tray app into riojoy\app (self-contained win-x64 — no .NET on target). # 1. Publish the tray app into riojoy\app (net48, framework-dependent — relies on
# the in-box .NET Framework 4.8 present on every Windows 10/11 machine).
$appOut = Join-Path $pkgDir 'app' $appOut = Join-Path $pkgDir 'app'
Write-Host "Publishing RioJoy.Tray ($Configuration, self-contained win-x64)..." Write-Host "Publishing RioJoy.Tray ($Configuration, net48 framework-dependent)..."
& dotnet publish (Join-Path $repo 'src\RioJoy.Tray\RioJoy.Tray.csproj') ` & dotnet publish (Join-Path $repo 'src\RioJoy.Tray\RioJoy.Tray.csproj') `
-c $Configuration -r win-x64 --self-contained true ` -c $Configuration -p:DebugType=none `
-p:PublishSingleFile=false -p:DebugType=none `
-o $appOut | Out-Null -o $appOut | Out-Null
if ($LASTEXITCODE -ne 0) { throw 'dotnet publish failed.' } if ($LASTEXITCODE -ne 0) { throw 'dotnet publish failed.' }
@@ -68,7 +68,7 @@ public sealed class AxisCalibrator
Clamp(x), Clamp(y), Clamp(z), Clamp(rx), Clamp(ry), Clamp(rz)); Clamp(x), Clamp(y), Clamp(z), Clamp(rx), Clamp(ry), Clamp(rz));
} }
private static int Clamp(int v) => Math.Clamp(v, 0, AxisOutputs.Max); private static int Clamp(int v) => RioJoy.Core.Compat.Net48Math.Clamp(v, 0, AxisOutputs.Max);
private int Throttle(int throttle) private int Throttle(int throttle)
{ {
+52
View File
@@ -0,0 +1,52 @@
// net48 compatibility shims. On net8 these APIs are in the BCL; net48 lacks them.
// (Language-feature polyfills — init/records/Index/Range/required — come from the
// PolySharp source generator; this file covers runtime helpers that PolySharp
// cannot supply because they live on existing BCL types.)
namespace System.Collections.Generic
{
public static class Net48CollectionExtensions
{
/// <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.
/// </summary>
public static TValue? GetValueOrDefault<TKey, TValue>(
this IReadOnlyDictionary<TKey, TValue> dictionary, TKey key)
{
if (dictionary is null) throw new ArgumentNullException(nameof(dictionary));
return dictionary.TryGetValue(key, out var value) ? value : default;
}
/// <summary>
/// Polyfill for KeyValuePair&lt;,&gt;.Deconstruct (netstandard2.1+), absent on
/// net48 — enables <c>foreach (var (k, v) in dictionary)</c>.
/// </summary>
public static void Deconstruct<TKey, TValue>(
this KeyValuePair<TKey, TValue> pair, out TKey key, out TValue value)
{
key = pair.Key;
value = pair.Value;
}
}
}
namespace RioJoy.Core.Compat
{
internal static class Net48Math
{
/// <summary>
/// Polyfill for Math.Clamp (netstandard2.1+), absent on net48. Returns
/// <paramref name="value"/> bounded to [<paramref name="min"/>, <paramref name="max"/>].
/// </summary>
public static T Clamp<T>(T value, T min, T max) where T : IComparable<T>
{
if (min.CompareTo(max) > 0)
throw new ArgumentException($"{nameof(min)} ({min}) cannot be greater than {nameof(max)} ({max}).");
if (value.CompareTo(min) < 0) return min;
if (value.CompareTo(max) > 0) return max;
return value;
}
}
}
+1 -1
View File
@@ -68,7 +68,7 @@ public static class SheetLayout
/// </summary> /// </summary>
public static IReadOnlyList<SheetCell> Parse(string csv, int maxTextLength = 40) public static IReadOnlyList<SheetCell> Parse(string csv, int maxTextLength = 40)
{ {
ArgumentNullException.ThrowIfNull(csv); if (csv is null) throw new ArgumentNullException(nameof(csv));
string[] lines = csv.Replace("\r\n", "\n").Replace('\r', '\n').Split('\n'); string[] lines = csv.Replace("\r\n", "\n").Replace('\r', '\n').Split('\n');
var cells = new List<SheetCell>(); var cells = new List<SheetCell>();
+1 -1
View File
@@ -52,7 +52,7 @@ public sealed class RioHidReport
/// <summary>Set an axis value (clamped to 0..<see cref="AxisMax"/>), little-endian.</summary> /// <summary>Set an axis value (clamped to 0..<see cref="AxisMax"/>), little-endian.</summary>
public void SetAxis(JoyAxis axis, int value) public void SetAxis(JoyAxis axis, int value)
{ {
ushort v = (ushort)Math.Clamp(value, 0, AxisMax); ushort v = (ushort)RioJoy.Core.Compat.Net48Math.Clamp(value, 0, AxisMax);
int offset = (int)axis * 2; int offset = (int)axis * 2;
_buffer[offset] = (byte)(v & 0xFF); _buffer[offset] = (byte)(v & 0xFF);
_buffer[offset + 1] = (byte)(v >> 8); _buffer[offset + 1] = (byte)(v >> 8);
+1 -1
View File
@@ -16,7 +16,7 @@ public sealed class RioInputMap
/// <summary>Create a map from raw 16-bit words indexed by address.</summary> /// <summary>Create a map from raw 16-bit words indexed by address.</summary>
public RioInputMap(IReadOnlyDictionary<int, ushort> entries) public RioInputMap(IReadOnlyDictionary<int, ushort> entries)
{ {
ArgumentNullException.ThrowIfNull(entries); if (entries is null) throw new ArgumentNullException(nameof(entries));
foreach ((int address, ushort raw) in entries) foreach ((int address, ushort raw) in entries)
this[address] = new RioMapEntry(raw); this[address] = new RioMapEntry(raw);
} }
@@ -1,5 +1,4 @@
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using System.Runtime.Versioning;
using Microsoft.Win32.SafeHandles; using Microsoft.Win32.SafeHandles;
using RioJoy.Core.Calibration; using RioJoy.Core.Calibration;
using RioJoy.Core.Hid; using RioJoy.Core.Hid;
@@ -14,7 +13,6 @@ namespace RioJoy.Core.Output;
/// <c>DeviceIoControl(IOCTL_RIO_SUBMIT_REPORT)</c>. Replaces /// <c>DeviceIoControl(IOCTL_RIO_SUBMIT_REPORT)</c>. Replaces
/// <see cref="NullJoystickSink"/> once the driver is installed (Phase 1/3b). /// <see cref="NullJoystickSink"/> once the driver is installed (Phase 1/3b).
/// </summary> /// </summary>
[SupportedOSPlatform("windows")]
public sealed class HidFeederJoystickSink : IJoystickSink, IDisposable public sealed class HidFeederJoystickSink : IJoystickSink, IDisposable
{ {
// Must match driver/RioGamepad/Public.h. // Must match driver/RioGamepad/Public.h.
-2
View File
@@ -1,5 +1,4 @@
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using System.Runtime.Versioning;
using RioJoy.Core.Mapping; using RioJoy.Core.Mapping;
using MouseButtonId = RioJoy.Core.Mapping.MouseButton; using MouseButtonId = RioJoy.Core.Mapping.MouseButton;
@@ -12,7 +11,6 @@ namespace RioJoy.Core.Output;
/// scancodes (most do) see the keypress. Targets the interactive session, which /// scancodes (most do) see the keypress. Targets the interactive session, which
/// is why RIOJoy is a tray app rather than a service (see docs/PLAN.md risks). /// is why RIOJoy is a tray app rather than a service (see docs/PLAN.md risks).
/// </summary> /// </summary>
[SupportedOSPlatform("windows")]
public sealed class SendInputSink : IInputSink public sealed class SendInputSink : IInputSink
{ {
public void KeyDown(byte virtualKey, bool extended) => SendKey(virtualKey, extended, keyUp: false); public void KeyDown(byte virtualKey, bool extended) => SendKey(virtualKey, extended, keyUp: false);
+2 -4
View File
@@ -1,4 +1,3 @@
using System.Runtime.Versioning;
using Nefarius.ViGEm.Client; using Nefarius.ViGEm.Client;
using Nefarius.ViGEm.Client.Targets; using Nefarius.ViGEm.Client.Targets;
using Nefarius.ViGEm.Client.Targets.Xbox360; using Nefarius.ViGEm.Client.Targets.Xbox360;
@@ -14,7 +13,6 @@ namespace RioJoy.Core.Output;
/// triggers, and the D-pad for the hat. RIO joystick buttons beyond /// triggers, and the D-pad for the hat. RIO joystick buttons beyond
/// <see cref="MappableButtonCount"/> are dropped (map those to the keyboard). /// <see cref="MappableButtonCount"/> are dropped (map those to the keyboard).
/// </summary> /// </summary>
[SupportedOSPlatform("windows")]
public sealed class ViGEmJoystickSink : IJoystickSink, IDisposable public sealed class ViGEmJoystickSink : IJoystickSink, IDisposable
{ {
// RIO joystick button (1-based) -> Xbox 360 button, in a natural order. // RIO joystick button (1-based) -> Xbox 360 button, in a natural order.
@@ -115,11 +113,11 @@ public sealed class ViGEmJoystickSink : IJoystickSink, IDisposable
// RIO axis 0..32766 (centre 16383) -> Xbox thumb short -32768..32767. // RIO axis 0..32766 (centre 16383) -> Xbox thumb short -32768..32767.
private static short ToThumb(int value) => private static short ToThumb(int value) =>
(short)Math.Clamp((value - AxisOutputs.Center) * 2, short.MinValue, short.MaxValue); (short)RioJoy.Core.Compat.Net48Math.Clamp((value - AxisOutputs.Center) * 2, short.MinValue, short.MaxValue);
// RIO axis 0..32766 -> Xbox trigger byte 0..255. // RIO axis 0..32766 -> Xbox trigger byte 0..255.
private static byte ToTrigger(int value) => private static byte ToTrigger(int value) =>
(byte)Math.Clamp(value * 255 / AxisOutputs.Max, 0, 255); (byte)RioJoy.Core.Compat.Net48Math.Clamp(value * 255 / AxisOutputs.Max, 0, 255);
public void Dispose() public void Dispose()
{ {
+1 -1
View File
@@ -27,7 +27,7 @@ public static class FontFitter
public static double BestFitSize( public static double BestFitSize(
string text, string fontFamily, double width, double height, ITextMeasurer measurer) string text, string fontFamily, double width, double height, ITextMeasurer measurer)
{ {
ArgumentNullException.ThrowIfNull(measurer); if (measurer is null) throw new ArgumentNullException(nameof(measurer));
// State mirrors the Scheme named-let: (fontsize last-extents last-fontsize adjust). // State mirrors the Scheme named-let: (fontsize last-extents last-fontsize adjust).
double fontsize = 6; // minimum possible fontsize (the loop's seed) double fontsize = 6; // minimum possible fontsize (the loop's seed)
@@ -26,7 +26,7 @@ public static class GoobieDataImporter
/// <summary>Parse the data file text into fields + rows.</summary> /// <summary>Parse the data file text into fields + rows.</summary>
public static Sheet Parse(string text) public static Sheet Parse(string text)
{ {
ArgumentNullException.ThrowIfNull(text); if (text is null) throw new ArgumentNullException(nameof(text));
List<List<string>> lists = ReadLists(text); List<List<string>> lists = ReadLists(text);
if (lists.Count == 0) if (lists.Count == 0)
@@ -53,7 +53,7 @@ public static class GoobieDataImporter
/// </summary> /// </summary>
public static IReadOnlyDictionary<string, string> LabelsForRow(Sheet sheet, int rowIndex = 0) public static IReadOnlyDictionary<string, string> LabelsForRow(Sheet sheet, int rowIndex = 0)
{ {
ArgumentNullException.ThrowIfNull(sheet); if (sheet is null) throw new ArgumentNullException(nameof(sheet));
if (rowIndex < 0 || rowIndex >= sheet.Rows.Count) if (rowIndex < 0 || rowIndex >= sheet.Rows.Count)
throw new ArgumentOutOfRangeException(nameof(rowIndex)); throw new ArgumentOutOfRangeException(nameof(rowIndex));
@@ -25,8 +25,8 @@ public sealed class OverlayLayoutEngine
IReadOnlyDictionary<string, string> labels, IReadOnlyDictionary<string, string> labels,
OverlayLayoutOptions? options = null) OverlayLayoutOptions? options = null)
{ {
ArgumentNullException.ThrowIfNull(template); if (template is null) throw new ArgumentNullException(nameof(template));
ArgumentNullException.ThrowIfNull(labels); if (labels is null) throw new ArgumentNullException(nameof(labels));
options ??= new OverlayLayoutOptions(); options ??= new OverlayLayoutOptions();
var placed = new List<PlacedLabel>(template.Regions.Count); var placed = new List<PlacedLabel>(template.Regions.Count);
@@ -41,8 +41,8 @@ public sealed class OverlayLayoutEngine
/// <summary>Lay out a single label within a region.</summary> /// <summary>Lay out a single label within a region.</summary>
public PlacedLabel Place(OverlayRegion region, string text, OverlayLayoutOptions? options = null) public PlacedLabel Place(OverlayRegion region, string text, OverlayLayoutOptions? options = null)
{ {
ArgumentNullException.ThrowIfNull(region); if (region is null) throw new ArgumentNullException(nameof(region));
ArgumentException.ThrowIfNullOrEmpty(text); if (string.IsNullOrEmpty(text)) throw new ArgumentException("Value cannot be null or empty.", nameof(text));
options ??= new OverlayLayoutOptions(); options ??= new OverlayLayoutOptions();
double rotation = region.RotationDegrees ?? 0; double rotation = region.RotationDegrees ?? 0;
@@ -20,20 +20,20 @@ public static class OverlayTemplateStore
public static string Serialize(OverlayTemplate template) public static string Serialize(OverlayTemplate template)
{ {
ArgumentNullException.ThrowIfNull(template); if (template is null) throw new ArgumentNullException(nameof(template));
return JsonSerializer.Serialize(template, Options); return JsonSerializer.Serialize(template, Options);
} }
public static OverlayTemplate Deserialize(string json) public static OverlayTemplate Deserialize(string json)
{ {
ArgumentException.ThrowIfNullOrWhiteSpace(json); if (string.IsNullOrWhiteSpace(json)) throw new ArgumentException("Value cannot be null or whitespace.", nameof(json));
return JsonSerializer.Deserialize<OverlayTemplate>(json, Options) return JsonSerializer.Deserialize<OverlayTemplate>(json, Options)
?? throw new JsonException("Overlay template JSON deserialized to null."); ?? throw new JsonException("Overlay template JSON deserialized to null.");
} }
public static void Save(OverlayTemplate template, string path) public static void Save(OverlayTemplate template, string path)
{ {
ArgumentException.ThrowIfNullOrWhiteSpace(path); if (string.IsNullOrWhiteSpace(path)) throw new ArgumentException("Value cannot be null or whitespace.", nameof(path));
string? dir = Path.GetDirectoryName(Path.GetFullPath(path)); string? dir = Path.GetDirectoryName(Path.GetFullPath(path));
if (!string.IsNullOrEmpty(dir)) if (!string.IsNullOrEmpty(dir))
Directory.CreateDirectory(dir); Directory.CreateDirectory(dir);
@@ -42,7 +42,7 @@ public static class OverlayTemplateStore
public static OverlayTemplate Load(string path) public static OverlayTemplate Load(string path)
{ {
ArgumentException.ThrowIfNullOrWhiteSpace(path); if (string.IsNullOrWhiteSpace(path)) throw new ArgumentException("Value cannot be null or whitespace.", nameof(path));
return Deserialize(File.ReadAllText(path)); return Deserialize(File.ReadAllText(path));
} }
} }
+4 -3
View File
@@ -48,8 +48,9 @@ public static class PlasmaCommands
/// <summary>Encode display text as raw bytes (Latin-1, one byte per char).</summary> /// <summary>Encode display text as raw bytes (Latin-1, one byte per char).</summary>
public static byte[] Text(string text) public static byte[] Text(string text)
{ {
ArgumentNullException.ThrowIfNull(text); if (text is null) throw new ArgumentNullException(nameof(text));
return Encoding.Latin1.GetBytes(text); // net48 has no Encoding.Latin1; codepage 28591 is ISO-8859-1 (Latin-1).
return Encoding.GetEncoding(28591).GetBytes(text);
} }
/// <summary> /// <summary>
@@ -72,7 +73,7 @@ public static class PlasmaCommands
public static (byte x, byte y, byte font, int length) ResolvePosText( public static (byte x, byte y, byte font, int length) ResolvePosText(
string text, byte x, byte y, byte font) string text, byte x, byte y, byte font)
{ {
ArgumentNullException.ThrowIfNull(text); if (text is null) throw new ArgumentNullException(nameof(text));
int len = text.Length; int len = text.Length;
if (font != 2) // not the Score font if (font != 2) // not the Score font
+2 -2
View File
@@ -48,7 +48,7 @@ public static class AutoSwitchResolver
/// <summary>Normalize an executable name for matching: basename, no ".exe", lower-case.</summary> /// <summary>Normalize an executable name for matching: basename, no ".exe", lower-case.</summary>
public static string Normalize(string executable) public static string Normalize(string executable)
{ {
ArgumentNullException.ThrowIfNull(executable); if (executable is null) throw new ArgumentNullException(nameof(executable));
string name = executable.Trim(); string name = executable.Trim();
// Strip any path (handle both separators regardless of OS). // Strip any path (handle both separators regardless of OS).
int slash = name.LastIndexOfAny(new[] { '/', '\\' }); int slash = name.LastIndexOfAny(new[] { '/', '\\' });
@@ -68,7 +68,7 @@ public static class AutoSwitchResolver
/// </summary> /// </summary>
public static SwitchDecision Resolve(AppConfig config, string? foregroundExecutable) public static SwitchDecision Resolve(AppConfig config, string? foregroundExecutable)
{ {
ArgumentNullException.ThrowIfNull(config); if (config is null) throw new ArgumentNullException(nameof(config));
if (!string.IsNullOrWhiteSpace(foregroundExecutable)) if (!string.IsNullOrWhiteSpace(foregroundExecutable))
{ {
+12 -2
View File
@@ -56,9 +56,19 @@ public sealed class AutoSwitchWatcher
/// <summary>Poll on <paramref name="interval"/> until cancelled.</summary> /// <summary>Poll on <paramref name="interval"/> until cancelled.</summary>
public async Task RunAsync(TimeSpan interval, CancellationToken cancellationToken) public async Task RunAsync(TimeSpan interval, CancellationToken cancellationToken)
{ {
using var timer = new PeriodicTimer(interval); // net48 has no PeriodicTimer; poll on a Task.Delay loop instead.
Poll(); Poll();
while (await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(false)) while (!cancellationToken.IsCancellationRequested)
{
try
{
await Task.Delay(interval, cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
break;
}
Poll(); Poll();
}
} }
} }
+4 -4
View File
@@ -18,13 +18,13 @@ public static class ConfigStore
public static string Serialize(AppConfig config) public static string Serialize(AppConfig config)
{ {
ArgumentNullException.ThrowIfNull(config); if (config is null) throw new ArgumentNullException(nameof(config));
return JsonSerializer.Serialize(config, Options); return JsonSerializer.Serialize(config, Options);
} }
public static AppConfig Deserialize(string json) public static AppConfig Deserialize(string json)
{ {
ArgumentException.ThrowIfNullOrWhiteSpace(json); if (string.IsNullOrWhiteSpace(json)) throw new ArgumentException("Value cannot be null or whitespace.", nameof(json));
return JsonSerializer.Deserialize<AppConfig>(json, Options) return JsonSerializer.Deserialize<AppConfig>(json, Options)
?? throw new JsonException("Config JSON deserialized to null."); ?? throw new JsonException("Config JSON deserialized to null.");
} }
@@ -32,7 +32,7 @@ public static class ConfigStore
/// <summary>Save the config to <paramref name="path"/> (creating directories).</summary> /// <summary>Save the config to <paramref name="path"/> (creating directories).</summary>
public static void Save(AppConfig config, string path) public static void Save(AppConfig config, string path)
{ {
ArgumentException.ThrowIfNullOrWhiteSpace(path); if (string.IsNullOrWhiteSpace(path)) throw new ArgumentException("Value cannot be null or whitespace.", nameof(path));
string? dir = Path.GetDirectoryName(Path.GetFullPath(path)); string? dir = Path.GetDirectoryName(Path.GetFullPath(path));
if (!string.IsNullOrEmpty(dir)) if (!string.IsNullOrEmpty(dir))
Directory.CreateDirectory(dir); Directory.CreateDirectory(dir);
@@ -45,7 +45,7 @@ public static class ConfigStore
/// </summary> /// </summary>
public static AppConfig Load(string path) public static AppConfig Load(string path)
{ {
ArgumentException.ThrowIfNullOrWhiteSpace(path); if (string.IsNullOrWhiteSpace(path)) throw new ArgumentException("Value cannot be null or whitespace.", nameof(path));
return File.Exists(path) ? Deserialize(File.ReadAllText(path)) : new AppConfig(); return File.Exists(path) ? Deserialize(File.ReadAllText(path)) : new AppConfig();
} }
} }
+3 -3
View File
@@ -11,7 +11,7 @@ public sealed class IniFile
public static IniFile Parse(string text) public static IniFile Parse(string text)
{ {
ArgumentNullException.ThrowIfNull(text); if (text is null) throw new ArgumentNullException(nameof(text));
var ini = new IniFile(); var ini = new IniFile();
var current = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); var current = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
ini._sections[string.Empty] = current; ini._sections[string.Empty] = current;
@@ -68,9 +68,9 @@ public sealed class IniFile
/// <summary>Parse an integer value that may be hex (<c>0x…</c>) or decimal.</summary> /// <summary>Parse an integer value that may be hex (<c>0x…</c>) or decimal.</summary>
public static int ParseInt(string value) public static int ParseInt(string value)
{ {
ArgumentNullException.ThrowIfNull(value); if (value is null) throw new ArgumentNullException(nameof(value));
string v = value.Trim(); string v = value.Trim();
bool neg = v.StartsWith('-'); bool neg = v.StartsWith("-", StringComparison.Ordinal);
if (neg) v = v[1..]; if (neg) v = v[1..];
int magnitude = v.StartsWith("0x", StringComparison.OrdinalIgnoreCase) int magnitude = v.StartsWith("0x", StringComparison.OrdinalIgnoreCase)
+1 -1
View File
@@ -15,7 +15,7 @@ public static class RioIniImporter
/// <summary>Parse <paramref name="iniText"/> into a profile named <paramref name="name"/>.</summary> /// <summary>Parse <paramref name="iniText"/> into a profile named <paramref name="name"/>.</summary>
public static RioProfile Import(string iniText, string name = "Imported") public static RioProfile Import(string iniText, string name = "Imported")
{ {
ArgumentNullException.ThrowIfNull(iniText); if (iniText is null) throw new ArgumentNullException(nameof(iniText));
IniFile ini = IniFile.Parse(iniText); IniFile ini = IniFile.Parse(iniText);
var profile = new RioProfile var profile = new RioProfile
+1 -1
View File
@@ -97,7 +97,7 @@ public sealed class PacketParser
/// </summary> /// </summary>
public void Feed(ReadOnlySpan<byte> data, Action<RioRxEvent> onEvent) public void Feed(ReadOnlySpan<byte> data, Action<RioRxEvent> onEvent)
{ {
ArgumentNullException.ThrowIfNull(onEvent); if (onEvent is null) throw new ArgumentNullException(nameof(onEvent));
foreach (byte ch in data) foreach (byte ch in data)
{ {
if (Feed(ch, out RioRxEvent ev)) if (Feed(ch, out RioRxEvent ev))
+1 -1
View File
@@ -30,7 +30,7 @@ public readonly struct RioPacket
public override string ToString() public override string ToString()
{ {
var hex = Convert.ToHexString(Payload.Span); var hex = BitConverter.ToString(Payload.ToArray()).Replace("-", string.Empty);
return Payload.IsEmpty ? Command.ToString() : $"{Command} [{hex}]"; return Payload.IsEmpty ? Command.ToString() : $"{Command} [{hex}]";
} }
} }
+12 -3
View File
@@ -1,10 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<!-- net8.0 LTS. Windows-flavored TFM: this library uses Win32 P/Invoke <!-- net48 test branch. Uses Win32 P/Invoke (SendInput, SystemParametersInfo)
(SendInput, SystemParametersInfo) and System.IO.Ports. --> and System.IO.Ports. Span/records/init/Index are polyfilled (System.Memory
<TargetFramework>net8.0-windows</TargetFramework> + PolySharp); System.Text.Json comes from NuGet (not in the net48 BCL). -->
<TargetFramework>net48</TargetFramework>
<Platforms>x64</Platforms> <Platforms>x64</Platforms>
<PlatformTarget>x64</PlatformTarget>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<LangVersion>latest</LangVersion> <LangVersion>latest</LangVersion>
@@ -13,6 +15,13 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Nefarius.ViGEm.Client" Version="1.21.256" /> <PackageReference Include="Nefarius.ViGEm.Client" Version="1.21.256" />
<PackageReference Include="System.IO.Ports" Version="8.0.0" /> <PackageReference Include="System.IO.Ports" Version="8.0.0" />
<PackageReference Include="System.Memory" Version="4.5.5" />
<PackageReference Include="System.Text.Json" Version="8.0.5" />
<PackageReference Include="Microsoft.Bcl.HashCode" Version="1.1.1" />
<PackageReference Include="PolySharp" Version="1.14.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup> </ItemGroup>
</Project> </Project>
+2 -2
View File
@@ -30,8 +30,8 @@ public sealed class RioRuntime : IRioCommandSink, IDisposable
AxisCalibrator? calibrator = null) AxisCalibrator? calibrator = null)
{ {
_link = link ?? throw new ArgumentNullException(nameof(link)); _link = link ?? throw new ArgumentNullException(nameof(link));
ArgumentNullException.ThrowIfNull(map); if (map is null) throw new ArgumentNullException(nameof(map));
ArgumentNullException.ThrowIfNull(input); if (input is null) throw new ArgumentNullException(nameof(input));
_joystick = joystick ?? throw new ArgumentNullException(nameof(joystick)); _joystick = joystick ?? throw new ArgumentNullException(nameof(joystick));
_calibrator = calibrator ?? new AxisCalibrator(); _calibrator = calibrator ?? new AxisCalibrator();
+15 -5
View File
@@ -22,7 +22,7 @@ public sealed class SerialPortTransport : IRioTransport
public SerialPortTransport(string portName) public SerialPortTransport(string portName)
{ {
ArgumentException.ThrowIfNullOrWhiteSpace(portName); if (string.IsNullOrWhiteSpace(portName)) throw new ArgumentException("Value cannot be null or whitespace.", nameof(portName));
_port = new SerialPort(portName, BaudRate, Parity.None, 8, StopBits.One) _port = new SerialPort(portName, BaudRate, Parity.None, 8, StopBits.One)
{ {
@@ -44,11 +44,21 @@ public sealed class SerialPortTransport : IRioTransport
public string Description => $"{_port.PortName} @ {BaudRate} 8N1"; public string Description => $"{_port.PortName} @ {BaudRate} 8N1";
public ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken) => // net48's Stream has no Memory-based ReadAsync/WriteAsync overloads, so bridge
_stream.ReadAsync(buffer, cancellationToken); // through a pooled array and copy into/out of the caller's Memory<byte>.
public async ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken)
{
byte[] tmp = new byte[buffer.Length];
int read = await _stream.ReadAsync(tmp, 0, tmp.Length, cancellationToken).ConfigureAwait(false);
new ReadOnlySpan<byte>(tmp, 0, read).CopyTo(buffer.Span);
return read;
}
public ValueTask WriteAsync(ReadOnlyMemory<byte> data, CancellationToken cancellationToken) => public async ValueTask WriteAsync(ReadOnlyMemory<byte> data, CancellationToken cancellationToken)
_stream.WriteAsync(data, cancellationToken); {
byte[] tmp = data.ToArray();
await _stream.WriteAsync(tmp, 0, tmp.Length, cancellationToken).ConfigureAwait(false);
}
public void Dispose() public void Dispose()
{ {
@@ -32,9 +32,9 @@ public sealed class ProfileWallpaperGenerator
string outputPath, string outputPath,
OverlayLayoutOptions? options = null) OverlayLayoutOptions? options = null)
{ {
ArgumentNullException.ThrowIfNull(template); if (template is null) throw new ArgumentNullException(nameof(template));
ArgumentNullException.ThrowIfNull(labels); if (labels is null) throw new ArgumentNullException(nameof(labels));
ArgumentException.ThrowIfNullOrWhiteSpace(outputPath); if (string.IsNullOrWhiteSpace(outputPath)) throw new ArgumentException("Value cannot be null or whitespace.", nameof(outputPath));
if (string.IsNullOrWhiteSpace(template.BaseImagePath)) if (string.IsNullOrWhiteSpace(template.BaseImagePath))
throw new InvalidOperationException("OverlayTemplate.BaseImagePath is not set."); throw new InvalidOperationException("OverlayTemplate.BaseImagePath is not set.");
+6 -1
View File
@@ -1,8 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net8.0-windows</TargetFramework> <TargetFramework>net48</TargetFramework>
<Platforms>x64</Platforms> <Platforms>x64</Platforms>
<PlatformTarget>x64</PlatformTarget>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<LangVersion>latest</LangVersion> <LangVersion>latest</LangVersion>
@@ -12,6 +13,10 @@
<!-- SkiaSharp: cross-platform 2D rasterizer used to render the wallpaper. --> <!-- SkiaSharp: cross-platform 2D rasterizer used to render the wallpaper. -->
<PackageReference Include="SkiaSharp" Version="2.88.8" /> <PackageReference Include="SkiaSharp" Version="2.88.8" />
<PackageReference Include="SkiaSharp.NativeAssets.Win32" Version="2.88.8" /> <PackageReference Include="SkiaSharp.NativeAssets.Win32" Version="2.88.8" />
<PackageReference Include="PolySharp" Version="1.14.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
+5 -5
View File
@@ -35,9 +35,9 @@ public sealed class SkiaOverlayRenderer
SKBitmap baseImage, SKBitmap baseImage,
OverlayLayoutOptions? options = null) OverlayLayoutOptions? options = null)
{ {
ArgumentNullException.ThrowIfNull(template); if (template is null) throw new ArgumentNullException(nameof(template));
ArgumentNullException.ThrowIfNull(labels); if (labels is null) throw new ArgumentNullException(nameof(labels));
ArgumentNullException.ThrowIfNull(baseImage); if (baseImage is null) throw new ArgumentNullException(nameof(baseImage));
options ??= new OverlayLayoutOptions(); options ??= new OverlayLayoutOptions();
var output = baseImage.Copy(); var output = baseImage.Copy();
@@ -114,8 +114,8 @@ public sealed class SkiaOverlayRenderer
string outputPath, string outputPath,
OverlayLayoutOptions? options = null) OverlayLayoutOptions? options = null)
{ {
ArgumentNullException.ThrowIfNull(template); if (template is null) throw new ArgumentNullException(nameof(template));
ArgumentException.ThrowIfNullOrWhiteSpace(outputPath); if (string.IsNullOrWhiteSpace(outputPath)) throw new ArgumentException("Value cannot be null or whitespace.", nameof(outputPath));
if (string.IsNullOrWhiteSpace(template.BaseImagePath)) if (string.IsNullOrWhiteSpace(template.BaseImagePath))
throw new InvalidOperationException("OverlayTemplate.BaseImagePath is not set."); throw new InvalidOperationException("OverlayTemplate.BaseImagePath is not set.");
-2
View File
@@ -1,4 +1,3 @@
using System.Runtime.Versioning;
using RioJoy.Core.Editing; using RioJoy.Core.Editing;
namespace RioJoy.Tray.Editor; namespace RioJoy.Tray.Editor;
@@ -8,7 +7,6 @@ namespace RioJoy.Tray.Editor;
/// <see cref="PanelView"/>) and raises <see cref="AddressSelected"/> when a button /// <see cref="PanelView"/>) and raises <see cref="AddressSelected"/> when a button
/// is clicked. Sized to the full panel so the host scrolls it. /// is clicked. Sized to the full panel so the host scrolls it.
/// </summary> /// </summary>
[SupportedOSPlatform("windows")]
internal sealed class PanelCanvas : Control internal sealed class PanelCanvas : Control
{ {
private static readonly IReadOnlyList<PanelButton> AllButtons = CockpitPanel.Buttons(); private static readonly IReadOnlyList<PanelButton> AllButtons = CockpitPanel.Buttons();
-2
View File
@@ -1,4 +1,3 @@
using System.Runtime.Versioning;
using RioJoy.Core.Editing; using RioJoy.Core.Editing;
namespace RioJoy.Tray.Editor; namespace RioJoy.Tray.Editor;
@@ -28,7 +27,6 @@ public sealed record CalibrationCell(JoyStickSetting Setting, string Label, int
/// shaded by whether the address is assigned (Off vs Dim); keypads are neutral /// shaded by whether the address is assigned (Off vs Dim); keypads are neutral
/// (no lamps). Shared by <see cref="PanelCanvas"/> and offline rendering. /// (no lamps). Shared by <see cref="PanelCanvas"/> and offline rendering.
/// </summary> /// </summary>
[SupportedOSPlatform("windows")]
public static class PanelView public static class PanelView
{ {
public const int CellW = 66; public const int CellW = 66;
+7 -7
View File
@@ -1,4 +1,3 @@
using System.Runtime.Versioning;
using System.Text; using System.Text;
using RioJoy.Core.Editing; using RioJoy.Core.Editing;
using RioJoy.Core.Output; using RioJoy.Core.Output;
@@ -17,7 +16,6 @@ namespace RioJoy.Tray.Editor;
/// wallpaper region <c>b-XX</c>) and <see cref="RioProfile.Buttons"/> (the iRIO /// wallpaper region <c>b-XX</c>) and <see cref="RioProfile.Buttons"/> (the iRIO
/// word); <c>Save</c> raises <see cref="Saved"/>. /// word); <c>Save</c> raises <see cref="Saved"/>.
/// </summary> /// </summary>
[SupportedOSPlatform("windows")]
public sealed class ProfileEditorForm : Form public sealed class ProfileEditorForm : Form
{ {
private static readonly Dictionary<int, string> GroupOf = private static readonly Dictionary<int, string> GroupOf =
@@ -30,7 +28,7 @@ public sealed class ProfileEditorForm : Form
private readonly PanelCanvas _canvas = new(); private readonly PanelCanvas _canvas = new();
private readonly TextBox _nameBox = new() { Location = new Point(66, 12), Width = 234 }; private readonly TextBox _nameBox = new() { Location = new Point(66, 12), Width = 234 };
private readonly TextBox _matchBox = new() { Location = new Point(66, 40), Width = 234, PlaceholderText = "game.exe, other.exe — auto-switch" }; private readonly TextBox _matchBox = new() { Location = new Point(66, 40), Width = 234 };
private readonly Label _info = new() { AutoSize = true, Location = new Point(12, 68), MaximumSize = new Size(310, 0) }; private readonly Label _info = new() { AutoSize = true, Location = new Point(12, 68), MaximumSize = new Size(310, 0) };
private readonly TextBox _labelBox = new() { Location = new Point(70, 100), Width = 230 }; private readonly TextBox _labelBox = new() { Location = new Point(70, 100), Width = 230 };
private readonly ComboBox _kindBox = new() { Location = new Point(70, 134), Width = 150, DropDownStyle = ComboBoxStyle.DropDownList }; private readonly ComboBox _kindBox = new() { Location = new Point(70, 134), Width = 150, DropDownStyle = ComboBoxStyle.DropDownList };
@@ -116,7 +114,7 @@ public sealed class ProfileEditorForm : Form
StartPosition = FormStartPosition.CenterScreen; StartPosition = FormStartPosition.CenterScreen;
MinimumSize = new Size(900, 500); MinimumSize = new Size(900, 500);
_kindBox.Items.AddRange(Enum.GetNames<RioRouteKind>()); _kindBox.Items.AddRange(Enum.GetNames(typeof(RioRouteKind)));
_kindBox.SelectedIndexChanged += OnKindChanged; _kindBox.SelectedIndexChanged += OnKindChanged;
_canvas.LabelProvider = a => _profile.OverlayLabels.TryGetValue(RegionFor(a), out string? t) ? t : null; _canvas.LabelProvider = a => _profile.OverlayLabels.TryGetValue(RegionFor(a), out string? t) ? t : null;
@@ -271,11 +269,11 @@ public sealed class ProfileEditorForm : Form
_valueCombo.Items.Add(new ValueItem(h.ToString(), (byte)(int)h)); _valueCombo.Items.Add(new ValueItem(h.ToString(), (byte)(int)h));
break; break;
case RioRouteKind.Mouse: case RioRouteKind.Mouse:
foreach (MouseAction m in Enum.GetValues<MouseAction>()) foreach (MouseAction m in (MouseAction[])Enum.GetValues(typeof(MouseAction)))
_valueCombo.Items.Add(new ValueItem(m.ToString(), (byte)m)); _valueCombo.Items.Add(new ValueItem(m.ToString(), (byte)m));
break; break;
case RioRouteKind.RioCommand: case RioRouteKind.RioCommand:
foreach (RioCommandCode c in Enum.GetValues<RioCommandCode>()) foreach (RioCommandCode c in (RioCommandCode[])Enum.GetValues(typeof(RioCommandCode)))
_valueCombo.Items.Add(new ValueItem(c.ToString(), (byte)c)); _valueCombo.Items.Add(new ValueItem(c.ToString(), (byte)c));
break; break;
} }
@@ -349,7 +347,9 @@ public sealed class ProfileEditorForm : Form
// The foreground executables that auto-activate this profile (comma-separated). // The foreground executables that auto-activate this profile (comma-separated).
_profile.MatchExecutables = _matchBox.Text _profile.MatchExecutables = _matchBox.Text
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries)
.Select(s => s.Trim())
.Where(s => s.Length > 0)
.ToList(); .ToList();
ApplyToCell(); ApplyToCell();
+1 -3
View File
@@ -1,4 +1,3 @@
using System.Runtime.Versioning;
using Microsoft.Win32; using Microsoft.Win32;
namespace RioJoy.Tray.Os; namespace RioJoy.Tray.Os;
@@ -8,7 +7,6 @@ namespace RioJoy.Tray.Os;
/// <c>Run</c> registry key. Per-user (HKCU) needs no elevation and matches the /// <c>Run</c> registry key. Per-user (HKCU) needs no elevation and matches the
/// tray app running in the interactive session. /// tray app running in the interactive session.
/// </summary> /// </summary>
[SupportedOSPlatform("windows")]
public static class AutoStartManager public static class AutoStartManager
{ {
private const string RunKey = @"Software\Microsoft\Windows\CurrentVersion\Run"; private const string RunKey = @"Software\Microsoft\Windows\CurrentVersion\Run";
@@ -24,7 +22,7 @@ public static class AutoStartManager
{ {
using RegistryKey key = Registry.CurrentUser.CreateSubKey(RunKey); using RegistryKey key = Registry.CurrentUser.CreateSubKey(RunKey);
if (enabled) if (enabled)
key.SetValue(ValueName, $"\"{Environment.ProcessPath}\""); key.SetValue(ValueName, $"\"{Application.ExecutablePath}\"");
else else
key.DeleteValue(ValueName, throwOnMissingValue: false); key.DeleteValue(ValueName, throwOnMissingValue: false);
} }
@@ -1,6 +1,5 @@
using System.Diagnostics; using System.Diagnostics;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using System.Runtime.Versioning;
using RioJoy.Core.Profiles; using RioJoy.Core.Profiles;
namespace RioJoy.Tray.Os; namespace RioJoy.Tray.Os;
@@ -10,7 +9,6 @@ namespace RioJoy.Tray.Os;
/// foreground window's owning process and returns its executable file name. Used /// foreground window's owning process and returns its executable file name. Used
/// by the auto-switch watcher to detect native games vs. supported titles. /// by the auto-switch watcher to detect native games vs. supported titles.
/// </summary> /// </summary>
[SupportedOSPlatform("windows")]
public sealed class ForegroundProcessProvider : IForegroundProcessProvider public sealed class ForegroundProcessProvider : IForegroundProcessProvider
{ {
public string? GetForegroundExecutable() public string? GetForegroundExecutable()
+4 -1
View File
@@ -20,7 +20,10 @@ internal static class Program
if (!createdNew) if (!createdNew)
return; // another RIOJoy is already running in this session return; // another RIOJoy is already running in this session
ApplicationConfiguration.Initialize(); // net48 has no source-generated ApplicationConfiguration.Initialize();
// do the equivalent setup directly.
Application.EnableVisualStyles();
Application.SetCompatibleTextRenderingDefault(false);
Application.Run(new TrayApplicationContext()); Application.Run(new TrayApplicationContext());
} }
} }
-2
View File
@@ -1,4 +1,3 @@
using System.Runtime.Versioning;
using RioJoy.Core; using RioJoy.Core;
using RioJoy.Core.Calibration; using RioJoy.Core.Calibration;
using RioJoy.Core.Mapping; using RioJoy.Core.Mapping;
@@ -22,7 +21,6 @@ namespace RioJoy.Tray;
/// edited (<see cref="BeginEditorSession"/>). This keeps the COM port free the rest /// edited (<see cref="BeginEditorSession"/>). This keeps the COM port free the rest
/// of the time so the native games can open it without clashing. /// of the time so the native games can open it without clashing.
/// </summary> /// </summary>
[SupportedOSPlatform("windows")]
public sealed class RioCoordinator : IDisposable public sealed class RioCoordinator : IDisposable
{ {
private readonly Func<AppConfig> _config; private readonly Func<AppConfig> _config;
+9 -1
View File
@@ -2,8 +2,9 @@
<PropertyGroup> <PropertyGroup>
<OutputType>WinExe</OutputType> <OutputType>WinExe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework> <TargetFramework>net48</TargetFramework>
<Platforms>x64</Platforms> <Platforms>x64</Platforms>
<PlatformTarget>x64</PlatformTarget>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<UseWindowsForms>true</UseWindowsForms> <UseWindowsForms>true</UseWindowsForms>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
@@ -16,4 +17,11 @@
<ProjectReference Include="..\RioJoy.Overlay\RioJoy.Overlay.csproj" /> <ProjectReference Include="..\RioJoy.Overlay\RioJoy.Overlay.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<PackageReference Include="PolySharp" Version="1.14.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
</Project> </Project>
@@ -1,4 +1,3 @@
using System.Runtime.Versioning;
using RioJoy.Core; using RioJoy.Core;
using RioJoy.Core.Mapping; using RioJoy.Core.Mapping;
using RioJoy.Core.Profiles; using RioJoy.Core.Profiles;
@@ -14,7 +13,6 @@ namespace RioJoy.Tray;
/// auto-switch watcher is polled on a UI timer so menu/status updates stay on the /// auto-switch watcher is polled on a UI timer so menu/status updates stay on the
/// UI thread. /// UI thread.
/// </summary> /// </summary>
[SupportedOSPlatform("windows")]
internal sealed class TrayApplicationContext : ApplicationContext internal sealed class TrayApplicationContext : ApplicationContext
{ {
private static readonly string ConfigPath = private static readonly string ConfigPath =
+1 -3
View File
@@ -1,5 +1,4 @@
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using System.Runtime.Versioning;
namespace RioJoy.Tray; namespace RioJoy.Tray;
@@ -14,7 +13,6 @@ namespace RioJoy.Tray;
/// a user-visible system setting, so it is only invoked on an explicit profile /// a user-visible system setting, so it is only invoked on an explicit profile
/// activation, never speculatively.</para> /// activation, never speculatively.</para>
/// </summary> /// </summary>
[SupportedOSPlatform("windows")]
public static class WallpaperApplier public static class WallpaperApplier
{ {
private const int SPI_SETDESKWALLPAPER = 0x0014; private const int SPI_SETDESKWALLPAPER = 0x0014;
@@ -27,7 +25,7 @@ public static class WallpaperApplier
/// </summary> /// </summary>
public static bool Apply(string imagePath) public static bool Apply(string imagePath)
{ {
ArgumentException.ThrowIfNullOrWhiteSpace(imagePath); if (string.IsNullOrWhiteSpace(imagePath)) throw new ArgumentException("Value cannot be null or whitespace.", nameof(imagePath));
string full = Path.GetFullPath(imagePath); string full = Path.GetFullPath(imagePath);
if (!File.Exists(full)) if (!File.Exists(full))
throw new FileNotFoundException("Wallpaper image not found.", full); throw new FileNotFoundException("Wallpaper image not found.", full);
@@ -77,6 +77,6 @@ public class SkiaOverlayRendererTests
byte[] png = new SkiaOverlayRenderer().RenderToPng(template, new Dictionary<string, string> { ["x"] = "HI" }, baseImg); byte[] png = new SkiaOverlayRenderer().RenderToPng(template, new Dictionary<string, string> { ["x"] = "HI" }, baseImg);
Assert.True(png.Length > 8); Assert.True(png.Length > 8);
Assert.Equal(new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A }, png[..8]); Assert.Equal(new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A }, png.AsSpan(0, 8).ToArray());
} }
} }
@@ -41,7 +41,7 @@ public class PacketBuilderTests
byte[] packet = PacketBuilder.Build(RioCommand.AnalogReply, payload); byte[] packet = PacketBuilder.Build(RioCommand.AnalogReply, payload);
Assert.Equal((byte)RioCommand.AnalogReply, packet[0]); Assert.Equal((byte)RioCommand.AnalogReply, packet[0]);
Assert.Equal(payload, packet[1..^1]); Assert.Equal(payload, packet.AsSpan(1, packet.Length - 2).ToArray());
Assert.Equal(RioChecksum.Compute(packet[..^1]), packet[^1]); Assert.Equal(RioChecksum.Compute(packet.AsSpan(0, packet.Length - 1)), packet[^1]);
} }
} }
@@ -1,8 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net8.0-windows</TargetFramework> <TargetFramework>net48</TargetFramework>
<Platforms>x64</Platforms> <Platforms>x64</Platforms>
<PlatformTarget>x64</PlatformTarget>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<LangVersion>latest</LangVersion> <LangVersion>latest</LangVersion>
@@ -13,6 +14,11 @@
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="xunit" Version="2.9.2" /> <PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" /> <PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
<PackageReference Include="System.Threading.Channels" Version="8.0.0" />
<PackageReference Include="PolySharp" Version="1.14.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -40,7 +40,7 @@ internal sealed class FakeTransport : IRioTransport
public ValueTask WriteAsync(ReadOnlyMemory<byte> data, CancellationToken cancellationToken) public ValueTask WriteAsync(ReadOnlyMemory<byte> data, CancellationToken cancellationToken)
{ {
_writes.Writer.TryWrite(data.ToArray()); _writes.Writer.TryWrite(data.ToArray());
return ValueTask.CompletedTask; return default; // net48 has no ValueTask.CompletedTask; default(ValueTask) is the completed task
} }
/// <summary>Read the next outbound write, failing if none arrives in time.</summary> /// <summary>Read the next outbound write, failing if none arrives in time.</summary>
+21
View File
@@ -0,0 +1,21 @@
namespace RioJoy.Core.Tests;
/// <summary>
/// net48 test-only polyfills. Visible to all tests via enclosing-namespace scope.
/// </summary>
internal static class TaskTestExtensions
{
/// <summary>
/// Polyfill for Task&lt;T&gt;.WaitAsync(TimeSpan) (net6+), absent on net48.
/// Throws <see cref="TimeoutException"/> if the task does not complete in time.
/// </summary>
public static async Task<T> WaitAsync<T>(this Task<T> task, TimeSpan timeout)
{
using var cts = new CancellationTokenSource();
Task completed = await Task.WhenAny(task, Task.Delay(timeout, cts.Token)).ConfigureAwait(false);
if (completed != task)
throw new TimeoutException($"Task did not complete within {timeout}.");
cts.Cancel(); // stop the delay timer
return await task.ConfigureAwait(false);
}
}