Files
VRIO/src/VRio.Core/Device/VRioDevice.cs
T
CydandClaude Fable 5 007d15e668 Wire carries stick-up as negative; Invert Y opts back to physical
The old Invert joystick Y box (checked by default) is now the wire's
native convention: full up = -80 in AnalogReply with the flag off.
Checking the renamed Invert Y sends the physical direction (up = +80).

Control strip cleanup: test-mode buttons and the firmware selector are
gone (the device still reports 4.2), Keyboard/Gamepad/Invert Y share
one line, the help text drops the bindings sentence, and the wire log
gets the reclaimed height.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 09:47:25 -05:00

428 lines
16 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 → TestModeChange ENTER, a BoardOk CheckReply per known
/// board (the self-test stream), then TestModeChange EXIT — the init
/// handshake the real board performs and the game waits (≤5s per step) on.</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>
/// The wire natively carries stick-up as negative (full up = 80 in
/// AnalogReply); set this to send the physical direction (up = positive)
/// instead. Only the host sees the difference — local state
/// (<see cref="GetAxis"/>, the panel's dot) keeps the physical stick
/// direction either way.
/// </summary>
public bool InvertJoystickY { get; set; }
/// <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>
/// Axis value as the next AnalogReply will carry it — same as
/// <see cref="GetAxis"/> except joystick Y follows the wire convention
/// (see <see cref="InvertJoystickY"/>).
/// </summary>
public short GetWireAxis(RioAxis axis)
{
short value = GetAxis(axis);
return axis == RioAxis.JoystickY ? WireY(value) : value;
}
private short WireY(short y) => InvertJoystickY
? y
: (short)Math.Min(AnalogCodec.Max, -y); // clamp: -Min (8192) is one past the 14-bit Max
/// <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:
// Init handshake (verified against a real v4.2 board tap): the
// host waits ≤5s for TestModeChange ENTER before anything else,
// and sends no requests at all until the matching EXIT arrives.
Logged?.Invoke("RX CheckRequest → test mode enter, all boards OK, exit");
Send(PacketBuilder.TestModeChange(1));
foreach ((byte number, string _) in RioAddressSpace.Boards)
Send(PacketBuilder.CheckReply(RioStatusType.BoardOk, number));
Send(PacketBuilder.TestModeChange(0));
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, WireY(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;
}
}
}