using VRio.Core.Protocol; namespace VRio.Core.Device; /// /// 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 /// ; everything it wants to transmit is raised through /// . The UI pokes it with , /// , etc., and listens for to /// light its on-screen buttons. /// /// Wire behavior (mirroring what RIOJoy expects from the real board): /// /// ACKs every well-formed inbound packet, NAKs one with a bad checksum. /// 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. /// VersionRequest → VersionReply with the configured firmware version /// (default 4.2, matching the real board's dumped EPROM). /// AnalogRequest → AnalogReply with the current five axis values. /// ResetRequest → re-zeroes the targeted axis (or all) like a /// recalibration, and reports it via . /// LampRequest → stores the lamp state and raises . /// 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). /// /// /// With 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. /// /// All members are thread-safe; events may fire on the caller's thread. /// 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; /// Firmware version reported by VersionReply (real boards run 4.2). public byte VersionMajor { get; set; } = 4; /// Firmware version reported by VersionReply (real boards run 4.2). public byte VersionMinor { get; set; } = 2; /// /// 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 /// (, the panel's dot) keeps the physical stick /// direction either way. /// public bool InvertJoystickY { get; set; } /// /// When true, retry exhaustion leaves the analog reply path wedged (the /// v4.2 latch-leak bug) until a host ResetRequest clears it. /// public bool EmulateReplyWedge { get; set; } /// True while the analog reply path is wedged (see ). public bool AnalogWedged { get { lock (_gate) return _analogMuted; } } /// Bytes the device wants on the wire (already framed). public event Action? Transmit; /// A lamp changed: (address 0x00–0x47, raw lamp-state byte). public event Action? LampChanged; /// The PC asked for an axis reset/recalibration. public event Action? ResetReceived; /// Axis values changed (reset or local set) — refresh gauges. public event Action? AxesChanged; /// Human-readable protocol log lines (analog polls excluded — see ). public event Action? Logged; /// Count of AnalogRequests served (RIOJoy polls ~18×/s; logging each would drown the log). public long AnalogRequests { get; private set; } /// Count of AnalogRequests silently dropped while wedged. public long AnalogDropped { get; private set; } /// Count of packets received with a bad checksum (NAK'd). public long BadChecksums { get; private set; } // ---- Local (UI-facing) state ------------------------------------------ /// Current raw value of an axis. public short GetAxis(RioAxis axis) { lock (_gate) return _axes[(int)axis]; } /// /// Axis value as the next AnalogReply will carry it — same as /// except joystick Y follows the wire convention /// (see ). /// 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 /// /// 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. /// 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(); } /// Current lamp state byte for a button address (0x00–0x47). public byte GetLamp(int address) { if (!RioAddressSpace.IsButton(address)) return 0; lock (_gate) return _lamps[address]; } /// /// Locally darken every lamp (a fresh board powers up dark; the host /// re-lights what it wants). Callers should refresh their display. /// public void ClearLamps() { lock (_gate) Array.Clear(_lamps, 0, _lamps.Length); Logged?.Invoke("Local: all lamps cleared"); } // ---- Local inputs → wire events --------------------------------------- /// Press the input at a RIO address (button or keypad key). public void PressAddress(int address) => SendInputEvent(address, pressed: true); /// Release the input at a RIO address (button or keypad key). public void ReleaseAddress(int address) => SendInputEvent(address, pressed: false); /// Announce a test-mode change (0 = exit test mode). public void SendTestMode(byte mode) { SendEvent(PacketBuilder.TestModeChange((byte)(mode & 0x7F))); Logged?.Invoke($"TX TestModeChange mode={mode}"); } /// /// 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. /// 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 ----------------------------------------------------- /// Feed bytes received from the PC. 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; } } }