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;