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);
}
/// <summary>The current report bytes (length <see cref="Size"/>).</summary>
public ReadOnlySpan<byte> Bytes => _buffer;
/// <summary>Copy of the current report bytes.</summary>
/// <summary>Copy of the current report bytes (length <see cref="Size"/>).</summary>
public byte[] ToArray() => (byte[])_buffer.Clone();
/// <summary>Set an axis value (clamped to 0..<see cref="AxisMax"/>), little-endian.</summary>
@@ -1,5 +1,5 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
namespace RioJoy.Core.Overlay;
@@ -11,24 +11,24 @@ namespace RioJoy.Core.Overlay;
/// </summary>
public static class OverlayTemplateStore
{
private static readonly JsonSerializerOptions Options = new()
private static readonly JsonSerializerSettings Options = new()
{
WriteIndented = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Converters = { new JsonStringEnumConverter() },
Formatting = Formatting.Indented,
NullValueHandling = NullValueHandling.Ignore,
Converters = { new StringEnumConverter() },
};
public static string Serialize(OverlayTemplate 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)
{
if (string.IsNullOrWhiteSpace(json)) throw new ArgumentException("Value cannot be null or whitespace.", nameof(json));
return JsonSerializer.Deserialize<OverlayTemplate>(json, Options)
?? throw new JsonException("Overlay template JSON deserialized to null.");
return JsonConvert.DeserializeObject<OverlayTemplate>(json, Options)
?? throw new JsonSerializationException("Overlay template JSON deserialized to null.");
}
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) =>
_transport.WriteAsync(data, ct).AsTask();
_transport.WriteAsync(data, ct);
}
+13 -9
View File
@@ -1,32 +1,36 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
namespace RioJoy.Core.Profiles;
/// <summary>
/// Loads and saves <see cref="AppConfig"/> as JSON. Replaces the legacy
/// 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>
public static class ConfigStore
{
private static readonly JsonSerializerOptions Options = new()
private static readonly JsonSerializerSettings Options = new()
{
WriteIndented = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Converters = { new JsonStringEnumConverter() },
Formatting = Formatting.Indented,
NullValueHandling = NullValueHandling.Ignore,
Converters = { new StringEnumConverter() },
};
public static string Serialize(AppConfig 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)
{
if (string.IsNullOrWhiteSpace(json)) throw new ArgumentException("Value cannot be null or whitespace.", nameof(json));
return JsonSerializer.Deserialize<AppConfig>(json, Options)
?? throw new JsonException("Config JSON deserialized to null.");
return JsonConvert.DeserializeObject<AppConfig>(json, Options)
?? throw new JsonSerializationException("Config JSON deserialized to null.");
}
/// <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
/// "invalid sample" sentinel — the legacy code ignores such replies.
/// </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))
throw new ArgumentException(
$"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
/// entry in the length table.
/// </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);
if (payload.Length != expected)
throw new ArgumentException(
@@ -23,13 +24,15 @@ public static class PacketBuilder
var packet = new byte[1 + expected + 1];
packet[0] = (byte)command;
payload.CopyTo(packet.AsSpan(1));
packet[^1] = RioChecksum.Compute(packet.AsSpan(0, 1 + expected));
Array.Copy(payload, 0, packet, 1, expected);
packet[packet.Length - 1] = RioChecksum.Compute(packet, 0, 1 + expected);
return packet;
}
/// <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 ---------------------
@@ -40,12 +43,12 @@ public static class PacketBuilder
public static byte[] AnalogRequest() => Build(RioCommand.AnalogRequest);
public static byte[] ResetRequest(RioResetTarget target) =>
Build(RioCommand.ResetRequest, stackalloc byte[] { (byte)target });
Build(RioCommand.ResetRequest, new[] { (byte)target });
/// <summary>
/// LampRequest: payload is <c>[lamp#, state]</c>. The state byte is a
/// 7-bit lamp-state value (see <see cref="RioLampState"/>).
/// </summary>
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>
/// Feed a chunk of received bytes, invoking <paramref name="onEvent"/> for
/// each event produced, in order.
/// Feed the first <paramref name="count"/> received bytes of
/// <paramref name="data"/>, invoking <paramref name="onEvent"/> for each
/// event produced, in order.
/// </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));
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);
}
}
@@ -110,11 +112,12 @@ public sealed class PacketParser
// _buffer = [command][payload…][checksum]; _count includes all of them.
int bodyLen = _count - 1; // command + payload (exclude checksum)
byte receivedChecksum = _buffer[bodyLen];
byte computed = RioChecksum.Compute(_buffer.AsSpan(0, bodyLen));
byte computed = RioChecksum.Compute(_buffer, 0, bodyLen);
bool checksumValid = computed == receivedChecksum;
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);
Reset();
+12 -5
View File
@@ -9,15 +9,22 @@ namespace RioJoy.Core.Protocol;
public static class RioChecksum
{
/// <summary>
/// Compute the 7-bit checksum over <paramref name="commandAndPayload"/>
/// (the command byte followed by its payload bytes — not the checksum byte).
/// Compute the 7-bit checksum over the command byte followed by its payload
/// bytes (not the checksum byte): <paramref name="count"/> bytes of
/// <paramref name="commandAndPayload"/> starting at <paramref name="offset"/>.
/// </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;
foreach (byte b in commandAndPayload)
sum += (byte)(b & 0x7F);
for (int i = offset; i < offset + count; i++)
sum += (byte)(commandAndPayload[i] & 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; }
/// <summary>
/// The payload bytes (high bit always clear). Length matches
/// <see cref="RioCommandTable.PayloadLength(RioCommand)"/>.
/// The payload bytes (high bit always clear; treat as read-only). Length
/// matches <see cref="RioCommandTable.PayloadLength(RioCommand)"/>.
/// </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);
if (payload.Length != expected)
throw new ArgumentException(
@@ -30,7 +31,7 @@ public readonly struct RioPacket
public override string ToString()
{
var hex = BitConverter.ToString(Payload.ToArray()).Replace("-", string.Empty);
return Payload.IsEmpty ? Command.ToString() : $"{Command} [{hex}]";
var hex = BitConverter.ToString(Payload).Replace("-", string.Empty);
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>
public static VersionInfo Parse(ReadOnlySpan<byte> payload)
public static VersionInfo Parse(byte[] payload)
{
Require(payload, RioCommand.VersionReply);
return new VersionInfo(payload[0], payload[1]);
@@ -24,8 +24,9 @@ public readonly struct VersionInfo
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);
if (payload.Length != expected)
throw new ArgumentException(
@@ -51,7 +52,7 @@ public readonly struct CheckStatus
}
/// <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);
return new CheckStatus((RioStatusType)payload[0], payload[1]);
+1 -2
View File
@@ -15,8 +15,7 @@
<ItemGroup>
<PackageReference Include="Nefarius.ViGEm.Client" Version="1.21.256" />
<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="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="Microsoft.Bcl.HashCode" Version="1.1.1" />
<PackageReference Include="PolySharp" Version="1.14.1">
<PrivateAssets>all</PrivateAssets>
+2 -2
View File
@@ -144,7 +144,7 @@ public sealed class RioRuntime : IRioCommandSink, IDisposable
private void OnPacket(RioPacket packet)
{
EnsureLampsInitialized();
ReadOnlySpan<byte> p = packet.Payload.Span;
byte[] p = packet.Payload;
switch (packet.Command)
{
case RioCommand.ButtonPressed when p[0] < RioAddress.ButtonCount:
@@ -185,7 +185,7 @@ public sealed class RioRuntime : IRioCommandSink, IDisposable
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).
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
/// bytes read; a return of 0 indicates the transport has closed.
/// </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>
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>
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);
try
@@ -155,7 +155,7 @@ public sealed class RioSerialLink
switch (packet.Command)
{
case RioCommand.AnalogReply:
if (AnalogReport.TryParse(packet.Payload.Span, out AnalogReport report))
if (AnalogReport.TryParse(packet.Payload, out AnalogReport report))
{
_sinceAnalog.Restart();
AnalogReceived?.Invoke(report);
@@ -163,11 +163,11 @@ public sealed class RioSerialLink
break;
case RioCommand.VersionReply:
VersionReceived?.Invoke(VersionInfo.Parse(packet.Payload.Span));
VersionReceived?.Invoke(VersionInfo.Parse(packet.Payload));
break;
case RioCommand.CheckReply:
CheckReceived?.Invoke(CheckStatus.Parse(packet.Payload.Span));
CheckReceived?.Invoke(CheckStatus.Parse(packet.Payload));
break;
}
}
+4 -14
View File
@@ -47,21 +47,11 @@ public sealed class SerialPortTransport : IRioTransport
public string Description => $"{_port.PortName} @ {BaudRate} 8N1";
// net48's Stream has no Memory-based ReadAsync/WriteAsync overloads, so bridge
// 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 Task<int> ReadAsync(byte[] buffer, CancellationToken cancellationToken) =>
_stream.ReadAsync(buffer, 0, buffer.Length, cancellationToken);
public async ValueTask WriteAsync(ReadOnlyMemory<byte> data, CancellationToken cancellationToken)
{
byte[] tmp = data.ToArray();
await _stream.WriteAsync(tmp, 0, tmp.Length, cancellationToken).ConfigureAwait(false);
}
public Task WriteAsync(byte[] data, CancellationToken cancellationToken) =>
_stream.WriteAsync(data, 0, data.Length, cancellationToken);
public void Dispose()
{
@@ -29,12 +29,12 @@ public class RioHidReportTests
{
var report = new RioHidReport();
report.SetAxis(JoyAxis.Z, 0x1234);
Assert.Equal(0x34, report.Bytes[4]);
Assert.Equal(0x12, report.Bytes[5]);
Assert.Equal(0x34, report.ToArray()[4]);
Assert.Equal(0x12, report.ToArray()[5]);
report.SetAxis(JoyAxis.Z, 70000); // over max → clamped to 32767
Assert.Equal(0xFF, report.Bytes[4]);
Assert.Equal(0x7F, report.Bytes[5]);
Assert.Equal(0xFF, report.ToArray()[4]);
Assert.Equal(0x7F, report.ToArray()[5]);
}
[Theory]
@@ -47,7 +47,7 @@ public class RioHidReportTests
{
var report = new RioHidReport();
report.SetHat(hat);
Assert.Equal(expected, report.Bytes[12] & 0x0F);
Assert.Equal(expected, report.ToArray()[12] & 0x0F);
}
[Theory]
@@ -59,10 +59,10 @@ public class RioHidReportTests
{
var report = new RioHidReport();
report.SetButton(button, pressed: true);
Assert.Equal(mask, report.Bytes[byteIndex]);
Assert.Equal(mask, report.ToArray()[byteIndex]);
report.SetButton(button, pressed: false);
Assert.Equal(0, report.Bytes[byteIndex]);
Assert.Equal(0, report.ToArray()[byteIndex]);
}
[Fact]
@@ -80,6 +80,6 @@ public class RioHidReportTests
report.SetButton(1, true);
report.SetButton(2, true);
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);
Assert.Equal((byte)RioCommand.AnalogReply, packet[0]);
Assert.Equal(payload, packet.AsSpan(1, packet.Length - 2).ToArray());
Assert.Equal(RioChecksum.Compute(packet.AsSpan(0, packet.Length - 1)), packet[^1]);
Assert.Equal(payload, packet.Skip(1).Take(packet.Length - 2).ToArray());
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
{
private static List<RioRxEvent> FeedAll(PacketParser parser, ReadOnlySpan<byte> data)
private static List<RioRxEvent> FeedAll(PacketParser parser, byte[] data)
{
var events = new List<RioRxEvent>();
foreach (byte b in data)
@@ -8,7 +8,7 @@ public class RioChecksumTests
[Fact]
public void Empty_IsZero()
{
Assert.Equal(0, RioChecksum.Compute(ReadOnlySpan<byte>.Empty));
Assert.Equal(0, RioChecksum.Compute(new byte[0]));
}
[Fact]
@@ -23,13 +23,13 @@ internal sealed class FakeTransport : IRioTransport
/// <summary>Signal that no more inbound data will arrive (transport closed).</summary>
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))
{
if (_incoming.Reader.TryRead(out byte[]? chunk))
{
chunk.AsSpan().CopyTo(buffer.Span);
Array.Copy(chunk, buffer, chunk.Length);
return chunk.Length;
}
}
@@ -37,10 +37,10 @@ internal sealed class FakeTransport : IRioTransport
return 0; // completed
}
public ValueTask WriteAsync(ReadOnlyMemory<byte> data, CancellationToken cancellationToken)
public Task WriteAsync(byte[] data, CancellationToken cancellationToken)
{
_writes.Writer.TryWrite(data.ToArray());
return default; // net48 has no ValueTask.CompletedTask; default(ValueTask) is the completed task
_writes.Writer.TryWrite((byte[])data.Clone());
return Task.CompletedTask;
}
/// <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 =>
{
packets++;
ReadOnlySpan<byte> pl = p.Payload.Span;
byte[] pl = p.Payload;
switch (p.Command)
{
case RioCommand.ButtonPressed when pl[0] < RioAddress.ButtonCount: