vRIO: virtual RIO cockpit device emulator

Speaks the device side of the RIO serial protocol (per riojoy's
PROTOCOL.md) on a COM port, behind an interactive replica of the
profile editor's cockpit panel: click cells to press buttons/keys,
drag the encoder gauges to move the five analog axes, and watch
host-commanded lamp states (incl. flash modes) light the cells.

Device behavior grounded in the real v4.2 firmware dump: version 4.2,
4-retry NAK budget ending in RESTART, and an optional emulation of the
analog reply-wedge latch leak for exercising host recovery watchdogs.

Verified: 33 unit tests, plus an interop harness driving RIOJoy's
actual RioSerialLink against VRioDevice over an in-memory transport
(version/check/analog/lamp/button/keypad/reset all round-trip).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Cyd
2026-07-06 07:39:38 -05:00
co-authored by Claude Fable 5
commit 7995c0b1c1
22 changed files with 2599 additions and 0 deletions
+396
View File
@@ -0,0 +1,396 @@
using VRio.Core.Protocol;
namespace VRio.Core.Device;
/// <summary>
/// The virtual RIO board: a pure (transport-free) protocol state machine that
/// behaves like the cockpit hardware on the wire. Feed it received bytes via
/// <see cref="OnReceived"/>; everything it wants to transmit is raised through
/// <see cref="Transmit"/>. The UI pokes it with <see cref="PressAddress"/>,
/// <see cref="SetAxis"/>, etc., and listens for <see cref="LampChanged"/> to
/// light its on-screen buttons.
///
/// <para>Wire behavior (mirroring what RIOJoy expects from the real board):</para>
/// <list type="bullet">
/// <item>ACKs every well-formed inbound packet, NAKs one with a bad checksum.</item>
/// <item>CheckRequest → a BoardOk CheckReply per known board.</item>
/// <item>VersionRequest → VersionReply with the configured firmware version
/// (default 4.2, matching the real board's dumped EPROM).</item>
/// <item>AnalogRequest → AnalogReply with the current five axis values.</item>
/// <item>ResetRequest → re-zeroes the targeted axis (or all) like a
/// recalibration, and reports it via <see cref="ResetReceived"/>.</item>
/// <item>LampRequest → stores the lamp state and raises <see cref="LampChanged"/>.</item>
/// <item>A NAK from the PC re-sends the last event packet up to 4 times, then
/// gives up with a RESTART byte — the real v4.2 firmware's retry budget
/// (riojoy/rio-firmware/RIOv4_2-ANALYSIS.md, counters $3173-$3175 limit 4,
/// give-up at $D9D5 sends $FE).</item>
/// </list>
///
/// <para>With <see cref="EmulateReplyWedge"/> on, the give-up path also
/// reproduces the v4.2 firmware's orphaned reply-in-progress latch ($2521):
/// every subsequent AnalogRequest is silently dropped (still ACK'd — the RX
/// path stays alive) until a host ResetRequest arrives, mirroring the
/// mash-stress analog mute seen on real hardware. Useful for exercising
/// RIOJoy's no-analog recovery watchdog.</para>
///
/// All members are thread-safe; events may fire on the caller's thread.
/// </summary>
public sealed class VRioDevice
{
// The real firmware retries a reply 4 times before giving up ($3173-$3175).
private const int MaxResends = 4;
private readonly object _gate = new();
private readonly PacketParser _parser = new();
private readonly short[] _axes = new short[5];
private readonly byte[] _lamps = new byte[RioAddressSpace.LampCount];
private byte[]? _lastEventPacket;
private int _resends;
private bool _analogMuted;
/// <summary>Firmware version reported by VersionReply (real boards run 4.2).</summary>
public byte VersionMajor { get; set; } = 4;
/// <summary>Firmware version reported by VersionReply (real boards run 4.2).</summary>
public byte VersionMinor { get; set; } = 2;
/// <summary>
/// When true, retry exhaustion leaves the analog reply path wedged (the
/// v4.2 latch-leak bug) until a host ResetRequest clears it.
/// </summary>
public bool EmulateReplyWedge { get; set; }
/// <summary>True while the analog reply path is wedged (see <see cref="EmulateReplyWedge"/>).</summary>
public bool AnalogWedged
{
get { lock (_gate) return _analogMuted; }
}
/// <summary>Bytes the device wants on the wire (already framed).</summary>
public event Action<byte[]>? Transmit;
/// <summary>A lamp changed: (address 0x000x47, raw lamp-state byte).</summary>
public event Action<int, byte>? LampChanged;
/// <summary>The PC asked for an axis reset/recalibration.</summary>
public event Action<RioResetTarget>? ResetReceived;
/// <summary>Axis values changed (reset or local set) — refresh gauges.</summary>
public event Action? AxesChanged;
/// <summary>Human-readable protocol log lines (analog polls excluded — see <see cref="AnalogRequests"/>).</summary>
public event Action<string>? Logged;
/// <summary>Count of AnalogRequests served (RIOJoy polls ~18×/s; logging each would drown the log).</summary>
public long AnalogRequests { get; private set; }
/// <summary>Count of AnalogRequests silently dropped while wedged.</summary>
public long AnalogDropped { get; private set; }
/// <summary>Count of packets received with a bad checksum (NAK'd).</summary>
public long BadChecksums { get; private set; }
// ---- Local (UI-facing) state ------------------------------------------
/// <summary>Current raw value of an axis.</summary>
public short GetAxis(RioAxis axis)
{
lock (_gate) return _axes[(int)axis];
}
/// <summary>
/// Move an axis. Values are clamped to the 14-bit signed range the wire
/// can carry; the new value is returned by the next AnalogReply.
/// </summary>
public void SetAxis(RioAxis axis, int value)
{
short clamped = (short)Math.Max(AnalogCodec.Min, Math.Min(AnalogCodec.Max, value));
lock (_gate) _axes[(int)axis] = clamped;
AxesChanged?.Invoke();
}
/// <summary>Current lamp state byte for a button address (0x000x47).</summary>
public byte GetLamp(int address)
{
if (!RioAddressSpace.IsButton(address)) return 0;
lock (_gate) return _lamps[address];
}
/// <summary>
/// Locally darken every lamp (a fresh board powers up dark; the host
/// re-lights what it wants). Callers should refresh their display.
/// </summary>
public void ClearLamps()
{
lock (_gate) Array.Clear(_lamps, 0, _lamps.Length);
Logged?.Invoke("Local: all lamps cleared");
}
// ---- Local inputs → wire events ---------------------------------------
/// <summary>Press the input at a RIO address (button or keypad key).</summary>
public void PressAddress(int address) => SendInputEvent(address, pressed: true);
/// <summary>Release the input at a RIO address (button or keypad key).</summary>
public void ReleaseAddress(int address) => SendInputEvent(address, pressed: false);
/// <summary>Announce a test-mode change (0 = exit test mode).</summary>
public void SendTestMode(byte mode)
{
SendEvent(PacketBuilder.TestModeChange((byte)(mode & 0x7F)));
Logged?.Invoke($"TX TestModeChange mode={mode}");
}
/// <summary>
/// Force the analog reply path into the wedged state right now (as if the
/// retry give-up just leaked the latch) — a one-click way to watch the
/// host's no-analog recovery kick in. A host ResetRequest clears it.
/// </summary>
public void WedgeAnalogNow()
{
lock (_gate) _analogMuted = true;
Logged?.Invoke("Local: ANALOG WEDGED (v4.2 bug emulation) — waiting for a host reset");
}
private void SendInputEvent(int address, bool pressed)
{
byte[] packet;
if (RioAddressSpace.IsButton(address))
{
packet = pressed
? PacketBuilder.ButtonPressed((byte)address)
: PacketBuilder.ButtonReleased((byte)address);
}
else if (RioAddressSpace.IsKeypad(address))
{
(byte pad, byte index) = RioAddressSpace.ToKeypad(address);
packet = pressed
? PacketBuilder.KeyPressed(pad, index)
: PacketBuilder.KeyReleased(pad, index);
}
else
{
throw new ArgumentOutOfRangeException(nameof(address), $"0x{address:X2} is not a RIO input address.");
}
SendEvent(packet);
Logged?.Invoke($"TX {(pressed ? "press" : "release")} 0x{address:X2}");
}
// Event packets (button/key/test) are remembered so a NAK can re-send them.
private void SendEvent(byte[] packet)
{
lock (_gate)
{
_lastEventPacket = packet;
_resends = 0;
}
Transmit?.Invoke(packet);
}
private void Send(byte[] packet) => Transmit?.Invoke(packet);
private void SendControl(RioControl control) => Transmit?.Invoke(new[] { (byte)control });
// ---- Wire → device -----------------------------------------------------
/// <summary>Feed bytes received from the PC.</summary>
public void OnReceived(byte[] data, int count)
{
for (int i = 0; i < count; i++)
{
bool produced;
RioRxEvent ev;
lock (_gate) produced = _parser.Feed(data[i], out ev);
if (produced)
HandleEvent(ev);
}
}
private void HandleEvent(RioRxEvent ev)
{
switch (ev.Kind)
{
case RioRxKind.Packet when !ev.ChecksumValid:
BadChecksums++;
SendControl(RioControl.Nak);
Logged?.Invoke($"RX {ev.Packet} — bad checksum, NAK'd");
break;
case RioRxKind.Packet:
SendControl(RioControl.Ack);
HandlePacket(ev.Packet);
break;
case RioRxKind.ControlByte:
HandleControl(ev.Byte);
break;
case RioRxKind.FramingError:
Logged?.Invoke($"RX framing error (0x{ev.Byte:X2} mid-packet) — resynced");
break;
}
}
private void HandlePacket(RioPacket packet)
{
byte[] p = packet.Payload;
switch (packet.Command)
{
case RioCommand.CheckRequest:
Logged?.Invoke("RX CheckRequest → all boards OK");
foreach ((byte number, string _) in RioAddressSpace.Boards)
Send(PacketBuilder.CheckReply(RioStatusType.BoardOk, number));
break;
case RioCommand.VersionRequest:
Logged?.Invoke($"RX VersionRequest → {VersionMajor}.{VersionMinor}");
Send(PacketBuilder.VersionReply(VersionMajor, VersionMinor));
break;
case RioCommand.AnalogRequest:
short t, l, r, y, x;
lock (_gate)
{
if (_analogMuted)
{
// The wedged v4.2 board drops the request in reply
// generation ($D758) — the packet was still ACK'd above.
AnalogDropped++;
return;
}
AnalogRequests++;
t = _axes[(int)RioAxis.Throttle];
l = _axes[(int)RioAxis.LeftPedal];
r = _axes[(int)RioAxis.RightPedal];
y = _axes[(int)RioAxis.JoystickY];
x = _axes[(int)RioAxis.JoystickX];
}
Send(PacketBuilder.AnalogReply(t, l, r, y, x));
break;
case RioCommand.ResetRequest:
var target = (RioResetTarget)p[0];
ApplyReset(target);
bool unwedged;
lock (_gate)
{
// The host reset/init handler ($C686) is what clears the
// real board's leaked reply latch — mirror that here.
unwedged = _analogMuted;
_analogMuted = false;
}
Logged?.Invoke($"RX ResetRequest {target} → re-zeroed" + (unwedged ? " (analog wedge cleared)" : ""));
ResetReceived?.Invoke(target);
break;
case RioCommand.LampRequest:
int lamp = p[0];
byte state = p[1];
if (RioAddressSpace.IsButton(lamp))
{
lock (_gate) _lamps[lamp] = state;
Logged?.Invoke($"RX Lamp 0x{lamp:X2} = 0x{state:X2} ({RioLampState.Describe(state)})");
LampChanged?.Invoke(lamp, state);
}
else
{
Logged?.Invoke($"RX LampRequest for unknown lamp 0x{lamp:X2} — ignored");
}
break;
default:
// A RIO→PC message arriving at the device end — echo it to the log.
Logged?.Invoke($"RX unexpected {packet} — ignored");
break;
}
}
// On the real board a reset re-references the encoder at its current
// position; the emulator's equivalent is snapping the value back to zero.
private void ApplyReset(RioResetTarget target)
{
lock (_gate)
{
switch (target)
{
case RioResetTarget.Throttle: _axes[(int)RioAxis.Throttle] = 0; break;
case RioResetTarget.LeftPedal: _axes[(int)RioAxis.LeftPedal] = 0; break;
case RioResetTarget.RightPedal: _axes[(int)RioAxis.RightPedal] = 0; break;
case RioResetTarget.VerticalJoystick: _axes[(int)RioAxis.JoystickY] = 0; break;
case RioResetTarget.HorizontalJoystick: _axes[(int)RioAxis.JoystickX] = 0; break;
default: Array.Clear(_axes, 0, _axes.Length); break; // general reset
}
}
AxesChanged?.Invoke();
}
private void HandleControl(byte b)
{
switch ((RioControl)b)
{
case RioControl.Ack:
lock (_gate)
{
_lastEventPacket = null;
_resends = 0;
}
break;
case RioControl.Nak:
byte[]? resend = null;
bool giveUp = false;
lock (_gate)
{
if (_lastEventPacket is null)
{
// nothing pending; stray NAK
}
else if (_resends < MaxResends)
{
_resends++;
resend = _lastEventPacket;
}
else
{
// Retry budget exhausted: the real firmware sends RESTART
// ($D9D5) and tears down — leaking its reply latch.
_lastEventPacket = null;
_resends = 0;
giveUp = true;
if (EmulateReplyWedge)
_analogMuted = true;
}
}
if (resend is not null)
{
Logged?.Invoke("RX NAK — re-sending last event");
Transmit?.Invoke(resend);
}
else if (giveUp)
{
Logged?.Invoke(EmulateReplyWedge
? "RX NAK — retries exhausted, RESTART sent; ANALOG WEDGED (v4.2 bug) until a host reset"
: "RX NAK — retries exhausted, RESTART sent");
SendControl(RioControl.Restart);
}
else
{
Logged?.Invoke("RX NAK — nothing to re-send (dropped)");
}
break;
case RioControl.Restart:
Logged?.Invoke("RX RESTART");
break;
case RioControl.Idle:
break; // keep-alive noise
default:
Logged?.Invoke($"RX stray byte 0x{b:X2}");
break;
}
}
}