Phase 8A (1/2): de-Span the serial layer, swap JSON to Newtonsoft

Prepares RioJoy.Core for the net40 (Windows XP) target, which has no
System.Memory, ValueTask, or System.Text.Json:
- IRioTransport and the whole protocol/framing layer now use byte[] +
  Task (RioPacket.Payload, PacketParser/Builder, RioChecksum, replies,
  AnalogReport, RioHidReport). At 9600 baud Span bought nothing; the
  SerialPortTransport bridge copies disappear entirely.
- ConfigStore/OverlayTemplateStore switch to Newtonsoft 13 with the
  same conventions (indented, PascalCase, string enums, null-skipping);
  verified against the real STJ-written config.json and regions.json
  (load + round-trip). System.Memory and System.Text.Json packages
  dropped.

275 tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Cyd
2026-07-11 20:36:19 -05:00
co-authored by Claude Fable 5
parent 02abfa8a14
commit 3b2af7b79a
21 changed files with 104 additions and 93 deletions
+1 -4
View File
@@ -43,10 +43,7 @@ public sealed class RioHidReport
SetHat(RioHat.Centered); SetHat(RioHat.Centered);
} }
/// <summary>The current report bytes (length <see cref="Size"/>).</summary> /// <summary>Copy of the current report bytes (length <see cref="Size"/>).</summary>
public ReadOnlySpan<byte> Bytes => _buffer;
/// <summary>Copy of the current report bytes.</summary>
public byte[] ToArray() => (byte[])_buffer.Clone(); public byte[] ToArray() => (byte[])_buffer.Clone();
/// <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>
@@ -1,5 +1,5 @@
using System.Text.Json; using Newtonsoft.Json;
using System.Text.Json.Serialization; using Newtonsoft.Json.Converters;
namespace RioJoy.Core.Overlay; namespace RioJoy.Core.Overlay;
@@ -11,24 +11,24 @@ namespace RioJoy.Core.Overlay;
/// </summary> /// </summary>
public static class OverlayTemplateStore public static class OverlayTemplateStore
{ {
private static readonly JsonSerializerOptions Options = new() private static readonly JsonSerializerSettings Options = new()
{ {
WriteIndented = true, Formatting = Formatting.Indented,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, NullValueHandling = NullValueHandling.Ignore,
Converters = { new JsonStringEnumConverter() }, Converters = { new StringEnumConverter() },
}; };
public static string Serialize(OverlayTemplate template) public static string Serialize(OverlayTemplate template)
{ {
if (template is null) throw new ArgumentNullException(nameof(template)); if (template is null) throw new ArgumentNullException(nameof(template));
return JsonSerializer.Serialize(template, Options); return JsonConvert.SerializeObject(template, Options);
} }
public static OverlayTemplate Deserialize(string json) public static OverlayTemplate Deserialize(string json)
{ {
if (string.IsNullOrWhiteSpace(json)) throw new ArgumentException("Value cannot be null or whitespace.", nameof(json)); if (string.IsNullOrWhiteSpace(json)) throw new ArgumentException("Value cannot be null or whitespace.", nameof(json));
return JsonSerializer.Deserialize<OverlayTemplate>(json, Options) return JsonConvert.DeserializeObject<OverlayTemplate>(json, Options)
?? throw new JsonException("Overlay template JSON deserialized to null."); ?? throw new JsonSerializationException("Overlay template JSON deserialized to null.");
} }
public static void Save(OverlayTemplate template, string path) public static void Save(OverlayTemplate template, string path)
+1 -1
View File
@@ -48,5 +48,5 @@ public sealed class PlasmaDisplay
} }
private Task WriteAsync(byte[] data, CancellationToken ct) => private Task WriteAsync(byte[] data, CancellationToken ct) =>
_transport.WriteAsync(data, ct).AsTask(); _transport.WriteAsync(data, ct);
} }
+13 -9
View File
@@ -1,32 +1,36 @@
using System.Text.Json; using Newtonsoft.Json;
using System.Text.Json.Serialization; using Newtonsoft.Json.Converters;
namespace RioJoy.Core.Profiles; namespace RioJoy.Core.Profiles;
/// <summary> /// <summary>
/// Loads and saves <see cref="AppConfig"/> as JSON. Replaces the legacy /// Loads and saves <see cref="AppConfig"/> as JSON. Replaces the legacy
/// SimpleIni single-file config with a profile library (see docs/PLAN.md). /// SimpleIni single-file config with a profile library (see docs/PLAN.md).
/// Newtonsoft rather than System.Text.Json so the net40 (Windows XP) flavor
/// shares one serializer — and one on-disk format — with net48 (PLAN.md §8A).
/// Conventions match the original STJ settings: indented, PascalCase names,
/// enums as strings, nulls skipped — existing config.json files parse as-is.
/// </summary> /// </summary>
public static class ConfigStore public static class ConfigStore
{ {
private static readonly JsonSerializerOptions Options = new() private static readonly JsonSerializerSettings Options = new()
{ {
WriteIndented = true, Formatting = Formatting.Indented,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, NullValueHandling = NullValueHandling.Ignore,
Converters = { new JsonStringEnumConverter() }, Converters = { new StringEnumConverter() },
}; };
public static string Serialize(AppConfig config) public static string Serialize(AppConfig config)
{ {
if (config is null) throw new ArgumentNullException(nameof(config)); if (config is null) throw new ArgumentNullException(nameof(config));
return JsonSerializer.Serialize(config, Options); return JsonConvert.SerializeObject(config, Options);
} }
public static AppConfig Deserialize(string json) public static AppConfig Deserialize(string json)
{ {
if (string.IsNullOrWhiteSpace(json)) throw new ArgumentException("Value cannot be null or whitespace.", nameof(json)); if (string.IsNullOrWhiteSpace(json)) throw new ArgumentException("Value cannot be null or whitespace.", nameof(json));
return JsonSerializer.Deserialize<AppConfig>(json, Options) return JsonConvert.DeserializeObject<AppConfig>(json, Options)
?? throw new JsonException("Config JSON deserialized to null."); ?? throw new JsonSerializationException("Config JSON deserialized to null.");
} }
/// <summary>Save the config to <paramref name="path"/> (creating directories).</summary> /// <summary>Save the config to <paramref name="path"/> (creating directories).</summary>
+2 -1
View File
@@ -44,8 +44,9 @@ public readonly struct AnalogReport
/// <c>0xFE</c> (<see cref="RioControl.Restart"/>), which the RIO uses as an /// <c>0xFE</c> (<see cref="RioControl.Restart"/>), which the RIO uses as an
/// "invalid sample" sentinel — the legacy code ignores such replies. /// "invalid sample" sentinel — the legacy code ignores such replies.
/// </summary> /// </summary>
public static bool TryParse(ReadOnlySpan<byte> payload, out AnalogReport report) public static bool TryParse(byte[] payload, out AnalogReport report)
{ {
if (payload is null) throw new ArgumentNullException(nameof(payload));
if (payload.Length != RioCommandTable.PayloadLength(RioCommand.AnalogReply)) if (payload.Length != RioCommandTable.PayloadLength(RioCommand.AnalogReply))
throw new ArgumentException( throw new ArgumentException(
$"AnalogReply payload must be {RioCommandTable.PayloadLength(RioCommand.AnalogReply)} bytes.", $"AnalogReply payload must be {RioCommandTable.PayloadLength(RioCommand.AnalogReply)} bytes.",
+9 -6
View File
@@ -13,8 +13,9 @@ public static class PacketBuilder
/// <paramref name="payload"/>. The payload length must match the command's /// <paramref name="payload"/>. The payload length must match the command's
/// entry in the length table. /// entry in the length table.
/// </summary> /// </summary>
public static byte[] Build(RioCommand command, ReadOnlySpan<byte> payload) public static byte[] Build(RioCommand command, byte[] payload)
{ {
if (payload is null) throw new ArgumentNullException(nameof(payload));
int expected = RioCommandTable.PayloadLength(command); int expected = RioCommandTable.PayloadLength(command);
if (payload.Length != expected) if (payload.Length != expected)
throw new ArgumentException( throw new ArgumentException(
@@ -23,13 +24,15 @@ public static class PacketBuilder
var packet = new byte[1 + expected + 1]; var packet = new byte[1 + expected + 1];
packet[0] = (byte)command; packet[0] = (byte)command;
payload.CopyTo(packet.AsSpan(1)); Array.Copy(payload, 0, packet, 1, expected);
packet[^1] = RioChecksum.Compute(packet.AsSpan(0, 1 + expected)); packet[packet.Length - 1] = RioChecksum.Compute(packet, 0, 1 + expected);
return packet; return packet;
} }
/// <summary>Build a zero-payload command packet (CheckRequest etc.).</summary> /// <summary>Build a zero-payload command packet (CheckRequest etc.).</summary>
public static byte[] Build(RioCommand command) => Build(command, ReadOnlySpan<byte>.Empty); public static byte[] Build(RioCommand command) => Build(command, EmptyPayload);
private static readonly byte[] EmptyPayload = new byte[0];
// --- Convenience builders for the PC → RIO commands --------------------- // --- Convenience builders for the PC → RIO commands ---------------------
@@ -40,12 +43,12 @@ public static class PacketBuilder
public static byte[] AnalogRequest() => Build(RioCommand.AnalogRequest); public static byte[] AnalogRequest() => Build(RioCommand.AnalogRequest);
public static byte[] ResetRequest(RioResetTarget target) => public static byte[] ResetRequest(RioResetTarget target) =>
Build(RioCommand.ResetRequest, stackalloc byte[] { (byte)target }); Build(RioCommand.ResetRequest, new[] { (byte)target });
/// <summary> /// <summary>
/// LampRequest: payload is <c>[lamp#, state]</c>. The state byte is a /// LampRequest: payload is <c>[lamp#, state]</c>. The state byte is a
/// 7-bit lamp-state value (see <see cref="RioLampState"/>). /// 7-bit lamp-state value (see <see cref="RioLampState"/>).
/// </summary> /// </summary>
public static byte[] LampRequest(byte lampNumber, byte state) => public static byte[] LampRequest(byte lampNumber, byte state) =>
Build(RioCommand.LampRequest, stackalloc byte[] { lampNumber, state }); Build(RioCommand.LampRequest, new[] { lampNumber, state });
} }
+10 -7
View File
@@ -92,15 +92,17 @@ public sealed class PacketParser
} }
/// <summary> /// <summary>
/// Feed a chunk of received bytes, invoking <paramref name="onEvent"/> for /// Feed the first <paramref name="count"/> received bytes of
/// each event produced, in order. /// <paramref name="data"/>, invoking <paramref name="onEvent"/> for each
/// event produced, in order.
/// </summary> /// </summary>
public void Feed(ReadOnlySpan<byte> data, Action<RioRxEvent> onEvent) public void Feed(byte[] data, int count, Action<RioRxEvent> onEvent)
{ {
if (data is null) throw new ArgumentNullException(nameof(data));
if (onEvent is null) throw new ArgumentNullException(nameof(onEvent)); if (onEvent is null) throw new ArgumentNullException(nameof(onEvent));
foreach (byte ch in data) for (int i = 0; i < count; i++)
{ {
if (Feed(ch, out RioRxEvent ev)) if (Feed(data[i], out RioRxEvent ev))
onEvent(ev); onEvent(ev);
} }
} }
@@ -110,11 +112,12 @@ public sealed class PacketParser
// _buffer = [command][payload…][checksum]; _count includes all of them. // _buffer = [command][payload…][checksum]; _count includes all of them.
int bodyLen = _count - 1; // command + payload (exclude checksum) int bodyLen = _count - 1; // command + payload (exclude checksum)
byte receivedChecksum = _buffer[bodyLen]; byte receivedChecksum = _buffer[bodyLen];
byte computed = RioChecksum.Compute(_buffer.AsSpan(0, bodyLen)); byte computed = RioChecksum.Compute(_buffer, 0, bodyLen);
bool checksumValid = computed == receivedChecksum; bool checksumValid = computed == receivedChecksum;
var command = (RioCommand)_buffer[0]; var command = (RioCommand)_buffer[0];
var payload = _buffer.AsSpan(1, bodyLen - 1).ToArray(); // copy out; buffer is reused var payload = new byte[bodyLen - 1]; // copy out; buffer is reused
Array.Copy(_buffer, 1, payload, 0, payload.Length);
var packet = new RioPacket(command, payload); var packet = new RioPacket(command, payload);
Reset(); Reset();
+12 -5
View File
@@ -9,15 +9,22 @@ namespace RioJoy.Core.Protocol;
public static class RioChecksum public static class RioChecksum
{ {
/// <summary> /// <summary>
/// Compute the 7-bit checksum over <paramref name="commandAndPayload"/> /// Compute the 7-bit checksum over the command byte followed by its payload
/// (the command byte followed by its payload bytes — not the checksum byte). /// bytes (not the checksum byte): <paramref name="count"/> bytes of
/// <paramref name="commandAndPayload"/> starting at <paramref name="offset"/>.
/// </summary> /// </summary>
public static byte Compute(ReadOnlySpan<byte> commandAndPayload) public static byte Compute(byte[] commandAndPayload, int offset, int count)
{ {
if (commandAndPayload is null) throw new ArgumentNullException(nameof(commandAndPayload));
byte sum = 0; byte sum = 0;
foreach (byte b in commandAndPayload) for (int i = offset; i < offset + count; i++)
sum += (byte)(b & 0x7F); sum += (byte)(commandAndPayload[i] & 0x7F);
return (byte)(sum & 0x7F); return (byte)(sum & 0x7F);
} }
/// <summary>Compute the checksum over all of <paramref name="commandAndPayload"/>.</summary>
public static byte Compute(byte[] commandAndPayload) =>
Compute(commandAndPayload, 0, commandAndPayload?.Length ?? 0);
} }
+7 -6
View File
@@ -11,13 +11,14 @@ public readonly struct RioPacket
public RioCommand Command { get; } public RioCommand Command { get; }
/// <summary> /// <summary>
/// The payload bytes (high bit always clear). Length matches /// The payload bytes (high bit always clear; treat as read-only). Length
/// <see cref="RioCommandTable.PayloadLength(RioCommand)"/>. /// matches <see cref="RioCommandTable.PayloadLength(RioCommand)"/>.
/// </summary> /// </summary>
public ReadOnlyMemory<byte> Payload { get; } public byte[] Payload { get; }
public RioPacket(RioCommand command, ReadOnlyMemory<byte> payload) public RioPacket(RioCommand command, byte[] payload)
{ {
if (payload is null) throw new ArgumentNullException(nameof(payload));
int expected = RioCommandTable.PayloadLength(command); int expected = RioCommandTable.PayloadLength(command);
if (payload.Length != expected) if (payload.Length != expected)
throw new ArgumentException( throw new ArgumentException(
@@ -30,7 +31,7 @@ public readonly struct RioPacket
public override string ToString() public override string ToString()
{ {
var hex = BitConverter.ToString(Payload.ToArray()).Replace("-", string.Empty); var hex = BitConverter.ToString(Payload).Replace("-", string.Empty);
return Payload.IsEmpty ? Command.ToString() : $"{Command} [{hex}]"; return Payload.Length == 0 ? Command.ToString() : $"{Command} [{hex}]";
} }
} }
+4 -3
View File
@@ -16,7 +16,7 @@ public readonly struct VersionInfo
} }
/// <summary>Decode a <see cref="RioCommand.VersionReply"/> payload (2 bytes).</summary> /// <summary>Decode a <see cref="RioCommand.VersionReply"/> payload (2 bytes).</summary>
public static VersionInfo Parse(ReadOnlySpan<byte> payload) public static VersionInfo Parse(byte[] payload)
{ {
Require(payload, RioCommand.VersionReply); Require(payload, RioCommand.VersionReply);
return new VersionInfo(payload[0], payload[1]); return new VersionInfo(payload[0], payload[1]);
@@ -24,8 +24,9 @@ public readonly struct VersionInfo
public override string ToString() => $"{Major}.{Minor}"; public override string ToString() => $"{Major}.{Minor}";
internal static void Require(ReadOnlySpan<byte> payload, RioCommand command) internal static void Require(byte[] payload, RioCommand command)
{ {
if (payload is null) throw new ArgumentNullException(nameof(payload));
int expected = RioCommandTable.PayloadLength(command); int expected = RioCommandTable.PayloadLength(command);
if (payload.Length != expected) if (payload.Length != expected)
throw new ArgumentException( throw new ArgumentException(
@@ -51,7 +52,7 @@ public readonly struct CheckStatus
} }
/// <summary>Decode a <see cref="RioCommand.CheckReply"/> payload (2 bytes).</summary> /// <summary>Decode a <see cref="RioCommand.CheckReply"/> payload (2 bytes).</summary>
public static CheckStatus Parse(ReadOnlySpan<byte> payload) public static CheckStatus Parse(byte[] payload)
{ {
VersionInfo.Require(payload, RioCommand.CheckReply); VersionInfo.Require(payload, RioCommand.CheckReply);
return new CheckStatus((RioStatusType)payload[0], payload[1]); return new CheckStatus((RioStatusType)payload[0], payload[1]);
+1 -2
View File
@@ -15,8 +15,7 @@
<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="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="System.Text.Json" Version="8.0.5" />
<PackageReference Include="Microsoft.Bcl.HashCode" Version="1.1.1" /> <PackageReference Include="Microsoft.Bcl.HashCode" Version="1.1.1" />
<PackageReference Include="PolySharp" Version="1.14.1"> <PackageReference Include="PolySharp" Version="1.14.1">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
+2 -2
View File
@@ -144,7 +144,7 @@ public sealed class RioRuntime : IRioCommandSink, IDisposable
private void OnPacket(RioPacket packet) private void OnPacket(RioPacket packet)
{ {
EnsureLampsInitialized(); EnsureLampsInitialized();
ReadOnlySpan<byte> p = packet.Payload.Span; byte[] p = packet.Payload;
switch (packet.Command) switch (packet.Command)
{ {
case RioCommand.ButtonPressed when p[0] < RioAddress.ButtonCount: case RioCommand.ButtonPressed when p[0] < RioAddress.ButtonCount:
@@ -185,7 +185,7 @@ public sealed class RioRuntime : IRioCommandSink, IDisposable
ButtonActivity?.Invoke(address, pressed); ButtonActivity?.Invoke(address, pressed);
} }
private static bool IsKeypad(ReadOnlySpan<byte> p) => p[0] is 0 or 1 && p[1] <= 0x0F; private static bool IsKeypad(byte[] p) => p[0] is 0 or 1 && p[1] <= 0x0F;
// IRioCommandSink: RIO commands routed from a button (RIOcmd port). // IRioCommandSink: RIO commands routed from a button (RIOcmd port).
void IRioCommandSink.Execute(RioCommandCode command) void IRioCommandSink.Execute(RioCommandCode command)
+7 -2
View File
@@ -15,8 +15,13 @@ public interface IRioTransport : IDisposable
/// Read available bytes into <paramref name="buffer"/>. Returns the number of /// Read available bytes into <paramref name="buffer"/>. Returns the number of
/// bytes read; a return of 0 indicates the transport has closed. /// bytes read; a return of 0 indicates the transport has closed.
/// </summary> /// </summary>
ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken); /// <remarks>
/// Plain <c>byte[]</c>/<see cref="Task"/> rather than Memory/ValueTask: the
/// link runs at 9600 baud, and the net40 (Windows XP) flavor has neither
/// System.Memory nor ValueTask (see docs/PLAN.md §Phase 8).
/// </remarks>
Task<int> ReadAsync(byte[] buffer, CancellationToken cancellationToken);
/// <summary>Write all of <paramref name="data"/> to the transport.</summary> /// <summary>Write all of <paramref name="data"/> to the transport.</summary>
ValueTask WriteAsync(ReadOnlyMemory<byte> data, CancellationToken cancellationToken); Task WriteAsync(byte[] data, CancellationToken cancellationToken);
} }
+4 -4
View File
@@ -79,7 +79,7 @@ public sealed class RioSerialLink
} }
/// <summary>Send a pre-built packet (see <see cref="PacketBuilder"/>) to the RIO.</summary> /// <summary>Send a pre-built packet (see <see cref="PacketBuilder"/>) to the RIO.</summary>
public async Task SendAsync(ReadOnlyMemory<byte> packet, CancellationToken cancellationToken = default) public async Task SendAsync(byte[] packet, CancellationToken cancellationToken = default)
{ {
await _writeLock.WaitAsync(cancellationToken).ConfigureAwait(false); await _writeLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try try
@@ -155,7 +155,7 @@ public sealed class RioSerialLink
switch (packet.Command) switch (packet.Command)
{ {
case RioCommand.AnalogReply: case RioCommand.AnalogReply:
if (AnalogReport.TryParse(packet.Payload.Span, out AnalogReport report)) if (AnalogReport.TryParse(packet.Payload, out AnalogReport report))
{ {
_sinceAnalog.Restart(); _sinceAnalog.Restart();
AnalogReceived?.Invoke(report); AnalogReceived?.Invoke(report);
@@ -163,11 +163,11 @@ public sealed class RioSerialLink
break; break;
case RioCommand.VersionReply: case RioCommand.VersionReply:
VersionReceived?.Invoke(VersionInfo.Parse(packet.Payload.Span)); VersionReceived?.Invoke(VersionInfo.Parse(packet.Payload));
break; break;
case RioCommand.CheckReply: case RioCommand.CheckReply:
CheckReceived?.Invoke(CheckStatus.Parse(packet.Payload.Span)); CheckReceived?.Invoke(CheckStatus.Parse(packet.Payload));
break; break;
} }
} }
+4 -14
View File
@@ -47,21 +47,11 @@ public sealed class SerialPortTransport : IRioTransport
public string Description => $"{_port.PortName} @ {BaudRate} 8N1"; public string Description => $"{_port.PortName} @ {BaudRate} 8N1";
// net48's Stream has no Memory-based ReadAsync/WriteAsync overloads, so bridge public Task<int> ReadAsync(byte[] buffer, CancellationToken cancellationToken) =>
// through a pooled array and copy into/out of the caller's Memory<byte>. _stream.ReadAsync(buffer, 0, buffer.Length, cancellationToken);
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 async ValueTask WriteAsync(ReadOnlyMemory<byte> data, CancellationToken cancellationToken) public Task WriteAsync(byte[] data, CancellationToken cancellationToken) =>
{ _stream.WriteAsync(data, 0, data.Length, cancellationToken);
byte[] tmp = data.ToArray();
await _stream.WriteAsync(tmp, 0, tmp.Length, cancellationToken).ConfigureAwait(false);
}
public void Dispose() public void Dispose()
{ {
@@ -29,12 +29,12 @@ public class RioHidReportTests
{ {
var report = new RioHidReport(); var report = new RioHidReport();
report.SetAxis(JoyAxis.Z, 0x1234); report.SetAxis(JoyAxis.Z, 0x1234);
Assert.Equal(0x34, report.Bytes[4]); Assert.Equal(0x34, report.ToArray()[4]);
Assert.Equal(0x12, report.Bytes[5]); Assert.Equal(0x12, report.ToArray()[5]);
report.SetAxis(JoyAxis.Z, 70000); // over max → clamped to 32767 report.SetAxis(JoyAxis.Z, 70000); // over max → clamped to 32767
Assert.Equal(0xFF, report.Bytes[4]); Assert.Equal(0xFF, report.ToArray()[4]);
Assert.Equal(0x7F, report.Bytes[5]); Assert.Equal(0x7F, report.ToArray()[5]);
} }
[Theory] [Theory]
@@ -47,7 +47,7 @@ public class RioHidReportTests
{ {
var report = new RioHidReport(); var report = new RioHidReport();
report.SetHat(hat); report.SetHat(hat);
Assert.Equal(expected, report.Bytes[12] & 0x0F); Assert.Equal(expected, report.ToArray()[12] & 0x0F);
} }
[Theory] [Theory]
@@ -59,10 +59,10 @@ public class RioHidReportTests
{ {
var report = new RioHidReport(); var report = new RioHidReport();
report.SetButton(button, pressed: true); report.SetButton(button, pressed: true);
Assert.Equal(mask, report.Bytes[byteIndex]); Assert.Equal(mask, report.ToArray()[byteIndex]);
report.SetButton(button, pressed: false); report.SetButton(button, pressed: false);
Assert.Equal(0, report.Bytes[byteIndex]); Assert.Equal(0, report.ToArray()[byteIndex]);
} }
[Fact] [Fact]
@@ -80,6 +80,6 @@ public class RioHidReportTests
report.SetButton(1, true); report.SetButton(1, true);
report.SetButton(2, true); report.SetButton(2, true);
report.SetButton(1, false); report.SetButton(1, false);
Assert.Equal(0x02, report.Bytes[13]); // only button 2 remains Assert.Equal(0x02, report.ToArray()[13]); // only button 2 remains
} }
} }
@@ -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.AsSpan(1, packet.Length - 2).ToArray()); Assert.Equal(payload, packet.Skip(1).Take(packet.Length - 2).ToArray());
Assert.Equal(RioChecksum.Compute(packet.AsSpan(0, packet.Length - 1)), packet[^1]); Assert.Equal(RioChecksum.Compute(packet, 0, packet.Length - 1), packet[packet.Length - 1]);
} }
} }
@@ -5,7 +5,7 @@ namespace RioJoy.Core.Tests.Protocol;
public class PacketParserTests public class PacketParserTests
{ {
private static List<RioRxEvent> FeedAll(PacketParser parser, ReadOnlySpan<byte> data) private static List<RioRxEvent> FeedAll(PacketParser parser, byte[] data)
{ {
var events = new List<RioRxEvent>(); var events = new List<RioRxEvent>();
foreach (byte b in data) foreach (byte b in data)
@@ -8,7 +8,7 @@ public class RioChecksumTests
[Fact] [Fact]
public void Empty_IsZero() public void Empty_IsZero()
{ {
Assert.Equal(0, RioChecksum.Compute(ReadOnlySpan<byte>.Empty)); Assert.Equal(0, RioChecksum.Compute(new byte[0]));
} }
[Fact] [Fact]
@@ -23,13 +23,13 @@ internal sealed class FakeTransport : IRioTransport
/// <summary>Signal that no more inbound data will arrive (transport closed).</summary> /// <summary>Signal that no more inbound data will arrive (transport closed).</summary>
public void CompleteIncoming() => _incoming.Writer.TryComplete(); public void CompleteIncoming() => _incoming.Writer.TryComplete();
public async ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken) public async Task<int> ReadAsync(byte[] buffer, CancellationToken cancellationToken)
{ {
while (await _incoming.Reader.WaitToReadAsync(cancellationToken).ConfigureAwait(false)) while (await _incoming.Reader.WaitToReadAsync(cancellationToken).ConfigureAwait(false))
{ {
if (_incoming.Reader.TryRead(out byte[]? chunk)) if (_incoming.Reader.TryRead(out byte[]? chunk))
{ {
chunk.AsSpan().CopyTo(buffer.Span); Array.Copy(chunk, buffer, chunk.Length);
return chunk.Length; return chunk.Length;
} }
} }
@@ -37,10 +37,10 @@ internal sealed class FakeTransport : IRioTransport
return 0; // completed return 0; // completed
} }
public ValueTask WriteAsync(ReadOnlyMemory<byte> data, CancellationToken cancellationToken) public Task WriteAsync(byte[] data, CancellationToken cancellationToken)
{ {
_writes.Writer.TryWrite(data.ToArray()); _writes.Writer.TryWrite((byte[])data.Clone());
return default; // net48 has no ValueTask.CompletedTask; default(ValueTask) is the completed task return Task.CompletedTask;
} }
/// <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>
+1 -1
View File
@@ -68,7 +68,7 @@ bool Changed(AnalogReport a, AnalogReport b) =>
link.PacketReceived += p => link.PacketReceived += p =>
{ {
packets++; packets++;
ReadOnlySpan<byte> pl = p.Payload.Span; byte[] pl = p.Payload;
switch (p.Command) switch (p.Command)
{ {
case RioCommand.ButtonPressed when pl[0] < RioAddress.ButtonCount: case RioCommand.ButtonPressed when pl[0] < RioAddress.ButtonCount: