diff --git a/GAL20v8a_5764.JED b/GAL20v8a_5764.JED
new file mode 100644
index 0000000..fb43e5c
--- /dev/null
+++ b/GAL20v8a_5764.JED
@@ -0,0 +1,38 @@
+

+Device    :    GAL20V8A

+Created By:    http://www.autoelectric.cn

+Date      :    2023-03-09 19:02

+*QP24

+*QF2706

+*G0

+*F0

+*L00000 11111111111111111111111111111111

+*L00032 11111111111111011111111111111111

+*L00064 11111111110111110000000000000000

+*L00320 11111111111111111111111111111111

+*L00352 11111111111111111111111111111111

+*L00384 11111110111111110000000000000000

+*L00640 11111111111111111111111111111111

+*L00672 11111111100101111011101110111011

+*L00704 10111011101101110000000000000000

+*L00960 11111111111111111111111111111111

+*L00992 11111111111001111111111111111111

+*L01024 11111111110111110110111111111111

+*L01056 11111111111111111101111110011011

+*L01088 11111111111111111111111111011111

+*L01280 11111111111111111111111111111111

+*L01312 11111111100101101011101110111011

+*L01344 10111011100110010000000000000000

+*L01600 11111111111111111111111111111111

+*L01632 11111111100101101011101110111011

+*L01664 10111011100110100000000000000000

+*L02240 11111111111111111111111111111111

+*L02272 11111111010111111111111111111111

+*L02304 11111111111111110000000000000000

+*L02560 01000000010101010011011100101110

+*L02592 00101110001011100010111000000000

+*L02624 00000000111111111111111111111111

+*L02656 11111111111111111111111111111111

+*L02688 111111111111111111

+*C5764

+*F676

diff --git a/Scans_018-014.pdf b/Scans_018-014.pdf
new file mode 100644
index 0000000..be55bab
Binary files /dev/null and b/Scans_018-014.pdf differ
diff --git a/rio-firmware/testlogs/riomash-patched-31250-20260718-003114.log b/rio-firmware/testlogs/riomash-patched-31250-20260718-003114.log
new file mode 100644
index 0000000..6c22c0f
--- /dev/null
+++ b/rio-firmware/testlogs/riomash-patched-31250-20260718-003114.log
@@ -0,0 +1,30 @@
+== RIO mash test :: COM1 @ 31250 8N1, 300s, chip label 'patched-31250' ==
+   lamp echo ON (drives reply/lamp collisions), wedge threshold 2.0s, app auto-recovery DISABLED
+   log: C:\VWE\riojoy\riomash-patched-31250-20260718-003114.log
+   firmware: (no version reply!)
+   counters before: (no status reply — reply path dead?)
+
+>>> MASH NOW: two hands, 8 lamp buttons, as fast as you can. <<<
+>>> Test runs 300s. A wedge alarm will beep + banner.   <<<
+>>> Ctrl+C ends the run early and still prints the summary.   <<<
+
+[  31.90s] progress: presses=188 (188/min) analog=1185 maxGap=0.09s wedges=0
+[  61.90s] progress: presses=395 (383/min) analog=2370 maxGap=0.09s wedges=0
+[  91.93s] progress: presses=798 (521/min) analog=3559 maxGap=0.09s wedges=0
+[ 121.96s] progress: presses=932 (459/min) analog=4713 maxGap=0.09s wedges=0
+[ 126.86s] operator stop (Ctrl+C) — ending run, snapshotting counters...
+
+== MASH SUMMARY [patched-31250] ==
+run              : 126s on COM1, lamps on, wedge threshold 2.0s
+firmware         : 4.2
+presses/releases : 946 / 977  (443 presses/min)
+analog replies   : 4927 (~2290 poll slots; 215.2%), sentinels 0
+framing / NAK    : 5655 / 5
+gap histogram    : <100ms:4925  100-250ms:0  250-500ms:0  0.5-1s:1  1-2s:0  2-5s:0  >5s:0
+longest gaps     : 0.54s@126.9s, 0.09s@28.6s, 0.08s@29.5s, 0.08s@121.9s, 0.08s@24.3s, 0.08s@108.9s, 0.08s@21.8s, 0.08s@85.9s, 0.08s@88.9s, 0.08s@83.5s
+wedge events     : 0
+counters before  : (no status reply — reply path dead?)
+counters after   : RestartCount=2  AbandonCount=65  FullBufferCount=0  [board/lamp faults reported!]
+counter delta    : (incomplete — a snapshot got no reply)
+verdict          : PASS — no wedge; compare gap histogram + counter delta to baseline
+log file         : C:\VWE\riojoy\riomash-patched-31250-20260718-003114.log
diff --git a/rio-firmware/testlogs/riomash-patched-62500-retx-20260719-131037.log b/rio-firmware/testlogs/riomash-patched-62500-retx-20260719-131037.log
new file mode 100644
index 0000000..b5e02fe
--- /dev/null
+++ b/rio-firmware/testlogs/riomash-patched-62500-retx-20260719-131037.log
@@ -0,0 +1,29 @@
+== RIO mash test :: COM1 @ 62500 8N1, 300s, chip label 'patched-62500-retx' ==
+   lamp echo ON (drives reply/lamp collisions), wedge threshold 2.0s, app auto-recovery DISABLED
+   log: C:\VWE\riojoy\riomash-patched-62500-retx-20260719-131037.log
+   firmware: 4.2
+   counters before: RestartCount=0  AbandonCount=0  FullBufferCount=0  [board/lamp faults reported!]
+
+>>> MASH NOW: two hands, 8 lamp buttons, as fast as you can. <<<
+>>> Test runs 300s. A wedge alarm will beep + banner.   <<<
+>>> Ctrl+C ends the run early and still prints the summary.   <<<
+
+[  33.03s] progress: presses=245 (245/min) analog=515 maxGap=0.27s wedges=0
+[  63.05s] progress: presses=568 (541/min) analog=992 maxGap=0.27s wedges=0
+[  93.11s] progress: presses=709 (457/min) analog=1470 maxGap=0.27s wedges=0
+[  95.30s] operator stop (Ctrl+C) — ending run, snapshotting counters...
+
+== MASH SUMMARY [patched-62500-retx] ==
+run              : 95s on COM1, lamps on, wedge threshold 2.0s
+firmware         : 4.2
+presses/releases : 709 / 710  (440 presses/min)
+analog replies   : 1522 (~1727 poll slots; 88.1%), sentinels 0
+framing / NAK    : 61 / 321 (NAK-triggered resends: 321)
+gap histogram    : <100ms:1510  100-250ms:9  250-500ms:2  0.5-1s:0  1-2s:0  2-5s:0  >5s:0
+longest gaps     : 0.27s@1.8s, 0.25s@95.3s, 0.13s@77.1s, 0.13s@26.6s, 0.12s@26.3s, 0.12s@57.4s, 0.12s@12.7s, 0.12s@11.5s, 0.12s@18.8s, 0.12s@31.3s
+wedge events     : 0
+counters before  : RestartCount=0  AbandonCount=0  FullBufferCount=0  [board/lamp faults reported!]
+counters after   : RestartCount=0  AbandonCount=0  FullBufferCount=0  [board/lamp faults reported!]
+counter delta    : RestartCount=+0  AbandonCount=+0  FullBufferCount=+0
+verdict          : PASS — no wedge; compare gap histogram + counter delta to baseline
+log file         : C:\VWE\riojoy\riomash-patched-62500-retx-20260719-131037.log
diff --git a/signal-2026-07-15-093011.jpeg b/signal-2026-07-15-093011.jpeg
new file mode 100644
index 0000000..b29bf35
Binary files /dev/null and b/signal-2026-07-15-093011.jpeg differ
diff --git a/signal-2026-07-15-093017.jpeg b/signal-2026-07-15-093017.jpeg
new file mode 100644
index 0000000..111ad66
Binary files /dev/null and b/signal-2026-07-15-093017.jpeg differ
diff --git a/signal-2026-07-15-093023.jpeg b/signal-2026-07-15-093023.jpeg
new file mode 100644
index 0000000..bd55569
Binary files /dev/null and b/signal-2026-07-15-093023.jpeg differ
diff --git a/signal-2026-07-15-093032.jpeg b/signal-2026-07-15-093032.jpeg
new file mode 100644
index 0000000..89dae75
Binary files /dev/null and b/signal-2026-07-15-093032.jpeg differ
diff --git a/signal-2026-07-15-093038.jpeg b/signal-2026-07-15-093038.jpeg
new file mode 100644
index 0000000..9c7b46c
Binary files /dev/null and b/signal-2026-07-15-093038.jpeg differ
diff --git a/signal-2026-07-15-093045.jpeg b/signal-2026-07-15-093045.jpeg
new file mode 100644
index 0000000..1aead52
Binary files /dev/null and b/signal-2026-07-15-093045.jpeg differ
diff --git a/signal-2026-07-15-093051.jpeg b/signal-2026-07-15-093051.jpeg
new file mode 100644
index 0000000..90f32c3
Binary files /dev/null and b/signal-2026-07-15-093051.jpeg differ
diff --git a/signal-2026-07-15-093058.jpeg b/signal-2026-07-15-093058.jpeg
new file mode 100644
index 0000000..3993e1e
Binary files /dev/null and b/signal-2026-07-15-093058.jpeg differ
diff --git a/signal-2026-07-15-093105.jpeg b/signal-2026-07-15-093105.jpeg
new file mode 100644
index 0000000..471dfde
Binary files /dev/null and b/signal-2026-07-15-093105.jpeg differ
diff --git a/src/RioJoy.Core/Serial/RioSerialLink.cs b/src/RioJoy.Core/Serial/RioSerialLink.cs
index 7e2f5ee..0ac9ed3 100644
--- a/src/RioJoy.Core/Serial/RioSerialLink.cs
+++ b/src/RioJoy.Core/Serial/RioSerialLink.cs
@@ -23,13 +23,14 @@ public sealed class RioSerialLink
     // Time since the last accepted AnalogReply, for the recovery watchdog.
     private readonly Stopwatch _sinceAnalog = new();
 
-    // NAK retransmit state: the last COMMAND packet sent (control-byte replies
-    // are excluded — the board never NAKs those) and how many resends it has
-    // consumed. Guarded by _retransmitGate; see RioSerialLinkOptions.NakRetransmitLimit.
-    private readonly object _retransmitGate = new();
-    private byte[]? _lastCommand;
-    private int _lastCommandResends;
-    private long _nakRetransmits;
+    // Stop-and-wait command state: one command in flight at a time
+    // (_commandGate), resolved by the board's ACK/NAK or by AckTimeout.
+    // Control-byte replies (our ACKs) bypass the gate — the board never
+    // ACK/NAKs those, and they must not queue behind a pending command.
+    private readonly SemaphoreSlim _commandGate = new(1, 1);
+    private readonly object _pendingGate = new();
+    private TaskCompletionSource<bool>? _pendingAck; // true = ACK, false = NAK
+    private long _retransmits;
 
     public RioSerialLink(IRioTransport transport, RioSerialLinkOptions? options = null)
     {
@@ -58,8 +59,8 @@ public sealed class RioSerialLink
     /// <summary>The transport's description, surfaced for status/logging.</summary>
     public string Description => _transport.Description;
 
-    /// <summary>Total NAK-triggered retransmits since the link was created.</summary>
-    public long NakRetransmits => Interlocked.Read(ref _nakRetransmits);
+    /// <summary>Total command retransmits (NAK- or timeout-triggered) since creation.</summary>
+    public long Retransmits => Interlocked.Read(ref _retransmits);
 
     /// <summary>
     /// Run the receive loop and (if enabled) the analog poll loop until
@@ -89,23 +90,56 @@ public sealed class RioSerialLink
         }
     }
 
-    /// <summary>Send a pre-built packet (see <see cref="PacketBuilder"/>) to the RIO.</summary>
+    /// <summary>
+    /// Send a pre-built packet (see <see cref="PacketBuilder"/>) to the RIO.
+    /// Command packets use stop-and-wait: the call completes once the board
+    /// ACKs, or after <see cref="RioSerialLinkOptions.CommandRetransmitLimit"/>
+    /// retransmits (NAK- or timeout-triggered) go unacknowledged — the command
+    /// is then dropped (all commands are idempotent state-setters, and the
+    /// caller's next update supersedes it). Control bytes bypass the wait.
+    /// </summary>
     public async Task SendAsync(byte[] packet, CancellationToken cancellationToken = default)
     {
         if (packet is null) throw new ArgumentNullException(nameof(packet));
 
-        // Only real command packets participate in NAK retransmit; single
-        // control bytes (our ACK/NAK replies) are never NAK'd by the board.
-        if (packet.Length > 1 && _options.NakRetransmitLimit > 0)
+        // Control-byte replies (len 1) are never ACK/NAK'd by the board, and a
+        // limit of 0 selects the legacy fire-and-forget behavior.
+        if (packet.Length <= 1 || _options.CommandRetransmitLimit <= 0)
         {
-            lock (_retransmitGate)
-            {
-                _lastCommand = packet;
-                _lastCommandResends = 0;
-            }
+            await WriteAsync(packet, cancellationToken).ConfigureAwait(false);
+            return;
         }
 
-        await WriteAsync(packet, cancellationToken).ConfigureAwait(false);
+        await Compat.TaskCompat.WaitAsync(_commandGate, cancellationToken).ConfigureAwait(false);
+        try
+        {
+            for (int attempt = 0; ; attempt++)
+            {
+                var pending = new TaskCompletionSource<bool>();
+                lock (_pendingGate) _pendingAck = pending;
+
+                await WriteAsync(packet, cancellationToken).ConfigureAwait(false);
+
+                Task winner = await Compat.TaskCompat.WhenAny(new[]
+                {
+                    pending.Task,
+                    Compat.TaskCompat.Delay(_options.AckTimeout, cancellationToken),
+                }).ConfigureAwait(false);
+
+                if (winner == pending.Task && pending.Task.Result)
+                    return; // ACK'd
+
+                // NAK'd or timed out (a shred so complete the board never NAK'd).
+                if (attempt >= _options.CommandRetransmitLimit)
+                    return; // budget spent — drop; idempotent, next update supersedes
+                Interlocked.Increment(ref _retransmits);
+            }
+        }
+        finally
+        {
+            lock (_pendingGate) _pendingAck = null;
+            _commandGate.Release();
+        }
     }
 
     private async Task WriteAsync(byte[] packet, CancellationToken cancellationToken)
@@ -121,24 +155,6 @@ public sealed class RioSerialLink
         }
     }
 
-    // The board NAK'd its most recent inbound packet: resend the last command
-    // (idempotent, see RioSerialLinkOptions.NakRetransmitLimit) unless its
-    // retry budget is spent.
-    private async Task RetransmitOnNakAsync(CancellationToken ct)
-    {
-        byte[]? packet;
-        lock (_retransmitGate)
-        {
-            if (_lastCommand is null || _lastCommandResends >= _options.NakRetransmitLimit)
-                return;
-            _lastCommandResends++;
-            packet = _lastCommand;
-        }
-
-        Interlocked.Increment(ref _nakRetransmits);
-        await WriteAsync(packet!, ct).ConfigureAwait(false);
-    }
-
     /// <summary>Request an analog update (<see cref="RioCommand.AnalogRequest"/>).</summary>
     public Task RequestAnalogAsync(CancellationToken cancellationToken = default) =>
         SendAsync(PacketBuilder.AnalogRequest(), cancellationToken);
@@ -188,8 +204,14 @@ public sealed class RioSerialLink
                 break;
 
             case RioRxEventKind.ControlByte:
-                if (ev.Byte == (byte)RioControl.Nak)
-                    await RetransmitOnNakAsync(ct).ConfigureAwait(false);
+                if (ev.Byte is (byte)RioControl.Ack or (byte)RioControl.Nak)
+                {
+                    // Resolve the in-flight command (stop-and-wait): the board
+                    // only emits ACK/NAK in response to our command packets.
+                    TaskCompletionSource<bool>? pending;
+                    lock (_pendingGate) pending = _pendingAck;
+                    pending?.TrySetResult(ev.Byte == (byte)RioControl.Ack);
+                }
                 ControlReceived?.Invoke(ev.Byte);
                 break;
 
@@ -201,6 +223,19 @@ public sealed class RioSerialLink
 
     private void DispatchTyped(RioPacket packet)
     {
+        if (packet.Command is RioCommand.AnalogReply or RioCommand.VersionReply or RioCommand.CheckReply)
+        {
+            // A reply proves the corresponding request landed, whether or not
+            // the board also sent an explicit ACK — resolve the in-flight
+            // command so request/reply exchanges never burn the ACK timeout.
+            // (One command is in flight at a time, so the only stray case is
+            // an unsolicited reply resend — harmless: it can only release an
+            // idempotent command's wait early.)
+            TaskCompletionSource<bool>? pending;
+            lock (_pendingGate) pending = _pendingAck;
+            pending?.TrySetResult(true);
+        }
+
         switch (packet.Command)
         {
             case RioCommand.AnalogReply:
diff --git a/src/RioJoy.Core/Serial/RioSerialLinkOptions.cs b/src/RioJoy.Core/Serial/RioSerialLinkOptions.cs
index 994d733..67a88c9 100644
--- a/src/RioJoy.Core/Serial/RioSerialLinkOptions.cs
+++ b/src/RioJoy.Core/Serial/RioSerialLinkOptions.cs
@@ -32,14 +32,22 @@ public sealed record RioSerialLinkOptions
 
     /// <summary>
     /// How many times a command packet is retransmitted when the board NAKs it
-    /// (0 = fire-and-forget, the pre-2026-07 behavior). The wire has no sequence
-    /// numbers, but every PC→RIO command is idempotent (lamp state, analog
-    /// request, reset), so resending the most recent packet on NAK is safe even
-    /// in the rare race where the NAK belonged to an earlier one. Bounded so a
-    /// NAK storm (e.g. a rate the board's RX can't sustain) can't multiply
-    /// traffic without limit. Bench origin: at 62500 baud ~6.5% of 4-byte lamp
-    /// commands arrive corrupt (RX-ISR overrun) — one retry cuts the visible
-    /// lamp-state error rate to ~0.4%, two to ~0.03%.
+    /// or the ACK never arrives (0 = fire-and-forget, the pre-2026-07 behavior).
+    /// Commands use stop-and-wait: ONE command in flight, resolved by
+    /// ACK / NAK / <see cref="AckTimeout"/> before the next is written. The
+    /// first (NAK-race) design resent "the most recent" packet and misfired
+    /// under mash bursts — by the time a NAK crossed USB, a newer command was
+    /// already the latest, so the corrupted write stayed lost (bench
+    /// 2026-07-19); with one-in-flight the attribution is exact, and the
+    /// timeout also catches corruptions so complete the board never NAKs.
     /// </summary>
-    public int NakRetransmitLimit { get; init; } = 2;
+    public int CommandRetransmitLimit { get; init; } = 2;
+
+    /// <summary>
+    /// How long to wait for the board's ACK/NAK of a command before treating it
+    /// as lost (stop-and-wait; see <see cref="CommandRetransmitLimit"/>). Board
+    /// turnaround is ~1-4 ms; 50 ms is generous without stalling the 55 ms
+    /// analog poll cadence on a healthy link.
+    /// </summary>
+    public TimeSpan AckTimeout { get; init; } = TimeSpan.FromMilliseconds(50);
 }
diff --git a/tests/RioJoy.Core.Tests/Serial/RioSerialLinkTests.cs b/tests/RioJoy.Core.Tests/Serial/RioSerialLinkTests.cs
index 1eb33c0..8d93646 100644
--- a/tests/RioJoy.Core.Tests/Serial/RioSerialLinkTests.cs
+++ b/tests/RioJoy.Core.Tests/Serial/RioSerialLinkTests.cs
@@ -142,124 +142,151 @@ public class RioSerialLinkTests
         await run;
     }
 
-    // --- NAK retransmit (RioSerialLinkOptions.NakRetransmitLimit) ------------
+    // --- stop-and-wait command retransmit (CommandRetransmitLimit) -----------
+
+    private static RioSerialLinkOptions StopAndWait(int limit = 2, int timeoutMs = 150) => new()
+    {
+        AutoPollAnalog = false,
+        CommandRetransmitLimit = limit,
+        AckTimeout = TimeSpan.FromMilliseconds(timeoutMs),
+    };
 
     [Fact]
-    public async Task Nak_RetransmitsLastCommand()
+    public async Task Nak_RetransmitsTheSamePacket()
     {
         var fake = new FakeTransport();
-        var link = new RioSerialLink(fake, new RioSerialLinkOptions { AutoPollAnalog = false });
+        var link = new RioSerialLink(fake, StopAndWait());
 
         using var cts = new CancellationTokenSource();
         Task run = link.RunAsync(cts.Token);
 
         byte[] lamp = PacketBuilder.LampRequest(0x05, 0x02);
-        await link.SendAsync(lamp);
+        Task send = link.SendAsync(lamp);
+
         Assert.Equal(lamp, await fake.NextWriteAsync());
+        fake.Enqueue((byte)RioControl.Nak);      // board: "that arrived corrupt"
+        Assert.Equal(lamp, await fake.NextWriteAsync()); // exact same packet again
+        fake.Enqueue((byte)RioControl.Ack);      // second copy lands
 
-        fake.Enqueue((byte)RioControl.Nak); // board: "that arrived corrupt"
-
-        Assert.Equal(lamp, await fake.NextWriteAsync()); // resent
-        Assert.Equal(1, link.NakRetransmits);
+        await send.WaitAsync(Timeout);
+        Assert.Equal(1, link.Retransmits);
 
         cts.Cancel();
         await run;
     }
 
     [Fact]
-    public async Task Nak_RetransmitStopsAtLimit()
+    public async Task Timeout_RetransmitsWhenBoardNeverResponds()
     {
         var fake = new FakeTransport();
-        var link = new RioSerialLink(fake, new RioSerialLinkOptions
-        {
-            AutoPollAnalog = false,
-            NakRetransmitLimit = 2,
-        });
+        var link = new RioSerialLink(fake, StopAndWait(limit: 2, timeoutMs: 80));
 
         using var cts = new CancellationTokenSource();
         Task run = link.RunAsync(cts.Token);
 
         byte[] lamp = PacketBuilder.LampRequest(0x05, 0x02);
-        await link.SendAsync(lamp);
-        await fake.NextWriteAsync(); // original
+        Task send = link.SendAsync(lamp);
 
-        for (int i = 0; i < 4; i++)
-            fake.Enqueue((byte)RioControl.Nak);
+        // Original + 2 timeout-driven retries, then the command is dropped.
+        Assert.Equal(lamp, await fake.NextWriteAsync());
+        Assert.Equal(lamp, await fake.NextWriteAsync());
+        Assert.Equal(lamp, await fake.NextWriteAsync());
+        await send.WaitAsync(Timeout); // completes (dropped), doesn't hang
+        Assert.Equal(2, link.Retransmits);
 
-        Assert.Equal(lamp, await fake.NextWriteAsync()); // retry 1
-        Assert.Equal(lamp, await fake.NextWriteAsync()); // retry 2
-
-        // Budget spent: NAKs 3 and 4 must produce no further writes.
         await Assert.ThrowsAnyAsync<OperationCanceledException>(
             () => fake.NextWriteAsync(TimeSpan.FromMilliseconds(200)));
-        Assert.Equal(2, link.NakRetransmits);
 
         cts.Cancel();
         await run;
     }
 
     [Fact]
-    public async Task Ack_DoesNotRetransmit()
+    public async Task Ack_CompletesWithoutRetransmit()
     {
         var fake = new FakeTransport();
-        var link = new RioSerialLink(fake, new RioSerialLinkOptions { AutoPollAnalog = false });
+        var link = new RioSerialLink(fake, StopAndWait());
 
         using var cts = new CancellationTokenSource();
         Task run = link.RunAsync(cts.Token);
 
-        await link.SendAsync(PacketBuilder.LampRequest(0x05, 0x02));
+        Task send = link.SendAsync(PacketBuilder.LampRequest(0x05, 0x02));
         await fake.NextWriteAsync();
-
         fake.Enqueue((byte)RioControl.Ack);
 
+        await send.WaitAsync(Timeout);
+        Assert.Equal(0, link.Retransmits);
+
         await Assert.ThrowsAnyAsync<OperationCanceledException>(
             () => fake.NextWriteAsync(TimeSpan.FromMilliseconds(200)));
-        Assert.Equal(0, link.NakRetransmits);
 
         cts.Cancel();
         await run;
     }
 
     [Fact]
