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:
@@ -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**.
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user