Pace TX bytes at the 9600-baud wire rate

vRIO wrote whole reply packets in one SerialPort.Write; through a
com0com null-modem (no UART) the host saw the bytes ~30-40 us apart,
a burst no real board can produce and the prime suspect in a rocky
game init. Replies now leave one byte per 10-bit frame (~1.04 ms), so
the 45-byte CheckRequest response takes ~47 ms like real hardware
(measured 0.94-1.10 ms gaps over a com0com pair).

- Transmit frames queue to a writer thread; each byte is scheduled
  against a monotonic slot deadline slot = max(prev + period, now),
  so the stream averages true 9600 baud without bursting after idle.
- After a write the schedule is floored at the actual emission time:
  a late wake-up can never be followed by a catch-up burst - two
  frames closer than the frame time is structurally impossible.
- 1 ms system timer resolution while the port is open (timeBeginPeriod)
  so the pacer sleeps most of each gap and only spins the last ~1.8 ms.
- Side benefit: UI clicks no longer block on SerialPort.Write (a
  stalled port could previously hang the UI for the 2 s write timeout).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Cyd
2026-07-06 11:31:39 -05:00
co-authored by Claude Fable 5
parent e590b89c47
commit d26000f906
2 changed files with 119 additions and 11 deletions
+6
View File
@@ -41,6 +41,12 @@ device behavior grounded in the **real v4.2 firmware dump**
(`riojoy/rio-firmware/RIOv4_2-ANALYSIS.md`):
- ACKs every well-formed packet; NAKs bad-checksum packets.
- **TX is paced at the wire rate** — one byte per 10-bit frame (~1.04 ms at
9600 8N1), never closer. A virtual null-modem has no UART, so unpaced
writes would land at the host in microsecond bursts no real board could
produce; vRIO's writer thread schedules each byte against a monotonic
slot deadline instead, so e.g. the 45-byte CheckRequest response takes
the same ~47 ms it takes real hardware.
- `CheckRequest` → one `BoardOk` CheckReply per board (the 11 boards from the
legacy firmware's table). `VersionRequest` → configurable version,
default **4.2**.
+113 -11
View File
@@ -1,4 +1,6 @@
using System.Diagnostics;
using System.IO.Ports;
using System.Runtime.InteropServices;
using VRio.Core.Protocol;
namespace VRio.Core.Device;
@@ -8,6 +10,14 @@ namespace VRio.Core.Device;
/// 9600 8N1 settings. On a single PC, pair it with RIOJoy through a virtual
/// null-modem (e.g. com0com): vRIO opens one end, RIOJoy the other.
///
/// <para>Outbound bytes are paced at the wire rate: one byte per 10-bit frame
/// time (~1.04 ms at 9600 8N1). A virtual null-modem has no UART, so an
/// unpaced multi-byte write lands at the host back-to-back in microseconds —
/// a burst no real board could produce, and a timing tell that has tripped up
/// hosts tuned to hardware. A writer thread schedules each byte against a
/// monotonic slot deadline (<c>slot = max(prevSlot + period, now)</c>), so
/// the stream averages the true baud rate without bursting after idle.</para>
///
/// <para>RIOJoy pulses DTR for 50 ms when it opens its end (the board-reset
/// handshake); through a null modem that arrives here as a DSR blip, which is
/// surfaced via <see cref="HostHandshake"/> so the UI can show that a host
@@ -18,12 +28,22 @@ public sealed class VRioSerialService : IDisposable
/// <summary>RIO link bit rate (must match RIOJoy's transport).</summary>
public const int BaudRate = 9600;
// One byte on the wire is 10 bits (start + 8 data + stop) at 9600 baud.
private static readonly long BytePeriodTicks = Stopwatch.Frequency * 10 / BaudRate;
// Below this remaining wait (~1.8 ms) Thread.Sleep(1) would overshoot the
// slot even at 1 ms timer resolution, so the pacer spins the remainder.
private static readonly long SpinThresholdTicks = Stopwatch.Frequency * 18 / 10_000;
private readonly VRioDevice _device;
private readonly object _writeGate = new();
private readonly object _txGate = new();
private readonly Queue<byte> _txQueue = new();
private SerialPort? _port;
private Thread? _reader;
private Thread? _writer;
private volatile bool _running;
private bool _timerResolutionRaised;
public VRioSerialService(VRioDevice device)
{
@@ -74,10 +94,18 @@ public sealed class VRioSerialService : IDisposable
_port = port;
_running = true;
lock (_txGate) _txQueue.Clear();
// 1 ms system timer resolution while the port is open, so the pacer's
// Thread.Sleep(1) actually sleeps ~1 ms instead of the 15.6 ms default.
_timerResolutionRaised = timeBeginPeriod(1) == 0;
_reader = new Thread(ReadLoop) { IsBackground = true, Name = "vRIO serial reader" };
_reader.Start();
_writer = new Thread(WriteLoop) { IsBackground = true, Name = "vRIO serial writer" };
_writer.Start();
Logged?.Invoke($"Opened {portName} @ {BaudRate} 8N1 — waiting for the host");
Logged?.Invoke($"Opened {portName} @ {BaudRate} 8N1 (TX paced at the wire rate) — waiting for the host");
ConnectionChanged?.Invoke(true);
}
@@ -89,6 +117,7 @@ public sealed class VRioSerialService : IDisposable
return;
_running = false;
lock (_txGate) Monitor.PulseAll(_txGate); // wake the writer so it can exit
_port = null;
port.PinChanged -= OnPinChanged;
try { port.Close(); }
@@ -97,6 +126,15 @@ public sealed class VRioSerialService : IDisposable
_reader?.Join(1000);
_reader = null;
_writer?.Join(1000);
_writer = null;
lock (_txGate) _txQueue.Clear();
if (_timerResolutionRaised)
{
timeEndPeriod(1);
_timerResolutionRaised = false;
}
Logged?.Invoke("Port closed");
ConnectionChanged?.Invoke(false);
@@ -144,23 +182,87 @@ public sealed class VRioSerialService : IDisposable
}
}
// The device's Transmit handler: queue the frame for the paced writer so
// the caller (UI click, reader thread mid-reply) never blocks on the port.
private void Write(byte[] data)
{
SerialPort? port = _port;
if (port is null || !port.IsOpen)
if (!_running)
return; // device poked while offline — drop silently
try
lock (_txGate)
{
lock (_writeGate)
port.Write(data, 0, data.Length);
}
catch (Exception ex) when (ex is IOException or InvalidOperationException or TimeoutException)
{
Logged?.Invoke($"Write failed: {ex.Message}");
foreach (byte b in data)
_txQueue.Enqueue(b);
Monitor.Pulse(_txGate);
}
}
private void WriteLoop()
{
var one = new byte[1];
long slot = Stopwatch.GetTimestamp();
while (_running)
{
lock (_txGate)
{
while (_txQueue.Count == 0)
{
if (!_running)
return;
Monitor.Wait(_txGate, 200); // timed, so a missed pulse can't wedge shutdown
}
one[0] = _txQueue.Dequeue();
}
// This byte's wire slot: one frame after the previous byte, or now
// if the line has been idle (no burst "catch-up" debt).
slot = Math.Max(slot + BytePeriodTicks, Stopwatch.GetTimestamp());
PaceUntil(slot);
SerialPort? port = _port;
if (port is null)
return;
try
{
port.Write(one, 0, 1);
}
catch (Exception ex) when (ex is IOException or InvalidOperationException or TimeoutException)
{
if (_running)
Logged?.Invoke($"Write failed: {ex.Message}");
return;
}
// If the wait overshot its slot, pace the next byte from the
// actual emission instead: a UART can never put two frames closer
// than the frame time, so a stall must not cause a catch-up burst.
long now = Stopwatch.GetTimestamp();
if (now > slot)
slot = now;
}
}
private static void PaceUntil(long slotTicks)
{
while (true)
{
long remaining = slotTicks - Stopwatch.GetTimestamp();
if (remaining <= 0)
return;
if (remaining > SpinThresholdTicks)
Thread.Sleep(1);
else
Thread.SpinWait(64);
}
}
[DllImport("winmm.dll")]
private static extern uint timeBeginPeriod(uint uMilliseconds);
[DllImport("winmm.dll")]
private static extern uint timeEndPeriod(uint uMilliseconds);
public void Dispose()
{
_device.Transmit -= Write;