-    public async Task NewCommand_ResetsRetryBudget()
+    public async Task Commands_AreSerialized_SecondWaitsForFirst()
     {
         var fake = new FakeTransport();
-        var link = new RioSerialLink(fake, new RioSerialLinkOptions
-        {
-            AutoPollAnalog = false,
-            NakRetransmitLimit = 1,
-        });
+        var link = new RioSerialLink(fake, StopAndWait());
 
         using var cts = new CancellationTokenSource();
         Task run = link.RunAsync(cts.Token);
 
         byte[] first = PacketBuilder.LampRequest(0x05, 0x02);
-        await link.SendAsync(first);
-        await fake.NextWriteAsync();
-        fake.Enqueue((byte)RioControl.Nak);
-        Assert.Equal(first, await fake.NextWriteAsync()); // budget of 'first' spent
-
         byte[] second = PacketBuilder.LampRequest(0x06, 0x01);
-        await link.SendAsync(second);
-        Assert.Equal(second, await fake.NextWriteAsync());
-        fake.Enqueue((byte)RioControl.Nak);
-        Assert.Equal(second, await fake.NextWriteAsync()); // fresh budget applies
+        Task sendA = link.SendAsync(first);
+        Task sendB = link.SendAsync(second);
+
+        // Only the first is on the wire until it resolves.
+        Assert.Equal(first, await fake.NextWriteAsync());
+        await Assert.ThrowsAnyAsync<OperationCanceledException>(
+            () => fake.NextWriteAsync(TimeSpan.FromMilliseconds(100)));
+
+        fake.Enqueue((byte)RioControl.Ack);
+        Assert.Equal(second, await fake.NextWriteAsync()); // now B goes out
+        fake.Enqueue((byte)RioControl.Ack);
+
+        await sendA.WaitAsync(Timeout);
+        await sendB.WaitAsync(Timeout);
 
         cts.Cancel();
         await run;
     }
 
     [Fact]
