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`):
|
(`riojoy/rio-firmware/RIOv4_2-ANALYSIS.md`):
|
||||||
|
|
||||||
- ACKs every well-formed packet; NAKs bad-checksum packets.
|
- 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
|
- `CheckRequest` → one `BoardOk` CheckReply per board (the 11 boards from the
|
||||||
legacy firmware's table). `VersionRequest` → configurable version,
|
legacy firmware's table). `VersionRequest` → configurable version,
|
||||||
default **4.2**.
|
default **4.2**.
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
using System.IO.Ports;
|
using System.IO.Ports;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
using VRio.Core.Protocol;
|
using VRio.Core.Protocol;
|
||||||
|
|
||||||
namespace VRio.Core.Device;
|
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
|
/// 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.
|
/// 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
|
/// <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
|
/// 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
|
/// 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>
|
/// <summary>RIO link bit rate (must match RIOJoy's transport).</summary>
|
||||||
public const int BaudRate = 9600;
|
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 VRioDevice _device;
|
||||||
private readonly object _writeGate = new();
|
private readonly object _txGate = new();
|
||||||
|
private readonly Queue<byte> _txQueue = new();
|
||||||
|
|
||||||
private SerialPort? _port;
|
private SerialPort? _port;
|
||||||
private Thread? _reader;
|
private Thread? _reader;
|
||||||
|
private Thread? _writer;
|
||||||
private volatile bool _running;
|
private volatile bool _running;
|
||||||
|
private bool _timerResolutionRaised;
|
||||||
|
|
||||||
public VRioSerialService(VRioDevice device)
|
public VRioSerialService(VRioDevice device)
|
||||||
{
|
{
|
||||||
@@ -74,10 +94,18 @@ public sealed class VRioSerialService : IDisposable
|
|||||||
|
|
||||||
_port = port;
|
_port = port;
|
||||||
_running = true;
|
_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 = new Thread(ReadLoop) { IsBackground = true, Name = "vRIO serial reader" };
|
||||||
_reader.Start();
|
_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);
|
ConnectionChanged?.Invoke(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,6 +117,7 @@ public sealed class VRioSerialService : IDisposable
|
|||||||
return;
|
return;
|
||||||
|
|
||||||
_running = false;
|
_running = false;
|
||||||
|
lock (_txGate) Monitor.PulseAll(_txGate); // wake the writer so it can exit
|
||||||
_port = null;
|
_port = null;
|
||||||
port.PinChanged -= OnPinChanged;
|
port.PinChanged -= OnPinChanged;
|
||||||
try { port.Close(); }
|
try { port.Close(); }
|
||||||
@@ -97,6 +126,15 @@ public sealed class VRioSerialService : IDisposable
|
|||||||
|
|
||||||
_reader?.Join(1000);
|
_reader?.Join(1000);
|
||||||
_reader = null;
|
_reader = null;
|
||||||
|
_writer?.Join(1000);
|
||||||
|
_writer = null;
|
||||||
|
lock (_txGate) _txQueue.Clear();
|
||||||
|
|
||||||
|
if (_timerResolutionRaised)
|
||||||
|
{
|
||||||
|
timeEndPeriod(1);
|
||||||
|
_timerResolutionRaised = false;
|
||||||
|
}
|
||||||
|
|
||||||
Logged?.Invoke("Port closed");
|
Logged?.Invoke("Port closed");
|
||||||
ConnectionChanged?.Invoke(false);
|
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)
|
private void Write(byte[] data)
|
||||||
{
|
{
|
||||||
SerialPort? port = _port;
|
if (!_running)
|
||||||
if (port is null || !port.IsOpen)
|
|
||||||
return; // device poked while offline — drop silently
|
return; // device poked while offline — drop silently
|
||||||
|
|
||||||
try
|
lock (_txGate)
|
||||||
{
|
{
|
||||||
lock (_writeGate)
|
foreach (byte b in data)
|
||||||
port.Write(data, 0, data.Length);
|
_txQueue.Enqueue(b);
|
||||||
}
|
Monitor.Pulse(_txGate);
|
||||||
catch (Exception ex) when (ex is IOException or InvalidOperationException or TimeoutException)
|
|
||||||
{
|
|
||||||
Logged?.Invoke($"Write failed: {ex.Message}");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
_device.Transmit -= Write;
|
_device.Transmit -= Write;
|
||||||
|
|||||||
Reference in New Issue
Block a user