diff --git a/README.md b/README.md
index 32d96b7..de7fc41 100644
--- a/README.md
+++ b/README.md
@@ -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**.
diff --git a/src/VRio.Core/Device/VRioSerialService.cs b/src/VRio.Core/Device/VRioSerialService.cs
index 4816e63..4d33cfe 100644
--- a/src/VRio.Core/Device/VRioSerialService.cs
+++ b/src/VRio.Core/Device/VRioSerialService.cs
@@ -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.
///
+/// 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 (slot = max(prevSlot + period, now)), so
+/// the stream averages the true baud rate without bursting after idle.
+///
/// 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 so the UI can show that a host
@@ -18,12 +28,22 @@ public sealed class VRioSerialService : IDisposable
/// RIO link bit rate (must match RIOJoy's transport).
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 _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;