-    public async Task OwnControlByteReplies_AreNeverRetransmitted()
+    public async Task RequestReply_ResolvesTheInFlightCommand_WithoutExplicitAck()
     {
         var fake = new FakeTransport();
-        var link = new RioSerialLink(fake, new RioSerialLinkOptions { AutoPollAnalog = false });
+        var link = new RioSerialLink(fake, StopAndWait());
 
         using var cts = new CancellationTokenSource();
         Task run = link.RunAsync(cts.Token);
 
-        // Inbound button packet -> the link replies with a 1-byte ACK. That ACK
-        // must not become "the last command" for retransmit purposes.
+        // An analog request answered by the reply alone (no ACK byte) must not
+        // burn the ACK timeout — the reply proves the request landed.
+        Task send = link.RequestAnalogAsync();
+        await fake.NextWriteAsync();
+        fake.Enqueue(PacketBuilder.Build(RioCommand.AnalogReply, new byte[10]));
+
+        await send.WaitAsync(TimeSpan.FromMilliseconds(120)); // well under AckTimeout
+        Assert.Equal(0, link.Retransmits);
+
+        cts.Cancel();
+        await run;
+    }
+
+    [Fact]
+    public async Task UnsolicitedNak_WithNothingPending_IsIgnored()
+    {
+        var fake = new FakeTransport();
+        var link = new RioSerialLink(fake, StopAndWait());
+
+        using var cts = new CancellationTokenSource();
+        Task run = link.RunAsync(cts.Token);
+
+        // Inbound button packet -> our 1-byte ACK reply (bypasses the command
+        // gate). A stray NAK afterwards must not resend anything.
         fake.Enqueue(PacketBuilder.Build(RioCommand.ButtonPressed, new byte[] { 0x05 }));
         Assert.Equal(new byte[] { (byte)RioControl.Ack }, await fake.NextWriteAsync());
 
@@ -267,7 +294,7 @@ public class RioSerialLinkTests
 
         await Assert.ThrowsAnyAsync<OperationCanceledException>(
             () => fake.NextWriteAsync(TimeSpan.FromMilliseconds(200)));
-        Assert.Equal(0, link.NakRetransmits);
+        Assert.Equal(0, link.Retransmits);
 
         cts.Cancel();
         await run;
@@ -277,22 +304,19 @@ public class RioSerialLinkTests
     public async Task Retransmit_Disabled_IsFireAndForget()
     {
         var fake = new FakeTransport();
-        var link = new RioSerialLink(fake, new RioSerialLinkOptions
-        {
-            AutoPollAnalog = false,
-            NakRetransmitLimit = 0,
-        });
+        var link = new RioSerialLink(fake, StopAndWait(limit: 0));
 
         using var cts = new CancellationTokenSource();
         Task run = link.RunAsync(cts.Token);
 
-        await link.SendAsync(PacketBuilder.LampRequest(0x05, 0x02));
+        // Completes immediately (no ACK wait), and a NAK triggers nothing.
+        await link.SendAsync(PacketBuilder.LampRequest(0x05, 0x02)).WaitAsync(Timeout);
         await fake.NextWriteAsync();
         fake.Enqueue((byte)RioControl.Nak);
 
         await Assert.ThrowsAnyAsync<OperationCanceledException>(
             () => fake.NextWriteAsync(TimeSpan.FromMilliseconds(200)));
-        Assert.Equal(0, link.NakRetransmits);
+        Assert.Equal(0, link.Retransmits);
 
         cts.Cancel();
         await run;
diff --git a/tests/RioJoy.Core.Tests/TestPolyfills.cs b/tests/RioJoy.Core.Tests/TestPolyfills.cs
index 0eca058..017b935 100644
--- a/tests/RioJoy.Core.Tests/TestPolyfills.cs
+++ b/tests/RioJoy.Core.Tests/TestPolyfills.cs
@@ -18,4 +18,15 @@ internal static class TaskTestExtensions
         cts.Cancel(); // stop the delay timer
         return await task.ConfigureAwait(false);
     }
+
+    /// <summary>Non-generic counterpart of <see cref="WaitAsync{T}"/>.</summary>
+    public static async Task WaitAsync(this Task task, TimeSpan timeout)
+    {
+        using var cts = new CancellationTokenSource();
+        Task completed = await Task.WhenAny(task, Task.Delay(timeout, cts.Token)).ConfigureAwait(false);
+        if (completed != task)
+            throw new TimeoutException($"Task did not complete within {timeout}.");
+        cts.Cancel();
+        await task.ConfigureAwait(false);
+    }
 }
diff --git a/tools/RioSerialMonitor/MashTest.cs b/tools/RioSerialMonitor/MashTest.cs
index 2792e79..5480bc9 100644
--- a/tools/RioSerialMonitor/MashTest.cs
+++ b/tools/RioSerialMonitor/MashTest.cs
@@ -319,7 +319,7 @@ internal static class MashTest
         Raw($"firmware         : {(version is null ? "(no reply)" : version.ToString())}");
         Raw($"presses/releases : {presses} / {releases}  ({presses / mins:F0} presses/min)");
         Raw($"analog replies   : {analogReplies} (~{expectedPolls} poll slots; {100.0 * analogReplies / Math.Max(1, expectedPolls):F1}%), sentinels {sentinels}");
-        Raw($"framing / NAK    : {framing} / {naks} (NAK-triggered resends: {link.NakRetransmits})");
+        Raw($"framing / NAK    : {framing} / {naks} (command resends NAK/timeout: {link.Retransmits})");
         Raw("gap histogram    : " + string.Join("  ", bucketNames.Select((n, i) => $"{n}:{buckets[i]}")));
         Raw("longest gaps     : " + (longestGaps.Count == 0
             ? "(none)"
